ADR-0015 · Continuation reification — a flat generalised-continuation machine (CalcReify); multi-shot / non-tail handlers
- Status: Accepted
- Summary: Continuation reification — a flat generalised-continuation machine (
CalcReify); CalcReifySim bisimulation paused per 0016 (the LR subsumes the goal).
Status update (per ADR-0016): The CalcReifySim bisimulation effort is paused. The Benton-Hur logical relation introduced by ADR-0016 provides stronger contextual equivalence and is what
compile_forward_simconsumes downstream. The CalcReify machine itself stays as the multi-shot/non-tail handler representation; only the bisimulation proof attempt is shelved. See ADR-0016 for context.
- Status: Accepted (machine + demonstrators verified; cross-checked vs an independent TS CPS interpreter on 2k+ random programs; in-Lean general theorem is the named next step)
- Date: 2026-06-01
- Related: 0011/0012/0013/0014 (all deferred reification — this is that frontier),
0004 (calculate, don't hand-design), 0009 (one construct at a time), 0008 (the
free-monad
eval), roadmap K3 + §8 reading canon (Tsuyama 2024)
Context
Every prior effect machine deliberately avoided continuation reification: Throws is zero-shot (discards the continuation), State resumes only in tail position. A general handler hands its operation clause the resumption as a first-class value, to be invoked zero, one (non-tail), or many times (nondeterminism, generators, backtracking). This is the genuine frontier (Hillerström–Lindley–Atkey, Effect handlers via generalised continuations; Tsuyama et al. 2024).
Two facts settled the machine's shape:
- Reification is defunctionalization — and Lean proves it. A resumption can't
be a meta-level function value:
vcont : (Value → …) → Valuefails Lean's strict-positivity check. That failure is not an obstacle but the reason reification exists — the continuation must be made data. - It forces the machine flatten that ADR-0011/0012/0013/0014 each predicted. The
closure machines reduce a subterm via a nested meta-
exec; a resumption cannot be captured across that meta-boundary. So reification needs a flat machine whose continuation is explicit data — a genuinely different shape.
Decision
Calculate a flat generalised-continuation machine, Bang/Reify/CalcReify.lean,
following the Hillerström–Lindley–Atkey representation: the continuation Kont is a
list of frames (clause = some ⇒ a handler frame; clause = none ⇒ a pure-return
frame), and a reified resumption is a captured prefix of that list, held in a
vcont value as data (capturedCode, capturedEnv, capturedStack, clause, clauseEnv).
perform eevaluatese, captures the current pure continuation up to the handler as avcont, and runs the clause with(payload, vcont)(payload @0, resumption @1).resume k vsplices the captured continuation back: it re-installs the handler around it (so the resumed body is re-handled — deep handlers) and pushes a pure frame carrying the clause's own continuation, so the resumption's result flows back to the clause.resumeis therefore a call that returns (non-tail by nature), and calling it twice runs the captured continuation twice (multi-shot).- Scope kept minimal so it stays provable (ADR-0009): arithmetic +
let+ one ophandle/resume; single handler depth (aperformis handled by the innermost handler frame; forwarding through pure-return frames to an outer handler is the documented follow-up); no closures/CBN (composing reification with the closure core is a separate step).
Status / what is verified
The machine (exec, compile, run) is built and its core behaviours are
statically verified by rfl in the build (seven demonstrators):
| program | demonstrates | result |
|---|---|---|
handle (resume@1 7 + 100) (perform 5 + 1000) | one-shot, non-tail | 1107 |
handle (resume@1 7) (perform 5 + 1000) | one-shot, tail | 1007 |
handle (resume@1 7 + resume@1 20) (perform 5 + 1000) | multi-shot (resumed twice) | 2027 |
handle 999 (perform 5 + 1000) | zero-shot (continuation discarded) | 999 |
handle (var0) 42 | normal return passes through | 42 |
handle (resume@1 7) (perform 1 + perform 2) | re-handling (perform inside a resumption) | 14 |
let x=5 in handle (x + resume@1 3) (perform x) | payload reaches the clause | 8 |
This is a genuine, working reified-handler machine — the capability all five prior
machines avoided. The file is sorry-free: it asserts exactly what it proves.
Foundation proven. exec_succ / exec_mono (fuel monotonicity) are proven,
sorry-free — the bedrock any correctness simulation needs (every machine step,
including the empty-code return-through, PERFORM, and RESUME, decreases fuel).
Independent empirical cross-check landed. Because there is no in-Lean reference
eval for this machine (a reference would itself be a second abstract machine — see
below), execreify is diff-tested against an independent TS CPS interpreter
(harness/src/reify-cps.ts): a direct free-monad interpreter of the same Src
where a resumption is a real JS closure (w) => Comp — exactly the representation
Lean's strict positivity forbids, hence a genuinely different implementation.
Capture is op-node construction; splice is bind; the deep handler re-installs
itself around the resumption; single-handler-depth is modelled by sealing a
clause's own standing perform to stuck. The two agree on the seven demonstrators,
the notok (stuck) shapes, and 2000 random multi-shot / non-tail programs per CI run
(stressed to 20 000 at depth 5 locally with zero disagreements) —
harness/test/calc-reify.test.ts. This is the "run the real journey" cross-check:
the hand-built Kont/Frame splicing matches the textbook closure semantics.
Bisimulation — reference built + pure core proven. The open theorem is now
being attacked directly, in two committed, sorry-free pieces:
-
Bang/Reify/CalcReifyRef.lean— the denotational reference exists in Lean. The positivity escape is concrete: CBPV (values vs computations split) + a free monadCompwhose resumptionInt → Compsits in positive position, plus fuel for the non-structuralk wre-runs. It isrfl-validated against the same seven demonstrators — a second in-Lean cross-check. -
Bang/Reify/CalcReifySim.lean— the pure core of the bisimulation: the continuation-passing simulation (cf.CalcCBN.sim) for theval/add/var/letfragment, with the handler stackKand data stack carried as passengers, proven by structural induction (pure_sim/pure_correct). Cruciallyeval_pure/pure_correct_refprove the structural denotationpdenis theret-fragment of the real fuel-indexed referenceCalcReifyRef.eval— so the pure core is a genuine machine-vs-reference agreement (bothCalcReify.runandCalcReifyRef.runyieldnon a closed pure program), not a parallel definition. The proven fragment now also covershandleover a pure body — an unfired handler is transparent — which brings the machine'sINSTALLinstruction and the return-through-a-handler-frame path into the proof (viahandleC_ret), still with novcont ↔ ekrelation needed (the handler is installed but its clause never runs). The value relationRelVal/RelEnvscaffolding is in place (int case); thevcont ↔ ekcase — a step-indexed logical relation for resumptions — is the remaining residual. Two firing results now exist in Lean, sorry-free:-
fire_agree— the first ∀-quantified firing theorem. For any pure payloadeand any pure non-resumingclause, machine and reference agree onhandle clause (perform e): both yield the clause's denotation under the payload. The clause genuinely runs with the captured continuation (zero-shot / payload-threading). Two ingredients made it provable: (i) an environment-independent structural fuel boundfuelOf : Src → Nat(replacingeval_pure's∃F), which breaks the circularity the reference's fuel-capturing resumption closure would otherwise create; (ii) a partialRelEnv.consKconstructor relating an opaque machinevcontslot to a referenceekslot — sound here because the clause never reads it. This is the genuine first step into thevcont ↔ ekfrontier. -
Agree— machine-checked agreement on specific harder programs.run = some (vint k) ∧ CalcReifyRef.run = some k, proven by⟨rfl, rfl⟩for the non-tail, multi-shot (incl. triple), re-handling, and payload demonstrators — program-specific, but covering the resuming behavioursfire_agreedoes not yet generalise, both sides in-Lean (strictly stronger than the TS fuzz). -
The step-indexed
vcont ↔ ekrelation is now formalized in Lean, sorry-free (theResumingsection ofCalcReifySim). The partialconsKstub is replaced by a realdef RelV : Nat → Value → Entry → Propcarrying the resumption agreement (withRelEnvI,observe,RefK), and Lean accepts it — so the #1 risk (that the relation is not even expressible under strict positivity) is retired. The escape, machine-checked: adef(not an inductive) by structural recursion on the index (thevcont↔ekclause ati+1refers toRelVonly ati), with the resumptiong : Int → Compoccurring only applied. It integrates with the existing pure scaffolding (relEnvI_lookup, referencebind_mono, a forgetful maprelEnvI_forgetto the oldRelEnv, andpure_sim_indexed). What remains iscapture_relates— that an actual PERFORM-capture satisfiesRelV— and the firing theorem on it. (See the playbook's K3 section for the four definability decisions and two sharpenings of where the difficulty lives.) -
Four ∀-quantified resuming firing theorems are now proven, sorry-free:
fire_resume_tail(handle (resume (var 1) v) (perform e) ≡ ⟦v⟧— tail resume, empty captured continuation),fire_resume_nontail_body(handle (resume (var 1) v) (add (perform e) rest) ≡ ⟦v⟧ + ⟦rest⟧— non-tail body, non-empty captured continuation; the 1007 demonstrator, now ∀-general),fire_multishot(handle (add (resume@1 v1) (resume@1 v2)) (perform e) ≡ ⟦v1⟧ + ⟦v2⟧— the resumption invoked twice, the signature reification capability; demonstrator27), andfire_deep(handle (resume (var 1) v) (add (perform e1) (perform e2)) ≡ w1 + w2— genuine deep re-handling: the resumed continuation itself performs and re-fires the handler; the 14 demonstrator, now ∀-general). These are the first results where the resumption is genuinely invoked generally (stronger thanfire_agree, non-resuming; stronger than theAgreerfl-demonstrators, program-specific), all by direct inside-out construction (machine side likemachine_fire; reference side via a cleaneval_*reduction +handleC).
The remaining residual splits along a sharper axis than "deep vs. shallow" (correcting an earlier framing): (A) fixed control-flow skeleton, ∀-general over pure subterms — including deep / re-handling (✅
fire_deep) — is direct-constructible, because the language has no recursion/loops, so a closed program's firing count is bounded by its skeleton. The remaining (A) leaves (non-tail clause, multi-shot × non-empty continuation, deeper skeletons) are more of the same — longer chains, no new ideas. (B) ∀-general over allSrc(the fullexec ∘ compile ≡ run) is the remaining frontier — the part that must invokeRelV's agreement (capture_relates). Progress into (B), sorry-free:capture_relates_tailandcapture_relates_addprove an actual PERFORM-capturedvcontsatisfiesRelVfor every one-shot capture (empty + non-empty pure) — the first proofRelVis inhabited by real captures (non-vacuous; contravariance does not bite). This also fixed a design bug (RefKwasInt → Comp → Comp; corrected toComp → Comp— the clause continuation consumes the resumption's result, not its payload), withpure_sim_backas new reusable infrastructure. The general-simulation architecture is now designed (2nd design pass) and its viability proven, sorry-free:RelKont(observational continuation correspondence =RelV's inlinedRelK, built by composition not by decompilingCode),sim_pure_lift(pure spine in the general shape), andsim_resume_pure_v— theresumecase that genuinely consumesRelVat a resume node (measure = theRelVstep index, fuel existential; predecessor-index headroom resolvesRelV's index drop). What remains: (a)capture_relates_pure_general+ composition lemmassim_structural→ the shallowbisim_forward(full ∀-Srcforward bisim minus deep re-handling — all "hard", no research gate, the guaranteed deliverable); (b)perf_outcome_mono(reference perf-outcome fuel-monotonicity, bisimulation- shaped — the research gate) →capture_relates_deep→ the full bisim; (c) backward/iff is separate. Key realisation:RelVtransfers machine-halts ⇒ reference-agrees, so it is needed only in the general ∀-Srcinduction, not the closed firing theorems. (See the playbook's K3 section for the full ladder.)
-
Named next step — the general theorem. Unlike the prior five machines, the
general exec ∘ compile ≡ eval is not yet proven here, and the honest
assessment is that it is research-grade, for a fundamental reason: strict
positivity forces any reference to defunctionalize the resumption into data, so the
reference is a second abstract machine (a Src-frame generalised-continuation
machine), related to exec's CodeKont only through compile. The proof is then a
bisimulation between the two machines (the continuation-correspondence invariant
CodeKont = compile <$> SrcKont preserved across PERFORM/RESUME) — the kind of
abstract-machine-correctness result that is a paper section (Hillerström–Lindley), a
different shape than the equality-style big-step sims the other eight machines use.
There is no denotational shortcut: Comp may hold functions, but Value cannot, so
first-class resumptions must be data on both sides. The standard empirical
cross-check — a harness fuzz against an independent TS CPS interpreter (JS
closures are real resumptions — no positivity problem) — has now landed (see
above), so the machine is empirically validated against a different implementation;
what remains open is only the in-Lean machine-checked bisimulation.
Rationale
- Faithful to the literature (ADR-0004): the generalised continuation and the capture/splice rules are Hillerström–Lindley–Atkey's machine; nothing is hand-designed beyond linearizing it into instructions.
- Honest about the proof boundary (the project's "prove only what you can; never
fake" rule): the demonstrators are
rfl-real; the general theorem is named, scoped, and planned rather than asserted orsorry-faked. - Minimal, to stay tractable: single op, single handler depth, no closures — isolating the new mechanism (capture + splice + re-install) from the orthogonal concerns (forwarding, closures), each a later increment.
Rejected alternatives
| option | why not |
|---|---|
meta-function resumptions (vcont holds Value → …) | fails Lean positivity — and that is the point: reification must defunctionalize to data |
extend a prior (nested-meta-exec) machine | a resumption can't cross the meta-exec boundary; reification requires the flat machine |
| full multi-handler + closures now | research-grade; would ship a large partial proof. Isolate the mechanism first (ADR-0009) |
Revisit if
- The general theorem is proven → update status to Accepted-proven and record the proof shape in the playbook.
- A second op / outer-handler forwarding is needed → generalise
performto unwind through pure-return frames, capturing them into thevcont(the full generalised continuation; needsvcontto hold a frame list — a mutual inductive). - Reification must compose with closures/CBN → fold the flat machine's frames over the closure core (the hardest composition; a separate ADR).