ADR-257: The Checker’s Types Move to Nikaia, and a Measurement Decides Their Shape First

Status: Superseded by ADR-294 — its D1-D5 are D12-D16 there Date: September 30, 2026 Target Version: Nikaia 0.0.283 Answers: open-decisions.md, How the ring after the tree reads the checker’s types. The owner chose option (1), moving them whole, and asked for this record and a measurement first. Related: ADR-250 (the road: a module moves when everything it reads is Nikaia or plain data), ADR-252 (the tree moved, and how), ADR-100 (a ledger is a file, believed only against its sources), ADR-024 D4, ADR-030 (? and $V)


1. Context

1.1 Two things called “type”

The compiler holds two different things that are both called a type.

The ledger’s records are declared around it in contracts/mod.rs. They are FnContract, TypeContract, Signature, FieldContract, VariantContract, ConfigContract, Ledger, and the small enums Sync, Lock, Crosses, Threads and Provenance.

1.2 How this became the blocker

ADR-250’s rule is that a module moves when everything it reads is Nikaia or plain data. The tree moved, so fold, trust and the rest of the first ring could move after it (ADR-252, 0.0.273–0.0.281; the self-hosted share went from 2.4 % to 3.8 %).

Every module of the next ring reads Ty or a ledger record:

So nothing more moves until these types are Nikaia, or until each module’s adapter flattens them into plain data. The second path is option (2) of the open decision. It builds an adapter per module that is deleted again once the types move, and the owner declined it.

1.3 What other compilers do

compiler the checker’s type across a unit boundary
rustc hir::Ty for what was written; ty::Ty<'tcx>, interned in an arena, compared by pointer .rmeta, a binary encoding of the interned types
Go go/ast for what was written; go/types, plain heap objects, compared by structure with identity rules (types.Identical) export data, a binary encoding
TypeScript one Type object per type, created through caches so each is created once, with flags for the kind .d.ts, a text form, re-read by the same parser
Swift canonical types, interned in the ASTContext .swiftinterface, a text form
Zig InternPool: every type and every comptime value is a u32 index into one pool the pool is what incremental compilation keeps between updates

Nikaia is closest to TypeScript today. Types are plain trees and the boundary is text. Go and TypeScript are also the two that moved (or are moving) their compiler to another language. Go 1.5 did it by mechanical translation, so the shape of the types did not change during the move. The move and the redesign were kept apart.

1.4 Why Zig interns, and why that may not be this compiler’s answer

Zig’s InternPool was not a micro-optimisation. It followed from four properties of Zig, each stated in its design talks and in the self-hosted compiler’s history:

  1. The front end is the product. Zig has its own back ends. The self-hosted x86-64 back end exists so that a debug build does not wait on LLVM. When nothing slow follows the front end, the front end’s memory traffic is the build time, and data-oriented design is about memory traffic: fewer bytes, fewer pointers, more that fits in cache.
  2. Types are comptime values. In Zig a function can return a type, and a type is compared, hashed and stored like any other comptime value. One pool for both, with equality as an integer comparison, is the natural representation of a language where the two are the same thing.
  3. Incremental compilation patches the binary in place. An index that stays the same across updates is what lets a later update find what an earlier one decided. A pointer does not survive that; an index does.
  4. The workload is large. Zig’s own benchmark is the Zig compiler and its standard library, several hundred thousand lines, and semantic analysis is being made to run on several threads, which the pool was sharded for.

For each of the four, Nikaia’s situation today is different. Each difference could close, so each is a condition to watch, not a permanent answer:

  1. rustc follows the front end. docs/subprocess-cost.md measured the whole rustc compile of json.rs at 1.72 billion instructions. If the front end is a few percent of a build, halving it is invisible to the person waiting. This changes if Nikaia gets a back end of its own.
  2. A type is not a value here. ADR-088 D2 reaches a type’s shape as members through a bound, with no @typeInfo and no function that returns a type. This changes if build-time evaluation ever computes types.
  3. Incrementality is per unit, and the boundary is text (ADR-100). A ledger is re-read, not patched, so a stable index buys nothing across units. This changes with an incremental mode inside a unit.
  4. The workload is small. The largest Nikaia unit is 1 197 lines. The whole corpus is about 6 200 lines. The compiler itself is 74 489 lines of Rust, and it is the program this road ends with. This changes as self-hosting proceeds. The measurement has to say how the cost grows with size, not only what it is today.

