Phases of compiler design
The six-phase model, measured against Zero[3] — a compiler that reports its own phase structure as machine-readable data.
Submitted by
Harshit Khemani
Co-authors
Kush Ahuja, Mohit Kumar Mishra, Kushagra Agrawal
Submitted to
Ms. Ankita Sharma
Abstract
Compiler construction is conventionally taught as a pipeline of six phases: lexical analysis, syntax analysis, semantic analysis, intermediate code generation, optimization, and target code generation, with symbol-table management and error handling running alongside all six. This decomposition is a teaching abstraction. Production compilers rarely expose it, so students seldom see the phases as separable, measurable objects.
This paper reviews that model against Zero[3] 0.3.4, an experimental graph-first systems language from Vercel Labs whose compiler reports its own phase names, per-phase timings, symbol tables, and diagnostics as structured JSON. We construct a corpus of 8 Zero programs (680 lines, 3,411 tokens, 2,216 graph nodes) and a second corpus of 10 deliberately malformed programs, then measure the compiler across 64 program-target build combinations.
We report four findings. First, the phases do not disappear in a graph-first design — they relocate. Lexical, syntactic and semantic analysis move to an ingestion boundary that admits programs into a persistent graph, after which the compile path performs only lowering, code generation and linking. All 7 front-end error cases were refused at that boundary, and in none of them did the malformed program enter the graph store. Second, lowering accounts for effectively all measurable phase time (100%); the entire front end runs below the compiler's 1 ms reporting resolution because it reads stored facts instead of recomputing them. Third, the front end and the back end accept different languages: 17 of 64 builds failed with BLD004 on programs that had already passed zero check, and the compiler reports ok: true alongside buildable: false in the same document. Fourth, and against the language's own premise, its structured output is far more expensive to read than its prose — zero check prints four bytes where zero check --json returns tens of thousands.
All figures produced by tools/capture.mjs against Zero 0.3.4 (build 5b3a90a) on 13th Gen Intel(R) Core(TM) i5-13450HX, 16 cores, win32/x64. Captured 2026-08-09.
1. Introduction
Every undergraduate compilers course opens with the same diagram: source text enters at the top, passes through a vertical stack of labelled boxes, and machine code emerges at the bottom. Two narrow rectangles run down the side of the stack, touching every box — the symbol table and the error handler. The diagram is due in its modern form to Aho, Lam, Sethi and Ullman[1], and it has organised the field for four decades.
The diagram is also, in an important sense, unfalsifiable by students. Real compilers fuse phases for speed, interleave them for incrementality, and expose none of the boundaries. A student who runs gcc or rustc sees a binary appear and, on failure, a paragraph of English. The phases are real, but they are not visible.
This paper asks a narrow question: does the classical six-phase model still describe a compiler built on a fundamentally different substrate, and if not, how does it differ? We answer it empirically — by building a corpus, instrumenting the compiler through its own reporting interfaces, and measuring.
- source
- lex + parse
- AST
- resolve
- type check
- IR lower
- optimize
- codegen
- artifact
Stages in order: source files → lexer/parser → AST → name resolution → type checking → IR lowering → optimization → codegen → artifact. All nine run on every invocation, as documented for Rust, Go, Zig and C. Figure 2 aligns them against Zero's sequence. Below 560px the ribbon becomes a vertical stack; the order is unchanged.
2. What Zero is
Zero[3] is an experimental systems programming language released by Vercel Labs in May 2026 under Apache-2.0, with source on GitHub. It compiles to standalone native executables, has no garbage collector, gives explicit control over memory, and sits in roughly the design space of C and Zig. Its compiler core is written in C. Programs use the .0 extension. We study version 0.3.4, build 5b3a90a.
Its distinguishing premise is stated in its own tagline: the programming language for agents. Zero is built on the assumption that AI agents, rather than humans, will be the primary consumers of compiler output — and the design follows that assumption further than any other language we are aware of.
pub fn main(world: World) -> Void raises {
check world.out.write("hello from zero\n")
}
Figure 1. pub fn exports. World carries runtime capabilities. raises marks the function fallible, and check propagates failure. This compiles to a 1536-byte native executable in 6 ms of lowering.
2.1 How it differs from current languages
Four departures matter for this paper.
The semantic graph is the program, not the text. A Zero package compiles from a binary zero.graph store holding declarations, types, calls, scopes, imports, capabilities and source-map facts as rows in sixteen relations. The .0 files a human reads are a projection of that graph. In every other mainstream language text is authoritative and the compiler's internal representation is derived and discarded; Zero inverts that relationship[6].
Diagnostics are data, not prose. Every command accepts --json against a versioned schema. Each diagnostic carries a stable code, a span, structured expected/actual facts, a fixSafety rating, and often a typed repair identifier.
Effects and capabilities are explicit and target-checked. A function performing I/O receives a World handle and is marked raises. Each target independently declares which capabilities it provides, so a program using the network is rejected at compile time for a target that declares no net capability.
The toolchain reports itself. A single zero check --json returns the phase list with timings, graph table row counts, the resolved call graph with contracts, cache keys with hit status, and a target readiness report. There is also zero tokens, which counts a program in LLM tokens.
2.2 Why its designers argue it is needed
The case Zero makes is about the cost of a translation layer. In a conventional agent loop, an agent writes text, the compiler renders its objection as English, and the agent parses that English back into an intent it already had. Zero's graph architecture documentation[4] frames the contrast as two loops.
Traditional agent source loop
- agent writes text
- check
- format
- build
- inspect failures
After step 5 the loop returns to step 1: (repeat). Every pass re-derives the same tokens, the same tree and the same bindings from text that has already been read, and the agent only learns an edit was invalid at the end of the pass.
Zero graph loop
- agent queries graph
- agent submits checked patch
- compiler rejects invalid graph edits immediately
- agent runs only task validation
- human reviews projection when useful
Terminates. No edge back to step 1.
There is no (repeat) edge. An invalid edit is refused at step 3, at submission time, rather than discovered at the end of a build; the store therefore never holds a state the next pass would have to find and undo.
Three specific failures are claimed. Line ranges are the wrong handle — they drift the moment anything reformats, whereas semantic node identifiers do not. English is a lossy encoding of compiler state — the compiler knew the exact repair and discarded that structure to print a sentence. Feedback latency bounds the loop.
A fourth motive is arguably strongest and less often stated: capability containment. For code an agent wrote and no human read line by line, a compiler that refuses to build a program using a capability the target does not declare is a real safety property.
We record the counterarguments in the same breath. Structured diagnostics are not new: rustc and TypeScript have emitted machine-readable errors for years. And the adoption argument cuts hard against it — models write best in the languages that dominate their training data, so a language with a few thousand GitHub stars is one agents are, today, measurably worse at than Go or Rust. Zero is betting a tight verifiable loop outruns familiarity. That bet is unproven, and this paper does not settle it.
3. How traditional compilers work
The canonical decomposition treats compilation as a sequence of meaning-preserving translations. Each phase consumes one representation and produces the next.
The analysis half — phases one to three, the front end — determines what the program means. Lexical analysis groups characters into tokens. Syntax analysis arranges tokens into a tree reflecting the grammar. Semantic analysis annotates that tree with types, resolves every name to a declaration, and rejects programs that are well-formed but meaningless.
The synthesis half — phases four to six, the back end — determines how the program runs: an intermediate representation, optimization over it, then instruction selection and object emission.
Two activities refuse to sit in any single box. The symbol table is written by the front end and read by every later phase. Error detection occurs in all six, and the phase that detects an error largely determines how good the message can be. That last point is the one we test in §6.4.
Phases 1–3 · at the ingestion gate
- Lexical analysisparselocus: ingestion
- Syntax analysisparselocus: ingestion
- Semantic analysisresolveinterfacechecklocus: both
These three run when text enters the graph and are not re-run to produce a build. Phase 3 is the boundary case: it is admitted here and then re-consulted on the compile path from stored symbol, type and scope tables rather than rebuilt.
Phases 4–6 · on the compile path
- Intermediate code generationlowerlocus: compile-path
- Code optimizationlowercodegenlocus: compile-path
- Target code generationcodegenobjectlinklocus: compile-path
These three are what a build actually costs. They read a graph whose names are already bound and whose types are already recorded, which is why the compile path can begin at lowering.
Symbol table management
Classical
A data structure rebuilt by the front end on every compilation.
As Zero realises it
A persisted set of graph tables (schema, package, module, declaration, scope, import, symbol, type, effect, capability, ownership, resource, node, edge, projection, sourceMap) carried between runs in zero.graph and addressable by stable node handles.
Error detection and reporting
Classical
Diagnostics rendered as prose for a human reader.
As Zero realises it
Structured records with a stable code, a span, expected/actual facts, a fixSafety rating and an optional typed repair id — designed to be consumed by a program, not parsed from English.
The classical six-phase stack, drawn once. The split is not a redrawing of the phases but a statement about cadence: phases 1–3 are paid when an edit is admitted, phases 4–6 when an artifact is requested. The two panels are the cross-cutting concerns the textbook draws as vertical bars beside the stack; in Zero both are persisted and directly inspectable, which is why the split above is observable rather than asserted. Phase numbering, input/output pairs, Zero phase names and locus values are read from lib/phases.ts. On a narrow screen the two bars move underneath the stack rather than beside it.
4. How Zero's compiler differs
Zero's own compile-path documentation[5] states the contrast directly. A conventional compiler runs source files through a lexer and parser to an AST, then name resolution, type checking, IR lowering, optimization and codegen. Zero starts from the graph store.
- zero.graph
- graph tables
- validate
- type check
- MIR
- codegen
- artifact
Stages in order: zero.graph → repository graph tables → semantic validation → type checking → MIR and backend facts → direct codegen → artifact. There is no lexer, parser or name-resolution stage here — those ran once, at the ingestion gate, when text entered the graph. Figure 2 is the stage-by-stage comparison. Below 560px the ribbon becomes a vertical stack; the order is unchanged.
Traditional parse-first path
Zero graph-first path
Both tracks read top to bottom. Slots are aligned, so a dashed box marks a stage one path has and the other does not: three traditional stages leave the Zero build path entirely, one more (optimization) is folded into two others, and two Zero stages have no traditional counterpart. Stage labels are the documented names, unabbreviated.
| # | Traditional parse-first stage | Zero graph-first stage |
|---|---|---|
| 1 | source files | zero.graph |
| 2 | lexer/parser | repository graph tables |
| 3 | AST | semantic validation |
| 4 | name resolution | type checking |
| 5 | type checking | MIR and backend facts |
| 6 | IR lowering | direct codegen |
| 7 | optimization | artifact |
| 8 | codegen | — |
| 9 | artifact | — |
The consequential difference is what is missing from the second sequence. There is no lexer, no parser and no separate name-resolution stage on the compile path, because by the time a program is in the graph store its names are already bound and its types already recorded. Those stages still exist — they run at the boundary where text is admitted into the graph.
This is why the compiler reports resolve before parse, an ordering that reads as a typo until you understand the substrate. The eight phases it reports, in its own order:
resolve— Binds names against stored symbol facts.parse— Graph-native; no character scan on the package path.interface— Public symbol surface and import graph fingerprinting.check— Types, effects, ownership, capability requirements.lower— Graph HIR to MIR, with contract verification before emission.codegen— MIR to target machine code via direct emitters.object— Object-file construction in the target format.link— The only phase the compiler marks non-cacheable.
And the two cross-cutting concerns become first-class inspectable objects:
- Symbol table management. A persisted set of graph tables (schema, package, module, declaration, scope, import, symbol, type, effect, capability, ownership, resource, node, edge, projection, sourceMap) carried between runs in zero.graph and addressable by stable node handles. Probe:
zero query --json --full - Error detection and reporting. Structured records with a stable code, a span, expected/actual facts, a fixSafety rating and an optional typed repair id — designed to be consumed by a program, not parsed from English. Probe:
zero check --json | zero explain --json | zero fix --plan --json
5. Methodology
5.1 Corpus construction
We wrote 8 Zero packages of increasing size and feature coverage, from a six-line hello-world to the 304-line arithmetic tokenizer in p08_lexer/src/lib.0// Token model and byte-level classification for the arithmetic tokenizer.
//
// `TokKind` names the nine kinds the scanner can produce and `Token` pairs a
// kind with the value a numeric literal decoded to. Both are the declared
// model of the tokenizer. zero 0.3.4's direct backend cannot lower an enum
// value through a parameter, local, or return slot (BLD004), so the scanner
// carries the parallel `kind_*` codes below: one code per variant, in the
// same declaration order, so the two stay readable side by side.
pub enum TokKind {
num,
plus,
minus,
star,. Each was developed until zero check, zero test and zero run all succeeded, or until the obstruction was documented as a finding. The corpus totals 680 non-empty lines, 3,411 tokens and 23 passing test blocks. All eight are reproduced in full in §7.
5.2 Error corpus
Measuring where errors are detected requires programs that fail on purpose. We wrote 10 minimal packages, each violating exactly one rule and targeting a specific phase. Each carries a case.json declaring the phase it targets before measurement, so the mapping in §6.4 is a prediction rather than a post-hoc fit.
5.3 Instrumentation
One harness, tools/capture.mjs, drives every measurement and writes a single JSON document. Per program it captures the token stream, parse summary, full semantic report, graph, source mappings, phase timings and artifact measurements, then builds every program for every advertised target. We report phase timings from zero time rather than zero check, because the former drives the pipeline through emission; and wall-clock separately, as median of five runs, because ~40 ms of process startup dominates at this scale.
5.4 Reproducibility
Every number derives from one machine-generated dataset. No figure is transcribed by hand; the prose reads the same JSON the charts do. Appendix A gives the commands. The environment was an 13th Gen Intel(R) Core(TM) i5-13450HX with 16 logical cores and 31.7 GB of memory, running win32/x64.
6. Results
6.1 Mapping the classical phases onto Zero
Zero reports eight phases; the classical model names six. They do not correspond one-to-one, and the mismatches are informative.
| # | Classical phase | Input → output | Zero phases | Probe | Locus |
|---|---|---|---|---|---|
| 1 | Lexical analysis | Character stream → Token stream | parse | zero tokens --json | ingestion |
| Divergence — Runs only when text enters the system. Package compilation reads a binary graph store and never re-scans characters, so the scanner is absent from the steady-state path. | |||||
| 2 | Syntax analysis | Token stream → Parse tree / AST | parse | zero parse --json | ingestion |
| Divergence — The tree is not the compiler’s working representation. Parsing exists to admit text into the graph; once admitted, structure is stored, not re-derived. | |||||
| 3 | Semantic analysis | AST + symbol table → Annotated AST, type facts | resolveinterfacecheck | zero check --json | both |
| Divergence — Split across three reported phases and persisted as typed graph facts. The symbol table is not rebuilt per compile — it is the stored `symbol`, `type` and `scope` tables. | |||||
| 4 | Intermediate code generation | Annotated AST → Intermediate representation | lower | zero size --json (loweredIrBytes) | compile-path |
| Divergence — Lowering runs graph HIR to MIR directly, and MIR contracts are verified before emission. The IR is never printed: --emit llvm-ir fails with BLD004 on every target, because the direct backend has no LLVM path. The only observable is its size in bytes. | |||||
| 5 | Code optimization | Intermediate representation → Improved IR | lowercodegen | zero build --profile release-small | tiny | compile-path |
| Divergence — Not a separately reported phase. Optimization is selected by build profile rather than exposed as a pass pipeline, so its cost is folded into lower and codegen. | |||||
| 6 | Target code generation | Optimized IR → Machine code / object | codegenobjectlink | zero build --emit exe && zero size --json | compile-path |
| Divergence — Three reported phases, not one. Direct per-format emitters replace a C bridge, and only `link` is marked non-cacheable. | |||||
Semantic analysis fragments into three reported phases because interface fingerprinting is separated out to drive incremental invalidation. Optimization has no reported phase: it is selected by build profile. And the IR is never printed — --emit llvm-ir fails with BLD004 on every target, leaving the lowered module's size as the only observable.
6.2 Where compile time actually goes
Across all 8 programs, 100% of reported phase milliseconds are spent in lower. Every other phase reports 0 ms.
- 1. Lexical analysis0 ms
- 2. Syntax analysis0 ms
- 3. Semantic analysis0 ms
- 4. Intermediate code generation72 ms
- 5. Code optimization—
- 6. Target code generation0 ms
| # | Classical phase | Reported as | ms | Share | State |
|---|---|---|---|---|---|
| 1 | Lexical analysis | parse | 0 | 0% | Under 1 ms |
| 2 | Syntax analysis | parse | 0 | 0% | Under 1 ms |
| 3 | Semantic analysis | resolve, interface, check | 0 | 0% | Under 1 ms |
| 4 | Intermediate code generation | lower | 72 | 100% | Measured |
| 5 | Code optimization | — | — | — | Not reported |
| 6 | Target code generation | codegen, object, link | 0 | 0% | Under 1 ms |
| Total reported | 72 | 100% | — | ||
Where the milliseconds go
Pick a program, smallest to largest. The left panel is every phase time the compiler reported for it; the right panel is the size of the IR that lowering produced, placed against the whole corpus.
Program, by non-empty source lines
Highlighted, drawn larger and ringed: p01_hello at 6 non-empty lines and 2,185 bytes of lowered IR.
| # | Phase | Reported | Share | Cacheable |
|---|---|---|---|---|
| 1 | resolve | 0 ms | 0% | yes |
| 2 | parse | 0 ms | 0% | yes |
| 3 | interface | 0 ms | 0% | yes |
| 4 | check | 0 ms | 0% | yes |
| 5 | lower | 6 ms | 100% | yes |
| 6 | codegen | 0 ms | 0% | yes |
| 7 | object | 0 ms | 0% | yes |
| 8 | link | 0 ms | 0% | no |
| All phases | 6 ms | 100% | — | |
| Program | Non-empty lines | Lowered IR bytes | Bytes per line | lower |
|---|---|---|---|---|
| p01_hello | 6 | 2,185 | 364 | 6 ms |
| p02_arith | 31 | 5,994 | 193 | 8 ms |
| p07_generics | 45 | 6,573 | 146 | 5 ms |
| p03_control | 54 | 10,340 | 191 | 7 ms |
| p05_errors | 54 | 8,731 | 162 | 6 ms |
| p04_shapes | 85 | 9,499 | 112 | 5 ms |
| p06_memory | 101 | 25,416 | 252 | 15 ms |
| p08_lexer | 304 | 52,110 | 171 | 20 ms |
Read the two panels together: the front end costs nothing measurable because it reads facts already stored in zero.graph rather than re-deriving them from text, and lower is the only phase whose time, and whose output, grows with the size of the program.
lower. Every other phase reports 0 ms.This is the expected consequence of the architecture. In a text-first compiler the front end reconstructs meaning from characters on every invocation. In Zero it reads a store where names are already bound, so it does almost no work. What remains expensive is the one phase that cannot be cached away.
The honest caveat is resolution: the compiler reports integer milliseconds, so "0 ms" means "under one millisecond", not "free".
lower at 20 ms.6.3 The symbol table, persisted
In the classical model the symbol table is a structure the front end builds and discards. In Zero it is sixteen persisted relations, carried between invocations and reported as row counts on every compile.
| Program | Lines | Bytes | Tokens | Nodes | Edges | Decls | Symbols | Types | Typed nodes | Calls | Artifact bytes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| p01_hello | 8 | 140 | 41 | 13 | 12 | 2 | 3 | 5 | 5 | 1 | 1,536 |
| p02_arith | 40 | 781 | 219 | 125 | 123 | 15 | 16 | 29 | 29 | 19 | 2,048 |
| p03_control | 62 | 1,624 | 297 | 179 | 177 | 16 | 17 | 31 | 31 | 21 | 2,048 |
| p04_shapes | 102 | 2,478 | 471 | 231 | 229 | 33 | 34 | 55 | 55 | 24 | 2,048 |
| p05_errors | 66 | 1,984 | 292 | 150 | 148 | 20 | 21 | 35 | 35 | 19 | — |
| p06_memory | 123 | 3,518 | 786 | 413 | 411 | 49 | 47 | 96 | 96 | 45 | 3,584 |
| p07_generics | 56 | 1,298 | 277 | 131 | 129 | 20 | 21 | 37 | 37 | 20 | 1,536 |
| p08_lexer | 335 | 10,390 | 1,804 | 974 | 972 | 108 | 109 | 176 | 176 | 106 | 5,632 |
Two structural invariants hold across the corpus. The sourceMap row count equals the node count exactly for every program — every graph node retains a source position. And the edge count is exactly two fewer than the node count, consistent with a spanning structure over the module and package roots.
6.4 Error handling: three admission gates, not one
This is the paper's central empirical result. We expected the error corpus to partition by phase within a single compile. Instead it partitions by gate, and there are three.
| # | Valve | In | Admitted | Rejected | Codes |
|---|---|---|---|---|---|
| 1 | zero import | 10 | 3 | 7 | PAR100 ×2, ERR003 ×1, MEM003 ×1, NAM003 ×1, TYP002 ×1, TYP009 ×1 |
| 2 | zero check --target | 3 | 2 | 1 | TAR002 ×1 |
| 3 | zero build | 2 | 0 | 2 | BLD004 ×2 |
| Reached an artifact | 0 | — | — | ||
10 deliberately malformed programs enter at the left. Each gate admits what it cannot fault and turns the rest away downward.
zero import
Front end
- lexical
- syntax
- name resolution
- type
- mutability
- effect
- memory
- 10
- 7
- 3
- PAR100×2
- ERR003×1
- MEM003×1
- NAM003×1
- TYP002×1
- TYP009×1
zero check --target
Target capability
- target capability
- 3
- 1
- 2
- TAR002×1
zero build
MIR lowering
- MIR lowering
- 2
- 2
- 0
- BLD004×2
Counts and codes are derived at render time from capture.errorCases: a case is attributed to the gate its rejectedAt field names, and the codes are the diagnostics that gate itself emitted. 0 of 10 malformed programs reach an artifact. On a narrow screen the gates stack, so the left-to-right flow becomes top to bottom; the rejected branch stays directly beneath its gate either way.
| # | Gate | Checks | Entered | Rejected | Continued | Codes rejected with |
|---|---|---|---|---|---|---|
| 1 | zero import | lexical, syntax, name resolution, type, mutability, effect, memory | 10 | 7 | 3 | PAR100 ×2, ERR003 ×1, MEM003 ×1, NAM003 ×1, TYP002 ×1, TYP009 ×1 |
| 2 | zero check --target | target capability | 3 | 1 | 2 | TAR002 ×1 |
| 3 | zero build | MIR lowering | 2 | 2 | 0 | BLD004 ×2 |
| Reached an artifact | — | 0 | — | |||
| Case | Targets phase | Import | Check | Build | Rejected at | Codes |
|---|---|---|---|---|---|---|
| e01_lexical | lexical | rejected | ok | — | import | PAR100 |
| e02_syntax | syntax | rejected | ok | — | import | PAR100 |
| e03_name | resolve | rejected | ok | — | import | NAM003 |
| e04_type | check | rejected | ok | — | import | TYP002 |
| e05_mutability | check | rejected | ok | — | import | TYP009 |
| e06_effect | check | rejected | ok | — | import | ERR003 |
| e07_memory | check | rejected | ok | — | import | MEM003 |
| e08_target | target | ok | rejected | rejected | check | TAR002 |
| e09_lowering | lower | ok | ok | rejected | build | BLD004 |
| e10_match | — | ok | ok | rejected | build | BLD004 |
The 7 front-end failures were all refused at zero import. Critically, zero check subsequently reported ok for all of them, because the malformed program never entered the store — after each rejection zero view --fn main still projected the previous program. The capability violation passed ingestion and was refused by zero check --target. And 2 cases passed both and were refused only at zero build.
Which gate stopped it, and what it said
Pick one of the 10 deliberately malformed programs. Zero offers three places to refuse it — admitting text into the graph, checking the stored graph, and building an artifact — and each panel below reports what that gate actually returned for this case.
Error case
e01_lexical
A byte the scanner cannot classify into any token kind.
Source as submitted
pub fn main(world: World) -> Void raises {
let bad: i32 = 4 § 2
check world.out.write("unreachable\n")
}
Graph store after the attempt — storeContaminated: false
The src/main.0 that zero.graph held once the attempt finished. Where a case is rejected at import, the malformed text never reaches the store, so this is the previous good program.
pub fn main(world: World) -> Void raises {
if greeting_code() == 42 {
check world.out.write("hello from zero\n")
}
}Three gates, in order
1zero import
rejectedAdmits text into the graph. Scanning, parsing, name binding, type, effect and frame-budget checking all run here, before anything is written to zero.graph.
This is the gate that turned the program away.
unexpected token in expression
- Location
- e01_lexical/src/main.0:2:22
- Expected
- expression
- Actual
- Â
- Help
- use canonical .0 text source
- Fix safety
- requires-human-review
- Repair
- repair-syntax — Repair the syntax at the reported parser span, then rerun zero check.
2zero check
passedRe-checks the stored graph and reports target readiness alongside a top-level verdict.
Ran after the earlier rejection, so it judged the graph as it then stood, not the submitted text.
ok: truetargetReadiness.ok: truetargetReadiness.buildable: truetargetReadiness.stage: readyNo diagnostic recorded at this gate.
3zero build
not reachedLowers the checked graph to MIR and emits an artifact for the selected target.
No diagnostic recorded at this gate.
| Gate | Command | Field | Value | Outcome | Diagnostics |
|---|---|---|---|---|---|
| 1 | zero import | importOk | false | rejected — rejected here | 1 |
| 2 | zero check | checkOk | true | passed | 0 |
| 3 | zero build | buildOk | — | not reached | 0 |
Each diagnostic above is the compiler’s own record, not a rendering of its prose: a stable code, a span, an expected and an actual fact, a fix-safety rating and, where the compiler has one, a named repair. That is what makes a rejection something a program can act on rather than a paragraph a human must read.
6.5 The front end and the back end accept different languages
We built every corpus program for every advertised target: 64 combinations. 47 succeeded (73%). Every one of the 17 failures was BLD004 — the back end declining to lower a construct semantic analysis had accepted.
darwin-arm64 emits exactly 16,632 bytes for all 8 programs it builds — from 6 non-empty lines to 304. The two blocks at left are drawn to scale and are the same size because the artifacts are the same size, to the byte. Program content does not reach the output size on this target at all; macho padding does. The same holds on darwin-x64 at 16,636 bytes for all 7 programs.
A weaker form of the same effect on win32-x64.exe, win32-arm64.exe: every artifact size there is a whole multiple of 512 bytes, so size moves in steps rather than with the program. The distinct-size column in the table below separates the three behaviours — one size for the whole corpus, a few 512-byte steps, and a size per program.
| Target | Format | p01_hello (B) | Min (B) | Max (B) | Distinct sizes | Built |
|---|---|---|---|---|---|---|
| darwin-arm64 | macho | 16,632 | 16,632 | 16,632 | 1 | 8 / 8 |
| darwin-x64 | macho | 16,636 | 16,636 | 16,636 | 1 | 7 / 8 |
| linux-musl-x64 | elf | 352 | 352 | 4,237 | 7 | 8 / 8 |
| linux-musl-arm64 | elf | 312 | 312 | 867 | 3 | 3 / 8 |
| linux-x64 | elf | 352 | 352 | 4,237 | 7 | 8 / 8 |
| linux-arm64 | elf | 312 | 312 | 867 | 3 | 3 / 8 |
| win32-x64.exe | coff | 1,536 | 1,536 | 5,632 | 4 | 7 / 8 |
| win32-arm64.exe | coff | 1,536 | 1,536 | 2,048 | 2 | 3 / 8 |
| Program | darwin-arm64 | darwin-x64 | linux-musl-x64 | linux-musl-arm64 | linux-x64 | linux-arm64 | win32-x64.exe | win32-arm64.exe |
|---|---|---|---|---|---|---|---|---|
| p01_hello | 16,632 | 16,636 | 352 | 312 | 352 | 312 | 1,536 | 1,536 |
| p02_arith | 16,632 | 16,636 | 633 | 649 | 633 | 649 | 2,048 | 2,048 |
| p03_control | 16,632 | 16,636 | 795 | 867 | 795 | 867 | 2,048 | 2,048 |
| p04_shapes | 16,632 | 16,636 | 826 | BLD004 | 826 | BLD004 | 2,048 | BLD004 |
| p05_errors | 16,632 | BLD004 | 826 | BLD004 | 826 | BLD004 | BLD004 | BLD004 |
| p06_memory | 16,632 | 16,636 | 2,439 | BLD004 | 2,439 | BLD004 | 3,584 | BLD004 |
| p07_generics | 16,632 | 16,636 | 588 | BLD004 | 588 | BLD004 | 1,536 | BLD004 |
| p08_lexer | 16,632 | 16,636 | 4,237 | BLD004 | 4,237 | BLD004 | 5,632 | BLD004 |
| Built | 8/8 | 7/8 | 8/8 | 3/8 | 8/8 | 3/8 | 7/8 | 3/8 |
- BLD004 · record — 9 builds
- BLD004 · IR_VALUE_CHECK — 4 builds
- BLD004 · unsupported instruction — 3 builds
- BLD004 · IR_VALUE_RESCUE — 1 build
The failures name specific constructs the MIR subset cannot represent: aggregate values crossing a function boundary, check and rescue on user-defined fallible functions, and instructions absent from an architecture's emitter.
First, backend completeness is target-specific. The p05_errors/src/main.0use lib
// A fallible function with a closed error set: Odd is the only failure it can
// report. Even inputs return normally, odd inputs raise.
fn even_or_raise(i: i32) -> i32 raises [Odd] {
if is_even(i) {
return i
}
raise Odd
}
// Propagating caller. `check` forwards Odd into this function's own error set,
// so doubling stays fallible for exactly the same reason its callee is.
fn double_even(i: i32) -> i32 raises [Odd] { program fails to build on the Windows host but cross-compiles to an 826-byte ELF for linux-musl-x64. Same graph, same front end, two answers.
Second, a passing zero check does not imply a buildable program — though the compiler is not ignorant. It records targetReadiness.buildable: false, stage: "lower" and a BLD004 naming the construct. The difficulty is that the same document carries ok: true with an empty top-level diagnostics array. A consumer testing the field the schema presents as the verdict gets the wrong answer.
| Case | Construct | ok | diagnostics | buildable | stage | Actual build |
|---|---|---|---|---|---|---|
| e09_lowering | Outcome | true | 0 | false | lower | BLD004 |
| e10_match | Match | true | 0 | false | lower | BLD004 |
| Target | OS | Arch | Object format | libc | args | env | fs | memory | net | proc | rand | stdio | time | Declared |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| darwin-arm64 | macos | aarch64 | macho | default | yes | yes | yes | yes | yes | yes | yes | yes | yes | 9 |
| darwin-x64 | macos | x86_64 | macho | default | — | — | yes | yes | — | — | yes | yes | yes | 5 |
| linux-musl-x64 | linux | x86_64 | elf | musl | yes | yes | yes | yes | — | — | yes | yes | yes | 7 |
| linux-musl-arm64 | linux | aarch64 | elf | musl | — | — | — | yes | — | — | yes | yes | yes | 4 |
| linux-x64 | linux | x86_64 | elf | gnu | — | — | — | yes | — | — | yes | yes | yes | 4 |
| linux-arm64 | linux | aarch64 | elf | gnu | — | — | — | yes | — | — | yes | yes | yes | 4 |
| win32-x64.exe | windows | x86_64 | coff | msvc | yes | yes | yes | yes | yes | yes | yes | yes | yes | 9 |
| win32-arm64.exe | windows | aarch64 | coff | msvc | — | — | — | yes | — | — | yes | yes | yes | 4 |
7. The corpus, in full
All 8 programs are reproduced below with their measurements, their test results and their real output. Together they are 680 non-empty lines of Zero. Each was written to exercise a distinct set of compiler tables.
p01_hello
- functions 1
- fallible calls 1
- ownership 2
- effects 2
pub fn greeting_code() -> i32 {
return 42
}
pub fn main(world: World) -> Void raises {
check world.out.write("hello from zero\n")
}
0 test(s) ok
note: no test blocks found; validated 1 function in 1 module (name, semantic, and capability contracts)
tip: add a test block in source, or zero patch --op 'addTest name="..." call="<fn>" arg0="..." expect="..." type="<type>"'hello from zerop01_hello
pub fn greeting_code() -> i32 {
return 42
}
pub fn main(world: World) -> Void raises {
check world.out.write("hello from zero\n")
}
p02_arith
// Shared constant used as the expected value of the polynomial at x = 2.
pub fn scale() -> i32 {
return 17
}
use lib
fn add(left: i32, right: i32) -> i32 {
return left + right
}
fn mul(left: i32, right: i32) -> i32 {
return left * right
}
// Evaluates x*x + 3*x + 7 using only the two pure helpers above.
fn poly(x: i32) -> i32 {
return add(mul(x, x), add(mul(3, x), 7))
}
pub fn main(world: World) -> Void raises {
if poly(2) == scale() {
check world.out.write("arith ok\n")
}
}
test "poly at two matches the shared scale" {
expect poly(2) == 17
expect poly(2) == scale()
}
test "add sums both operands" {
expect add(40, 2) == 42
}
test "mul feeds nested calls" {
expect mul(3, 2) == 6
expect add(mul(2, 2), 13) == 17
}
p03_control
// Maximum number of iterations `count_down` is allowed to spend before its
// break guard trips. Shared so main and the tests agree on one number.
pub fn limit() -> i32 {
return 8
}
use lib
// Sums the odd integers in 1..=n. Even candidates are skipped with `continue`
// rather than nested inside an else branch, so the loop body stays flat.
fn sum_odds(n: i32) -> i32 {
var i: i32 = 0
var total: i32 = 0
while i < n {
i = i + 1
if i % 2 == 0 {
continue
}
total = total + i
}
return total
}
// Steps `start` down toward zero one unit at a time, but never runs more than
// `limit()` iterations: the guard breaks out early and reports whatever is
// left, so a large input cannot spin the loop indefinitely.
fn count_down(start: i32) -> i32 {
var remaining: i32 = start
var steps: i32 = 0
while remaining > 0 {
if steps == limit() {
break
}
remaining = remaining - 1
steps = steps + 1
}
return remaining
}
pub fn main(world: World) -> Void raises {
let leftover: i32 = count_down(limit() + 4)
if sum_odds(10) == 25 && leftover == 4 {
check world.out.write("control ok\n")
}
}
test "sum_odds skips the even candidates" {
expect sum_odds(10) == 25
expect sum_odds(1) == 1
expect sum_odds(0) == 0
}
test "count_down reaches zero inside the step budget" {
expect count_down(3) == 0
expect count_down(limit()) == 0
}
test "count_down stops early once the break guard trips" {
expect count_down(limit() + 4) == 4
expect count_down(20) == 12
}
p04_shapes
// Scalar helpers shared by the shape, enum, and choice code in src/main.0.
pub fn abs_i32(value: i32) -> i32 {
if value < 0 {
return 0 - value
}
return value
}
// Score carried out of the Outcome.ok arm: a walk that fit its budget.
pub fn reached(distance: i32) -> i32 {
return abs_i32(distance)
}
// Score carried out of the Outcome.err arm: an overrun charged as a penalty.
pub fn penalty(overrun: i32) -> i32 {
return 0 - abs_i32(overrun)
}
// A grid walk scored against a per-mode step budget.
//
// Zero 0.3.4 checks shapes, enums, choices, and exhaustive `match` in the
// frontend, but its direct backend lowers only the functions reachable from
// `main`, and that MIR subset has no `Match` statement and no enum or choice
// parameters (BLD004). `budget` and `settle` are therefore proved by
// `zero check`, while `main` reproduces the same per-arm scores through the
// scalar helpers in src/lib.0 that each arm returns.
use lib
const fast_budget: i32 = 64
const small_budget: i32 = 8
type Point {
x: i32,
y: i32,
}
enum Mode {
fast,
small,
}
choice Outcome {
ok: i32,
err: i32,
}
// A shape crosses a function boundary as a read-only borrow.
fn manhattan(p: ref<Point>) -> i32 {
return abs_i32(p.x) + abs_i32(p.y)
}
// Exhaustive match over the enum: one step budget per mode.
fn budget(mode: Mode) -> i32 {
match mode {
.fast {
return fast_budget
}
.small {
return small_budget
}
}
}
// Exhaustive match over the choice: one score per arm.
fn settle(outcome: Outcome) -> i32 {
match outcome {
.ok(walked) {
return reached(walked)
}
.err(overrun) {
return penalty(overrun)
}
}
}
pub fn main(world: World) -> Void raises {
let near: Point = Point { x: 3, y: -4 }
let far: Point = Point { x: 40, y: 40 }
let near_score: i32 = reached(manhattan(&near))
let far_score: i32 = penalty(manhattan(&far) - small_budget)
if near_score == 7 && far_score == -72 {
check world.out.write("shapes ok\n")
}
}
test "the ok arm keeps the distance that fit the budget" {
expect reached(7) == 7
expect reached(0) == 0
}
test "the err arm charges the overrun as a penalty" {
expect penalty(72) == -72
expect penalty(1) == -1
}
test "manhattan adds absolute offsets" {
expect abs_i32(-4) == 4
expect abs_i32(3) + abs_i32(-4) == 7
}
p05_errors
// Total parity guard. It declares no error set, so the fallible function in
// src/main.0 can branch on it without `check` or `rescue`.
pub fn is_even(i: i32) -> Bool {
return i % 2 == 0
}
// The value an Odd failure is folded onto. Exporting it keeps the rescue
// fallback and every assertion that reads it in one place.
pub fn odd_fallback() -> i32 {
return 0
}
use lib
// A fallible function with a closed error set: Odd is the only failure it can
// report. Even inputs return normally, odd inputs raise.
fn even_or_raise(i: i32) -> i32 raises [Odd] {
if is_even(i) {
return i
}
raise Odd
}
// Propagating caller. `check` forwards Odd into this function's own error set,
// so doubling stays fallible for exactly the same reason its callee is.
fn double_even(i: i32) -> i32 raises [Odd] {
let value: i32 = check even_or_raise(i)
return value + value
}
// Total mirror of "call even_or_raise, then rescue Odd". The graph test runner
// evaluates only the total subset of the language, so the tests below assert
// the recovery contract through this function instead of rescuing directly.
fn even_or_fallback(i: i32) -> i32 {
if is_even(i) {
return i
}
return odd_fallback()
}
pub fn main(world: World) -> Void raises {
// Rescued path: 7 is odd, so Odd is absorbed and the fallback stands in.
let recovered: i32 = rescue even_or_raise(7) err 0
// Checked path: 20 is even, so the value flows out through both frames.
let doubled: i32 = check double_even(20)
if recovered == even_or_fallback(7) && doubled == 40 {
check world.out.write("errors ok\n")
}
}
test "the parity guard decides which inputs raise" {
expect is_even(20)
expect !is_even(7)
}
test "an odd input lands on the shared fallback" {
expect even_or_fallback(7) == odd_fallback()
expect even_or_fallback(7) == 0
}
test "an even input passes through unchanged" {
expect even_or_fallback(20) == 20
}
p06_memory
use std.mem
// A small shape that callers keep in their own frame and lend out by reference.
pub type Vec2 {
x: i32,
y: i32,
}
// The per-axis steps bump applies, factored out so they can be pinned directly.
pub fn step_x(x: i32) -> i32 {
return x + 1
}
pub fn step_y(y: i32) -> i32 {
return y + 2
}
// Mutable borrow: the callee writes through the reference into the caller's value.
pub fn bump(point: mutref<Vec2>) -> Void {
point.x = step_x(point.x)
point.y = step_y(point.y)
}
// Read-only borrow: fields may be read, never written.
pub fn manhattan(point: ref<Vec2>) -> i32 {
return point.x + point.y
}
// One accumulation step; the span loop below applies it once per element.
pub fn accumulate(total: u32, byte: u32) -> u32 {
return total + byte
}
// Read-only view: length and elements are readable, ownership stays with the caller.
pub fn checksum(bytes: Span<u8>) -> u32 {
var total: u32 = 0
var i: usize = 0
while i < std.mem.len(bytes) {
total = accumulate(total, bytes[i] as u32)
i = i + 1
}
return total
}
// The guard that keeps a narrowed view inside its backing storage.
pub fn fits(len: usize, capacity: usize) -> Bool {
return len <= capacity
}
// Narrowing a view yields another view over the same bytes, never a copy.
pub fn head_checksum(bytes: Span<u8>, len: usize) -> u32 {
if fits(len, std.mem.len(bytes)) == false {
return 0
}
return checksum(std.mem.prefix(bytes, len))
}
// Indexing traps on an out-of-range index, so the guard runs before the read.
pub fn byte_at(bytes: Span<u8>, index: usize) -> u8 {
if fits(index + 1, std.mem.len(bytes)) == false {
return 0
}
return bytes[index]
}
use lib
use std.mem
use std.testing
pub fn main(world: World) -> Void raises {
// Fixed-size storage owned by this frame.
var storage: [4]u8 = [1, 2, 3, 4]
// A writable view borrows that storage; std.mem.copy writes through the view.
let writable: MutSpan<u8> = storage
let copied: usize = std.mem.copy(writable, std.mem.span("zero"))
// While the view is live, element writes go through it rather than around it.
writable[0] = 9
// A read-only view over the very same bytes, read three ways.
let view: Span<u8> = storage
let first: u8 = view[0]
let last: u8 = byte_at(view, 3)
let head: u32 = head_checksum(view, 2)
let total: u32 = checksum(view)
// A shape mutated through a mutable borrow, then read through a shared one.
var origin: Vec2 = Vec2 { x: 1, y: 2 }
bump(&mut origin)
let sum: i32 = manhattan(&origin)
let view_ok: Bool = copied == 4 && std.mem.len(view) == 4 && first == 9 && last == 111
let sums_ok: Bool = head == 110_u32 && total == 335_u32
if view_ok && sums_ok && sum == 6 {
check world.out.write("memory ok\n")
}
}
test "bump applies one step per axis" {
expect step_x(10) == 11
expect step_y(20) == 22
expect step_x(step_y(0)) == 3
}
test "checksum accumulates one byte at a time" {
expect accumulate(0, 9) == 9_u32
expect accumulate(accumulate(0, 9), 101) == 110_u32
expect accumulate(110_u32, 114) == 224_u32
}
test "a narrowed view must fit its backing storage" {
expect fits(2, 4)
expect fits(4, 4)
expect fits(5, 4) == false
}
test "byte views compare by content" {
expect std.mem.eql("zero", "zero")
expect std.testing.equalBytes("zero", "zero")
expect std.testing.containsBytes("zerolang", "lang")
}
p07_generics
// One generic container definition, reused at every concrete element type.
pub type Box<T: Type> {
value: T,
}
// Identity: a function that is generic over its entire signature.
pub fn id<T: Type>(value: T) -> T {
return value
}
// The element values the program boxes, kept here so main and the
// test blocks agree on what each instantiation should hold.
pub fn seed_count() -> i32 {
return 7
}
pub fn seed_flag() -> Bool {
return true
}
use lib
// Box<i32>: the container instantiated at a numeric element type.
fn boxed_count() -> i32 {
let counter: Box<i32> = Box { value: seed_count() }
let count: i32 = counter.value
return id(count)
}
// Box<Bool>: the same container instantiated at a boolean element type.
fn boxed_flag() -> Bool {
let toggle: Box<Bool> = Box { value: seed_flag() }
let flag: Bool = toggle.value
return id(flag)
}
pub fn main(world: World) -> Void raises {
if boxed_count() == 7 && boxed_flag() {
check world.out.write("generics ok\n")
}
}
test "id instantiated at i32" {
expect id(seed_count()) == 7
expect id(11) == 11
}
test "id instantiated at Bool" {
expect id(seed_flag())
expect id(true)
}
test "both boxes carry the seeded elements" {
expect seed_count() == 7 && seed_flag()
}
p08_lexer
// Token model and byte-level classification for the arithmetic tokenizer.
//
// `TokKind` names the nine kinds the scanner can produce and `Token` pairs a
// kind with the value a numeric literal decoded to. Both are the declared
// model of the tokenizer. zero 0.3.4's direct backend cannot lower an enum
// value through a parameter, local, or return slot (BLD004), so the scanner
// carries the parallel `kind_*` codes below: one code per variant, in the
// same declaration order, so the two stay readable side by side.
pub enum TokKind {
num,
plus,
minus,
star,
slash,
lparen,
rparen,
end,
bad,
}
pub type Token {
kind: TokKind,
value: i64,
}
// Runtime encoding of TokKind, one code per variant.
pub const kind_num: i32 = 0
pub const kind_plus: i32 = 1
pub const kind_minus: i32 = 2
pub const kind_star: i32 = 3
pub const kind_slash: i32 = 4
pub const kind_lparen: i32 = 5
pub const kind_rparen: i32 = 6
pub const kind_end: i32 = 7
pub const kind_bad: i32 = 8
// ASCII bytes the scanner recognises, named so the classifier below reads as
// prose instead of as a table of magic numbers.
pub const byte_tab: u8 = 9
pub const byte_newline: u8 = 10
pub const byte_return: u8 = 13
pub const byte_space: u8 = 32
pub const byte_lparen: u8 = 40
pub const byte_rparen: u8 = 41
pub const byte_star: u8 = 42
pub const byte_plus: u8 = 43
pub const byte_minus: u8 = 45
pub const byte_slash: u8 = 47
pub const byte_zero: u8 = 48
pub const byte_nine: u8 = 57
// Hard ceiling on scanner iterations for one input. Every scan loop is
// already bounded by the span length; this second bound makes the budget
// explicit at the top of the file rather than implicit in each loop.
pub const scan_budget: usize = 4096
// True for the ten decimal digit bytes and nothing else.
pub fn is_digit(byte: u8) -> Bool {
return byte >= byte_zero && byte <= byte_nine
}
// True for the separators the scanner drops between tokens.
pub fn is_space(byte: u8) -> Bool {
if byte == byte_space || byte == byte_tab {
return true
}
return byte == byte_newline || byte == byte_return
}
// Numeric value of one digit byte. Non-digits answer 0, so callers that
// already guarded with `is_digit` read cleanly and stray bytes stay harmless.
pub fn digit_value(byte: u8) -> u8 {
if is_digit(byte) {
return byte - byte_zero
}
return 0
}
// Kind of a single byte. Digits open a number, the six single-byte operators
// and parentheses map to their own kinds, and everything else is `bad`.
// Whitespace also answers `bad`: it is not a token, and the scanner drops it
// with `is_space` before it ever reaches this function.
pub fn classify(byte: u8) -> i32 {
if is_digit(byte) {
return kind_num
}
if byte == byte_plus {
return kind_plus
}
if byte == byte_minus {
return kind_minus
}
if byte == byte_star {
return kind_star
}
if byte == byte_slash {
return kind_slash
}
if byte == byte_lparen {
return kind_lparen
}
if byte == byte_rparen {
return kind_rparen
}
return kind_bad
}
// True for the four binary operator kinds.
pub fn is_operator(kind: i32) -> Bool {
if kind == kind_plus || kind == kind_minus {
return true
}
return kind == kind_star || kind == kind_slash
}
// True for the two grouping kinds.
pub fn is_paren(kind: i32) -> Bool {
return kind == kind_lparen || kind == kind_rparen
}
// Folds one more digit into a decimal literal being read left to right.
pub fn accumulate(total: i64, digit: i64) -> i64 {
return total * 10 + digit
}
test "decimal digits open a number token" {
expect is_digit(byte_zero)
expect is_digit(byte_nine)
expect is_digit(53_u8)
expect is_digit(47_u8) == false
expect is_digit(58_u8) == false
expect classify(byte_zero) == kind_num
expect classify(55_u8) == kind_num
}
test "single-byte operators map to their own kinds" {
expect classify(byte_plus) == kind_plus
expect classify(byte_minus) == kind_minus
expect classify(byte_star) == kind_star
expect classify(byte_slash) == kind_slash
expect classify(byte_lparen) == kind_lparen
expect classify(byte_rparen) == kind_rparen
expect is_operator(classify(byte_star))
expect is_paren(classify(byte_rparen))
expect is_operator(classify(byte_lparen)) == false
}
test "multi-digit numbers fold left to right" {
expect digit_value(byte_zero) == 0
expect digit_value(53_u8) == 5
expect digit_value(byte_nine) == 9
expect digit_value(byte_plus) == 0
expect accumulate(0, 1) == 1
expect accumulate(accumulate(0, 1), 2) == 12
expect accumulate(accumulate(accumulate(0, 1), 2), 3) == 123
expect accumulate(accumulate(0, 4), 0) == 40
}
test "unrecognised bytes are bad and spaces are not tokens" {
expect classify(63_u8) == kind_bad
expect classify(64_u8) == kind_bad
expect classify(97_u8) == kind_bad
expect classify(byte_space) == kind_bad
expect is_space(byte_space)
expect is_space(byte_tab)
expect is_space(byte_newline)
expect is_space(byte_plus) == false
expect is_operator(kind_bad) == false
expect is_paren(kind_bad) == false
}
use lib
// The scanner walks a byte span with an explicit cursor. Every loop is
// bounded twice over: by the span length, and by `scan_budget` steps, so no
// input can spin the scanner. Span indexing is bounds-checked and traps at
// runtime, so every `src[index]` below sits under a proven `index < len`
// guard rather than relying on the trap as a control-flow mechanism.
// A buildable companion to `Token`: the same pair of fields, with the kind
// carried as its runtime code so the pair can live in a local.
type Scan {
kind: i32,
total: i64,
}
// Index just past the run of decimal digits starting at `start`. Returns
// `start` unchanged when the byte at `start` is not a digit, so callers can
// use the result as a progress test.
fn number_end(src: Span<u8>, start: usize) -> usize {
let len: usize = std.mem.len(src)
var index: usize = start
while index < len {
if is_digit(src[index]) == false {
break
}
index = index + 1
}
return index
}
// Decimal value of the digit run in `[start, stop)`, folded left to right.
// `stop` is clamped against the span length so a stale bound cannot index
// past the end.
fn number_value(src: Span<u8>, start: usize, stop: usize) -> i64 {
let len: usize = std.mem.len(src)
var index: usize = start
var value: i64 = 0
while index < stop {
if index >= len {
break
}
value = accumulate(value, digit_value(src[index]) as i64)
index = index + 1
}
return value
}
// Kind of the token that starts at `index`, or `kind_end` once the cursor
// has walked past the last byte.
fn kind_at(src: Span<u8>, index: usize) -> i32 {
let len: usize = std.mem.len(src)
if index >= len {
return kind_end
}
return classify(src[index])
}
// Number of tokens in `src`. A run of digits collapses into one `num` token,
// each operator or parenthesis byte is one token, runs of spaces produce
// none, and every unrecognised byte produces exactly one `bad` token.
pub fn token_count(src: Span<u8>) -> usize {
let len: usize = std.mem.len(src)
var index: usize = 0
var count: usize = 0
var steps: usize = 0
while index < len {
if steps >= scan_budget {
break
}
steps = steps + 1
let byte: u8 = src[index]
if is_space(byte) {
index = index + 1
continue
}
if is_digit(byte) {
index = number_end(src, index)
count = count + 1
continue
}
index = index + 1
count = count + 1
}
return count
}
// Total of every numeric literal in `src`. Non-numeric bytes are stepped
// over one at a time; a digit run is consumed whole, so its digits are never
// counted twice and the cursor always advances.
pub fn sum_numbers(src: Span<u8>) -> i64 {
let len: usize = std.mem.len(src)
var index: usize = 0
var total: i64 = 0
var steps: usize = 0
while index < len {
if steps >= scan_budget {
break
}
steps = steps + 1
if is_digit(src[index]) == false {
index = index + 1
continue
}
let stop: usize = number_end(src, index)
total = total + number_value(src, index, stop)
index = stop
}
return total
}
// Number of bytes that classify as `bad`: neither a digit, nor one of the
// six operator and parenthesis bytes, nor a separator.
pub fn bad_count(src: Span<u8>) -> usize {
let len: usize = std.mem.len(src)
var index: usize = 0
var bad: usize = 0
while index < len {
let byte: u8 = src[index]
if is_space(byte) == false && classify(byte) == kind_bad {
bad = bad + 1
}
index = index + 1
}
return bad
}
// Number of operator tokens, parentheses excluded.
pub fn operator_count(src: Span<u8>) -> usize {
let len: usize = std.mem.len(src)
var index: usize = 0
var found: usize = 0
while index < len {
if is_operator(classify(src[index])) {
found = found + 1
}
index = index + 1
}
return found
}
pub fn main(world: World) -> Void raises {
// A well-formed expression: 11 tokens, and 12 + 34 + 5 + 6 + 7 = 64.
let counted: usize = token_count("12 + 34 * (5 - 6) / 7")
let total: i64 = sum_numbers("12 + 34 * (5 - 6) / 7")
let operators: usize = operator_count("12 + 34 * (5 - 6) / 7")
let report: Scan = Scan { kind: kind_at("12 + 34", 0), total: total }
// The cursor past the last byte reports `end`, not a token kind.
let past_end: i32 = kind_at("12 + 34", 7)
// A malformed expression: `?` is one `bad` token beside the two numbers.
let bad: usize = bad_count("1 ? 2")
let bad_tokens: usize = token_count("1 ? 2")
if counted == 11 && total == 64 && operators == 4 {
if report.kind == kind_num && report.total == 64 && past_end == kind_end {
if bad == 1 && bad_tokens == 3 {
check world.out.write("lexer ok\n")
}
}
}
}
7.1 Watching one compile
A replay of the recorded measurements, stepped through phase by phase. This is not a live compile — it is the captured data for the selected program, advanced one reported phase at a time.
Binds names against stored symbol facts.
- 3
- 3
- 4
- 0
zero time --json reported for this package on a cold run; the ninth is the captured run. Nothing compiles in the browser — the values advance at a fixed 450 ms and can be paused, stepped or jumped at any point. Seven of the eight phases report 0 ms; only lower is resolvable at millisecond granularity, which is the measurement the paper builds on.8. Interactive phase explorer
Take one program and watch it become each successive representation. Every panel shows real captured output from the command named in its caption.
One program through six phases
Pick a program and a phase. Each panel shows the artifact Zero 0.3.4 emitted for that program at that phase, together with the command that produced it.
pub fn main(world: World) -> Void raises {
check world.out.write("hello from zero\n")
}
1Lexical analysis
Character stream → Token stream. Carried by parse.
| Kind | src/lib.0 | src/main.0 | Total |
|---|---|---|---|
| word | 5 | 11 | 16 |
| symbol | 5 | 10 | 15 |
| number | 1 | 0 | 1 |
| string | 0 | 1 | 1 |
| newline | 3 | 3 | 6 |
| eof | 1 | 1 | 2 |
| All kinds | 15 | 26 | 41 |
Output of zero tokens --json: the character stream of src/main.0 classified into 6 kinds, counted per file.
The interactive explorer is available in the web edition at zero.khe.money.
9. Playground
Write Zero and see it analysed. Lexing and parsing run in your browser: we reimplemented Zero's scanner and declaration parser in TypeScript, then validated them against the compiler's own zero tokens --json output. Agreement is exact — kind, text, line and column — on all 8 corpus programs, 2,666 tokens in total, and the parser recovers the same function set as zero parse --json on all eight. Those two phases are therefore real, not simulated, and they run on whatever you type.
Phases three to six cannot run here. Zero 0.3.4 ships no WebAssembly target and its own selfHostRouting report marks browserCompiler as removed, so for the corpus programs those phases are replayed from the recorded capture — and the terminal says so explicitly once you edit the buffer away from a captured program.
Editor
Terminal
Lexing and parsing run natively in your browser, from a TypeScript reimplementation validated token-for-token against the real compiler — so zero tokens, zero parse and zero check work on anything you type. Phases three to six are replayed from the recorded capture, because Zero 0.3.4 has no WebAssembly target; those four commands refuse to answer once the buffer differs from the program that was recorded.
The playground is available in the web edition at zero.khe.money.
10. When the reader is a program
Zero is built on the premise that its output will be consumed by a program rather than read by a person. Every reporting command has a --json form: tokens, parse trees, the semantic graph, phase timings, artifact sizes and diagnostics all answer in a schema on request. Diagnostics carry a stable code, a span, expected and actual facts, a fix-safety rating and a typed repair identifier instead of a sentence. The graph can be queried without re-reading a character of source. The implicit promise is efficiency: a machine reader should not have to pay for prose that was shaped for a human.
We measured that promise, and it does not hold. The result is worth stating before the method rather than after it, because it runs the opposite way to the intuition the design invites.
Structured output is far more expensive than prose, not less. Asked whether a program is correct, zero check answers in 4 bytes. zero check --json answers the same question for the same 6-line program in 17,898 bytes, and for the largest program in the corpus in 189,743 bytes — a factor of 17,625× over the corpus as a whole.
10.1 Method: one question, two answer forms
For each of the 8 corpus programs the harness asks the compiler three questions twice — once in the form a person would read, once in the form a program would parse — and records the size of each answer. Show me one function is zero view --fn main against zero query --json --fn main. What does this program call, and is each call checked is the whole source against zero query --json --calls std. Is this program correct is zero check against zero check --json. Byte counts are exact; token figures are estimated at four characters per token and are only ever estimates.
| Program | Lines | Source | view --fn | query --json --fn | check | check --json |
|---|---|---|---|---|---|---|
| p01_hello | 6 | 141 | 95 | 3,286 | 4 | 17,898 |
| p02_arith | 31 | 782 | 128 | 3,924 | 4 | 47,207 |
| p03_control | 54 | 1,625 | 196 | 4,341 | 4 | 46,633 |
| p04_shapes | 85 | 2,479 | 355 | 4,972 | 4 | 59,943 |
| p05_errors | 54 | 1,985 | 262 | 4,475 | 4 | 51,380 |
| p06_memory | 101 | 3,519 | 752 | 7,001 | 4 | 104,221 |
| p07_generics | 45 | 1,299 | 147 | 3,951 | 4 | 46,971 |
| p08_lexer | 304 | 10,391 | 723 | 6,106 | 4 | 189,743 |
| Corpus total | 680 | 22,221 | 2,658 | 38,056 | 32 | 563,996 |
Two effects are tangled together in that table, and separating them is the whole argument. Targeting works. Reading one function through zero view --fn costs 2,658 bytes across the corpus against 22,221 bytes of source — 8.4× cheaper — because the compiler already knows where the function is and a text-first reader does not.
Structuring does not. The same targeted answer requested as data costs 38,056 bytes: 14.3× the text view, and 1.7× the cost of simply reading every line of every program in the corpus. The saving that targeting earns is spent on the encoding, and then some. At the extreme, the verdict on a 6-line program grows from 4 bytes to 17,898.
So the trade a machine-first compiler offers is not tokens for tokens. It is tokens for actionability, and the next table is what the extra tokens buy.
10.2 What the extra bytes carry
For diagnostics the premium is much smaller and the return is much clearer. Across the 7 error cases the ingestion gate refuses, prose costs 2,061 bytes and the structured form costs 4,796 — 2.3× in aggregate, 1.9× to 4.0× case by case. For that the structured form exposes 9 to 10 separately addressable fields and a typed repair identifier on every one.
| Case | Import | Code | Prose | JSON | Ratio | Machine fields | Typed repair | Fix safety |
|---|---|---|---|---|---|---|---|---|
| e01_lexical | rejected | PAR100 | 175 | 586 | 3.3× | 10 | yes | requires-human-review |
| e02_syntax | rejected | PAR100 | 141 | 561 | 4.0× | 9 | yes | requires-human-review |
| e03_name | rejected | NAM003 | 330 | 692 | 2.1× | 10 | yes | requires-human-review |
| e04_type | rejected | TYP002 | 276 | 584 | 2.1× | 10 | yes | behavior-preserving |
| e05_mutability | rejected | TYP009 | 322 | 734 | 2.3× | 10 | yes | behavior-preserving |
| e06_effect | rejected | ERR003 | 350 | 770 | 2.2× | 10 | yes | api-changing |
| e07_memory | rejected | MEM003 | 467 | 869 | 1.9× | 10 | yes | requires-human-review |
| e08_target | accepted | — | 56 | 2,240 | 40.0× | 0 | no | — |
| e09_lowering | accepted | — | 58 | 2,251 | 38.8× | 0 | no | — |
| e10_match | accepted | — | 55 | 2,236 | 40.7× | 0 | no | — |
The distinction the ratio column hides is not information but addressability. Zero's prose diagnostic is not terse — it carries a code, a path, a line and column and a help line in 294 bytes on average. What it does not carry is a schema. A consumer wanting the expected type has to find it inside a sentence; a consumer wanting to know whether an automated fix would change behaviour has to infer it. The structured form names both, and adds one thing the prose has no equivalent of: a repair identifier drawn from a closed vocabulary. Our 10 error cases between them produce 8 distinct identifiers — check-or-rescue-fallible-call, choose-supported-backend, choose-target-with-required-capability, declare-missing-symbol, make-binding-mutable, manual-review, move-large-locals-off-stack, repair-syntax — each paired with one of 3 fix-safety ratings: requires-human-review, behavior-preserving and api-changing. A program can branch on those without a language model in the loop. That is what the premium buys, and it is a real thing to buy.
The premium is worst where there is nothing to say. The 3 cases the gate admits produce 169 bytes of prose between them and 6,727 bytes of JSON — 39.8× to report success. A structured schema pays its fixed cost whether or not the run had anything to report, and most runs do not.
10.3 The token budget, and what is on the wrong side of it
Zero ships zero tokens as a first-class command. A token count is something the compiler will tell you about a program on request, in the same way it will report its phase timings or its artifact size — and the token is the unit in which a language model is metered. A compiler that publishes one is a compiler that expects to be read by something that counts.
Set that against Table 10.1. The compiler reports 35 tokens for p01_hello, and returns 17,898 bytes — roughly 4,475 estimated model tokens — when asked in JSON whether those 35 tokens are correct. Across the corpus the structured verdict runs between 32× the size of the program it describes (p08_lexer) and 128× (p01_hello). The compiler's account of a program is consistently, and by a wide margin, the largest artifact in the exchange.
We do not read this as an argument against structured output; §10.2 is an argument for it. We read it as a measurement that the structured interface has not yet been costed for the reader it was designed for. Nothing in the schema is negotiable per call: we found no field selection, no severity filter and no way to ask zero check --json for the verdict without the report that surrounds it. A compiler whose stated audience is billed by the token has, on this build, no way to ask it for less.
The third bar cuts against the premise. Structured retrieval only pays off once a program is large: 7 of 8 programs cost more to query one function from than to read end to end.
| Program | Whole source | view --fn | query --json | check | check --json |
|---|---|---|---|---|---|
| p01_hello | 141 | 95 | 3,286 | 4 | 17,898 |
| p02_arith | 782 | 128 | 3,924 | 4 | 47,207 |
| p03_control | 1,625 | 196 | 4,341 | 4 | 46,633 |
| p04_shapes | 2,479 | 355 | 4,972 | 4 | 59,943 |
| p05_errors | 1,985 | 262 | 4,475 | 4 | 51,380 |
| p06_memory | 3,519 | 752 | 7,001 | 4 | 104,221 |
| p07_generics | 1,299 | 147 | 3,951 | 4 | 46,971 |
| p08_lexer | 10,391 | 723 | 6,106 | 4 | 189,743 |
| Total | 22,221 | 2,658 | 38,056 | 32 | 563,996 |
11. Discussion
11.1 Phases relocate; they do not disappear
It would be easy to read Zero's architecture as abolishing the front end. It does not. Every classical phase is present and every one still runs — but the first three moved out of the compile path into an ingestion gate that runs once per edit rather than once per build.
This relocation explains all our findings at once: the timing distribution in §6.2, the containment result in §6.4, and the acceptance gap in §6.5. One architectural choice, three consequences.
11.2 Two levels of truth in one document
Our first reading of the acceptance gap was wrong: we recorded it as the compiler failing to detect a problem. It detects it. targetReadiness carries the correct verdict and a diagnostic naming the offending construct.
What the compiler does is subtler and, for its stated audience, arguably worse than not checking: it publishes two summaries of the same compilation that disagree. A human reading the whole document notices. A program reading the field the schema presents as the verdict does not. A language whose central claim is that output should be consumed by machines has taken on an obligation a human-facing compiler has not: its top-level fields are an API. Machine-readability is necessary but not sufficient for machine-reliability.
11.3 Implications for compiler pedagogy
Our practical conclusion is narrower than Zero's marketing and, we think, more durable. A student using Zero can print the phase list, time each phase, read the symbol table as sixteen relations, watch a program be refused at three distinct gates, and diff the object formats produced by five emitters from one source graph — from the command line, without patching a compiler. We are not aware of another production-intent compiler where the phase structure is this directly inspectable.
12. What this study does not cover
A phase-by-phase account of a compiler invites the reader to assume that everything in the textbook was examined. It was not. Five classical topics are absent from our results, and in each case the reason is different: one is absent because Zero does not implement it, two because Zero does not expose them, one because Zero replaces it with something that is not a phase, and one because our method could not reach it. Naming which is which is more useful than an apology.
Not covered: error recovery, incremental invalidation cost, register allocation, instruction selection, and bootstrapping the compiler in its own source language. The optimization phase is covered only in the form Zero provides it — a fixed catalogue of build profiles rather than a pass pipeline.
12.1 Error recovery
A classical front end is expected to recover. On a syntax error it discards tokens to a synchronising symbol, resumes, and reports as many independent errors per run as it can without inventing them. The quality of that resynchronisation is a research topic in its own right, and it is the difference between a compiler that costs one edit-compile cycle per error and one that costs a cycle per run.
Zero does not recover. Its ingestion gate either admits an edit into the graph or refuses it whole. We did not set out to measure this and cannot claim to have tested it properly — every case in our error corpus seeds exactly one defect — but the consequence is visible in the output all the same.
Every one of the 10 cases produced exactly one diagnostic, at whichever gate refused it. 3 of them attach a related source location to that single diagnostic — a cross-reference inside one record, not a second finding. The store was never contaminated: a refused edit leaves no partial state behind, which is why a second run of the same command reports the same one diagnostic rather than a different one.
What that buys is the absence of the cascade. A missing closing brace in a conventional compiler produces a first honest error and then a column of phantom ones caused by the parser's own recovery guess, and a reader — human or otherwise — has to decide which ones are real. Zero never presents that decision. What it costs is round trips: with one diagnostic per run, an edit containing five defects takes five refusals to clear, and for a caller paying per round trip that is five times the fixed cost measured in §10.
We are explicit that this is an observation, not a finding. Establishing it would need a corpus of multi-defect programs and a comparison against a recovering front end on the same inputs. Neither exists here.
12.2 Incremental compilation and the cost of invalidation
Zero reports its caches, and this is the part of the compiler where its self-description is most complete: 6 named caches, each with a key, a hit flag and a plain-language statement of what invalidates it. We reproduce that report rather than summarise it, because the invalidatesOn column is the design.
| Cache | Invalidates on | Cold run | Warm run |
|---|---|---|---|
| parseTree | ProgramGraph input | hit | hit |
| interface | graph public symbols/import graph | hit | hit |
| checkedBody | ProgramGraph input or target | hit | hit |
| specialization | ProgramGraph input, target, or profile | hit | hit |
| mappedFinalMir | ProgramGraph input, target, emit kind, backend, or compiler version | hit | hit |
| emittedObject | ProgramGraph input, target, profile, or backend | miss | miss |
| Summary | rebuild expected on warm run: yes | 5 hit / 1 miss | 5 hit / 1 miss |
The table contains a result we did not expect and should not bury. After zero clean --all, the first run still reports 5 of 6 caches as hits. Only emittedObject misses, and it misses on the warm run too. The reason is in the invalidatesOn column: every cache above it is keyed on the ProgramGraph input, and cleaning a build directory does not change the graph. Our “cold” measurement is therefore a cold artifact directory, not a cold cache.
That has a direct consequence for scope. We never observed a miss on parseTree, interface, checkedBody, specialization or mappedFinalMir, so we cannot report the cost of an invalidation — which is the only number that matters for incremental compilation. Producing it would require mutating source between runs and re-measuring, which our harness does not do.
The same gap covers interface fingerprinting. The interface cache invalidates on graph public symbols/import graph, and the compiler describes its strategy as fingerprint changed modules and dependent bodies. The point of that design is that editing a function body without changing its signature should not force dependents to be re-checked. Our packages contain at most 2 modules and none depends on another package, so the longest dependency chain the fingerprint could protect is 1 edge long. There is no dependent far enough away for the optimisation to show. We report the mechanism; we do not report evidence that it works.
12.3 Register allocation and instruction selection
These are the two topics a back-end course spends the most time on, and this study says nothing about either. That is not a choice we made. Zero's reported phase list ends lower → codegen → object → link, and none of those four decomposes further in any --json payload we could find: there is no allocator report, no instruction-selection trace, no spill count, no register pressure figure.
Nor can the question be approached from the artifact side. Asking for the intermediate form directly fails on every corpus program with BLD004: direct backend does not support --emit llvm-ir. The direct emitters go from MIR to 3 object formats (macho, elf, coff) without an inspectable middle, so the only back-end observables the compiler offers are the size of the lowered IR in bytes, the size of the artifact, and whether the build succeeded. Everything a classical back-end chapter is about happens inside a step that reports one number.
12.4 Optimization is a profile, not a pass pipeline
Phase five of the classical model is code optimization, and Zero has no phase by that name. What it has instead is a fixed catalogue of 6 build profiles, each a named bundle of a codegen setting, a link setting, a metadata retention policy and a size budget. Selecting --profile tiny is the closest a user gets to requesting an optimization, and the request is categorical rather than compositional: there is no -O2, and no way to enable one transformation without the rest of its bundle.
| Profile | Aliases | Optimization goal | Codegen | Link | Debug info | Budget |
|---|---|---|---|---|---|---|
| debug | debug | observability | none | keep-debug-names | yes | 65,536 |
| dev | dev | edit-latency | none | keep-debug-names | yes | 32,768 |
| release-fast | fastrelease-fast | throughput | speed | section-gc-strip | no | 24,576 |
| release-small | smallrelease-smallrelease | small-binary-size | size | section-gc-strip | no | 12,288 |
| tiny | tiny | minimum-binary-size | size-min | section-gc-strip-minimal-metadata | no | 10,240 |
| audit | audit | release-auditability | size-with-audit-metadata | section-gc-keep-audit-metadata | yes | 65,536 |
Read down the goal column and the catalogue turns out not to be a speed dial at all. Exactly 1 of the 6 profiles names throughput as its goal; 2 name binary size at different intensities, and the rest name observability, edit latency and release auditability. That is a defensible set of axes for a compiler aimed at machine-generated code, where binary size and reproducible metadata matter more than the last few percent of a benchmark. But it means the classical question — which transformations ran, in what order, and what did each one buy — has no answer here, and we do not pretend to have measured one. Our §6 figures fold optimization cost into lower and codegen because the compiler does.
12.5 Bootstrapping and self-hosting
Whether a compiler can compile itself is the traditional closing chapter, and it is the one topic here where Zero answers the question directly and the answer is short: not yet, by design, and it has removed the machinery it would have used to get there.
| Property | Reported value |
|---|---|
| Mode | native-bootstrap |
| Contract version | 1 |
| Subset compatible | yes |
| Phase parse routed to | zero-c |
| Phase check routed to | zero-c |
| Phase lower routed to | zero-c |
| Phase emit routed to | zero-c |
| seedCompiler | removed |
| browserCompiler | removed |
| portableEmitter | removed |
| C bridge | policy removed, required no, fallback never-c-bridge |
The mode is native-bootstrap and every reported phase — parse, check, lower, emit — routes to zero-c. None routes to a compiler written in Zero. The three components a bootstrap normally needs are all recorded as removed: seedCompiler, browserCompiler, portableEmitter. The C bridge is gone with them, replaced by direct per-format emitters.
This is a coherent position rather than an omission — a compiler that emits 3 object formats directly has no need of a portable C fallback, and removing the seed compiler removes a whole class of trust problem. But it means the classical bootstrapping exercise cannot be run on this build, and a compiler that is not written in its own language has not yet made the argument that the language is adequate for compilers. We note the same restriction bites elsewhere in this paper: the browser playground in §9 reimplements the lexer in TypeScript precisely because browserCompiler is one of the removed components.
One last piece of scope worth naming, since it is easy to miss. The 16 graph tables in §6 are what the compiler chooses to publish. Nothing in this study inspects the graph store's own encoding, its index structures, or its behaviour under concurrent writers. We measured a reporting interface, and a reporting interface is not an implementation.
13. Threats to validity
Single version, single platform. All measurements are from Zero 0.3.4 build 5b3a90a on one Windows x64 machine. Zero is explicitly experimental and shipped four minor versions in roughly a month; the backend gaps we document are the ones most likely to close.
Corpus scale. 680 lines across 8 programs is small. It is adequate for demonstrating phase structure and the acceptance gap, both qualitative properties, but too small to resolve front-end phase timings against the compiler's 1 ms granularity or to claim asymptotic scaling.
Corpus authorship. The corpus was written to exercise particular compiler tables, so coverage is deliberate rather than representative. Several programs were shaped by what the backend would accept, which biases them toward the lowerable subset — if anything this understates the acceptance gap.
Timing methodology. Wall-clock includes ~40 ms of process startup we could not separate without instrumenting the binary, so all phase-level claims rest on the compiler's self-reported times.
Browser reimplementation. The playground's lexer and parser are ours, not Zero's. They agree exactly with zero tokens --json across all 8 corpus programs, but that corpus is 680 lines written by us: agreement on it is evidence of correctness on the constructs we used, not a proof of equivalence. Input using syntax the corpus never exercises may diverge, and only the native compiler is authoritative.
Documentation discrepancies. Several examples in the shipped language guide do not compile on this build. We report these as observations about version 0.3.4, not claims about the language design.
15. Outlook
It is worth separating what this study licenses us to say about where compilers are going from what we would merely like to be true. The table states which is which for each claim we make below; the prose then argues them in order.
| Claim | What it rests on | Status |
|---|---|---|
| Compiler interfaces are becoming APIs. | Every structural claim in this paper was read out of a documented --json schema. None required patching the compiler, scraping a log, or parsing an English sentence. | Measured |
| A top-level field carries an obligation prose never did. | 2 of 10 error cases report ok: true at the top level of zero check --json while the nested targetReadiness reports buildable: false. | Measured |
| Observability is worth having whether or not agent-oriented languages win. | 16 graph tables, per-phase timings, 3 distinct refusal gates and 8 target emitters, all reachable from a shell without a debugger. | Argued from the measurements |
| Adoption will be decided by pretraining distribution, not interface quality. | Nothing in this study bears on it. We have measured one compiler, not a market. | Speculation |
15.1 The interface becomes the contract
The most durable observation in this paper is also the least dramatic: we were able to write it. Every claim we make about the compiler's structure came out of a documented --json schema — the exceptions are wall-clock times, which no compiler can report about itself, and the sizes of the compiler's own prose output in §10, which are the point of the comparison. A decade ago the equivalent study would have required instrumenting a compiler; here the instrumentation was the product. That direction of travel is not unique to Zero — rustc --error-format=json and the TypeScript compiler API arrived at the same place from a different premise — and it is the part of Zero's design we would expect to generalise regardless of what happens to the language.
The consequence is an obligation that prose never carried. An English diagnostic that overstates its confidence is read by a person who can weigh it against the rest of the output. A JSON field named ok is not weighed; it is branched on. We found 2 of 10 error cases where zero check --json reports ok: true at the top level while its own nested targetReadiness reports buildable: false, with a diagnostic naming the construct the backend will refuse. The compiler knew in every one of those cases. A human reading the whole document notices the disagreement. A program reading the field the schema presents as the verdict does not.
We think this is the general shape of the problem rather than a bug in one build. Publishing a schema converts every top-level field into a promise with a much wider blast radius than a sentence, and a compiler that adds a field faster than it can define what the field means will produce exactly this class of contradiction. Machine-readable is a property of a format. Machine-reliable is a property of a contract, and it is a harder thing to ship.
15.2 Observability outlives the premise that motivated it
Zero exposes its internals because it expects an agent to consume them. That motivation may or may not turn out to be right, and the observability is valuable either way. A student can print the phase list, time each phase, read the symbol table as 16 relations, watch the same program be refused at 3 distinct gates, and diff the object formats produced by 8 emitters from one source graph — from a shell, without patching a compiler or attaching a debugger.
None of that depends on the agent thesis being correct. It is a teaching property that fell out of an engineering decision, and it is the property we would most like to see other toolchains copy, because it is the one that costs the least to adopt. A compiler does not have to be graph-first to report its phases honestly, and §10 suggests it should think carefully about the size of the report while it does — the 2.3× premium structured diagnostics carry is a fair price; the premium on the full verdict payload is not yet costed for anyone.
15.3 The open question is distribution, and we cannot answer it
What follows is speculation, and we mark it as such because nothing in our dataset bears on it. A language designed for machine authorship faces a bootstrapping problem that has nothing to do with compilers: a model writes the languages it has seen. A language with no corpus is a language a model must be taught in-context, on every call, at a token cost that competes directly with the savings a machine-first interface is supposed to deliver. Interface quality does not obviously move that constraint, and neither does a good diagnostic schema, if the language guide has to travel in the context window alongside the program the diagnostic is about.
We can say what would change our mind, which is the most an honest outlook can offer. The measurement that matters is end-to-end: tokens spent per accepted edit, for the same task, in a language with a large pretraining corpus and a prose-oriented compiler versus a language with no corpus and a structured one. §10 supplies one half of that — the cost of the compiler's side of the loop — and says nothing at all about the other. If the structured loop wins on that measurement, the design is vindicated on its own terms. If it does not, the observability in §15.2 is still worth keeping, and that is the conclusion this paper is actually in a position to defend.
16. Conclusion
The classical six-phase model survives contact with a graph-first compiler, but not in the shape the diagram suggests. Across 8 programs and 64 build combinations we find that Zero implements every classical phase while relocating the first three out of the compile path into an ingestion gate. That single change explains our measurements: lowering accounts for 100% of reported phase time; all 7 front-end error cases were contained at the gate; and 17 of 64 builds failed on programs the front end had accepted.
For a compilers course, the practical finding is that Zero makes the phases visible in a way mainstream toolchains do not. Whether the language succeeds on its own agent-oriented terms is a separate question, whose answer probably has more to do with training-data distribution than with compiler design. The observability is worth studying either way.
References
- A. V. Aho, M. S. Lam, R. Sethi, J. D. Ullman. Compilers: Principles, Techniques, and Tools. 2nd ed., Pearson, 2007. pearson.com
- K. D. Cooper, L. Torczon. Engineering a Compiler. 3rd ed., Morgan Kaufmann, 2022. elsevier.com
- Vercel Labs. Zero — the programming language for agents. zerolang.ai · github.com/vercel-labs/zerolang (Apache-2.0). Version 0.3.4, build 5b3a90a.
- Vercel Labs. Graph architecture. zerolang.ai/concepts/graph-architecture
- Vercel Labs. Compile path. zerolang.ai/concepts/compile-path
- Vercel Labs. Semantic graph vs text. zerolang.ai/concepts/semantic-vs-text
- Vercel Labs. Getting started. zerolang.ai/getting-started. Version-matched language documentation retrieved via
zero skills get language --full. - Microsoft. .NET Compiler Platform (Roslyn). github.com/dotnet/roslyn
- N. Matsakis et al. The rustc query system. rustc-dev-guide.rust-lang.org/query.html
- The Rust Project. JSON output. doc.rust-lang.org/rustc/json.html
- salsa-rs. salsa — on-demand, incrementalized computation. github.com/salsa-rs/salsa
- Vercel Labs. wterm — a DOM-based terminal emulator with a WebAssembly core. github.com/vercel-labs/wterm. Reviewed as a delivery option; see A.1.
- Vercel. Geist and Web Interface Guidelines. vercel.com/geist · vercel.com/design/guidelines, with WCAG 2.2 AA for contrast and structure.
Appendix A. Reproduction
Everything is regenerated from one command. Source and dataset: github.com/HKTITAN/phases-of-zero.
curl -fsSL https://zerolang.ai/install.sh | bash
export PATH="$HOME/.zero/bin:$PATH"
zero --version # 0.3.4 (build 5b3a90a)
npm install
npm run paper # capture -> qr -> build -> pdf -> epub -> previewsA.1 Why the explorer ships precomputed data
We considered compiling Zero in the browser, following wterm[12], which runs a Zig-authored terminal core as WebAssembly. Zero 0.1.3 advertised wasm32-wasi and wasm32-web targets. Version 0.3.4 advertises neither: the target list contains 8 native targets and no WebAssembly target, and the compiler's own selfHostRouting report marks browserCompiler as removed. We therefore reimplemented phases one and two in TypeScript for the playground and replay the rest from the capture.
A.2 Corpus contents
p01_hello, p02_arith, p03_control, p04_shapes, p05_errors, p06_memory, p07_generics, p08_lexer under corpus/, and e01_lexical, e02_syntax, e03_name, e04_type, e05_mutability, e06_effect, e07_memory, e08_target, e09_lowering, e10_match under errors/.