Issue #468: Build ADR-321: compile and run build-time code against the dependencies, with a step budget

Open and answer this issue on GitHub.

language, compiler, ready · opened 2026-10-05 · updated 2026-10-08 · 13 comments

How it came up

While deciding #115 and #467, three things were found in build_time.rs, the interpreter that runs a comptime’s calls:

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.

The decision round, briefly

  1. Interpreted or compiled? The owner asked what to expect: compiling should have a fixed cost and win on large tasks. The levers: opt-level=0, rustc directly, one crate per package, a result cache, running beside the rest of the build, and folding only literal expressions.
  2. Measured, compiled path only (callgrind, instructions; the owner asked not to measure the interpreter):
    • fib(10), static: 133 M (rustc 44 M, lld 86 M). With bfd instead of lld: 436 M. opt-level=1: 148 M.
    • Cargo alone: 206 M.
    • Linking 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.
  3. The owner asked why linking is so expensive. Static linking merges the dependencies and their debug information into every binary. The owner proposed linking into the compiler’s own process. That was rejected: the compiler holds only the part of 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.
  4. Dynamic linking, measured: 132 M to build (rustc 83 M, lld 47 M) and 36 M to start and run (the loader binding a 29 MB library).
  5. The owner asked about project dependencies and remembered that Cargo rebuilds everything for a dylib. Checked: that is true when the difference goes through 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).

The decision in short

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.

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.

