ADR-311: A Build-Time Value Crosses Into the Program in Its View Form — a List as an Array or a ref Array[T], a Map as a Fixed Table Written as Pairs, a Declared Row Held as a View, and the Lookup Is the Compiler’s

Status: Accepted Date: October 3, 2026 History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached Answers: what shape must a value computed while the program is built have to arrive in the running program?; what does a program write, for a map the build can see? and may a Fixed table hold a struct? from the former open-decisions.md; #174 (no push and no aggregate value at build time) Related: ADR-287 (the comptime binding and what a build-time body may do; this record is what it may hand over); ADR-179 (ref Array[T] is a type, the crossing walks the declaration, NK1179 for a run a body owns); ADR-184 D1-D3 (a view is spelled ref X; ref String, ref Array[T]); ADR-152 D1, D4 (Array[T, N], and a literal takes the type its use asks for); ADR-135 (the list literal); ADR-010 D5 (the compiler picks a map’s hasher from where its keys came from); ADR-310 (perfect hashing is compiler-side staging, not user code); ADR-005 D8 (the build is byte-for-byte deterministic); ADR-296 D17 (the emitter is syntactic and knows no types); ADR-293 (a map read is a T?); ADR-283 D3 and ADR-282 D7 (a copy is written or refused, never inserted); ADR-118 D3 (a value used after a move is refused); ADR-292 (an unresolved call has an unknown touch set); ADR-177 (a grammar run while the program is built hands its result to this crossing); ADR-024 D1 (? fits everything)

1. Question

A value computed while the program is built has to arrive in a program that is running. What shape must it have to be written down, and what does the program hold once it has arrived?

The crossing is not free. The destination is a constant in the generated file, and a constant cannot own memory: a parse that produces a Vec[Row] parses perfectly well and has nowhere to land. So the question is not what can be computed — that is ADR-287’s — but what a result has to be to be written down at all, and how a program then reads it.

The use case that motivates comptime makes the question sharp: a table whose keys come from a file, looked up at run time. Four things follow from the answer and are decided with it — what a list arrives as, what a map arrives as and how it is written, how it is looked up, and what a table may hold.

2. How others do it

Language / tool Its answer What it costs there Source
Rust const const X: &[T] and const X: &str exist; const X: Vec<T> cannot, because a const cannot allocate a value has to be written in its borrowed form to be a constant the language below, as this compiler emits it
Rust phf crate a perfect-hash map built ahead of time, as a type of its own with a build step the program names the implementation; CHD with displacements the crate’s own algorithm, used here (D10)
Serialising a value as bytes and casting it back a blob in the binary layout and pointer width must match between the machine that baked it and the one that reads it considered in D16
Nikaia’s own hasher choice the compiler picks a map’s hasher from where the keys came from, and nothing in the source mentions hashing none in the source ADR-010 D5

What transfers: the language below already draws a line between a buffer and a view in exactly the place this crossing needs one. What does not: a perfect-hash type in the program’s own vocabulary, which would freeze one implementation into an interface (D7).

3. Options, weighed

Option Why it lost
Build the value once at program start into a static gives up what comptime is for: the claim becomes about something that happens later, and a failure moves back to run time
A Vec or an owned String as the crossed form a const cannot hold one; the language below refuses it
Refuse push in a build-time body, so a table is written at its length and filled by index too narrow: the build has computed the value before it is compared with the declaration, so its length is a fact; what cannot be a Vec is the crossed form, not the body’s working value
A user-visible PerfectHashMap with a build() names one implementation in the source; the right lookup moves with the table’s size, which the source does not know
A map literal a new form in the grammar, and a second question about what it means outside a comptime; a list of pairs and a declared type say the same with nothing new
Emit a match per table a linear scan over the same static arrays costs about what a match does in the range where a match wins, and a table is a value, keeps the emitter type-blind and makes the threshold data
Derive Copy on a struct whose fields allow it, so a table can hand rows back by value makes a struct’s copyability a consequence of its fields one file away, and silences the used after a move refusal for a whole class of types
Serialise to a blob to admit more types admits no more types: only fixed-width, pointer-free fields survive two targets with two pointer widths