If the measurement comes out differently from Zig’s answer, this is why. If it comes out the same, Zig’s reasons were not the only ones.

2. Decisions

D1 — The ledger’s types move whole, as the tree did

Ty, Shape, the ledger records of §1.1 and their small enums are declared in Nikaia. They go in a tool module beside ast.nika, lowered by nikaia lower-std and re-exported from contracts, so every reader keeps its path. This is ADR-252 D1’s route, used a second time.

D2 — Four steps, each one shippable

The shape is decided before (b) and not during it. Interning later would touch the checker’s 633 lines a second time. Deciding first is what keeps the move and the redesign apart, which is the lesson of Go 1.5 in §1.3.

D3 — The rule the measurement is read against, written before it ran

Interning (a u32 handle into a pool, as Zig and rustc do) is chosen only if both of these hold on the corpus’s largest programs:

  1. What interning removes is worth removing. The instructions spent cloning, dropping and comparing Ty are at least 15 % of the front end’s instructions, net of what interning would add (a hash and a lookup for every type built).
  2. The front end is what the person waits for. The front end is at least 10 % of an end-to-end debug build of the same program.

It is also chosen if the measured growth with program size carries (1) past 15 % at the size of the compiler itself, 75 000 lines, even though it holds today at neither.

Otherwise Ty moves as it is, a plain tree with a text form (D1), and §4 records the numbers that would reopen this decision.

D4 — The prediction, written and committed before the measurement

These numbers are this record’s guess, committed in a separate commit before the first measurement ran, so that the result cannot be shaped to fit them.

The weakest of these is P6. The corpus spans only 15 to 1 197 lines, and a size effect that starts later would not show in it.

D5 — The method, and what could make it wrong

The corpus. Every .nika file in examples/, tests/samples/, benches/ and crates/nikaia/tests/fixtures/, each lowered on its own. Then nikaia lower-std, which checks the eleven tool modules and text.nika. tests/errors/ is left out, because a program refused early measures the refusal.

The build. The release profile with line tables (CARGO_PROFILE_RELEASE_DEBUG=1), because that is the compiler people run.

Two instruments, never mixed.

The known sources of error, and what is done about each:

# error direction correction
E1 No real users’ programs. The corpus is examples, tests and the compiler’s own tools, written by the people who wrote the compiler, and skewed toward what the compiler already does well. unknown Reported per program, never only as a total. Growth is fitted across sizes, and the rule (D3) is applied at an extrapolated size, not only a measured one. Where this changes the answer, the record says so; the conclusion is stated with the size range it was measured on.
E2 The lowering cache. A second run reuses the lowering and measures nothing. lower --no-cache on every run.
E3 Fixed cost hides the per-line cost. On small programs the run is mostly reading std.contracts. lower An empty program is measured, and each program is reported as a total and as the part above that baseline.
E4 Wall-clock on a shared machine is noise. either Decisions rest on instruction counts. Wall-clock is reported only as a median of repeated runs, with its spread, to say what the counts feel like.
E5 Instructions are not time. Zig’s argument (§1.4) is cache misses, which an instruction count does not see: a pointer-chasing clone costs more than its instructions. lower Callgrind’s cache simulation (--cache-sim=yes) is run too, and misses under the Ty functions are reported beside their instructions.
E6 Inlining hides clones. A derived clone or drop the optimiser inlines into its caller is counted as the caller’s. lower The census build puts #[inline(never)] on the written-out functions. Its total instructions are compared with the unmodified build’s, and the difference bounds what inlining hid.
E7 What interning removes is less than what Ty costs. Building a type still happens: it becomes a hash and a lookup. higher The number of types built is counted (census) and charged at a hash’s cost, and the net is what D3 reads.
E8 The counting build is slower and different. either Counts only, never costs, from that build (above).
E9 The test suite’s own programs are not in the corpus. About 1 800 tests write a program inline in Rust. unknown Acknowledged. They are small (mostly under 20 lines), so they would add programs rather than size, and E3’s baseline would dominate them.
E10 One run of a deterministic count can still depend on the environment (hash seeds, the path length, the sysroot’s location). small Two runs of the same program are compared; a difference over 0.5 % is reported.

