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’sStatus: 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)
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.
| 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).
| 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 |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
[("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.
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.
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.
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.
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).
Writing the value as bytes and casting it back looks like a way to admit types D1 excludes, and it is not:
repr the program never asked about, and padding bytes are a hazard to
ADR-005 D8’s byte-determinism.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.
Fixed type. No perfect-hash type, no build(), no map literal, no
annotation about lookup.std carries one type, Fixed, with get, has, len and is_empty,
and the compiler one table generator beside the crossing. The compiler’s hash
and std’s are one function in two crates with no type between them, so only
a test that runs a program over both table shapes holds them together.NK1165 (index), NK1166 (not
the declared type), NK1167 (owns memory, naming the field or variant),
NK1169 (duplicate key), NK1170 (key not text). NK1127 stays for what
the evaluator cannot read.Fixed built at run time. There is nothing to build it from: the point
is a closed key set, and the crossing is what closes it.Fixed[ref String, Fixed[ref String, i64]] is neither
refused by name nor provided.Copy exists in the language at all. D14 declines to derive it;
it does not say a program may never ask for one.