4. Decision

D1 — A result crosses in its view form.

A Vec[T] arrives as a ref Array[T], or as an Array[T, N] where the program writes the length into the declared type. A String arrives as a ref String. What was built with push is fixed once it has crossed, and the binding’s type at run time is the view.

This is not a new distinction; it is the buffer-and-view separation Part I already makes, forced at the one place where it cannot be chosen. It is also where the language below draws its own line — a const may hold a &[T] and cannot hold a Vec<T> — so the two boundaries coincide and nothing has to be negotiated between them. The crossing walks the declared type beside the value, down through a struct’s fields and a variant’s payload, so the same computed list is an array for an Array[i64, 3] and a view for a ref Array[i64] (ADR-179). A list of text crosses as an array of views, Array[ref String, N], and ref Array[u8] is the byte buffer.

What crosses: an integer, a u8 with its range checked, a finite float (written so it reads back as the same bits; an infinity or a NaN has no literal and is not written), a bool, text (decoded, so .len() and == at build time answer about the value and not its spelling), a list, a nested list, a pair, a struct, an enum variant with what it carries, and a value of a std type through the parts its ledger entry’s constant column names.

Changed by ADR-318 D5: a std type the compiler cannot see into crosses through its constant column.

D2 — What cannot be written down is refused by what it is, and the message names it.

Not by the parse, which succeeds. A comptime whose value owns memory is refused (NK1167) by its type for a struct and, for an enum, by the variant the value is: Shape::Empty is a const and Shape::Many([1, 2]) is not, though both are a Shape, so the message names the variant and says another variant may cross. A comptime declared String is sent to ref String, never to .to_string(). The way out is the view-shaped equivalent, and where the build has computed a length the help names it.

D3 — A build-time value may be a list, and a body may build it with push.

The build-time evaluator holds a list: the list literal, xs[i], xs[i] = …, xs[i] += …, xs.push(…) and xs.len(). A body may grow a Vec while it runs at build time; by the time the comptime is compared with its declaration the value is computed and its length is a fact, so it lands as D1 says:

fn squares() -> Vec[i64] {
    let mut xs = []
    for i in 0..<5 { xs.push(i * i) }
    return xs
}

comptime TABLE: Array[i64, 5] = squares()   // const TABLE: [i64; 5] = [0, 1, 4, 9, 16];

A list literal in a comptime is answered by the declaration, as a let’s is (ADR-152 D4). A value that is not the declared type is NK1166, and an Array[T] with no length is one: the help writes Array[T, N] with the length the build computed. A table with no declared type takes its element type from its elements.

D4 — An index the list does not have is refused by name, and once.

Changed by ADR-321 D17: the code is NK1244 and the words are the run time’s; NK1165 is retired.

xs[7] of three elements in a body run at build time is NK1165, with the indexes there are. At run time the same read aborts (Part III A.2); at build time there is no run to abort in, and this compiler cannot evaluate it would send the reader looking for a missing feature instead of at the line (Part III C.2). A refusal made by name is said once: NK1127 (cannot evaluate) is kept for a shape the evaluator does not read and is not added beside a named refusal.

D5 — An Array knows its own length in the ledger.

Array::len and Array::is_empty are entries in std.contracts. Without them xs.len() on an array would resolve to no entry, and an unresolved call has an unknown touch set (ADR-292): for i in 0..<xs.len(), the natural spelling, would make a function uncallable from a comptime.

D6 — A map built at build time crosses as a fixed map, and how it is looked up is the compiler’s.

Its keys cannot change after the crossing. That is not a restriction for tidiness: a closed key set is the precondition for a perfect hash, and the crossing is where it closes. The program writes ROUTES.get(name) and never learns what happens inside, the way ADR-010 D5 picks a hasher from the keys’ provenance with nothing in the source mentioning hashing.

D7 — There is no user-visible perfect-hash type, and no build() to call.