3. What the measurement runs

Everything is in benches/ty-census/, so that anyone can run it again on a later compiler and compare the numbers:

The machine is the shared four-core virtual machine of docs/subprocess-cost.md, with valgrind 3.22.

4. Result

The prediction in D4 is left as it was written. What follows is set against it.

4.1 What a person waits for (D3’s second condition)

A real edit to examples/json (one string literal changed), in a project whose dependencies are already built, as a person editing it would run it. Instructions per process, on the compiler before §4.4’s fixes, which change json’s front end by 0.1 %:

process Ir share
rustc on the crate, incremental 780.7 M 42.5 %
the linker 580.6 M 31.6 %
the front end (nikaia build) 198.8 M 10.8 %
cargo 196.1 M 10.7 %
nikaia again, as the build’s rustc wrapper 67.8 M 3.7 %
the rest (rustc --version, cc) 14.3 M 0.8 %
total 1 838.3 M  

With incremental compilation off (CARGO_INCREMENTAL=0), rustc on the crate is 2 464.6 M, and the front end is 5.7 % of the build.

P1 was wrong. The front end is 5.7–10.8 % of a build, not under 5 %, and both of the compiler’s processes together are 7.7–14.5 %. P1 was read off a September figure for a whole rustc compile of a standalone json.rs. That is not what a person editing a project waits for: incremental rustc and already-built dependencies make the back end smaller and the front end’s share larger. D3’s second condition holds for the case that matters most, an edit.

The wrapper’s 67.8 M is a finding of its own. On a cache hit it still reads std.contracts (§4.6).

4.2 What Ty costs (D3’s first condition)