To build

  1. The bundle crate. Generate <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.
    • Test: after cargo build of the program, the bundle build reports every dependency Fresh.
  2. The comptime crate. Lower every 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.
    • Tests: comptime FIB_10: i64 = fib(10) gives 55; comptime D = 5.seconds() runs from std’s Rust half.
  3. Same semantics. 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.
  4. Ledger rule, unchanged. A callee that is not sync, or that touches anything but the build’s parameters, or that no ledger describes, is NK1152 before anything is compiled.
    • Tests: println in a callee; an undescribed Rust dependency function.
  5. Step budget. The lowering counts each loop turn and each call in the comptime crate; past the budget the run stops and the build gives NK1152, naming the comptime and the call path. The call depth stays bounded.
    • Test: fib(50) is refused rather than hanging; fib(20) passes.
    • The default number and how a program raises it are open (ADR-321 §6): use a provisional constant, mark it in code, and prepare the question for the owner.
  6. Result cache. Key: each initialiser and its transitive callees’ text, the toolchain, and the build parameters (ADR-287 D15).
    • Test: a second build with no change neither compiles nor runs the comptime crate. Changing a callee re-runs it.
  7. Parallelism. Start the comptime build as soon as the ledger has decided what may run. Only NK1116’s fit check (ADR-287 D21, #467) and the writing of the values wait for it.
  8. Cross-compiling. When --target is not the host, build the dependencies the bundle reaches for the host as well.
    • Test: --target wasm32-unknown with a comptime that calls std.
  9. Remove the call-evaluating part of build_time.rs and its tests. Keep the checker’s fold for literal expressions (ADR-285 D22).
  10. Follow-ups:
    • #374’s steps 5–8 no longer need seconds() rewritten in Nikaia (ADR-318 D6 changed).
    • #467’s step 5 (typed arithmetic at build time) is this issue’s step 3.

Comments

keywan-ghadami, 2026-10-05

Added: ADR-321 D8 and D9 — which dependency code may run at build time (0.0.515, 5c616a8)

How it came up

After ADR-321 the owner asked whether foreign code could slip harmful code through its ledger. Checked:

Decision

Specification: Part II 10.2, Part III 13.5 (new build_time column), NK1152.

To build (additional steps, after step 4)

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.

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.


Generated by Claude Code

keywan-ghadami, 2026-10-05

Correction: ADR-321 D9 — no Rust code but 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.

Steps 4a and 4b (derived ledger for Nikaia dependencies; no sources, no build-time run) stand.


Generated by Claude Code

keywan-ghadami, 2026-10-05

Build plan (the building session)

Checked before starting

Stages, one package each

  1. Compiled path, std only.
    • The bundle (nikaia-std only).
    • One comptime crate per 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.
    • Compiled with rustc directly.
    • The result cache keyed by the crate’s text, rustc -vV and the bundle.
    • Earlier comptimes are cache hits when the sub-program is checked.
    • Used where the build has a workshop and the initialiser calls something.
    • The interpreter stays, until stage 4, for a bare check with no workshop and for initialisers that read an asset or run a grammar.
    • Tests: fib(10), sq(100000) stops the build, 5.seconds() inside a callee.
  2. Step budget and call depth (D7, step 5) in the lowering of the comptime crate; fib(50) is refused.
  3. One crate per package (D4), parallel to the rest of the lowering (D5, step 7).
  4. The interpreter’s call path goes (step 9). The tests move to a workshop.
  5. Nikaia dependencies in the bundle with derived ledgers (D8, 4a/4b); no foreign Rust (D9, 4c); cross-compiling (step 8).

Question for the deciding session

Which code reports a build-time run that stops as the program would, for example on an overflow (step 3)?

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

keywan-ghadami, 2026-10-05

Added: bounds for a build-time run (ADR-321 D7, D10–D12; f88f917, 72582eb)

How it came up

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:

Owner’s answers

  1. A comptime raises a bound at its own declaration, not in the manifest and not on the command line.
  2. No measurement first. Generous defaults: 4 GiB, and a step budget sized for about 20 s. Taking 10⁸–10⁹ counted steps a second, that is 10 000 000 000 steps. This is an estimate.
  3. A memory bound: yes.
  4. A progress message for a long run: yes.

Not decided: the spelling a comptime uses to raise a bound (ADR-321 §6). It is being asked now.

To build: step 5 replaced, 5a–5c added

  1. Step budget. Lower a counter into the comptime crate: decrement at each loop back-edge and function entry, and abort past the budget, which is 10¹⁰ by default. Keep a stack of function numbers for the message. NK1152 names the comptime, the bound and the call path.
    • Test: fib(60) is refused with the path fib → fib → …; fib(25) passes.
    • Measure the counter’s overhead with callgrind on a tight loop, with and without the counter (instructions), and record it in the issue. 5a. Memory bound. A counting #[global_allocator] in the comptime crate: live bytes, abort past 4 GiB by default with NK1152.
    • Test: let mut xs = []; while true { xs.push(1) } is refused by the memory bound, not the step budget.
    • Test: "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

keywan-ghadami, 2026-10-05

Stages 1 and 2 are built

0.0.518 (4334347), stage 1: a comptime that calls a function is compiled and run

How it works:

What it does now:

Limits of this stage, where the interpreter still answers:

0.0.519 (3651a17), stage 2: the bounds

Tests: tests/comptime_compiled.rs (9). The bench and example lowerings are unchanged.

Measured for the deciding session (D7’s default)

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:

Next

Stage 3: one crate per package (D4), beside the rest of the lowering (D5).


Generated by Claude Code

keywan-ghadami, 2026-10-05

The compiled path covers more (0.0.520–0.0.524)

Each package passes the build_time, comptime, grammar, project and examples tests, the bench and example lowerings are byte-identical, and clippy is clean.

Where the interpreter still answers (stage 4 is to remove it)

  1. A check with no workshop. This is the library API the unit tests use. Every CLI build has a workshop. The plan: move the tests (build_time.rs and the others) onto a shared workshop, then report NK1127 where there is no workshop.
  2. Option defaults (ADR-318). 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.
  3. Grammar entries in an initialiser. These go through the grammar run, which compiles rather than interprets. Its dispatch lives in build_time.rs and stays there.
  4. An initialiser whose type has no written form here (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

keywan-ghadami, 2026-10-05

0.0.525 (4df38b9): build_time’s tests run on the compiled path

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.

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

keywan-ghadami, 2026-10-05

Step 5b decided: the spelling (ADR-321 D11, a6525af)

comptime(steps: 50G, ram: 16Gi) TABLE = build_table(asset("data.csv"))

How it was decided

The decision round weighed these forms:

To build

5b. Bounds at the declaration.


Generated by Claude Code

keywan-ghadami, 2026-10-05

0.0.528 and 0.0.530: the ledger’s pass stops computing comptimes; step 5b is built

0.0.528 (047df7b): the ledger’s pass computes no comptime

The 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.

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:

The next step is to move those tests onto a shared workshop.

0.0.530 (744d8e6): comptime(steps: …, ram: …) (step 5b)

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)?


Generated by Claude Code

keywan-ghadami, 2026-10-05

Stage 4: the tests run through a workshop (0.0.532 e3d4d1d, 0.0.533 3e46d96)

What changed:

What still reaches the interpreter. Checked by making that path report itself and running every build-time test file:

Next:

  1. Hand the outer ledger’s computed defaults to the sub-program, so the run has nothing to compute again.
  2. Then remove the call-evaluating part of 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.

(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

keywan-ghadami, 2026-10-06

0.0.600 (cc27792): step 1 of Next, a run reads the defaults the program’s ledger computed

What 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:

Tests in option_defaults.rs:

Still open:


Generated by Claude Code

keywan-ghadami, 2026-10-07

The four questions asked in the comments above, gathered in #525 (decision):

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

keywan-ghadami, 2026-10-08

Where this stands (checked against develop, 0.0.655)

Built since the last comment:

Still open:

  1. D4/D5, stage 3: one crate per package, run beside the build. Not built. Each 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.
  2. D8’s refusal (step 4a/4b): no case reaches it today. The run already reads the entries derived from each dependency’s sources (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:
    • A path dependency’s shipped ledger cannot disagree, because every build rewrites it (and --locked refuses a mismatch).
    • A package without sources cannot be depended on until #99. Both tests belong to #99’s build.
  3. Step 8 (cross-compiling). The bundle and the run are built for the host whatever --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.
  4. Step 9 waits on #525’s question 4.

Generated by Claude Code

Back to Open Issues