Status: Accepted
Date: October 5, 2026 (D13-D15: October 6, 2026, #479; D16-D19: October 9, 2026, #525)
Answers: what runs a comptime initialiser’s calls, and what stops one that
does not end
Related:
ADR-100 D1, D3 (a consumer believes a dependency’s ledger while its sources hash as recorded);
ADR-290 D5 (a Rust crate’s description is the consumer’s, reviewed like code, and a judgement rather than a derivation);
ADR-287 D13-D18, D21 (what a build-time body may do; restrict and interpret, no step budget, the call depth; an integer comptime is open, so no type waits for a value);
ADR-177 D1-D3 (a grammar runs while the program is built by compiling the parser it generates, because an interpreter is a second implementation);
ADR-311 (what a result has to be to cross, and how it is written);
ADR-318 D5, D6 (an opaque std type crosses through its constant column; which std functions may run at build time);
ADR-285 D5, D8, D22 (an overflow aborts; the check is carried by the generated project; a literal expression is folded by the checker);
ADR-005 D8, ADR-021 (determinism and the build cache);
ADR-310 (what a build may read)
What executes the code a comptime calls, and what bounds it?
A comptime initialiser that calls a function runs that function while the
program is built. Until now an interpreter inside the compiler did it, and
nothing stopped a body that did not end.
An interpreter is a second implementation of the language. It computed
sq(100000) with fn sq(x: i32) -> i32 as 10000000000, where the program
stops on the overflow; it cannot run a std function written in Rust, such as
5.seconds(); and it is slow enough that fib(30) takes the build minutes. A
build without a step budget hangs without a word, and a budget added later
refuses only programs that already hang is not so: it also refuses slow ones
that end.
| Language | Build-time code runs | Bounded by |
|---|---|---|
Rust const |
rustc’s own interpreter (Miri’s core); no heap values | a lint after a large number of steps, an error by default |
Rust build.rs, proc-macros |
compiled for the host and run, with the dependencies | nothing; full privileges |
Zig comptime |
the compiler’s interpreter | backward branches, 1000 by default, raised with @setEvalBranchQuota |
C++ constexpr |
the compiler’s interpreter | steps (-fconstexpr-steps), loop iterations, operations |
| Nikaia grammars (ADR-177) | the generated parser, compiled and run | — |
What transfers: Rust’s build scripts show that compiling for the host against the dependencies works at scale; Zig and C++ show that a deterministic count is how a build-time evaluator is bounded. What does not: their interpreters, for ADR-177 D1’s reason.
| Option | Why it lost |
|---|---|
| Keep the interpreter and teach it the language’s types | a second implementation that every feature must be built into twice and kept equal |
| Interpret small cases, compile large ones | two implementations, and a size threshold that decides which answer a program gets |
Emit Rust const fn and let rustc’s const evaluation compute it |
no heap: a table built from a file, the main use, cannot be a value there |
| Link the build-time program statically | measured: about 1 050 million instructions to compile and link a call into std, nearly all of it the linker merging std’s dependencies and their debug information; 338 million with the debug information stripped |
Load the build-time code into the compiler’s own process, using the std the compiler contains |
the compiler holds only the part of std it calls, its symbols are not exported, and it is built with its own profile; a std shipped as a dynamic library with the compiler ties it to one set of build options and one platform’s rules for shared libraries |
A persistent host process that loads std once |
saves only the start of one program per package and build (about 36 million instructions measured); not worth its machinery yet |
| A time limit | not deterministic: the same build would pass on one machine and fail on a slower one (ADR-005 D8) |
comptime whose initialiser calls a function is compiled to Rust and run; it is never interpreted.The calls are lowered as the program is, built for the machine that builds and
run there; the program’s answer is the value. One implementation of the
language computes both the build-time and the run-time answer, so an overflow,
a narrowing or a std function behaves at build time exactly as it does when
the program runs: comptime C = sq(100000) stops the build at the
multiplication, in the program’s words.
An initialiser of literals alone — 4 * 1024 — is folded by the checker, which
computes it anyway (ADR-285 D22), and is not compiled.
std’s Rust half and the Nikaia dependencies.ADR-287 D13-D14 still decide it from the ledger: sync, and a
touch set that is empty or the build’s parameters. A function that passes may
run whether this program, std or a Nikaia dependency declares it (D8):
5.seconds() runs from std’s Rust half. A function the ledger does not
describe touches everything and is refused (NK1152). A Rust crate’s never
runs (D9).
Narrowed by D14: the library holds Nikaia code and std only.
Cargo builds the package’s dependencies as it does for the program. Beside the
package the compiler generates one crate that names std and every Nikaia
dependency build-time code may reach and builds it as a dynamic library, from the same compiled
dependencies: a crate type of its own changes nothing the dependencies are
built with, so they are reused and not built again. The build-time code links
against that one library and reaches the dependencies through it.
Measured on a call into std: about 132 million instructions to compile and
link, and 36 million to start and run, against 1 050 million for a static link.
The library is rebuilt when a dependency changes, not when a comptime does.
The shipped program stays statically linked.
Where the target is not the machine that builds, the dependencies build-time code reaches are built a second time, for that machine, as Cargo builds a build script’s.
comptime of a package, and its results are cached.The package’s build-time code is one crate, compiled by rustc directly and run
once; Cargo starts only for the dependencies. Its results are kept under a key
of the code that produced them — each initialiser and everything it calls — the
toolchain and the build’s parameters (ADR-287 D15), so an
unchanged comptime is neither compiled nor run again.
No type waits for a build-time value (ADR-287 D21): the run starts once the ledger has decided what may run, and only the check that a value fits and the writing of the values wait for it.
The build-time program writes each value in an encoding the compiler reads back,
as a grammar run’s does (ADR-177 D4); the crossing to the program
is unchanged. A value the crossing cannot write is still refused by what it is
(NK1167).
The compiled build-time code counts each turn of a loop and each call; past the
budget the run stops, and the build is refused with NK1152, naming the
comptime and the call path it was in, which the run keeps as a stack of
function numbers. The count is the same on every machine, so a build that
passes passes everywhere. The call depth stays bounded as well.
The default is 10 000 000 000 steps, chosen generously: a mistake should cost
the programmer about twenty seconds, not a hung machine, and compiled code
takes on the order of 10⁸ to 10⁹ counted steps a second. It is an estimate, not
a measurement, and it is not tuned to this repository’s own comptimes. A call
into std’s Rust half is one step however much it does inside; D10 bounds what
such a call can allocate.
Changes ADR-287 D17.
A loop that grows a list fills memory faster than a step budget stops it, and
a machine that starts swapping is the hang this record is about. The
build-time crate is generated, so it gets its own global allocator, which
counts the bytes live at each request and stops the run past the bound, refused
with NK1152 like D7. It counts what the program asks for, not what the
operating system grants, so the same program stops at the same request on every
machine of one architecture; and it counts std’s Rust half too, since every
allocation passes through it. The default is 4 GiB.
Rejected: a limit set by the operating system (setrlimit), which macOS
does not enforce for a process’s memory and Windows does not have in that form.
comptime that needs it: comptime(steps: 50G, ram: 16Gi) NAME = ….The bounds are named in parentheses after the word, as a dsl block’s
build-time arguments are (ADR-299 D3), with
ADR-322’s scales; either may be left out, and comptime NAME = …
keeps both defaults (10G steps, 4Gi). A name other than steps or ram
is refused (NK1109). The word keeps the name on its left
(ADR-287 D20), so a package’s constants keep their form.
Not in the manifest, which would raise it for every comptime of the project
because one needs more, and not on the command line, which would make one
project build on one machine and not on another. The bound a comptime asks
for is visible in review where the cost is.
The compiler starts the run, so it knows how long it has been going; after a
few seconds it prints which comptime is still running and for how long. It
is a message, never a bound, and changes nothing the build produces.
A consumer believes a dependency’s shipped ledger while its sources hash as
recorded (ADR-100 D3). That guards against a stale ledger, not
a false one: a package can ship honest hashes beside an entry that says
touches = [] for a function that writes a file. At run time such an entry
misleads the checker; at build time it would let the package’s code read and
write on the building machine. So before a dependency’s function runs at build
time, this build derives that package’s ledger from its sources, keyed by
their hashes and done once per version, and D2’s rule reads the derived
entries. Where they disagree with the shipped ones, the build is refused
(NK1152), naming the package and both answers. A package that ships no
sources has nothing to derive from, and none of its functions runs at build
time.
std’s Rust half.What this compiler can vouch for is what it derives: sync and touches of
Nikaia code, its own and, by D8, a dependency’s. A Rust body gives it nothing to
derive from — Rust reaches the system through unsafe and calls no signature
shows — and a description’s columns are a person’s judgement
(ADR-290 D5), made for ordering statements at run time. A claim
does not become a proof by being written twice. So a Rust crate’s function
never runs at build time, described or not, and neither does a Nikaia function
whose calls reach one: the call is refused (NK1152), naming the Rust
function and the path to it. std’s Rust half is the exception: it is this
project’s own code, reviewed and shipped with the compiler
(ADR-020 D5).
Rejected: believing the description’s touches, which runs a stranger’s
native code on a judgement made for another purpose; and a second, explicit
per-function claim in the description, which is the same judgement written
again.
Added October 6, 2026 (#479).
A grammar’s actions are Nikaia (ADR-177 D2), and what they call
is what any build-time body may call: a function of the grammar’s own package
or of a Nikaia dependency, on entries this build derived (D8), sync and
touching nothing but the build’s parameters (D2). A driver’s grammar resolves
names, decides which column may be NULL and asks the logic package
(ADR-270 D24) in ordinary functions of the driver. A call whose
path reaches a Rust crate or a C function is refused as in any comptime
(NK1152).
D3’s library is built from the Nikaia code of the packages build-time code may
reach and from std, and from nothing else: no library an extern "C" block
links, no Rust crate but std’s. Loading a library runs its constructors (an
ELF .init_array, a Rust crate’s static initialiser) before any function is
called, so a library that is only linked would run foreign code past D9. A
package that mixes Nikaia with extern "C" or a Rust crate is compiled a
second time for the build-time program without those parts; nothing reachable
at build time is lost, since D9 refuses every call that reaches them.
Rejected: linking the package as the program does and relying on no call reaching the foreign part, which leaves the load to run it.
Not an entry in nikaia.toml, which the author of a cloned project writes; not
a grant on the user’s machine, which a README can talk a reader into; not a
sandbox. What build-time code needs - parsing, checking against a schema,
computing tables, asking the logic package - is written in Nikaia, and foreign
code is what the program calls when it runs.
Rejected: a grant held by the user and bound to the hash of the foreign
code, which still runs that code natively with every right once granted; and
running foreign code in a WebAssembly runtime without imports, which would
contain it but brings back the runtime, the wasm32 target and the
determinism asterisk ADR-287 D18 declined, for no build-time
task that needs it.
NK1244, in the words the program would print.Decided in #525.
The run was allowed, it ran, and the code it ran aborted: an overflow, an index
past the end, an unwrap of nothing, a panic(). That is the program’s own
mistake met early, and the fix is the fix of the program, so it gets a code of
its own. NK1152 keeps what the rule refuses before anything runs, and D7 and
D10’s bounds, which are limits of the build rather than mistakes of the code;
NK1127 keeps what this compiler cannot compute. The message is the run
time’s, at the line where it stopped, with the comptime it was computing.
Rejected: keeping NK1152, which sends the reader to the rule a body broke
when it broke none; NK1127, which sends them looking for a missing feature.
NK1165 is retired.Changes ADR-311 D4.
An initialiser of literals the checker folds (comptime Y = XS[7]) stops as
the compiled run does: NK1244, with the run time’s message. NK1165’s own
sentence goes, and the code is not reused. A better message for an index past
the end is written for the run time, and both get it.
Rejected: translating the run’s abort back into NK1165’s sentence, which
makes one mistake read two ways depending on when it is met; D1 is that it
does not.
NK1102.comptime(steps: one()) and comptime(steps: -5) give an argument the bound
does not take, which is what NK1102 says; each gets its own sentence, so a
negative number is not called not a whole number. NK1109 stays for a name
other than steps or ram.
A check made through the library without a place to compile and run
build-time code, which every command-line build has, cannot compute a
comptime that calls a function. It refuses it with NK1127, with a note that
this check has no place to compile build-time code in. A caller is never told a
program is checked when one of its values was never computed.
Rejected: leaving the value open, as the ledger’s pass did, which is quieter and lets a caller take an unchecked program for a checked one.
comptime and the program cannot disagree: there is one implementation.std: a Rust crate stays out of it.std’s runs (D9).comptime changed; a second build of the dependencies when cross-compiling;
and the interpreter’s call-evaluating part goes, with its tests.A Rust dependency’s build.rs and procedural macros. Cargo runs them
natively, with full privileges, while it builds the dependencies, whatever a
comptime does; D8 and D9 do not reach them.