Callgrind on the release compiler. The table is after the two fixes of §4.4; lower-std is shown both before and after them. “std.contracts read” is Ledger::parse, inclusive. Four programs are left out: two are refused on purpose (foreign-runtime/crossing and smuggled), and two need a dependency that --input does not read (3.3 of #93).

program lines Ir std.contracts read Ty narrow Ty broad
fn main() {} 2 91.3 M 88.3 % 1.40 % 6.87 %
hello_world 3 103.3 M 89.3 % 1.25 % 6.03 %
calc 112 128.4 M 71.6 % 1.15 % 5.29 %
config 87 145.7 M 63.3 % 1.22 % 4.72 %
tally 68 152.0 M 60.5 % 1.08 % 4.67 %
k-nucleotide 184 176.3 M 52.2 % 1.80 % 4.81 %
n-body 192 177.5 M 51.9 % 3.46 % 6.61 %
access-log 151 182.1 M 50.6 % 2.08 % 4.93 %
1brc 184 185.1 M 49.7 % 1.91 % 4.88 %
http 232 198.8 M 47.0 % 1.65 % 4.34 %
json 229 236.6 M 39.0 % 1.31 % 3.50 %
benches/jumps 190 236.1 M 39.0 % 1.12 % 3.44 %
lower-std, before §4.4 3 603 3 893.0 M 14.6 % 13.85 % 16.16 %
lower-std, after 3 603 1 649.7 M 34.6 % 2.51 % 4.55 %

P2 held for the examples and failed for the compiler’s own modules. On the examples, Ty is 3.4–6.6 % broad and 1.1–3.5 % narrow, lower than the best guess of 6 %. On lower-std it was 16 %, above D3’s 15 %, which is exactly the answer the rule would have acted on.

It was not Ty. §4.4 found two walks whose cost grows faster than the program. With them fixed, the output unchanged and Ty unchanged, lower-std falls by 58 % and Ty falls to 4.6 %. D3’s first condition does not hold anywhere in the corpus, before netting out what interning would add (E7).

P3 held. Reading std.contracts is 47–89 % of every program under 100 lines, and still 39 % of json.

4.3 How it grows (E1, and P6)

The first attempt was the error E1 warned about. A least-squares fit across the 21 programs gives total instructions ∝ lines^0.60 and Ty ∝ lines^1.04. Extrapolated, Ty would be 15.7 % at 10 000 lines and 37.9 % at 75 000, and D3 would say intern. The fit was rejected for three reasons:

The one large point disagrees with it: lower-std, 1 945 lines of code, is 4.4 % where the fit says about 7.5 %.

What replaced it holds the kind of code fixed and changes only size. scale.py copies one program k times, and the Ty share (broad) is measured for each k:

program (lines of code) k = 1 2 4 8 16 32
1brc (91 → 871) 4.97 % 5.03 % 5.21 % 5.06 % 4.36 %  
access-log (87 → 837) 5.03 % 5.07 % 5.20 % 5.08 % 4.29 %  
k-nucleotide (92 → 842) 4.90 % 4.58 % 3.97 % 3.55 % 3.05 %  
n-body, before §4.4 (128 → 1 868) 7.07 % 8.14 % 9.59 % 11.33 % 13.34 %  
n-body, after (128 → 3 724) 6.42 %   7.56 %   6.98 % 5.57 %

P6 holds once the walk is fixed, and was false before it. n-body’s growth was the second walk of §4.4, not Ty. What this overstates is the number of distinct types: every copy holds the same ones.

4.4 Two walks that grew faster than the program

Both were found by asking where the Ty share came from. Both are fixed in this version. Both change no output: nikaia lower-std rewrites no file, and the test suite passes.

This is the lesson of the measurement, and it is the answer to E1’s worry from the other side. A share attributed to a type is not a property of the type’s representation. Interning would have made both walks cheaper per step and left their step counts alone. It would have hidden a quadratic behind a smaller constant.

4.5 Counts, cache misses, and the errors that were checked

Counts (the census build, before §4.4):

program top-level clones == fits distinct types cloned
fn main() {} 1 100 0 0 95
hello_world 1 129 18 6 96
json 5 630 801 546 106
n-body 15 920 660 402 100
lower-std 996 496 3 086 3 994 182

Cache misses (E5), with --cache-sim, after §4.4’s first fix:

program Ty broad, instructions first-level read misses last-level read misses
json 3.49 % 19.2 % 0.18 %
n-body 7.20 % 23.9 % 0.20 %
lower-std 4.58 % 17.1 % 0.22 %

This is Zig’s argument, visible here too. Per instruction, code over Ty misses the first-level cache three to five times as often as the compiler’s average, because it follows pointers. It does not decide anything at this size, because almost nothing misses the last level: the whole run fits in cache. Charging a first-level miss 10 cycles and a last-level miss 100 moves Ty’s share from 3.49 % to 3.7 % (json), 7.2 % to 7.5 % (n-body), and 4.6 % to 4.8 % (lower-std). Zig’s workload (hundreds of thousands of lines in one process, with comptime values in the same pool) is where a working set stops fitting, and that is where its answer pays. The last-level miss share is the number that would say ours has stopped fitting, and report.py prints it.

The other errors of D5:

Three more errors, found by making the measurement:

4.6 What the measurement found that is not about Ty

These are measured, not decided, and go to #122:

4.7 The answer, and what would reopen it

D3 answers: Ty moves as it is, a plain tree with a text form (P7 held). The first condition fails everywhere in the corpus (at most 6.6 % broad after §4.4). The second holds for an edit. Growth held within one program is flat up to 3 724 lines of code.

The range this was measured on: units of up to 3 724 lines of code, runs of up to 1.65 billion instructions, programs written by the compiler’s authors, and no program with dependencies. Outside it, the answer is a guess, and it is reopened when any of these is seen:

  1. Ty broad at or above 15 % in any report.py row, or rising by more than a point per doubling in a scale.py series, after the growth has been checked for a walk like §4.4’s.
  2. Last-level read misses under Ty at or above 5 %: the working set has stopped fitting, which is Zig’s condition.
  3. A back end of Nikaia’s own, which makes the front end the build (§1.4, 1).
  4. Build-time evaluation that computes types (§1.4, 2).
  5. A unit past 4 000 lines, which the checker’s own modules will be when they move. The measurement is run again then, and D4’s method (a prediction committed first) is used again.

5. What is built

The record, the measurement (§3–§4), and §4.4’s two fixes (0.0.283).

Step (b)’s first half, and the writing half of (c) (0.0.285): tools/ty.nika declares Ty and Shape, the words a sequence carries, and Ty::text, which is what a diagnostic prints and a ledger stores. The compiler’s contracts::ty re-exports them, and what was impl Ty is the trait TyOps beside it, since a crate cannot write an inherent impl for another crate’s type. Display is five lines of Rust in nikaia-std that call text, because the language has no Display to write it in. Three things changed shape on the way, each in the language’s terms:

The reading half of (c) (0.0.286): Ty::parse and split_args are tools/ty.nika’s. The Rust parser was kept long enough to run both over every type the two shipped ledgers write, the text of each read back, and ninety-odd edge cases (ref mut, &[T], fn(a]) -> b, Seq[i64] sync pauses, non-ASCII names): 989 inputs, identical.

