Open and answer this issue on GitHub.
language, compiler, ready · opened 2026-10-05 · updated 2026-10-08 · 13 comments
While deciding #115 and #467, three things were found in build_time.rs, the interpreter that runs a comptime’s calls:
fn sq(x: i32) -> i32 { return x * x }, comptime C = sq(100000) lowered to const C: i64 = 10000000000, while let n = sq(100000) stops the program (attempt to multiply with overflow).std function written in Rust. This is why ADR-318 D6 had to move seconds() into std’s Nikaia half, which NK1209 (no impl i64) blocks (#374).fib(25) took about 7 s and fib(50) hung the build without a word (ADR-287 D16: no step budget).ADR-177 had already refused an interpreter for grammars, because an interpreter is a second implementation of the language. The same argument applies to ordinary code.
opt-level=0, rustc directly, one crate per package, a result cache, running beside the rest of the build, and folding only literal expressions.fib(10), static: 133 M (rustc 44 M, lld 86 M). With bfd instead of lld: 436 M. opt-level=1: 148 M.nikaia-std statically, with the generated project’s own profile (found with cargo build -v; the first attempt used a mismatched rlib): 1 052 M, of which the linker is 948 M. With -C strip=debuginfo: 338 M.std it calls, its symbols are not exported, it is built with its own profile, and a std shipped as a dylib would be fragile across platforms and options.RUSTFLAGS or a profile. It is not true when only a generated bundle crate gets --crate-type dylib and -- -C prefer-dynamic. In that case cargo rustc -p bundle … reported 46 × Fresh and compiled only the bundle. A comptime program reached nikaia_std through bundle::nikaia_std and gave the right result, at the same cost (132 M + 36 M).ADR-321 (0.0.513, 1bc83e5). Changed with it: ADR-287 D16 and D18, ADR-318 D6. Specification: Part II 10.2, Part III 13.2b, NK1127, NK1152.
comptime that calls a function is compiled and run, never interpreted. Literal-only expressions stay folded by the checker.sync, touches ⊆ build parameters), now including std’s Rust half and the dependencies.rustc direct, results cached by code, toolchain and parameters.NK1152.Not decided (ADR-321 §6): the budget’s default and how a program raises it; memory; Windows; whether a grammar run joins the same crate.
<package>-build beside the package in the generated workspace, depending on every dependency a comptime may reach (nikaia-std always). Build it with cargo rustc -p <bundle> --lib --crate-type dylib -- -C prefer-dynamic, in the same target directory and profile as the program.
cargo build of the program, the bundle build reports every dependency Fresh.comptime of the package whose initialiser calls a function into one crate: the initialisers, everything they call, and a main that writes each value in ADR-177 D4’s encoding. Compile it with rustc directly: --edition=2024 -C opt-level=0 -C overflow-checks=on -C debug-assertions=off -C prefer-dynamic --extern bundle=… -L dependency=…, with paths through bundle::. Run it with the bundle’s and the toolchain’s library directories on the loader path.
comptime FIB_10: i64 = fib(10) gives 55; comptime D = 5.seconds() runs from std’s Rust half.comptime C = sq(100000) with an i32 sq stops the build at the multiplication, with the program’s message mapped to the .nika line (ADR-300). Test.sync, or that touches anything but the build’s parameters, or that no ledger describes, is NK1152 before anything is compiled.
println in a callee; an undescribed Rust dependency function.NK1152, naming the comptime and the call path. The call depth stays bounded.
fib(50) is refused rather than hanging; fib(20) passes.NK1116’s fit check (ADR-287 D21, #467) and the writing of the values wait for it.--target is not the host, build the dependencies the bundle reaches for the host as well.
--target wasm32-unknown with a comptime that calls std.build_time.rs and its tests. Keep the checker’s fold for literal expressions (ADR-285 D22).seconds() rewritten in Nikaia (ADR-318 D6 changed).5c616a8)After ADR-321 the owner asked whether foreign code could slip harmful code through its ledger. Checked:
nikaia.contracts while its sources hash as recorded (ADR-100 D1, D3). That protects against a stale ledger, not a false one: a package can ship honest hashes next to touches = [] on a function that writes files. A published package without sources is believed outright. --locked re-derives only path dependencies.NK2505 on moved sources). But touches cannot be derived from Rust: it is a person’s judgement.build.rs and proc-macros of Rust dependencies natively, regardless of anything Nikaia does.NK1152, naming the package and both answers. A package without sources runs nothing at build time.build_time = true. The column is hand-written, absent means no, and nikaia describe never writes it. std’s Rust half is exempt.build.rs and proc-macros.Specification: Part II 10.2, Part III 13.5 (new build_time column), NK1152.
4a. Derived ledger for build-time calls. For every Nikaia dependency whose functions the comptime crate reaches, derive its ledger from its sources (the same inference as --locked), cached by the hashes in its nikaia.derived. Check D2’s rule against the derived entries.
touches = [] for a function that calls println is refused with NK1152, naming the package, the function and both entries.4b. No sources, no build-time run. Test: a dependency with a ledger and no sources, called from a comptime, gives NK1152.
4c. build_time column. Read it from contracts/<crate>.contracts. The describer never writes it; check nikaia describe output in a test.
sync = true, touches = [] and no build_time, called from a comptime, is refused, and the help names build_time = true. With the column it runs.std’s Rust half (5.seconds()) runs without it.Generated by Claude Code
std’s runs at build time (3a23bb2)The build_time = true column from the previous comment was the session’s own choice and was not discussed with the owner. The owner rejected the principle behind it: the compiler can vouch only for Nikaia code, whose sync/touches it derives. A Rust crate’s description is a judgement, and a second claim beside it is the same judgement written again. The column is gone.
Now (D9): a Rust crate’s function never runs at build time, described or not. Neither does a Nikaia function whose calls reach one. Both are refused with NK1152, naming the Rust function and the path to it. std’s Rust half is the exception: it is this project’s own code. D2 and D3 are narrowed to match: the bundle names std and the Nikaia dependencies.
Step 4c is replaced:
4c. No foreign Rust at build time.
comptime calling a described Rust-crate function (sync = true, touches = []) is refused with NK1152, naming the function.comptime calling a Nikaia function that calls it is refused too, naming the path.5.seconds() from std runs.Steps 4a and 4b (derived ledger for Nikaia dependencies; no sources, no build-time run) stand.
Generated by Claude Code
nikaia build lowers every member in the driver, before cargo build. The rustc wrapper lowers each member a second time inside Cargo, and that second lowering hits the build cache. So:
Reads::building_in, beside the build cache, with its own target directory), which the grammar runs use. The bundle and the comptime crates go there. That is a separate target directory from the program’s, so the second lowering inside Cargo can never collide with Cargo’s lock. The cost is that std is built once more per machine for build time, not once per program.cargo rustc --lib --crate-type dylib -- -C prefer-dynamic on a bundle pub extern crate nikaia_std; builds libnikaia_bundle.so (34 s cold, debug).rustc --edition=2024 -C prefer-dynamic --extern nikaia_bundle=….so --extern nikaia_std=<the rlib in deps> -L dependency=… is 17 KB and links nikaia_std from the dylib. The second --extern is what lets nikaia_std::… paths resolve in submodules; a use bundle::nikaia_std reaches only the crate root.rustc --print target-libdir on LD_LIBRARY_PATH.5.seconds() gives 5. An i32 x * x with x = 100000 panics with attempt to multiply with overflow.nikaia-std only).comptime for now: the program lowered without main, plus fn __nikaia_comptime_value() -> T { return <initialiser> }, plus a main that dumps it with grammar_run’s dumper.rustc directly.rustc -vV and the bundle.comptimes are cache hits when the sub-program is checked.check with no workshop and for initialisers that read an asset or run a grammar.fib(10), sq(100000) stops the build, 5.seconds() inside a callee.fib(50) is refused.Which code reports a build-time run that stops as the program would, for example on an overflow (step 3)?
NK1152 is not allowed, and its text now also carries the budget and the depth.NK1127 is cannot be computed.comptime C = sq(100000) has run and aborted at the multiplication.Until this is answered, stage 1 reports it as NK1152. The message names the comptime, the runtime’s message (attempt to multiply with overflow) and the .nika line, where the runtime reports one. The code stays easy to change.
Generated by Claude Code
f88f917, 72582eb)The owner wanted a way to say how long build-time code may run. The point is not protection against malicious code but protecting the programmer from themselves: one wrong line and the machine hangs. The discussion found two ways a machine hangs. An endless loop is one, and the step budget covers it. Memory filling up and the system swapping is the other, and a step budget stops it too late. The owner then asked how to measure both:
std’s Rust half counts as one step.#[global_allocator] that counts live bytes. It is deterministic per architecture, works on every platform, and covers std too. OS limits (setrlimit) were rejected because macOS does not enforce them and Windows has no equivalent in that form.comptime raises a bound at its own declaration, not in the manifest and not on the command line.Not decided: the spelling a comptime uses to raise a bound (ADR-321 §6). It is being asked now.
NK1152 names the comptime, the bound and the call path.
fib(60) is refused with the path fib → fib → …; fib(25) passes.#[global_allocator] in the comptime crate: live bytes, abort past 4 GiB by default with NK1152.let mut xs = []; while true { xs.push(1) } is refused by the memory bound, not the step budget."x".repeat(5_000_000_000) from std is refused.
5b. Raising a bound at the declaration. Wait for the spelling decision. Until then the defaults are fixed.
5c. Progress message. When a run passes a few seconds, the compiler prints `TABLE` in config.nika is running (5 s), at most every few seconds. Test with a fake slow run: the message appears and the build output is unchanged.Generated by Claude Code
comptime that calls a function is compiled and runHow it works:
comptime_run.rs builds a sub-program: the file without its main, the earlier comptimes as the literals they came to, and fn __nikaia_comptime_value() -> T { return <initialiser> }. Its main writes the value in the grammar run’s encoding.std, built once in the workshop. The bundle is built with cargo rustc --crate-type dylib -- -C prefer-dynamic, and the program is compiled with rustc directly.rustc -vV and the bundle (D4). A stop is cached too.What it does now:
comptime C = sq(100000) with an i32 sq is NK1152, C stopped while the program was built: attempt to multiply with overflow., with the caret on return x * x (step 3).text::digit_value('7') inside a callee gives 7. The interpreter refused it with NK1127.Limits of this stage, where the interpreter still answers:
asset;Build::counts_steps makes the emitter write nikaia_std::build_time::enter("f") into every function’s opening brace and step() into every loop body.
NK1152, F took more than … steps, with the path (It was in SPUN > spin.).fib(50) are refused; fib(20) passes.#[global_allocator] in the run’s own program is not used when std is linked dynamically: a counter in it stayed at 0. So the counting allocator lives in the generated bundle, and the run’s main sets the bound. Past 4 GiB: held more than … bytes at once.X is still being computed while the program is built (N s).Reads::bounded), so the tests ask for smaller ones.Tests: tests/comptime_compiled.rs (9). The bench and example lowerings are unchanged.
Callgrind, instructions, on a loop that only counts:
| opt-level | instructions per counted step |
|---|---|
| 0 (step 2’s setting) | 37 |
| 1 | 13 |
The slope was taken over 10 million steps, with step() as one relaxed fetch_sub on a static. That puts the default of 10¹⁰ steps at about 3.7·10¹¹ instructions at opt-level=0. That is minutes, not D7’s estimate of about twenty seconds. opt-level=1 would be about 1.3·10¹¹. The ADR measured opt-level=1 at 148 M instructions to compile, against 133 M.
Question: is the default meant to be reached in about twenty seconds? If so:
opt-level=0, oropt-level=1.Stage 3: one crate per package (D4), beside the rest of the lowering (D5).
Generated by Claude Code
Each package passes the build_time, comptime, grammar, project and examples tests, the bench and example lowerings are byte-identical, and clippy is clean.
std or from the program: "abc".to_uppercase() gives "ABC", and the interpreter had refused it. D2 is now read off the ledger entry derived for the sub-program’s value function. That covers every call it reaches, methods included.modules lowers a package.sq, in another file of the program.asset("…") together with a call (twice(asset("data.txt"))).
asset before lowering.NK1175 and NK1176 are reported before anything is compiled.comptimes of any shape are carried in. Lists, tuples, structs and variants are written as literals (ORIGIN.scaled(10).sum()).grammar.
winnow and winnow_grammar, so such a program compiles its ordinary comptimes.Cfg::file(…)) still goes through the grammar run (ADR-177 / §6).build_time.rs and the others) onto a shared workshop, then report NK1127 where there is no workshop.contracts::evaluate_defaults evaluates them during ledger inference, with Reads::none(). Compiling them means handing the workshop down to the inference, a pipeline change of its own.build_time.rs and stays there.Ty::Unknown).Then stage 3 (one crate per package, run beside the lowering) and stage 5 (D8/D9 dependencies, cross-compiling).
Generated by Claude Code
The 47 tests in tests/build_time.rs now run through a workshop, as a build does, so they exercise the compiled path. 43 passed unchanged.
The D2 refusal now names the callee as the call resolves: io::read_to_string by its path, Reader::read by its key.
Three tests now expect ADR-321’s answers instead of the interpreter’s:
| Case | Before (interpreter) | Now (compiled) |
|---|---|---|
forever(n + 1) |
too deeply (depth 128) | went more than 10000 calls deep |
comptime N = io::read_to_string() |
NK1127 |
NK1152, You can’t call io::read_to_string… (std’s Rust half is under D2 now) |
xs[7] on an Array[i64, 3] in a callee |
NK1165, help The indexes go from 0 to 2. |
NK1152, BAD stopped while the program was built: index out of bounds: the len is 3 but the index is 7., with the caret on return xs[7] |
One more question for the deciding session, beside the stop code asked above. Part III’s table still describes NK1165 as an index into an Array at build time.
NK1165 be retired, along with whatever code a stopped run gets?NK1165’s sentence?Today the program’s runtime message is the same one the program gives when it runs. So the build-time and run-time words now agree, which is D1’s point.
Generated by Claude Code
a6525af)comptime(steps: 50G, ram: 16Gi) TABLE = build_table(asset("data.csv"))
The decision round weighed these forms:
dsl sqlite(schema: APP) does (ADR-299 D3);; at the end of the line;@comptime(…) annotation. Rejected: Nikaia has no annotation system outside grammars, and building one for this case is too much;comptime(expr; …) as an expression. Rejected: all 60 real comptimes in the repository are package-level, and that would need a top-level let, or package constants turned into functions, which loses ADR-287 D21’s open number;comptime(step=1G, ram=4G) NAME =. Adopted, with : instead of = (named arguments everywhere are name: value) and with scale literals from ADR-322 (#469’s build), including Gi for binary.5b. Bounds at the declaration.
comptime(steps: <int>, ram: <int>) NAME = … at item level and in a body. Either name may be omitted, and comptime NAME = … keeps the defaults (10G steps, 4Gi). Any other name gives NK1109.50G / 16Gi.comptime(steps: 100) X = fib(20) is refused by the step budget, while comptime X = fib(20) passes.comptime(steps: 60G) X = fib(45) passes where the default would refuse.Generated by Claude Code
047df7b): the ledger’s pass computes no comptimeThe ledger’s inference checks every file to learn what each call resolves to. That check used to compute every comptime and every computed default through the interpreter, and then the real check compiled and ran them again.
check_for_the_ledger is the same walk, but it leaves build-time values alone and reports nothing about them.Shape::Num(1.5)) is no longer treated as a call to compile.What remains on the no-workshop path (stage 4, found by making that path panic and running the suite). Only callers of the check API that pass no workshop still reach it:
comptime.rs, comptime_item.rs, option_defaults.rs, modules.rs and specification.rs;The next step is to move those tests onto a shared workshop.
744d8e6): comptime(steps: …, ram: …) (step 5b)comptime_compiled.rs:
comptime(steps: 100) F = fib(20) is refused, both at item level and in a body.comptime(steps: 1M) passes where a default of 1 000 refused.comptime(ram: 64Mi) stops a list that grows without end at 67108864 bytes.comptime(stpes: 5) gives NK1109 with Did you mean steps?.fib(45) and steps: 60G is not written. At opt-level=0 it takes minutes, as measured above. The 1M-against-1 000 test checks the same thing.Question for the deciding session: what does a bound get whose value is not a whole number the build knows (comptime(steps: one()), or a negative one)?
NK1102 for now: steps takes a whole number the build knows, and this is not one. Nothing runs.NK1102 is described as an argument is not what the parameter takes, which is close. If it should be NK1109 or another code, that is a one-line change.Generated by Claude Code
e3d4d1d, 0.0.533 3e46d96)What changed:
tests/common now has workshop(), reads(), infer() and checked(). These test files use them: comptime.rs, comptime_item.rs, option_defaults.rs, modules.rs and specification.rs.specbook::verdicts and report take a workshop. The example uses the user’s build-time workshop, as a build of a loose file does. EXPECTED.txt is unchanged.emit_program_reading now also compiles defaults in its workshop. The workshop for defaults is kept per thread, so tests running side by side no longer share it.comptime a second time through the interpreter: Needs::of asked each file what it uses with a full check. Build::leaves_values now skips the values there."hallo".to_uppercase(). That is now computed, as "HALLO".What still reaches the interpreter. Checked by making that path report itself and running every build-time test file:
Next:
build_time.rs (step 9).Question for the deciding session. Once the interpreter is gone, what does a check with no workshop answer for a comptime that calls something? This is the library API, e.g. check::check. A CLI build always has a workshop.
NK1127, can’t be computed while the program is built, with a note that this check has no place to compile build-time code.(a) is more honest toward a caller that wants an answer. (b) is quieter, but a caller could then take a program as checked although its comptime was never run. Until this is answered, the interpreter stays for exactly this case.
The note on an undeclared callee is also stale and goes with the interpreter. It still reads “it comes from std or a package, which is compiled Rust … the compiler can only run code written in your program”, which ADR-321 made untrue.
Generated by Claude Code
cc27792): step 1 of Next, a run reads the defaults the program’s ledger computedWhat was broken: a default that only std’s Rust half can compute (mark: ref String = "!".repeat(3)) was refused with NK1127 and the interpreter’s note. The run that computes it lowers every function of the program. A function beside it that calls shout("yo") and leaves mark out had no value for it, so rustc refused the run, and the interpreter answered.
What changed:
comptime’s run (comptime_run::compute(…, outer)). The run’s own inference takes each default from that ledger and no longer computes it again without a workshop.unreachable!("a default not computed yet"). A run can only reach it through a ring, which NK1168 refuses first. If a run does stop there, its default was refused, and the run is answered as before.ref String default is computed as the String it views (comptime_run::as_built), as a comptime’s value is.String option with a text default was handed "…" (a &str) and refused by rustc.Tests in option_defaults.rs:
a_default_std_computes_reaches_a_comptime, with ref String and with String;a_text_default_for_an_owned_string_is_one.Still open:
build_time.rs.NK1165, NK1102 for a bound, and what a check without a workshop answers.Generated by Claude Code
The four questions asked in the comments above, gathered in #525 (decision):
NK1165;Step 9 waits on the last one. Stages 3 and 5 do not wait on any of them, so this issue stays ready.
Generated by Claude Code
develop, 0.0.655)Built since the last comment:
build_time_dependencies.rs.Still open:
comptime still compiles a sub-program of its own. Only tests/build-time has comptimes that call something (28, plus 2 in std); no example or benchmark does. So this is first-build cost for one test package for now. Warm builds hit the result cache.comptime_run::by_package), never the shipped ones, so a false touches cannot slip through. There are two places a refusal could fire, and neither exists yet:
--locked refuses a mismatch).--target says, which is what step 8 asks. There is no test: wasm32-unknown is refused before anything is built (std needs mmap and threads there), and this machine has no other Rust target installed.Generated by Claude Code