Status: Superseded by ADR-280 — its D1-D4 are D1-D4 there (D3 together with ADR-159 D3)
Closes: ADR-023 §3.2
Supersedes: ADR-023 D7’s {error:full} format spec
Related: ADR-023 D6 (an error knows the site that raised it),
ADR-018 (why the short form is the default)
Part I 7.1 promised that every error carries file, line and a stack trace, “invisibly”. The word doing the damage is every: errors here are the expected kind, one per rejected line or failed parse, so a promise made per error is a promise made a million times in a program that validates a million rows.
ADR-023 §3.2 left the cost open and asked for a number.
valgrind --tool=callgrind, 20 000 errors raised eight frames deep and caught, -O, one binary
per variant. rustc 1.94.0-nightly (8d670b93d 2025-12-31).
| variant | instructions | per error | against the baseline |
|---|---|---|---|
| no trace in the error at all | 37 524 906 | — | baseline |
| a trace captured per error | 603 017 418 | +28 300 | +1507 % |
| the same binary, capture suppressed | 38 536 318 | +51 | +2.7 % |
Sixteen times the whole program. Not a percentage to weigh against ergonomics — a different order of magnitude, for a value almost nothing reads.
And the trap, which is why the number was worth taking. The obvious
implementation — put a Backtrace in the error type and assume it is dormant
until someone asks — is the 1507 % row the moment anything enables capture.
Deduction would have reached “it costs something”; it would not have reached
“a single environment variable moves the program between those two rows”, and
that is the part that decides the design.
The switch is the language’s own, and the reason is a second trap.
Backtrace::capture() consults RUST_BACKTRACE/RUST_LIB_BACKTRACE and is
Disabled unless one of them is set — checked rather than assumed:
capture() |
force_capture() |
|
|---|---|---|
RUST_BACKTRACE unset |
Disabled | Captured |
RUST_BACKTRACE=0 |
Disabled | Captured |
RUST_BACKTRACE=1 |
Captured | Captured |
So an error type built on capture() behind a flag of its own would tell the
reader to set that flag and then silently not capture. D3’s NIKAIA_TRACE is
therefore read once and drives force_capture: one variable decides whether
there is a trace, and it is the one the message names.
The objection to instruction counts is that they answer how much work and not how long, and
that they are blind exactly where memory behaviour, branch prediction or vector width change. So
the same runs under cachegrind:
| D refs | D1 miss rate | LLd miss rate | |
|---|---|---|---|
| no trace | 14 891 477 | 0.2 % | 0.0 % |
| captured | 198 298 276 | 0.0 % | 0.0 % |
Nothing about memory behaviour moved — the miss rate falls, because a stack walk is a tight loop over hot data. The cost is work and only work, which is the case in which an instruction count is not a proxy for the answer but the answer. A change that had moved those rates would have needed a different measurement, and this note is here so that the next reader can tell the two situations apart.
Reproducibility. Callgrind simulates rather than samples: the same binary gives the same count to the digit, and did (37 524 906 twice). That is why it is used here and wall-clock is not — but the number is only stable for this binary, so the toolchain is recorded above. A count taken under a different rustc is not comparable to these, and comparing across time without saying so is the mistake this paragraph exists to prevent.
What would have falsified the decision: a per-error cost under about a hundred instructions. It is 28 300.
The default is no capture and no Backtrace field’s worth of work. NIKAIA_TRACE turns it on for
a process, read once rather than per error.
This is ADR-010’s rule at a second site — “instead of making every program pay for the worst case” — and the measurement is what settles which side of it a trace falls on. Where ADR-010 could pick the cheap thing for the common case and the safe thing for the rare one, here there is no cheap capture: it is 28 300 instructions or nothing.
A runtime switch, not a build one. A build flag that changed whether errors carry a trace would make the error type depend on how the program was built, and ADR-011 D4 says the switch chooses the executor and nothing else. The same source produces the same types.
The chapter promised file, line and a trace on every error. File and line — the site — stay, and cost nothing at run time: ADR-023 D6 has the compiler write the raise site into the binary as text, which is a string constant rather than work. The trace does not stay. A reader of 7.1 must not be able to conclude that a trace is there when it is not.
error.full() on an error raised without tracing ends in (no trace; set NIKAIA_TRACE=1 to capture
one). An operator reading a report must not have to wonder whether a trace was lost, or whether
the program was built without one, or whether this error simply has none.
The same rule covers an error from below Nikaia: it has no site to name, and says “raised below
Nikaia; no site recorded” rather than inventing one. This is ADR-024 D1’s ? as a sentence — the
absence of a claim is itself reportable.
error.full() is a method, not a format specADR-023 D7 said {error:full} needed no new syntax, because a hole may carry a format
spec after the first colon. That was wrong in a way only the implementation shows: a format spec is
passed through to the language below, which has no full, so it would have needed a special case
in the emitter — exactly the “new syntax” the decision claimed to avoid.
A hole already holds an expression, including a call. f"{error.full()}" therefore needs nothing at
all, which is what D7 wanted and did not get.
error.full().std gains nikaia_std::error: Raised (the author’s error plus the site plus an optional
trace), raise, and the Full trait that puts full() on what a catch binds. Display is the
author’s message and nothing else, which is what makes D7’s short form safe in front of a
stranger.throw lowers through raise rather than Box::new, so the site travels with the error.
The emitter writes the enclosing function’s name: ADR-023 D6 asks for a
source-derived key rather than a positional one, and a name is the part of that key this compiler
has. The ordinal within a function waits on the mark table.Full trait is imported where a program can fail, and nowhere else — crates/nikaia-std
compiles its own .nika files, where nikaia_std is not an external crate.NK-2C7 resolving offline to a source line — is
not built. The site is a function name today, which is readable in a log and not yet something a
user can read out over a telephone.NIKAIA_TRACE=1 is Rust’s, with Rust frame names in it. Mapping those back
to .nika lines is the source map of ADR-012 applied to a second input, and it is
not applied yet.