A PerfectHashMap with a constructor would freeze one implementation into an interface. The right lookup moves with the size of the table, and a decision whose answer moves with the input belongs where the input is known, which is not the source. What a program says is what it means — this map is fixed — and not how the compiler should honour that.

D8 — A map the build can see is written as a list of pairs, and the declared type says it is a map.

comptime ROUTES: Fixed[ref String, i64] = [("get", 1), ("post", 2)]

No new syntax: the list literal (ADR-135), the tuple, and a use answers a literal (ADR-152 D4) one shape out. An empty list is a table of nothing, since ? fits everything (ADR-024 D1). A map literal could still arrive later and would find this crossing under it.

D9 — What it crosses as is a table, a value in std, and no lookup code is generated.

Fixed[K, V] is a type in std holding four &'static parts — a seed, displacements, keys and values. The comptime lands as a const of that type, and ROUTES.get(k) is an ordinary method call that hands back a V? (ADR-293). A linear scan over the same static arrays is within 17 % of a generated match up to twenty-four keys, past the point where the perfect hash has already won, so a match buys nothing a table does not; and a table is a value a program may pass and store, the emitter stays one that knows no types (ADR-296 D17), and the threshold is data in a const rather than a branch in the compiler.

D10 — Which table is the compiler’s: a scan below twelve keys, a perfect hash from twelve.

Below twelve keys the table carries no displacements and get is a linear scan with a length check. From twelve it carries them and get is one hash, one displacement, one slot, one compare — CHD, the algorithm the phf crate uses. Twelve is where a scan and a perfect hash cross, and anywhere from eight to sixteen costs under 30 % on the wrong side, so the number is not worth moving by two. The hash is FNV-1a and not SipHash, for ADR-010’s reason: the keys are the compiler’s own, there is no remote peer to be protected from, and the faster hash is the honest choice.

D11 — A duplicate key is refused, at the build.

[("get", 1), ("get", 2)] has no meaning a compiler may pick between. It is NK1169, naming the key and both pairs. The failure moves from the first read at run time to the line that wrote it, which is what a comptime is for.

D12 — Keys are text, and any other key is refused by name.

Fixed[ref String, V] is the fixed map. A key that is a number or a bool wants a different table — most likely a dense array, with a measurement of its own — so it is refused with NK1170 rather than hashed as if it were text.

D13 — Where the value is a type the program declares, the table holds a view of it.

Fixed[ref String, Row] lowers to a table of &'static Row, each row’s & promoted to 'static by the const itself, as for a ref Array[T]. get hands back a value, which in the language below has to be Copy, because a get that handed back a reference would put a reference to a reference in the generated file for a table of text and make ?? over it ambiguous. A Nikaia struct is not Copy and a reference to one is, so nothing about get changes: the ledger still promises -> $V?, and TABLE.get(k)?.field ?? … reads a field of the row. What the program gets is a view of a row that lives in the binary, which is the only thing such a table could hand it. An enum is the same case. The rows are written against their declarations, so a ref Array[T] inside one gets its & and an Array[T, N] does not.

D14 — Copy is not derived on a Nikaia struct.

Deriving it wherever every field allows would make a struct’s copyability a consequence of its fields one file away — adding a String field would silently remove the type from every table that held it, and turn quiet copies back into moves elsewhere. It would also silence a check the language has: a Copy value is never moved, so the refusal of a value used after a move (ADR-118 D3) would go quiet for a whole class of types. A copy is written or refused, never inserted (ADR-283 D3, ADR-282 D7), and a derive is an insertion the source cannot see.

D15 — A part of a row the language below cannot write is NK1167, naming the field.

A table’s rows are pairs, and the walk that asks what a const cannot hold reaches into them: Fixed[ref String, Bad] whose Bad holds a Vec[i64] the crossing cannot view is refused by name, not left to the language below to answer about the generated file (Part III C.1).

D16 — A serialised blob is a size optimisation, and admits no type D1 does not.

Writing the value as bytes and casting it back looks like a way to admit types D1 excludes, and it is not:

Where a blob would win is a table large enough that emitting it as source is slow to compile. That is a reason for later, and it admits the same set of types.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.