Emission rung-1 probe — pure ⊥-row arithmetic → real .wat, run on wasmtime
Verdict (one sentence). Rung 1 of ◊5.5 (ADR-0059's "pure → native Wasm") is TRACTABLE and DEMONSTRATED: a structural emitter over the typed
Compturns closed ⊥-row integer arithmetic into core-wasm.wat, and four sample programs ran onwasmtime45 with values MATCHINGSource.eval— the first time bang output has executed outside Lean. The load-bearing design finding is emit from the typedComp, NOT fromCode:compileconstant-folds the pure fragment to a singleRET v, so theCodepath is degenerate (it would emit the interpreter's answer, not a compiled program).
Spike branch spike-emission-rung1. Artifacts (all additive/leaf — no proof-bearing file
touched): Bang/Backend/WasmEmit.lean (the emitter + by decide/simp self-tests, axiom
set [propext]), EmitMain.lean (leaf runner exe emit-rung1), tools/emit-rung1-diff.sh
(the wasmtime-vs-Source.eval differential harness), scratch/EmitProbe.lean (the
compile-folds-arithmetic finding, by rfl).
1 · The side-by-side — bang output on a real engine (deliverable 2)
sample program wasmtime oracle verdict
prog0 1 + 2 3 3 OK
prog1 let x = 1 + 2 in x * 3 9 9 OK
prog2 let x = 5 in x + 10 15 15 OK
prog3 let x = 2*3 in let y = x+4 in y-1 9 9 OK
wasmtime = wasmtime run --invoke main progN.wat (real engine, core wasm 3.0, no GC / no
exceptions / no imports — runs on ANY engine). oracle = Source.eval 1000 progN (the
kernel reference). Reproduce inside nix develop: bash tools/emit-rung1-diff.sh (exit 0 =
every module ran with a matching value; a mismatch is a LOUD exit 1).
The emitted .wat for prog1 (let x = 1 + 2 in x * 3):
(module
(func $main (export "main") (result i64) (local i64)
(local.set 0 (i64.add (i64.const 1) (i64.const 2)))
(i64.mul (local.get 0) (i64.const 3)))
)The de-Bruijn binder let x = … becomes a wasm local (local.set 0 / local.get 0);
+/* become native i64.add/i64.mul; the arithmetic is preserved AS wasm computation,
not precomputed. This is exactly ADR-0059 rung 1 ("pure → native Wasm, engine codegen").
2 · The hop question — emit from Comp, not from Code (deliverable 1's SAY-WHY)
The task asked which hop rung 1 wants (compile → Code, or directly from the typed Comp).
Answer: the typed Comp. The evidence (scratch/EmitProbe.lean, all by rfl):
compile (binop add (vint 1) (vint 2)) [] = [RET (vint 3)] -- FOLDED at compile time
compile (letC (binop add 1 2) (binop mul #0 3)) []
= [RET (vint 3), SUBST (binop mul #0 3)] -- the residual re-compiles at RUNTIME
Two facts kill the Code path for a static emitter:
compileconstant-folds closed arithmetic.binop op (vint a) (vint b)collapses ontoRET (op.eval a b)at compile time (AbstractMachine.lean:452, invariant #4 — the calculation forces it). Socompile prog0 [] = [RET (vint 3)]: the answer, already computed. Emitting wasm from that would produce(i64.const 3)— a module that returns the interpreter's result and exercises no wasm arithmetic at all. It would "pass" the diff while proving nothing.SUBST/APP/CASEcarry RESIDUALComps thatexecre-compiles AT RUNTIME under fuel (AbstractMachine.lean:2095 —exec … (compile (Comp.subst v N) c) …). A static emitter cannot consume aSUBST Ninstruction without running the interpreter to know whatN[v]compiles to. TheCode/execpair is a fuel-driven CK interpreter, not a static lowering IR.
So the honest rung-1 emitter is a structural recursion over the typed Comp (WasmEmit.emitComp)
that maps each pure former to native wasm, keeping arithmetic as a wasm computation. This matches
CBPV's value/computation split cleanly: Val → an i64-leaving expression, Comp → the function
body. The Code-hop is the RIGHT input for the proof (compile_forward_sim targets the GC-frame
machine that consumes Code); it is the WRONG input for pure static codegen, because the pure
grade is exactly where the calculation already collapsed the control structure away.
This is not a contradiction with the two-hop architecture. The verified hop (
compile_forward_sim) proves the interpreterexec ∘ compilesimulatesevalD. The emitter is a THIRD, tested-stratum artifact that lowers the pure grade directly — its oracle isSource.eval(invariant #1), reached across the engine boundary. See §5 for what a proof-grade emitter would instead require.
3 · The rung-1 instruction / former map (deliverable 3's enumeration)
Because we emit from Comp (not Code), the map is former → wasm, and the calculated-VM
Instr column records what the SAME former becomes in the interpreter (for cross-reference):
Comp/Val former (pure) | calculated-VM Instr | wasm 3.0 emission | status in the spike |
|---|---|---|---|
Val.vint n | (operand of RET/binop) | (i64.const n) | ✅ emitted |
Val.vvar i (de Bruijn) | (operand) | (local.get $lᵢ) via a depth→local map | ✅ emitted |
Comp.ret v | RET v | the value expression (leaves one i64) | ✅ emitted |
Comp.binop {add,sub,mul} v w | folds → RET | (i64.add/sub/mul …) | ✅ emitted |
Comp.binop div v w | folds → RET | (i64.div_s …) | ⚠️ emitted, /0 mismatch (§4) |
Comp.binop {lt,eq} v w | folds → RET | sum-encoded bool → rung-1.5 | ⛔ refused (loud) |
Comp.letC M N | SUBST N (runtime recompile) | (local.set $k …M…) then …N… | ✅ emitted (wasm locals) |
Comp.force (vthunk M) | erases → compile M | inline the body (no call) | ◻ stretch (mapped, not emitted) |
Comp.lam / Comp.app | LAMI / APP | wasm func + call (non-recursive) | ◻ stretch (mapped, not emitted) |
Comp.case/split/unfold | CASE/SPLIT/RET | struct/sum rep → rung-1.5 | ⛔ refused (loud) |
perform/handle | OP/HANDLE/THROW/UNMARK | rung 2/3 (§6) | ⛔ refused (loud) |
Refusals are fail-loud (Emit.unsup <reason>, invariant #1): an out-of-fragment former is a
NAMED refusal, never a silent wrong emission. The self-tests assert both directions —
emitModule progN |>.isOk = true on every pure sample, and = false on an effectful/vunit
former.
The i64 choice: bang Int is unbounded; the spike models it as wasm i64 (rung-1 samples fit).
A faithful bignum rep is a rung-1.5 item (a boxed/memory big-int runtime, or a checked i64 with
overflow→trap); the note flags it, the spike does not chase it.
4 · What rung 1 STUBBED (honest gaps in the demonstrated slice)
- Unbounded
Int→ i64. Samples fit i64; large-magnitude arithmetic would silently wrap in wasm where the kernel is exact. A rung-1.5 fix (bignum runtime, or trap-on-overflow). NOT a soundness hole for the demonstrated corpus, but a named scope edge. div_svs total kerneldiv. wasmi64.div_straps on/0; the kernelBinOp.evaldivis TOTAL (a / 0 = 0, LeanIntdivision — IR.lean:184). So a program dividing by a statically-zero denominator diverges between emitter (trap) and oracle (0). The spike does not emit such a program; a proof-grade emitter must either match the kernel's total div (emit aselectguard:if d = 0 then 0 else a/d) or the kernel must adopt a checkedthrows-div. Named in ADR-0065's own div comment as a post-v1throwseffect.- Comparisons + ADTs are refused, not emitted.
lt/eqreturnboolVal(inl unit/inr unit, IR.lean:173) — a sum value needing a struct/tag rep;case/splitconsume those. That is rung-1.5 (still pure ⊥-row, but needs a value rep beyond i64). Deliberately out of the one-program scope. - No functions.
lam/app/forceare mapped (§3) but not emitted — the stretch. A non-recursive call lowers to a wasmfunc+call; recursion needs a fuel/stack story. One running arithmetic program was the scoped win; five half-mapped features were the anti-goal. - wasmtime
--invokeis experimental. Returning an i64 from an exportedmainuses wasmtime's--invoke(flagged experimental for value-returning funcs). A hardened harness would emit a WASI_startthat prints the result, or a host-driver (node +WebAssembly.instantiate). The value crosses the boundary correctly today; the invocation ergonomics are stub-grade.
5 · The verification story — TESTED-stratum spike, and what proof-grade would need
Per the stratification principle (CLAUDE.md): the emitter is the tested superset, separated from the verified core by an explicit seam. Concretely:
- What is verified today: nothing about the emitter's correctness is proven. The
by decide/simpself-tests inWasmEmit.leanare STRUCTURAL guards (emission succeeds/refuses on the right formers; axiom set[propext]) — they do NOT proveemit(M)computesSource.eval M. - What rides the reference:
tools/emit-rung1-diff.shis the differential test — it runs the emitted wasm on a real engine and diffs againstSource.eval(invariant #1, now crossing the engine boundary). This is the SAME discipline as theAgreebattery, one hop further out. - What a PROOF-GRADE emitter would require (the honest what-remains):
- A formal semantics of the wasm fragment in Lean (a small-step
i64-stack machine over the emittedInstrsubset —const/add/sub/mul/local.get/local.set), i.e. a third abstract machine. This is real work but BOUNDED (the pure fragment is tiny). - A theorem
wexec (emit M) ≡ Source.eval Mfor the pure fragment — a forward simulation structurally analogous tocompile_forward_sim, but source-to-wasm-directly rather than source-to-GC-machine. The de-Bruijn→local map is the one non-trivial invariant (a binder depth ↔ local-index bijection preserved byletC). - The differential corpus generalizes to a fuzzed
Compgenerator (theWitness/Fuzzmachinery already exists) diffed engine-vs-oracle — the tested rung under the proof. The emitter's structural shape (one arm per former, fail-loud refusal) is deliberately chosen so that (2) would be a per-former case analysis, not a re-architecture.
- A formal semantics of the wasm fragment in Lean (a small-step
6 · The rung-2 wall visible from here (abort → exceptions)
ADR-0059's rung 2 = throws → Wasm exception + state/`transaction → tail-call. From the rung-1
vantage the wall is concrete:
throws→try_table/throw(wasm 3.0 exception handling).compileemitsHANDLE/THROW/UNMARK; the kernel's zero-shot abort (discard the inner continuation, resume the handler's saved outer continuation with the payload — AbstractMachine.lean:2116) is EXACTLY wasm's exception semantics:throw $tag payloadunwinds to the enclosingtry_tablecatch. The identity-keyed dispatch (unwindFind n) maps to a per-handler-instance tag. This is the engine-independent half ADR-0059 calls "for free" — but it needs the SAME emit-from-Compdecision (theHANDLEInstrcarries a RAW body thatexecre-compiles at the mint, so a static emitter must recurse thehandle'sCompbody under the minted cap, not readCode).state/transaction→ tail-call / in-place resume. One-shot in-place resumption (ADR-0025): the handler servicesget/putand continues the SAME continuation — a direct call in wasm, no reification. The store threads as wasm locals/globals or amemorycell.- The wall proper: the emit-from-
Comprecursion must now track the handler-frame nesting to placetry_tablescopes and mint tags, where rung 1 only tracked a locals environment. That is a bigger environment (a handler stack, mirroringHStack) but still structural. The GENERAL (multishot) leg — reified resumptions on the GC-frame chain — stays post-v1 (ADR-0059 §v1/post-v1 boundary; nothing in v1's three handler forms reifies).
Rung-1.5 (the smaller next step, pure-only): comparisons + ADTs — a value rep beyond i64
(i32 tag + memory/struct for sums/products) so lt/eq/case/split emit. This unblocks
if-sugar and boolean-guarded arithmetic while staying ⊥-row (no exceptions yet).
7 · One-glance status
DEMONSTRATED pure ⊥-row Int arithmetic + let-bindings → core .wat → wasmtime, 4/4 == Source.eval
HOP DECISION emit from typed Comp (compile folds the pure fragment to RET — Code path degenerate)
STRATUM tested (differential vs Source.eval); emitter axiom set [propext]; proof-grade = §5
NEXT (pure) rung-1.5: comparisons + ADTs (i32 tag + memory rep)
NEXT (effect) rung-2 wall: throws→try_table/throw, state/transaction→tail-call (§6)
LEAF/ADDITIVE Bang/Backend/WasmEmit.lean · EmitMain.lean · tools/emit-rung1-diff.sh — gate green (755 jobs)
8 · Rung-1.5 LANDED (comparisons + guarded div + generated corpus)
What landed (2026-07-10). The two pure-fragment gaps §4 named — the
div_s//0mismatch and comparisons+case— are CLOSED, and the 4-program hand corpus is now a 53-program differential battery (4 hand anchors + 7 rung-1.5 witnesses + 42 seed-generated), allwasmtime == Source.eval. Still the TESTED stratum (emitter axiom set[propext], no proof).
8.1 · Guarded div — the reviewer ruling: preserve the kernel's total div
The kernel BinOp.eval div is total, a / 0 = 0 (Lean Int division, IR.lean:184). wasm
i64.div_s traps on a zero divisor. Proof rides the reference (invariant #1), so the emitter
matches the kernel, NOT the trap — emitDiv wraps the divide in a guard:
(if (result i64) (i64.eqz <divisor>)
(then (i64.const 0))
(else (i64.div_s <dividend> <divisor>)))The extra instructions are free (invariant #7, performance second-class). Operands are pure Val
expressions (i64.const/local.get — no side effects, no traps), so the divisor is duplicated in
the eqz test and the divide without a scratch local. Corpus witnesses: div1 (7/0 ⇒ 0, static
zero), div2 (let d=3-3 in 100/d ⇒ 0, dynamic zero) — both agree with the oracle. Residual gap
(unchanged, §4.1): i64.div_s also traps on INT64_MIN / -1 (signed overflow) — the pre-existing
unbounded-Int→i64 edge, orthogonal to /0; the corpus stays in the i64-representable range.
8.2 · Comparisons + case-on-bool = the wasm if (the if-then-else pattern)
The kernel has NO standalone bool — boolVal false = inl unit, boolVal true = inr unit
(IR.lean:173), and binop lt/eq reduces to ret (boolVal c). The surface if a<b then E₂ else E₁
is exactly letC (binop cmp a b) (case (vvar 0) N₁ N₂): the comparison binds a boolVal, and
case (vvar 0) eliminates it (inl → N₁ else, inr → N₂ then, Eval.lean:96). The emitter
recognises this fused shape and emits a native wasm if — the comparison leaves an i32 0/1,
consumed by the if condition:
(if (result i64) (i64.lt_s <a> <b>) ;; TRUE(1)=inr → then=N₂ ;; FALSE(0)=inl → else=N₁
(then <N₂>)
(else <N₁>))A bare comparison (not immediately case-eliminated) stays Emit.unsup — a boolVal has no
standalone i64 rep. The general sum-case (arbitrary ADTs) stays out of scope (rung-2).
The de Bruijn two-binder subtlety (a real finding, caught BY the corpus). Inside a branch the
kernel context has TWO extra binders over the pre-letC scope: index 0 = the case unit payload,
index 1 = the outer letC-bound boolVal. A first-pass emitter that pushed only ONE unusable slot
(none :: env) mis-indexed any branch referencing an outer variable — if3
(let x=2+3 in if x<4 then x*2 else x) diverged (wasmtime 5 vs oracle NON-INT-VALUE) because
the emitter read x where the kernel read the boolVal. Fix: branch env = none :: none :: env
(both payload and boolVal unusable — no i64 rep), and the generator lifts branch bodies by
Comp.shiftFrom 0 twice. env is now List (Option Nat) — some l = a real wasm local,
none = a bound-but-unusable slot that fails loud if read. This is the differential test earning
its keep: a value-agreement corpus refuted the first derivation for the price of one run.
8.3 · The generated corpus + false-green defenses
EmitMain.genComp is a deterministic LCG-seeded structured generator over the emittable fragment
(int atoms, in-scope-only vars ⇒ always closed, arithmetic + guarded div with a forced-zero-divisor
branch, letC nesting, the fused if). Total (structural fuel recursion — no partial, no
inhabited-type obligation). tools/emit-rung1-diff.sh runs all 53 emitted → wasmtime → diff vs
Source.eval, and the false-green defenses the repo's bash conventions demand are all present: the
emit exe prints EMITTED_COUNT/REFUSED_COUNT footers; the harness ASSERTS emitted ≥ 50,
refused == 0 (a generator drifting out of the fragment fails LOUD, not silently), .wat-count ==
emitted, and checked == emitted (no silent skip); wasmtime's exit is captured separately (never a
piped exit code); a mismatch prints the program + both values and exits 1.
8.4 · What rung-2 needs next (the wall from here, refining §6)
- General ADTs (the OTHER half of rung-1.5, deferred).
lt/eqare handled ONLY in the fusedcaseshape; arbitraryinl/inr/pair/fold+ non-fusedcase/split/unfoldstill need a value rep beyond i64 (ani32tag +memory/structfor sums/products, §6). That unblocks bool-VALUED expressions (a bool bound, passed, returned — not just immediately eliminated). throws → try_table/throw(§6, unchanged): the fused-ifdecision generalises — the emitter now tracks a de Bruijn→local and unusable-slot environment; the handler-frame nesting fortry_tablescopes + minted tags is the next environment layer (mirroringHStack).- Proof-grade (§5, unchanged): a formal wasm-fragment semantics + a
wexec (emit M) ≡ Source.eval Mforward simulation. Theif/div-guard arms are per-former case analyses under that theorem, not a re-architecture — the structural one-arm-per-former shape is preserved.
LANDED (rung-1.5) guarded div (a/0=0) · comparison+case-on-bool → wasm `if` · 53-program corpus, 53/53 == Source.eval
FINDING fused-if branches carry TWO kernel binders (payload+boolVal); env = List (Option Nat), branch = none::none::env
STRATUM tested (differential); emitter defs axiom set [propext] (no sorryAx); leaf-additive (no proof-bearing file)
NEXT (pure) general ADTs — i32 tag + memory rep (bool-VALUED, non-fused case/split/unfold)
NEXT (effect) rung-2: throws→try_table/throw (§6), state/transaction→tail-call
9 · Rung-2 LANDED (throws → Wasm-3.0 exceptions: try_table/throw)
What landed (2026-07-10). The rung-2 abort leg (ADR-0059's
throws → Wasm exception) is DEMONSTRATED:handle (throws ℓ) Memits a Wasm-3.0try_table/throw, and 7 throws programs — caught raise, continuation-discard, normal return, compute-then-return, nested inner-catch, nested outer-catch, computed payload — ran onwasmtime45 with values MATCHINGSource.eval. The corpus is now 60 programs (53 pure/rung-1.5 + 7 rung-2), allwasmtime == Source.eval. Still the TESTED stratum (emitter axiom set[propext], no proof). The STOP-gate ("does the engine accept exception opcodes?") passed: wasmtime 45 supportstry_table/throwbehind the-W exceptions=yfeature flag (structurally fine — see §9.3).
9.1 · The kernel→wasm mapping — abort IS try_table/throw
The kernel's zero-shot throws (Eval.lean Source.step + Dispatch.lean dispatchOn) is EXACTLY
wasm exception semantics, confirmed against scratch/U5bSpine.lean's composition lemmas:
kernel step (Source.step / dispatchOn) | wasm emission |
|---|---|
handle (throws ℓ) M: mint fresh id g, push handleF g, run subst (vcap g ℓ) M | (block $hₜ (try_table (result i64) (catch $exnₜ $hₜ) <emit M>)) |
caught raise (dispatchOn throws arm): discard Kᵢ, deliver payload w to Kₒ (ret w) | throw $exnₜ (emit v) → catch branches to $hₜ with the payload as block result |
normal return (handleF _ _ :: K, ret v ↦ K, ret v, handler-return = identity) | body value flows out of the try_table = the block result (no throw) |
The catch-target $hₜ is a block WRAPPING the try_table, whose result type is the body's i64.
On a caught throw the payload is delivered as that block's result; on normal fall-through the body's
value IS that result — one uniform result type, so both outcomes leave one i64 (verified on wasmtime,
§9.4). This is the "engine-independent half ADR-0059 calls 'for free'" made concrete.
9.2 · The frame-stack design — the de-Bruijn env IS the handler stack (the rung-2 wall, resolved)
§6 named the wall: "the emit-from-Comp recursion must now track the handler-frame nesting to place
try_table scopes and mint tags, where rung 1 only tracked a locals environment." The resolution
is the SAME move rung-1.5 made for the two-binder subtlety, generalized: one unified de-Bruijn
environment (emitVal/emitComp take List Slot), because BOTH binders bind index 0 —
inductive Slot | val (l : Nat) -- letC binder ⇒ wasm LOCAL l
| cap (t : Nat) -- handle(throws) binder ⇒ wasm exception TAG t
| dead -- case-on-bool payload (rung-1.5 unusable slot)
handle (throws ℓ) M (ADR-0054: handle binds a capability at index 0 in M, like lam) pushes
.cap t :: env where t = nextTag is minted by descent; letC pushes .val next :: env. A raise
perform (vvar i) "raise" v reads env[i]: a .cap t slot emits throw $exnₜ; a .val/.dead
slot (or a non-raise op) is unsup — FAIL-LOUD, never a wrong throw. The de-Bruijn env IS the
HStack mirror: a cap-slot per open handle, threaded on the SAME recursion as the locals — so
tag-minting needed no separate stack, just a third slot variant + a nextTag counter (the emit
return became Emit × maxLocal × maxTag).
Tag-minting choice: one distinct tag per handle frame (not one global tag). Justification: a
try_table (catch $exnₜ $hₜ) catches ONLY tag t, so throw $exnₜ unwinds to exactly the
lexically-enclosing handle that minted t — tag identity IS the wasm image of identity-keyed
idDispatch (the cap names its lexically-enclosing handler, ADR-0052/0054). This is what makes the
nested cases correct WITHOUT any runtime identity counter: thr4 (inner vvar 0 → inner tag, inner
catch) vs thr5 (inner body vvar 1 skips the inner cap-slot → outer tag → throw $exn0 propagates
PAST the inner try_table (catch $exn1) to the outer catch) both matched the oracle (5 and 8). A
single global tag would MIS-route thr5 (the inner catch would swallow the outer-bound raise).
This is the HANDLE-defer-recompile idiom's static shadow: compile can't read a label from a
vvar cap statically, and mints the id at exec-time; the STATIC emitter likewise can't see a runtime
vcap, but it mints the wasm TAG structurally by descent — the tag plays the role of the identity g,
and the de-Bruijn binder position (not a minted value) is what perform routes on.
9.3 · The tag-minting + engine-flag choices (deliverable 3's SAY-WHY)
- Tag rep:
(tag $exnₜ (param i64))per minted frame. Each abort carries one i64 payload (the raise value), matching bang's rung-2 i64 fragment.emitModuledeclaresnumTagssuch tags at the module head; a pure/rung-1.5 program mints ZERO tags, so its module is byte-identical to the rung-1 form — the extension is purely ADDITIVE (no pure module changed a byte). - wasmtime flag:
-W exceptions=y. wasmtime 45 gates the exception-handling proposal behind this feature flag (it is Wasm-3.0 CORE but not on-by-default yet). The flag is INERT for pure modules, so ONE invocation covers the whole 60-program corpus. Added totools/emit-rung1-diff.shwith a comment. This is the STOP-gate the brief named: exceptions are STRUCTURALLY supported (a hand-writtentry_table/throwreturning its payload ran clean), just behind a flag — NOT a structural rejection.
9.4 · The side-by-side — throws output on a real engine
sample program wasmtime oracle verdict
thr0 handle throws { raise 7 } 7 7 OK (caught, payload delivered)
thr1 handle throws { let _ = raise 7 in 99 } 7 7 OK (continuation discarded)
thr2 handle throws { 42 } 42 42 OK (normal return)
thr3 handle throws { 3 + 4 } 7 7 OK (compute then normal return)
thr4 handle throws { handle throws { raise@inner 5 } } 5 5 OK (inner catches)
thr5 handle throws { handle throws { raise@outer 8 } } 8 8 OK (throw skips inner try_table)
thr6 handle throws { let x = 6*7 in raise x } 42 42 OK (computed payload, cap@idx1)
The emitted .wat for thr1 (raise discards the let-continuation):
(module
(tag $exn0 (param i64))
(func $main (export "main") (result i64) (local i64)
(block $h0 (result i64)
(try_table (result i64) (catch $exn0 $h0)
(local.set 0 (throw $exn0 (i64.const 7))) ;; throw unwinds BEFORE the local.set/99 run
(i64.const 99)))))The throw sits where the letC-bound computation would leave its value; because throw is
stack-polymorphic (produces the empty/unreachable result), wasm type-checks it as the local.set
operand AND unwinds before the set fires — so the local.set 0 … (i64.const 99) continuation is
DISCARDED exactly as the kernel's dispatchOn throws-arm discards Kᵢ (result 7, not 99). The
differential test earns its keep again: this is subtle, and wasmtime confirmed it.
9.5 · Scope + what rung-2 STUBBED (honest gaps)
throws/raiseONLY.state/transaction/customhandlers, and any non-raiseop (get/put/newTVar/…), stayunsup(loud). The resumptive handlers are the OTHER rung-2 leg (state/transaction → tail-call / in-place resume, §6) — a different wasm shape (thread the store as locals/globals + a direct call, notry_table), deliberately out of this abort-only slice.- Generator stays pure. The 42-program seed generator was NOT extended into effect nesting; the 7
throws witnesses are HAND anchors. A generated throws corpus (random handle-nesting + in-scope cap
targets) is a cheap next step but needs the generator to track the cap-frame depth (mirror of the
emitter's
Slotstack) to stay in-fragment. - Forwarding a raise to a MISMATCHED-kind or ESCAPED cap is out of fragment. The minimal fragment
emits only raises caught by a lexically-enclosing
throwshandle (thehandle_throws_caught/_forwardcomposition lemmas' caught case). A cap escaping its handler (escapedCap) has no static wasm image here — post-v1 scoped-cap types make it untypeable anyway. - Proof-grade (§5, unchanged). No
wexec (emit M) ≡ Source.eval Mtheorem; the throws arms would be per-former cases under it (thetry_tableframe ↔handleFframe is the one new invariant — a tag-identity ↔ handler-identity bijection, the static analog ofWellCounted/StratFresh).
9.6 · The rung-3 wall from here
- state/transaction → tail-call (the OTHER rung-2 leg). One-shot in-place resumption (ADR-0025):
the handler services
get/put/TVar ops and continues the SAME continuation — a direct call in wasm, store threaded as locals/globals/memory. NOtry_table(no unwind); the emit env grows a RESUMPTIVE frame variant carrying the store cells. This is the tractable next slice. - custom (user effects) → tail-call over a clause table.
dispatchOn's custom arm isstate's resume with USER clause logic; the wasm image is the same tail-resume shape with the clause body emitted as the continuation. Gated on the resumptive leg landing first. - general (multi-shot) → the GC-frame chain (post-v1). Reified resumptions on the WasmGC
frame-chain (ADR-0059 §v1/post-v1). Nothing in v1's three handler forms reifies, so this stays
post-v1; the WasmFX
switch/resumefast-path plugs in once standardized.
LANDED (rung-2) throws → try_table/throw (abort → exceptions) · 60-program corpus (53 pure + 7 throws), 60/60 == Source.eval
DESIGN de-Bruijn env IS the handler stack: Slot = val l | cap t | dead; one tag per handle frame = tag-identity = idDispatch
STOP-GATE PASSED wasmtime 45 accepts try_table/throw behind `-W exceptions=y` (Wasm-3.0 core, feature-flagged, not structural)
STRATUM tested (differential); emitter defs axiom set [propext] (no sorryAx); leaf-additive (WasmEmit.lean/EmitMain.lean/harness)
NEXT (effect) state/transaction → tail-call (in-place resume, no unwind) — the tractable rung-2 leg; then custom; general = post-v1 GC-chain
10 · Rung-2b LANDED (state → in-place resume: the store cell is a mutable wasm LOCAL)
What landed (2026-07-10). ADR-0059's OTHER rung-2 leg —
state → tail-call / in-place resume— is DEMONSTRATED:handle (state ℓ s₀) Mmaps the store cell to a mutable wasm local,getreads it (local.get),putwrites it (local.set), and execution continues STRAIGHT-LINE — notry_table, no unwind. 6 state programs — get-only, put-then-get, arithmetic-around-get, computed put-payload, read-modify-write, normal-return — ran onwasmtime45 with values MATCHINGSource.eval. The corpus is now 66 programs (53 pure/rung-1.5 + 7 throws + 6 state), allwasmtime == Source.eval. Still the TESTED stratum (emitter axiom set[propext], nosorryAx). The STOP-gate the brief named — "do state ops need dispatch context the static tree lacks?" — passed: they do NOT. One handler per label, and the cap slot's de-Bruijn POSITION identifies the cell. No id-vs-label context is needed at emit time (§10.2). These are CORE wasm (locals only) — no engine feature flag, unlike the throws leg.
10.1 · The kernel→wasm mapping — state RESUMES (no abort), so the cell is a mutable local
The derivation is forced by dispatchOn's .state arm (Dispatch.lean:133) + handler-return-identity
(Eval.lean:65). Unlike throws (which ABORTS — discards Kᵢ), state RESUMES Kᵢ on both ops:
kernel step (dispatchOn .state ℓ' s arm) | wasm emission |
|---|---|
handle (state ℓ s₀) M: mint id g, push handleF g (state ℓ s₀), run subst (vcap g ℓ) M | mint local l; (local.set l <emit s₀>) then <emit M under .state l :: env> — body value flows out |
get: some (Kᵢ ++ handleF n (state ℓ' s) :: Kₒ, .ret s) — RESUME Kᵢ with s, cell unchanged | (local.get l) — an i64-leaving expression; the surrounding wasm IS the resumed Kᵢ |
put v: some (Kᵢ ++ handleF n (state ℓ' v) :: Kₒ, .ret .vunit) — RESUME Kᵢ with UNIT, cell now v | (local.set l <emit v>) — a STATEMENT; then the continuation runs (fused letC put; N, §10.3) |
normal return (handleF _ _ :: K, ret v ↦ K, ret v) | body value flows straight out (no block, no catch) |
Because RESUME reinstalls the frame with the threaded store and continues the SAME continuation, there
is nothing to unwind — the "in-place resume" is literally: the store lives in a wasm local, and the
Kᵢ the kernel resumes is the code that textually follows in the emitted function body. This is exactly
ADR-0059's "one-shot in-place resumption (ADR-0025) … a direct call in wasm, no reification." Here the
state fragment needs not even a call — a letC-threaded local sequence suffices (a custom clause
body would be the first thing to need a real tail-call; §10.5).
The Slot type gains a fourth variant, mirroring the .cap move for throws:
inductive Slot | val l -- letC binder ⇒ wasm LOCAL (rung 1)
| cap t -- handle(throws) binder ⇒ exception TAG (rung 2)
| state l -- handle(state) binder ⇒ wasm LOCAL holding the store cell (rung 2b) ← NEW
| dead -- case-payload / put's unit result (no i64 rep)
A perform (vvar i) op _ routes on env[i]: .state l + "get" ⇒ (local.get l); .state l +
"put" ⇒ (local.set l …) (fused, §10.3). A KIND MISMATCH ("raise" on a .state, "get" on a
.cap) is unsup — FAIL-LOUD, never a wrong read/throw, the static shadow of idDispatch's fail-loud
handlesOp guard.
10.2 · The STOP-gate answered — labels ARE sufficient; no id-vs-label context at emit time
The brief flagged the risk: "if state ops in the typed Comp require dispatch context the static tree
lacks (the id-vs-label question at emit time)." They do not, for the minimal fragment. The reason is
the SAME as throws: the emitter routes a perform by its cap's de-Bruijn binder position, not by a
runtime identity or a label. handle (state ℓ s₀) M binds the cap at index 0 in M (ADR-0054, like
lam); a get/put names it by vvar i; env[i] = .state l gives the cell's local. One handle per
label in the fragment means the binder position uniquely picks the frame — exactly the "tag-identity =
idDispatch" argument (§9.2), now "local-identity = idDispatch." The emitter never reads a label to
dispatch — the cell local l plays the role of the runtime identity g, assigned by structural descent.
The de-Bruijn-shift finding (caught BY the corpus, the differential test earning its keep again).
A put's continuation is subtle: put returns UNIT and resumes Kᵢ, and in the surface let _ = put v in N the letC binds that unit at index 0 of N. So the cap that was vvar i before the put is
vvar (i+1) inside N — the whole env shifts by one across a put. The refute-first oracle probe
(now EmitMain.rung2bSamples, run against Source.eval compiled) confirmed this directly: the
put-then-get witness stt1 is STUCK if the get names vvar 0 (that binds the put-unit), and correct
only at vvar 1. The emitter's fused letC (put) N arm pushes .dead :: env for N (the put-unit is
unusable, no i64 rep) — identical to the case-payload treatment — so the shifted indices land correctly.
stt4 (read-modify-write) exercised three threaded locals (cell, x=get, y=x+1, put y, get ⇒ 6).
10.3 · Why put is FUSED with its letC continuation (the one asymmetry with get)
get returns a VALUE (ret s), so it slots anywhere an i64 is expected — let x = get in x + 5 flows
through the ORDINARY letC m n arm (emit get into a fresh local, run n). put returns UNIT (ret .vunit) — which has no i64 rep — so it cannot be a bare i64-leaving expression. The emitter handles
put ONLY in the fused shape letC (perform (vvar i) "put" v) N: emit (local.set l <emit v>) as a
STATEMENT, then emit N under .dead :: env. A bare put (a put in tail position, returning
unit as the program's answer) is unsup — it would leave unit where the module's result i64 demands
an int, out of the int fragment (and a unit-returning program is NON-INT-VALUE to the oracle anyway).
This mirrors rung-1.5's bare-comparison refusal: an op whose result has no standalone i64 rep is
emittable only in the FUSED elimination shape that consumes it.
10.4 · The side-by-side — state output on a real engine
sample program wasmtime oracle verdict
stt0 handle state(5) { get } 5 5 OK (read initial cell)
stt1 handle state(0) { let _ = put 7 in get } 7 7 OK (write then read; cap idx1 in cont)
stt2 handle state(10) { let x = get in x + 5 } 15 15 OK (arithmetic around get)
stt3 handle state(0) { let v = 3*4 in put v; get } 12 12 OK (computed put payload)
stt4 handle state(5) { let x=get; let y=x+1; put y; get } 6 6 OK (read-modify-write)
stt5 handle state(99) { 20 + 22 } 42 42 OK (normal return, cell unread)
The emitted .wat for stt4 (read-modify-write — three threaded locals, all straight-line, no unwind):
(module
(func $main (export "main") (result i64) (local i64) (local i64) (local i64)
(local.set 0 (i64.const 5)) ;; cell l=0 := s₀ (=5)
(local.set 1 (local.get 0)) ;; x := get (local 1)
(local.set 2 (i64.add (local.get 1) (i64.const 1))) ;; y := x+1 (local 2)
(local.set 0 (local.get 2)) ;; put y — write the CELL local 0
(local.get 0))) ;; get — read it back ⇒ 6Local 0 is the state CELL (written by both the init and each put); locals 1/2 are ordinary
letC binders. get/put are plain local.get/local.set of the cell — the "in-place resume" made
concrete: no try_table, no block, no throw; the store never unwinds because state never aborts.
A pure/throws program mints ZERO state locals beyond its own, so this extension is purely ADDITIVE.
10.5 · Scope + what rung-2b STUBBED (honest gaps)
state/get/putONLY, single cell.transaction(multi-cell STM) andcustom(user effects) stayunsup(loud). Aputreached OUTSIDE the fusedletC(bare unit tail) is refused (§10.3).putparam is READ on resume, never captured. v1 state is one-shot in-place (ADR-0025), which is what the mutable-local model gives. A multi-shot resume (re-enteringKᵢtwice with different stores) would need the store REIFIED, not a single mutating local — post-v1 GC-chain (§9.6), out of v1's three handler forms (none reifies).- Generator stays pure. The 42-seed generator was NOT extended into state nesting; the 6 state
witnesses are HAND anchors (like the 7 throws). A generated state corpus needs the generator to track
the cap-frame depth AND the put-shift (mirror of the emitter's
Slotstack) to stay in-fragment. - Proof-grade (§5, unchanged). No
wexec (emit M) ≡ Source.eval M; the state arms would be per-former cases under it. The one new invariant: a.state lslot ↔ a store cell whose value at each program point mirrors the kernel'shandleF n (state ℓ' s)payload — a local-value ↔ store-value bijection preserved byget(read, no change) andput(write). Straight-line (no reification) makes this the SIMPLEST arm to prove of the three effect legs.
10.6 · What the store-as-a-local design implies for TRANSACTION (note-only analysis)
transaction ℓ Θ (Dispatch.lean:143) is the MULTI-CELL generalization of state: Θ : List Val is the
transaction heap, newTVar APPENDS (allocation, returns the new index), readTVar/writeTVar
index/update a cell — and it RESUMES exactly like state (reinstalls a deep frame with the threaded
Θ', ADR-0025 pattern). So the naive wasm image is the state design SCALED UP: the heap is a block of
wasm memory (or an array of locals/globals), readTVar i = a load, writeTVar i w = a store,
newTVar v = bump a length pointer + store. The resumptive (commit) path is straight-line, same as
state — no unwind.
But the ROLLBACK question is the real wall, and the store-as-mutable-cell design does NOT solve it for
free. The kernel's rollback is elegant (Dispatch.lean:139 comment): an abort is a zero-shot throws
that ESCAPES the transaction frame, so the threaded Θ' is discarded WITH the frame and never commits —
allocations survive (the heap's append-only growth) but writes vanish. In wasm, the transaction body is
inside a try_table (the abort leg, §9), and a throw unwinds control to the catch — but a
local.set/memory.store already executed inside the body is NOT rolled back by the unwind. wasm has
no transactional memory: mutation is destructive-in-place. So a faithful transaction emitter needs ONE
of:
- A JOURNAL (write-set). Buffer
writeTVars in a side structure (a wasmmemoryregion keyed by TVar index), and only APPLY them to the base heap on the commit path (fall-through past thetry_table); on the abort path (catch), DROP the journal. This is the classic STM implementation, and it maps cleanly: the journal is a secondmemoryblock, commit = a copy loop, abort = a pointer reset. It is MORE code than state but structurally the SAME idioms (memory + a length). - A COPY-ON-ENTRY snapshot. Snapshot the heap into a scratch region at
handleentry; on abort, restore from the snapshot. Simpler to state, costlier per-transaction (whole-heap copy); the write path stays destructive soreadTVarneeds no journal indirection. Invariant #7 (performance second-class) says this is an acceptable v1 baseline. - RE-USE the throws unwind + re-execute — NOT viable: the kernel does not re-execute, it discards; and wasm mutation is not idempotent under re-entry.
The honest verdict: transaction is NOT a small delta on state. State rides on wasm's native mutable
local because state never rolls back (its only control move is resume-in-place). Transaction ADDS the
abort leg's unwind (which rung-2 already emits) OVER a mutable heap that MUST be made transactional by an
explicit journal/snapshot — the piece wasm gives for free is the unwind (the try_table/throw control
flow), NOT the memory rollback. That journal is rung-3 CODE (a real slice, tractable via memory + a
write-set), not a note. The state leg proves the resume half; the transaction leg's novelty is entirely
in the rollback half, and it is the journal that the store-as-a-local design leaves unsolved.
10.7 · The honest boundary to rung-3 (the wall from here)
- transaction → journal/snapshot over
memory(§10.6): the resume half is state-scaled-to-memory; the abort half re-uses rung-2'stry_table, but the memory ROLLBACK needs an explicit write-set. The tractable next slice, but genuinely more than state. - custom (user effects) → tail-call over a clause table (§9.6, unchanged): the resume shape is
state's, but the resumed value is a USER CLAUSE BODY (a
Comp), not a hardcodedget/putresult. So custom needs the clauseCompemitted as the continuation — the FIRST arm that needs a real wasmcall(or an inlined body), because the clause computes before resuming. Gated on state landing (it did) + the clause-emission machinery. - general (multi-shot) → the GC-frame chain (post-v1) (§9.6, unchanged): reified resumptions; nothing
in v1's three handler forms reifies, so this stays post-v1 (WasmFX
switch/resumefast-path).
LANDED (rung-2b) state → in-place resume (store cell = mutable wasm LOCAL) · 66-program corpus (53 pure + 7 throws + 6 state), 66/66 == Source.eval
DESIGN Slot gains `state l`; get = local.get, put = local.set (fused letC); NO try_table/unwind (state RESUMES, never aborts)
STOP-GATE PASSED no id-vs-label context needed at emit time — cap de-Bruijn POSITION picks the cell (one handle/label); local-identity = idDispatch
FINDING a `put`'s letC-continuation shifts the cap index by one (put-unit binds idx0) — env = .dead :: env in the cont; caught by the oracle probe
STRATUM tested (differential); emitter defs axiom set [propext] (no sorryAx); leaf-additive (WasmEmit.lean/EmitMain.lean/harness)
TRANSACTION NOT a small delta: the resume half scales to `memory`, but the ABORT half needs an explicit JOURNAL/snapshot — wasm mutation is destructive, the unwind does not roll back memory (§10.6)
NEXT (effect) transaction (journal over memory) → custom (clause body as tail-call) → general = post-v1 GC-chain