What it costs, measured as §3 measures: this language has no slice of a String, so every piece of a type’s text is a copy where the Rust sliced in place. Reading std.contracts went from 90.96 M instructions to 95.46 M after the obvious savings (one view marker read per type, no list of characters where no bracket closes the text), which is 5.0 % of an empty program and 2.3 % of json. Recorded rather than hidden: it is the first measured price of a module moving, and a slice of text would give it back.

The ledger’s small records (0.0.289): Provenance, Sync, Lock, Crosses, Threads, Touch, State, Held, Count, Class and the field, variant and config entries are ty.nika’s, with the methods that say them. Two things the Rust derived and the language does not give are written where they are used, each as the value or the order the derive meant: Default (Sync::No, Lock::No, Threads::Undecided, Crosses::Undecided, Count::Atomic, and hand-written Defaults on the compiler’s own records), and Ord, which only three places needed (a set of touches kept by kind, parameter and access; classes sorted by members and Count::rank; join and or as a match). The language question of an order on a declared type stays where ADR-204 §4 left it. No ledger byte changes.

Signature, FnContract and TypeContract (0.0.290). Two fields change name, because their words are reserved: FnContract.throws is fails_with and FnContract.sync is sync_claim, and the ledger file keeps its keys (throws = …, sync = …). A Default becomes empty(), the constructor the Nikaia declares.

Ledger and Notes (0.0.291), which ends (b). The tables are collections::BTreeMap and BTreeSet, so the file stays ordered. What the compiler does with a ledger stays Rust behind LedgerOps until (c) and (d) move it; the orphan rule that made TyOps makes every caller import it.

Reading a ledger back (0.0.292), which is (c): tools/ledger.nika reads the file whole, with the records ty.nika declares beside it, and Ledger::parse is one call. The reader is where the text lives, so it also took what (b) left behind its *Ops traits: a touch, a view’s state, a sharing class, a variant and a signature read back, and unspell. It was measured before it shipped, because §4.6 found reading std’s ledger to be most of an empty program: as first written it cost a third more than the Rust, and as shipped it costs less (93.1 M instructions for an empty program, from 95.7 M). The difference was the lowering and not the reader: a list of a text’s characters is asked for at its size now, for every Nikaia module.

What the checker asks of a type (0.0.295), which is (d): fits, erase, parameterise, as_a_view, is_seen, unseen, is_a_view and is_unknown are Ty’s methods in ty.nika, and bind, substitute, qualify and renamed its functions. The compiler calls them as it did, because a type’s own method is found before an extension trait’s. What stays Rust is what cannot be Nikaia yet or is not worth it: the constructors from a name (Ty::named takes anything that becomes text), base, which hands back a slice of the name, and from_ast, which reads this compiler’s tree.

fits is asked at every argument, assignment and return, so it was measured: an empty program, 1brc, n-body and k-nucleotide each lower within 0.2 % of the instructions the Rust took (93.1 M, 186.8 M, 178.5 M, 177.1 M). The move found a checker defect on the way: an element of a ref Array[T] was ?, so a method on it was one nothing describes and the function around it became a future; it is the item now.

All of D2 is built.

§4.6’s first finding, answered (0.0.296): reading std.contracts was mostly reading it again. Seven places parsed STD for themselves, so an empty program read it seven times; every reader now borrows the one parse, and an empty program lowers in 22.8 M instructions, from 93.4 M. The other two findings stay in #122.

The one parse, made cheaper (0.0.297): the reader is three grammars in tools/ledger.nika that slice views out of the text instead of walking it as Vec[char], and the costs that were the parser library’s were removed there (winnow-grammar: a memo for the whitespace skip, first-byte guards on alternatives, errors a hand-written rule builds only for the diagnosing pass). Reading std.contracts is 5.9 M of an empty program’s 17.4 M, from 11.4 M of 22.8 M.

This site is open source. Improve this page.