Axiom 0.6.1
0.6.1 — 2026-08-31
-
A program contains only what it uses. A hello world was a
103,592-byte binary holding 388defines, of which a walk from
mainreaches 22. It is 34,640 bytes now, and the compiler emits
242 fewer definitions of itself. The linkage was only half the
cause: 375 of 388 definitions carried external linkage, which does
block dead-code elimination — butemitSymbolTablewrites a
ptrtointfor EVERY function, so the backtrace table pinned all 388
addresses and internalising them was worth 6%. The removal is the
emitter's now, before the table is built. Reading the emitted IR
rather than the AST is what makes it safe: in IR a call, a closure
record'sptrtointand a thunk are the same token, so a callback
needs no special case, and a wrong root iserror: use of undefined valueout ofopt— a failed build, never a binary that links and
crashes.--emit-staticlibprunes nothing, which is the one named
consumer, so no flag was added.scripts/check-dead-code.shasserts
on the LINKED binary vianm, with a walk deliberately tighter than
the compiler's so it is not agreeing with itself. -
Two soundness holes:
checksaid OK and the program segfaulted.
(:: g (-> Int Box))with body7was accepted and exited 139 — the
literal dereferenced as a handle. The same mismatch in ARGUMENT
position was refused all along, so the rule existed and was not
applied in one place;checkDeclaredReturncompared by a name list
rather than bytyCompat. 22 type pairs flipped toAX3004, and no
site in the tree was newly refused because the exemption is exactly
this tree'sInt-as-handle convention and nothing more —
tests/selfhost/987-handle-convention-roundtrip.axpins that
exemption at run time, and removing it turns the fixture red.
Separately,emitApplyChainpassed an empty evidence vector, so
over-applied andcast-spine arguments were classified "not a
reference" —462-surplus-closure-arg.axexits 139 built by 0.6.0
and 15 built by this release. Neither was caught by 55 green gates,
because a soundness hole lives where no fixture thought to look. -
restrict(no-alloc)could not fail, and seven claims were false.
A function claiming it and constructing a value checked clean:
findFnEntanswers 0 for every constructor, andwalkCallHead's
branch added nothing. It was not an oversight —MM-EXEC-9arecorded
constructor-invisibility as a DECISION — but the decision stopped
being survivable the day the restriction shipped, because a decision
that makes a checked claim unfalsifiable is the check not existing.
123 of 3,725 effect rows gainAlloc; seven claims are withdrawn
with the reason written at each site. The nullary arm is
load-bearing:(Empty)allocates nothing, so arity decides, not
constructor-ness. -
muton a struct field was parsed and thrown away. A write to a
non-mutfield through an immutable binding compiled clean with no
diagnostic;parseOneFieldsaid so in its own comment. This project
removedlinear,consumeandderivingfor exactly that. Enforced
rather than deleted — 29 fields migrated, found by building the
compiler with the refusal armed rather than by grep, which was wrong
in both directions.explain AX3012's claim that "structure fields
are written withmemSetWordinstead" is corrected. -
The REPL has a terminal UI. Editing, history with reverse search,
syntax highlighting painted from the compiler's own lexer, and
completion including bindings defined earlier in the session — which
exist in no file.stdlib/Tui/{Keys,Edit,Term}.aximport nothing
fromself_host/. The piped surface is byte-identical: 22 sessions,
44 streams, 341,394 bytes before and after, zero differing. -
The language server learns
textDocument/declarationand call
hierarchy. Declaration is not an alias for definition here — Axiom
writes a function twice, so declaration lands on(:: f T)and
definition on the body, and a signature with nofnyet answers one
andnullfor the other.rangeFormattingis REFUSED on a
measurement: 8,437 form-aligned slices, 21 disagreeing, every one
comment placement, so format-on-save and format-selection would each
rewrite what the other wrote. -
The shared compiler was rebuilt by every gate.
gate_source_stamp
hashes$axiom, and teachinggate_initto honourAXIOM_AXC—
itself a fix, for twelve gates silently measuring the installed
binary — changed what that means on one side only, so the stamp
written by the builder could never equal the one a gate computes.
build-shared-axc.sh's own header prices the sharing at ~16 minutes
per run. -
A program contains only what it uses. A hello world was a
103,592-byte binary holding 388defines, of which a walk from
mainreaches 22:(import IO)pulls inErr,Fmt,Pathand
Systransitively and every function of them was emitted, linked and
shipped. Nothing downstream removed them either —nmon the linked
executable found 361 of the 366 unreachable ones, in a binary of 397
symbols, while a program with no imports at all was 34,216 bytes. So
roughly 70 KB of that hello world was code it could not run.Two things pinned it, and only the first is the one a reader expects.
375 of the 388defines carried EXTERNAL linkage, which forbids LLVM
from deleting them because another translation unit might call them —
and Axiom is a whole-program compiler with no other translation unit.
ButemitSymbolTablealso writesptrtoint (ptr @F to i64)for
every function in the module, so the backtrace table was a live
global holding all 388 addresses, and that pin survives any linkage
change. Measured: marking all 384 non-runtime definesinternaland
runningopt -O1deleted exactly ONE of them and left all 397
symbols in the binary — 103,592 bytes to 97,368, a 6% win. Dropping
the table pin as well took the same program to 17,224 bytes. The
linkage was the smaller half of the problem, which is why the fix is
the emitter's rather than the linker's.pruneDeadDefsinself_host/codegen.axnow walks the module to a
reachable set and drops the rest, BEFORE the symbol table is built,
so the table describes exactly the functions that survived — a
stripped function can never appear in a backtrace. Hello world is
34,648 bytes, 22 defines and 29 symbols, against a no-import
program's 17,432 and 17;self_host/main.axloses 242 of its 3,599
defines, because the compiler uses most of what it imports.The walk reads the EMITTED IR rather than the AST, and that is the
design rather than an implementation detail. The class that sinks a
naive reachability answer is the function whose ADDRESS is taken
rather than called — a callback, a comparator, a capability-record
field — and the effect walk already gives up on exactly that shape
and marks it#effects-incomplete. In the IR the class does not
exist: there is one way to mention a function at all, the token
@name, whether the mention is a call, aptrtointinto a closure
record, or a thunk over a bare reference. One scan finds all of them
with no per-shape knowledge to keep in step as the emitter grows.
The failure mode is loud rather than silent for the same reason:
LLVM's textual IR has no implicit declaration, so a root this walk
got wrong iserror: use of undefined valueout ofopt— a failed
build naming the symbol, not a binary that links and then crashes.--emit-staticlibprunes nothing and needs no flag to say so: an
archive exists precisely so another translation unit can call in,
everypub fnis a C symbol by contract, and the whole-program
assumption is false there by construction.axiom_alloc,
axiom_retainandaxiom_releaseare roots in every build, archive
or not, because a--cratehost calls them from Rust where no IR of
ours mentions them.scripts/check-dead-code.shis the new gate, and its walk is
deliberately TIGHTER than the compiler's — it roots only the entry
and those three FFI symbols, not the emitter's generous
outside-a-define catch-all — so it asks a stricter question rather
than re-running the pass and agreeing with itself. Both answers are
22 of 22 today, which is the evidence that the generous roots retain
nothing. Turning the pass off in a shadow tree puts 361 of 397
symbols back and names all four of the functions the measurement
called out.check-backtrace.shhad two floors that expired on this:rows >= 200, calibrated on "a probe importing Sys had 275 on 2026-08-24",
and afound >= 20anti-vacuousness guard. Both went red on a module
that had got smaller for the right reason, and the second printed "0
of 16 table names are in no symbol table" — blaming the emitter for
the number that was correct. Both now rest on the probe's own
six-deep chain, which cannot expire when the module's size moves
again. -
BREAKING:
muton a struct field is a checked constraint, and a
field is immutable without it.(struct P (x : Int))with no
marker took(set p.x 9)in silence —checkOK, the program
exited 9 — because the parser recognisedmuton a field, skipped
it and recorded nothing — whichparseOneFieldsaid in as many words
at the time, that the marker was "not recorded ... only skipped". That is exactly
the shapeAX2004's explain text calls worse than no marker at all,
"a marker that reads as an ownership guarantee and supplies none is
worse than no marker, because a reader spends trust on it", and it
is whylinear,consumeandderivingwere removed from this
language.mutwas enforced instead, because unlike those three it
had a rule worth keeping: a field is immutable unless declared
otherwise, which is the ruleletalready follows.A store into an unmarked field is
AX3012assign-to-immutable—
the same code as the immutable-binding refusal, anchored on the
field name in the write rather than on the declaration, which is
often in another unit. Only the LAST segment of a path is governed:
(set a.b.c v)needscdeclaredmutand says nothing aboutb,
because the store mutates the valuebpoints at rather than the
bslot.memSetWordis outside the rule entirely.The migration, measured rather than guessed: 55 dotted-path
field writes across the tree resolve to 29 distinct (struct, field)
pairs and 30 declaration sites, every one of them found by compiling
the tree with the new refusal and reading what it named — no write
was hidden behind an alias the checker could not follow. Fourmut
field declarations existed before this, all intests/fmt/, because
the marker did nothing.stdlibtook 15 of the 30:HttpReader's
buf/filled/consumed,HttpReq.body,HttpRouter.missing,
HtmlBuf.len,FallibleTally.count,KeyIn'slen/pos/eof
and nineLineEdfields.The standard library's public surface did not move: 619
normalised AXSYM rows are byte-identical across the change, and no
#fields=entry carries mutability, so this takes no line in
compat/BREAKING— that file's subject is the library API and this
is a language-surface change.axiom explain AX3012also lost a
false sentence, that "structure fields are written withmemSetWord
instead", which the working(set p.y 10)had always contradicted.Gates:
tests/diagnostics/467-set-immutable-field.ax(the
refusal, plus two writes that stay silent — amutfield, and a
non-mutintermediate on a nested path — so the check cannot pass
by refusing everything);tests/selfhost/561-field-store-mut.ax
(the runtime half: amutfield still writes, through an immutable
binding, through a nested path and through a parameter). -
The reference documents partial application of a lambda, which
works. The section was titled "Partial Application — Not
Supported" and was accurate only about top-level functions, burying
a working feature under the heading for a refused one. One rule
decides both halves, and it is the oneAX3013's own note gives: a
partial application must hold the arguments it was not given, and a
top-level function has no closure record to hold them in — a lambda
has one.tests/selfhost/988-lambda-partial-application.axpins
seven properties the corpus had no fixture for, one bit each:
argument order survives; arguments may be supplied one at a time;
a capture survives the partial; the partial escapes its creating
function; it stores in a struct field and calls back through.f;
a_hole applies to a lambda's arguments too; and the whole spine
at once is the ordinary saturated call it looks like — which is why
((add3 1 2) 3)compiles on a top-leveladd3while
(let ((h (add3 1 2))) (h 3))isAX3013. The saturation count is
taken at the spine's ROOT. -
The REPL has a real terminal interface: editing, cursor motion, a
coloured prompt, and a redraw that stays correct when the line
wraps.self_host/repl.axread plain lines with no editing, no
prompt and no colour, and its own header called that a deliberate
divergence from stage0's rustyline. It is no longer one. Three new
modules, and they are STANDARD LIBRARY modules rather than compiler
ones —Tui.Keys,Tui.EditandTui.Term, understdlib/Tui/:
Tui.Keysdecodes terminal bytes into key events — printable UTF-8
throughUtf8.ax's decoder, the control bytes, and the CSI/SS3
grammar PARSED rather than pattern-matched, so an unbound but
well-formed sequence (a mouse report, a cursor-position reply) is
consumed whole instead of typed into the line one byte at a time.
Tui.Editis a gap buffer of code points with the readline motions,
kills and one kill ring.Tui.Termis the reader, the raw-mode
bracket and the event loop.Nothing in
stdlib/Tui/imports a compiler module, and the gate
asserts it: 0 such imports across the 3 files. The two rules it
would have borrowed are parameters instead — the word boundary is a
caller-suppliedwordChars(the REPL passes Axiom's operator set,
so Alt-b crosses the whole ofvec-push!), and the prompt arrives
already painted and is measured withtuiVisLen. Those are second
statements of ruleslexer.isIdentCharandstyle.visLenalready
make, so978-line-editor.axsweeps all 128 ASCII bytes through the
first pair and a 12-string corpus through the second and requires
exact agreement — independence without drift, which is the
discipline990-char-class.axwas written for. The REPL is the
library's first caller, not its owner.The piped surface did not move, and that is the contract. Every
new behaviour hangs off ONE predicate,replInteractive, true only
when fd 0 and fd 1 are both terminals;scripts/check-repl-tui.sh
asserts that the predicate is written exactly once across
self_host/. Measured before and after over all 22 checked-in REPL
sessions, stdout and stderr: 341,394 bytes each way, and the only
difference is:time's duration, which is a clock. Zero ESC bytes
reach a pipe.The new gate drives the REPL on a pty it allocates itself and makes
44 assertions: that the piped and pty transcripts differ by more than
the terminal's own carriage returns, that Ctrl-C arrives as a KEY and
not a signal (raw mode is entered with ISIG cleared, so nothing the
terminal can generate kills the process while it is raw), that
arrows, Ctrl-A/E/K/U/W/Y, Alt-b/d and a lone timed-out ESC all edit,
that one Backspace removes a two-byte character rather than one byte
of it, that the terminal comes back byte-exact on both exit paths,
and that the wrapped screen matches an independently written VT model
at the deferred-wrap boundary and away from it. Deleting the forced
newline that drives that wrap turns exactly two of its checks red and
leavescheck-repl-selfhost.shgreen, which is the measured proof
that no existing check could see it.tests/selfhost/975-key-decode.ax
gates the decoder with no terminal at all (51 cases, one of them a
negative control), and978-line-editor.axsweeps the wrap
arithmetic across every width from 2 to 80.Forty-nine gates now call
gate_build_axc, and both REPL gates run
in the parallel set.check-repl-selfhost.shhad carried a HAZARD
saying they must not — every REPL writing/tmp/axiom-repl-1,
becausefmtIntStranswers "1" above 3 — and the new gate was
written to obey it. It had been fixed twice before it was read
(decStrfor the pid, 2026-08-08; a private
<tmp>/axiom-repl-<pid>.dat mode 0700, 2026-08-23). Probed: six
axiom replprocesses at once each answered its own expression and
left nothing in/tmp. The paragraph is corrected in place rather
than deleted, because a stale hazard is a claim with no expiry and
the next reader acts on it — this one was one commit from costing the
battery two of its slower gates' parallelism.History, syntax highlighting and completion are not in this: their
keys are decoded and reachledApplyas explicit no-ops, so each is
a branch beside an existing one. -
The language server learns the one distinction this language has
and most do not:textDocument/declarationis not
textDocument/definition. A function is written twice —(:: bump (-> Int Int))declares it,(fn (bump x) ...)defines it — and the
parser has carried both as separate nodes with their own name spans
since it was written.lspFindDeclskipsTAG_D_SIGdeliberately,
sodefinitionhas always answered thefn; nothing answered the
::. Nowdeclarationdoes, indefinition's own scoping order
with one step inserted: a local binding first (a local is declared by
being bound, so the two agree there by construction), then this
document's signature, then this document's declaration, then the
imported modules signature-first. It answers wheredefinition
cannot in exactly one place — a signature whosefnis still to be
written, which is whatAX3015reports and what an editor sees
between two keystrokes. -
Call hierarchy, from the walk the server already had:
textDocument/prepareCallHierarchy,callHierarchy/incomingCalls
andcallHierarchy/outgoingCalls. A call site is defined as an
occurrence thatSECTION NAV's scope-aware walk resolved to a
top-level name AND that stands first inside a(— two questions
already answered, intersected by byte offset, so the whole feature is
a projection rather than a new analysis and costs one parse and one
linear scan of the bytes. That definition is what makes it right
where a spelling match is wrong:(fn (apply k v) (k v))calls its
parameter and not the top-levelk, and(fn (handoff z) helper)
NAMEShelperwithout applying it.axiom symbols --callsreports
that second edge (#calls=helper, measured) because it is the
checker's effect walk; it also costs a full typecheck and carries no
positions, andfromRangesis a list of ranges the protocol
requires. Incoming calls read this document and every other OPEN
document whose imports resolve to it, which isreferences's
workspace; a caller that calls twice is one entry with two ranges.
An item whose document the server has not opened answersnull, not
[], because[]claims a function has no callers.Held by
scripts/check-lsp-selfhost.sh: five documents written by
tests/lsp/drive.pyitself, every expected range computed from their
own bytes,declarationanddefinitionasked at the same position
and required to answer DIFFERENT ranges, and a floor that refuses to
run if either confusable shape ever leaves the corpus or if a comment
in it spells one of the indexed names — a trap that had already shifted
every occurrence number once while these checks were being written.
Five ablations, each in a scratch tree with every golden re-blessed
from the broken build, are recorded in that script's header. -
textDocument/rangeFormattingis refused with a measurement rather
than an argument. 8,437 form-aligned slices ofstdlib/and
self_host/were formatted alone and compared against the same forms
cut out of the whole document's formatted output: 8,416 agreed and 21
did not, every disagreement comment placement — a trailing; bold redthat the whole-document pass moves onto the next declaration, and
a comment block that migrates across a top-level form boundary. An
editor with format-on-save and format-selection both bound would have
each rewrite what the other wrote.docs/lsp.mdcarries the numbers. -
textDocument/semanticTokensis written down as settled, not
missing.docs/lsp.md's Highlighting section said the server
"offers no semantic tokens", which reads as unfinished work; it now
says the server will not send them and why — one highlighter cannot
disagree with itself — and pre-empts the obvious-looking idea that
self_host/replhl.ax's lexer-driven painter makes them nearly free.
A REPL paints from the lexer because it has no grammar and no editor
to consult; an editor has both. -
A comment in
self_host/lsp.axthat contradicted the code beneath
it. The paragraph abovelspRenderFieldssaid a bare(name)was
"every field of a GENERATED struct". The code branches on
lspTyKnown, and the expander was fixed to carry the type node into
the product; measured over a real JSON-RPC pipe,axiom/expandMacro
over a struct-generating macro answers(left : Int). The section
header twelve hundred lines above had the correct version all along.