Wado

WEP: NIR Interpreter (niri) Evolution Plan

Context

The Wado optimizer needs to reduce expressions the source made constant — literal arithmetic, a branch whose condition is known, a pure call with constant arguments — to the value they denote. niri ("NIR Interpreter") is the engine that answers what a NIR expression evaluates to at compile time. Constant folding is its primary consumer; branch pruning, constant propagation, and compile-time function evaluation reuse it.

This WEP records the trajectory so contributors don't re-litigate the design each time we want to fold a richer expression. It states capabilities, not mechanisms: what niri can and cannot evaluate. How it does so is the code's business.

Decision

niri is a partial evaluator for NIR: it reduces what it can and leaves a residual otherwise. Beyond the in-process engine, a complementary wasm-CTFE backend runs full pure calls through a real Wasm runtime, using Wado's effect system as a type-checked purity gate.

Why two backends

niri (in-process) wasm execution
Sweet spot 2+3 → 5, identity simplification, branch pruning fib(20), lookup-table generation, full pure-call CTFE
Cost / call µs ms (codegen + instantiate), amortized via module cache
Partial eval Yes (residuals) No (whole-call)
Coverage Whatever we hand-write All of Wado, for free

These are complementary, not alternatives. niri stays cheap and fine-grained; wasm execution covers anything niri balks at.

Scope boundary against the ValueGraph

niri evaluates pure values: given an expression and what is known about its inputs, what does it denote. The engine's ValueGraph owns everything flow-sensitive: reaching definitions, branch merges, loop and heap-write invalidation, field store-to-load seeding.

The boundary is load-bearing. niri once carried its own per-local map of known fields, with branch-merge and loop-invalidation logic; it was built and then retired once the ValueGraph covered the same ground. Anything that needs to know which definition reaches a use belongs to the ValueGraph, and a proposal to teach niri about control flow between statements should be read as a sign the fact belongs on the other side of this line.

What the line does not forbid is a store over the values the engine itself constructed. Inside a frame niri already executes statements in order, so a value built there — and reachable from nothing the frame did not build — can be written through and read back without asking which definition reaches a use. The program's heap stays the ValueGraph's; the engine's own is the engine's.

Effects are the purity gate

CTFE soundness rests on effect inference: a function admitted for compile-time evaluation is one the effect system called pure. A bug there lets an impure function be evaluated at compile time. This is the same trust already placed in effect checking elsewhere.

Done

Value model:

Bindings:

Control flow:

Calls:

Sequences:

Regions:

TODO

Values the engine cannot represent

Calls

Control flow

Sequences

Regions

Compile-time string formatting

A template whose interpolations are all constant still formats at run time. `n=${42}` reaches the end of the optimizer as a buffer allocation, two byte pushes, a Formatter literal, and a call to i32::fmt_decimal, paying a digit-count loop and a division loop per evaluation — and keeping the formatting code alive in the binary — for four bytes decided at compile time. The same string written "n=42" folds to a deduplicated constant global. Every ${} over constants, every to_string() on a literal, every constant assert message, and every constant ${x:?} pays this.

Nothing here waits on trait dispatch: Display::fmt is monomorphized and devirtualized to a free call before the optimizer runs. The aggregate exit, the store, the frame-executable call, and region recognition together fold the region to the literal the source could have written, after which constant-object globalization deduplicates it and DCE drops the formatting functions no live call reaches. What the remaining coverage waits on is the value model: an interpolation that keeps its Formatter literal needs an enum value for the alignment field and a place-naming value for the &mut buffer field, so a callee's write through f.buf lands in the region's buffer rather than in a copy inside the Formatter aggregate.

Fold the region, not the call. A region constructs its own buffer, so every value inside it is concrete and nothing is assumed about it.

The call-level fold — rewriting one fmt over a constant into push_str(<literal>), which is what a template mixing constant and runtime interpolations needs — claims more than one concrete evaluation shows: that the callee appends the same bytes to every buffer, not just to the one it ran against. #[compiler_item] is where that comes from, as it already does for push_str — the rewrite expanding buf.push_str("abc") into per-byte push is licensed by the marker, not by an analysis of either body.

Mark Formatter's write primitives, not Display::fmt. Marking the trait would extend the trust across every user-written impl, where nothing is checkable; a primitive is one small stdlib function a reader can confirm, the same obligation push_str already carries.

What a marked primitive declares is a region append: everything it does to the buffer happens at or above the length the buffer had on entry, and what lies below is neither read nor moved. That is the stdlib's formatting idiom as written — prepare_int_write reserves a region the digit writers fill backwards, mark / apply_padding appends content and then shifts it to make room for alignment, and fpfmt's writers reserve and slide a fractional tail to insert the point. A strictly-append contract, where bytes land on the end and are never revisited, is not the design: a padded float cannot learn its length before appending, so reaching it would mean formatting through an intermediate buffer, which costs more at run time than the contract is worth. Region append covers all three idioms unchanged and is still one sentence.

What any particular fmt body does is then derived rather than declared: run it against a buffer the engine constructed, and admit the result when every buffer access either went through a marked primitive or landed inside a region one of them just returned. A body that reads the buffer's prior length for its own purposes, or reaches f.buf outside that, is refused — a condition the engine checks rather than an invariant it hopes for.

The markers also keep the buffer plumbing out of the interpreter: a marked push_str is applied by its declared meaning, so grow's undecidable capacity test and realloc_to's prefix copy are never interpreted. The reserved region a primitive hands back is the same place the frame store hands out, so the two capabilities want the same representation.

What is left, each red/green with the fixture first:

A template with any runtime interpolation keeps today's imperative form, including the loop-buffer reuse tmpl_hoist gives it.

wasm-CTFE backend

Complexity

Reduction is monotone — expressions only move toward literal form — and idempotent, and the optimizer's fixed-point loop is the only fixed point: niri does not iterate internally. Each extension should keep the engine's work bounded in the size of what it is asked about; a rule whose cost is quadratic in body size, or that rebuilds a large value per query, is the failure mode to watch for. Everything else about speed is a profiling question, not a design-time one.

Determinism

Out of scope

Open questions