Terminal iteration of the standalone loop/recur milestone. Replaces the iter-1 lower_term CodegenError::Internal stub with real LLVM-IR lowering: loop binders as entry-block allocas (the mut.3 pending_entry_allocas mechanism, reusing mut_var_allocas so the existing Term::Var load path needs zero new code), a fresh loop-header block reached by an unconditional br, recur stores + back-edge br, a new loop_frames codegen stack saved/restored at the lambda boundary (mut.3 triple precedent). Four Boss design calls baked into the plan header, all on architectural-consistency / spec-pinned-invariant grounds (not effort): (1) alloca + clang -O2 mem2reg, NOT hand-emitted phi (no linear-emit phi precedent; spec's "phi" is the secondary impl-shape; mem2reg gives identical optimized output); (2) reuse mut_var_allocas + the existing Term::Var load path (Var-lowering byte-unchanged; representation- sharing only); (3) loop-exit value is the emergent product of the existing if/match join once recur sets block_terminated, no separate result phi; (4) reuse the single block_terminated field, recur sets it at its own emit site (parallel setter), zero edits to existing SET/READ sites (tail-app byte-unchanged). Three new .ail fixtures: sum_to->55 run, deep-n 1e6->500000500000 (clause-2 correctness made executable), infinite-loop build-only. iter-3 is codegen-only: no schema/typecheck change, hash pins + drift trio stay green untouched. Self-review item 7 (the systemic fix folded into iter-2) applied: T1's field-add is not a fn-signature change, no deferred-caller-past-gate.
28 KiB
loop-recur.3 — Codegen (Component 5) + run-to-value E2E — Implementation Plan
Parent spec:
docs/specs/2026-05-17-loop-recur.mdFor agentic workers: REQUIRED SUB-SKILL: use
skills/implementto run this plan. Steps use- [ ]checkboxes for tracking.
Goal: Replace the iter-1 lower_term CodegenError::Internal
stub for Term::Loop/Term::Recur with real LLVM-IR lowering so a
sum_to-class loop program builds and runs to the correct printed
value, a deep-n variant is safe by construction, and an infinite
loop compiles — the terminal iteration of the loop/recur milestone.
Architecture: Loop binders are loop-carried values lowered as
entry-block allocas via the established mut.3 pending_entry_allocas
mechanism, registered in the existing mut_var_allocas map so the
existing Term::Var load path resolves them with ZERO new
Var-resolution code (representation-sharing — a named alloca slot;
the iter-2 AST/typecheck binder distinction is post-typecheck-
irrelevant to codegen). The loop header is a fresh basic block
reached by an unconditional br from the pre-header (current)
block; recur lowers each arg, stores into the binder allocas, and
back-edges br to the header. A new loop_frames codegen stack
(header label + binder slots) lets recur find its target, is
saved/reset/restored at the single lambda-lowering boundary exactly
as mut.3's mut_var_allocas triple. recur's back-edge br is a
block terminator: it sets the existing block_terminated field at
recur's own emit site (the "parallel setter" — tail-app's SET
sites byte-unchanged), so the enclosing if/match join excludes
the recur block exactly like a tail-app block — which is precisely
how the loop's exit value is materialised (no separate loop-result
phi/exit-block). clang -O2 mem2reg promotes the binder allocas to
the phi nodes the spec's implementation-shape describes.
Tech Stack: ailang-codegen (lib.rs — lower_term
Term::Loop/Term::Recur arms + the loop_frames field;
lambda.rs — the save/reset/restore boundary); examples/ (3 new
.ail fixtures); crates/ail/tests/e2e.rs (3 run/build tests).
Boss design calls baked into this plan (settled — do not re-litigate)
The recon surfaced four genuine codegen forks. All four are resolved here on architectural-consistency / spec-pinned-invariant grounds (NOT effort — each names its substantive reason):
- Loop binders → entry-block alloca +
mut_var_allocasreuse, NOT hand-emittedphi. The spec's "phi" wording lives only in the explicitly-secondary §"Concrete code shapes" implementation-shape subsection, which the spec itself subordinates to the planner's exact-bytes authority and to the acceptance criteria (none of which mandate literalphiin the emitted text). The established in-repo loop-carried-value mechanism is alloca-in-entry-block +clang -O2mem2reg (mut.3 — which the spec explicitly invokes for the lambda-boundary handling). The linear-emit codegen architecture structurally cannot hand-emit a headerphiwhose back-edge predecessors are discovered during body lowering without a string-splice hack that has no precedent (theifarm sidesteps this by opening its join block last — a loop header cannot be opened last). mem2reg produces the identical optimized binary, so the spec's loop-carried-SSA intent is delivered. Rationale = architectural consistency + absence of a linear-emitphiprecedent + identical -O2 output; effort is explicitly NOT the reason. - Binders ride the existing
mut_var_allocasmap + the existingTerm::Varload path (representation-sharing). A loop binder's codegen representation — a named alloca slot, loaded on eachTerm::Varuse, stored onrecur— is structurally identical to a mut-var's. Reusing the map keepsTerm::Varlowering byte-unchanged (a parallel map would force editing Var-resolution to consult it too — the opposite of the minimal-touch discipline). This is representation-only; the iter-2 AST/typecheck distinction (binders live inlocals, notmut_scope_stack) is unaffected because codegen runs post-typecheck and needs only the value representation. mut.3's shipped+E2E-green mut-var read path is the proof the load works. - The loop's exit value is the emergent product of the existing
if/matchjoin oncerecursetsblock_terminated— no separate loop-result phi/exit-block. The spec says "loop is an expression; its value is body's value on the exiting branch." The body is typically(if c <exit> (recur …)); the existingif-arm one-branch-terminated logic (lib.rs:1606-1613) already drops the terminated (recur) branch and returns the non-recur branch's value — iff recur setsblock_terminated. TheTerm::Looparm therefore returnslower_term(body)'s(ssa,ty)directly. Building a separate loop-result phi would duplicate machinery theif/matcharm already provides and risk a double-terminator. The emergent interaction is correct by the existingblock_terminatedlockstep (Boss call 4), not by accident. An infinite loop ((loop (i …) (recur …)), body = bare recur) compiles becauserecursetsblock_terminated→ it propagates through theemit_fnfall-through guard (lib.rs:1124 if !self.block_terminated) exactly as a tail-app-terminated function — the spec's "typechecks AND compiles, no termination claim" acceptance. - Reuse the single
block_terminatedfield;recursets it at its own emit site (a new SET call-site), zero edits to any existing SET or READ site.block_terminatedmodels exactly "this block emitted its terminator — skip a fall-through, exclude from a surrounding join." A back-edgebris a block terminator with exactly that semantics. A literal second field would force duplicating every READ site (lib.rs:1124/1581/1594/1600-1612/1718) to also check it — the opposite of the spec-pinned invariant "tail-app's use of the seam byte-unchanged". "Parallel setter" =recur's ownself.block_terminated = truecall-site, parallel to (not replacing) the tail-app SET site atlib.rs:2279. Tail-app's SET and every READ site stay byte-identical.
Files this plan creates or modifies
- Modify:
crates/ailang-codegen/src/lib.rs:725-adjacent — add theloop_framesstruct field (next tomut_var_allocas: BTreeMap<String,(String,Type)>).:855-adjacent — initloop_frames: Vec::new()(next tomut_var_allocas: BTreeMap::new()).:1845-1847— replace the iter-1Term::Loop {..} | Term::Recur {..} => Err(CodegenError::Internal(...))stub with two real arms.
- Modify:
crates/ailang-codegen/src/lambda.rs:144-adjacent —let saved_loop_frames = std::mem::take(&mut self.loop_frames);(next tosaved_mut_allocas).:260-adjacent —self.loop_frames = saved_loop_frames;(next to theself.mut_var_allocas = saved_mut_allocas;restore).
- Create:
examples/loop_sum_to_run.ail— runnablesum_to(loop/recur),mainprintssum_to 10→55. - Create:
examples/loop_sum_to_deep.ail— same,sum_to 1000000→500000500000(deep-nsafe-by-construction). - Create:
examples/loop_forever_build.ail—main+ an infiniteloop(no non-recurexit); build-only acceptance. - Modify:
crates/ail/tests/e2e.rs— 3 test fns (2 build+run, 1 build-only). - No edit / byte-frozen:
verify_tail_positions+ every existingblock_terminatedSET/READ site + the tail-app/tail-do lowering (Decision 8); the iter-2 typecheck (synthLoop/Recur arms,verify_loop_body,loop_stack, the fourRecur*variants); the iter-1synth_with_extrasLoop→body-type / Recur→Unit pass-through (codegen type synthesis, a separate path fromlower_term);hash_pin.rs(loop_recur + iter13a pins) and the drift trio +carve_out_inventory.rs(iter-3 is codegen-only — no schema/AST/serde change; recon confirmed these stay green untouched, the planner adds NO anchor work).
Task 1: sum_to run-to-value E2E (RED) → loop/recur LLVM-IR lowering (GREEN)
Files:
-
Create:
examples/loop_sum_to_run.ail -
Modify:
crates/ail/tests/e2e.rs -
Modify:
crates/ailang-codegen/src/lib.rs,crates/ailang-codegen/src/lambda.rs -
Step 1: Create the runnable positive fixture
Create examples/loop_sum_to_run.ail (mirrors
examples/mut_counter.ail's main + (app print …) shape;
sum 1..10 = 55, matching the sum.ail → "55" e2e precedent):
(module loop_sum_to_run
(fn main
(doc "loop-recur iter 3 — sum 1..10 via loop/recur. Expected stdout: 55.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print (app sum_to 10))))
(fn sum_to
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body
(loop (acc (con Int) 0) (i (con Int) 1)
(if (app > i n)
acc
(recur (app + acc i) (app + i 1)))))))
- Step 2: Add the failing E2E test
In crates/ail/tests/e2e.rs, after the sum.ail → "55" test,
add:
#[test]
fn loop_recur_sum_to_runs_to_value() {
let stdout = build_and_run("loop_sum_to_run.ail");
assert_eq!(stdout.trim(), "55");
}
Run: cargo test -p ail --test e2e loop_recur_sum_to_runs_to_value 2>&1 | tail -8
Expected: FAIL — ail build fails: codegen hits the iter-1 stub
CodegenError::Internal("Term::Loop/Term::Recur lowering lands in loop-recur iter 3").
- Step 3: Add the
loop_framescodegen field + init
In crates/ailang-codegen/src/lib.rs, immediately after the
mut_var_allocas: BTreeMap<String, (String, Type)>, field
(:725), add:
/// loop-recur iter 3: stack of enclosing `Term::Loop` codegen
/// frames. Each frame is `(header_block_label, binder_slots)`
/// where `binder_slots` is `(binder_name, alloca_ssa, llvm_ty)`
/// in declaration order. Pushed on `Term::Loop` body entry,
/// popped on exit; `Term::Recur` reads `.last()` for its target
/// header + the binder allocas to store into. Saved/reset/
/// restored at the lambda-lowering boundary exactly as the
/// mut.3 `mut_var_allocas` triple (a `recur` cannot cross a
/// lambda boundary).
loop_frames: Vec<(String, Vec<(String, String, String)>)>,
In the constructor, immediately after
mut_var_allocas: BTreeMap::new(), (:855), add:
loop_frames: Vec::new(),
- Step 4: Lambda-boundary save/reset + restore of
loop_frames
In crates/ailang-codegen/src/lambda.rs, immediately after
let saved_mut_allocas = std::mem::take(&mut self.mut_var_allocas);
(:144-region), add:
// loop-recur iter 3: a lambda thunk is its own fn frame —
// a `recur` cannot target a loop enclosing the lambda
// (iter-2 typecheck already rejects it; codegen resets so
// the thunk's own loops work and the outer frames cannot
// leak in). Save + reset (mem::take empties the Vec),
// restored below alongside the mut.3 triple.
let saved_loop_frames = std::mem::take(&mut self.loop_frames);
In the restore block, immediately after
self.mut_var_allocas = saved_mut_allocas; (:260-region), add:
self.loop_frames = saved_loop_frames;
- Step 5: Replace the stub with the
Term::Looplowering arm
In crates/ailang-codegen/src/lib.rs, replace the iter-1 stub
(:1845-1847, verbatim):
Term::Loop { .. } | Term::Recur { .. } => Err(CodegenError::Internal(
"Term::Loop/Term::Recur lowering lands in loop-recur iter 3".into(),
)),
with the Term::Loop arm (the Term::Recur arm is Step 6 — both
land in this same replacement; write Step 5's arm then Step 6's
arm contiguously, replacing the single stub arm with two arms):
Term::Loop { binders, body } => {
// loop-recur iter 3: loop binders are loop-carried
// values lowered as entry-block allocas (the mut.3
// `pending_entry_allocas` mechanism) registered in
// `mut_var_allocas` so the existing `Term::Var` load
// path resolves them with zero new Var code
// (representation-sharing; Boss calls 1+2). The loop
// header is a fresh block reached by an
// unconditional `br` from the pre-header (current)
// block; `recur` stores new values into the binder
// allocas and back-edges here. `clang -O2` mem2reg
// promotes the allocas to the phi nodes the spec's
// implementation-shape describes. The loop's value
// is the body's value on the non-`recur` exit,
// materialised by the existing `if`/`match` join
// once `recur` sets `block_terminated` (Boss call 3).
let id = self.fresh_id();
let header = format!("loop.header.{id}");
let mut saved: Vec<(String, Option<(String, Type)>)> = Vec::new();
let mut frame_slots: Vec<(String, String, String)> = Vec::new();
for b in binders {
let alloca_name =
format!("%loopv_{}_{}", b.name, self.fresh_id());
let llvm_ty = llvm_type(&b.ty)?;
self.pending_entry_allocas
.push_str(&format!(" {alloca_name} = alloca {llvm_ty}\n"));
let (init_ssa, init_ty) = self.lower_term(&b.init)?;
if init_ty != llvm_ty {
return Err(CodegenError::Internal(format!(
"Term::Loop binder `{}`: init LLVM type {init_ty} != declared {llvm_ty}",
b.name
)));
}
self.body.push_str(&format!(
" store {llvm_ty} {init_ssa}, ptr {alloca_name}\n"
));
let prior = self
.mut_var_allocas
.insert(b.name.clone(), (alloca_name.clone(), b.ty.clone()));
saved.push((b.name.clone(), prior));
frame_slots.push((b.name.clone(), alloca_name, llvm_ty));
}
self.body.push_str(&format!(" br label %{header}\n"));
self.loop_frames.push((header.clone(), frame_slots));
self.start_block(&header);
let body_result = self.lower_term(body);
self.loop_frames.pop();
// Restore prior bindings unconditionally (mirrors the
// mut.3 `Term::Mut` arm — defensive on the `?` path).
for (name, prior) in saved.into_iter().rev() {
match prior {
Some(p) => {
self.mut_var_allocas.insert(name, p);
}
None => {
self.mut_var_allocas.remove(&name);
}
}
}
body_result
}
- Step 6: Add the
Term::Recurlowering arm
Immediately after the Term::Loop arm from Step 5 (still inside
the match, replacing the back half of the original single stub
arm), add:
Term::Recur { args } => {
// loop-recur iter 3: re-enter the innermost
// enclosing loop. Typecheck (iter 2) pinned arity ==
// binder count and per-arg type == binder type, so a
// miss/mismatch here is an internal error (the
// `Term::Assign`-arm precedent). ALL args are lowered
// to SSA values BEFORE any store, so a recur arg that
// reads a binder sees its OLD value (recur is a
// simultaneous positional rebind, e.g.
// `(recur (+ acc i) (+ i 1))`). The back-edge `br` is
// a block terminator: set `block_terminated` at
// recur's OWN emit site (the parallel setter — Boss
// call 4; tail-app's SET sites byte-unchanged) so the
// enclosing `if`/`match` join excludes this block
// exactly like a tail-app block. recur never falls
// through; its value is never used — return the
// canonical Unit/dead SSA (`("0","i8")`, the
// `Term::Assign` / both-if-branches-terminated
// precedent).
let (header, slots) = self
.loop_frames
.last()
.cloned()
.ok_or_else(|| {
CodegenError::Internal(
"Term::Recur reached codegen with no enclosing loop frame \
— typecheck should have rejected this earlier"
.into(),
)
})?;
if args.len() != slots.len() {
return Err(CodegenError::Internal(format!(
"Term::Recur arg count {} != enclosing loop binder count {} \
— typecheck should have rejected this earlier",
args.len(),
slots.len()
)));
}
let mut lowered: Vec<(String, String)> =
Vec::with_capacity(args.len());
for a in args {
lowered.push(self.lower_term(a)?);
}
for ((arg_ssa, arg_ty), (_name, alloca_name, llvm_ty)) in
lowered.into_iter().zip(slots.iter())
{
if &arg_ty != llvm_ty {
return Err(CodegenError::Internal(format!(
"Term::Recur arg LLVM type {arg_ty} != binder type {llvm_ty} \
— typecheck should have rejected this earlier"
)));
}
self.body.push_str(&format!(
" store {llvm_ty} {arg_ssa}, ptr {alloca_name}\n"
));
}
self.body.push_str(&format!(" br label %{header}\n"));
self.block_terminated = true;
Ok(("0".into(), "i8".into()))
}
- Step 7: Run the positive E2E to verify GREEN
Run: cargo test -p ail --test e2e loop_recur_sum_to_runs_to_value 2>&1 | tail -8
Expected: PASS — stdout.trim() == "55". (Binder allocas hoisted
to the entry block; Term::Var acc/i load via the existing
mut_var_allocas path; recur stores + back-edges; the if
non-recur branch yields acc; clang -O2 mem2reg promotes the
allocas. sum 1..10 = 55.)
- Step 8: Confirm no regression
Run: cargo build -p ailang-codegen 2>&1 | tail -2
Expected: PASS — Finished.
Run: cargo test -p ail --test e2e 2>&1 | grep -E '^test result:'
Expected: PASS — every pre-existing e2e test still green (the stub
removal touched no built fixture; tail-app/mut e2e unaffected).
Task 2: Deep-n safe-by-construction + infinite-loop-compiles
Files:
-
Create:
examples/loop_sum_to_deep.ail,examples/loop_forever_build.ail -
Modify:
crates/ail/tests/e2e.rs -
Step 1: Create the deep-
nfixture
Create examples/loop_sum_to_deep.ail (sum 1..1_000_000 =
500000500000; a hand-written non-tail self-recursion to depth 1e6
would stack-overflow — the loop back-edge is iterative, safe by
construction; this is the spec §"Testing strategy" clause-2 claim
made executable):
(module loop_sum_to_deep
(fn main
(doc "loop-recur iter 3 — sum 1..1000000 iteratively. A non-tail hand-recursion to this depth would overflow; loop/recur is safe by construction. Expected stdout: 500000500000.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print (app sum_to 1000000))))
(fn sum_to
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body
(loop (acc (con Int) 0) (i (con Int) 1)
(if (app > i n)
acc
(recur (app + acc i) (app + i 1)))))))
- Step 2: Add the deep-
nE2E test
In crates/ail/tests/e2e.rs, after the Task-1 test, add:
#[test]
fn loop_recur_deep_n_safe_by_construction() {
// 1..1_000_000 summed via the loop back-edge (no stack growth);
// a hand-written non-tail self-recursion to this depth would
// overflow. sum 1..1_000_000 = 500000500000 (fits i64).
let stdout = build_and_run("loop_sum_to_deep.ail");
assert_eq!(stdout.trim(), "500000500000");
}
Run: cargo test -p ail --test e2e loop_recur_deep_n_safe_by_construction 2>&1 | tail -6
Expected: PASS — stdout.trim() == "500000500000" (the loop runs
1e6 iterations via the back-edge with no stack growth; Task-1's
mechanism already shipped, so this is the executable proof of the
clause-2 correctness claim).
- Step 3: Create the infinite-loop build-only fixture
examples/loop_forever.ail (iter-2) has NO main (it is the
typecheck-pin fixture). The "infinite loop compiles" acceptance
needs a main-bearing build target. Create
examples/loop_forever_build.ail:
(module loop_forever_build
(fn main
(doc "loop-recur iter 3 — an infinite loop (no non-recur exit) must COMPILE (typechecks AND compiles; no termination claim). Build-only; by design never returns, so the binary is never executed.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print (app spin 0))))
(fn spin
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body (loop (i (con Int) 0) (recur (app + i 1))))))
- Step 4: Add the build-only E2E test
In crates/ail/tests/e2e.rs, after the Step-2 test, add (build
only — the binary by design never returns, so it is NOT executed;
this asserts the spec "typechecks AND compiles, no termination
claim"):
#[test]
fn loop_recur_infinite_loop_compiles() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join("loop_forever_build.ail");
let tmp = std::env::temp_dir().join(format!(
"ailang_e2e_loopforever_{}",
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let out = tmp.join("bin");
let status = Command::new(ail_bin())
.args(["build", src.to_str().unwrap(), "-o"])
.arg(&out)
.status()
.expect("ail build failed to run");
assert!(
status.success(),
"ail build must succeed on an infinite loop (typechecks AND compiles, no termination claim)"
);
}
(Path and Command are already imported at the top of e2e.rs
— use std::path::Path; use std::process::Command;. Do not
re-import.)
Run: cargo test -p ail --test e2e loop_recur_infinite_loop_compiles 2>&1 | tail -6
Expected: PASS — ail build exits 0. (spin's loop has no
non-recur exit → recur sets block_terminated → it propagates
through emit_fn's if !self.block_terminated fall-through guard,
so spin emits no ret and is a well-formed never-returning
function; the unreachable print/ret in main after the
non-returning spin call is LLVM-legal. The binary is never run.)
Task 3: Regression gates — tail-app / hash / full suite byte-unchanged
Files: none (verification only — no source changes).
- Step 1:
tail-app/tail-do/ Decision-8 non-regression
Run: cargo test --workspace tail 2>&1 | grep -E '^test result:' | tail -5
Expected: PASS — every tail-app/tail-do/verify_tail_positions
test byte-identical. (iter-3 added a parallel block_terminated
SET call-site in Term::Recur and touched no existing SET/READ
site; Boss call 4. This re-confirms the spec's frozen-tail-app
acceptance criterion at the codegen level.)
- Step 2: Hash-pin + drift non-regression (codegen-only iter)
Run: cargo test -p ailang-core --test hash_pin 2>&1 | grep -E '^test result:'
Expected: PASS — loop_recur_schema_extension_preserves_pre_loop_recur_hashes
iter13a_schema_extension_preserves_pre_13a_hashesgreen. (iter-3 changed onlyailang-codegen+examples/*.ail+ e2e tests — no schema/AST/serde change; the recon confirmed the hash pins and the drift trio +carve_out_inventoryare not codegen-dependent and stay green untouched. The new fixtures are.ailnot.ail.json, so they are round-trip-auto-covered, not carve-outs.)
- Step 3: Full workspace suite
Run: cargo test --workspace 2>&1 | grep -E '^test result: ok' | awk '{p+=$4} END {print p" passed"}'; cargo test --workspace 2>&1 | grep -E 'FAILED|error\[|^test result: FAILED' | head
Expected: passed count ≥ the loop-recur.2 baseline (616) + the 3
new e2e tests; zero FAILED/error[.
- Step 4: Round-trip auto-coverage of the new
.ailfixtures
Run: cargo test -p ailang-surface --test round_trip 2>&1 | grep -E '^test result:'
Expected: PASS — round_trip.rs auto-discovers the three new
examples/*.ail fixtures and proves parse→print→parse
idempotency on each (the milestone's Roundtrip-Invariant gate,
now covering runnable loop/recur programs).
Acceptance criteria (this iteration)
- The iter-1
lower_termCodegenError::Internalstub is gone;Term::Loop/Term::Recurlower to real LLVM IR. examples/loop_sum_to_run.ailbuilds and runs to55;examples/loop_sum_to_deep.ailruns 1e6 iterations to500000500000(deep-nsafe by construction — the clause-2 correctness claim made executable).- An infinite
loop(examples/loop_forever_build.ail) compiles (ail buildexit 0); it is never executed (no termination claim is made or enforced). tail-app/tail-do/verify_tail_positions/Decision 8 byte-unchanged; every existingblock_terminatedSET/READ site byte-unchanged (recur adds a parallel SET call-site only); the iter-2 typecheck (syntharms,verify_loop_body,loop_stack, the fourRecur*variants) byte-unchanged; the iter-1synth_with_extraspass-through byte-unchanged.- Pre-existing canonical-JSON hashes bit-stable (iter-3 is
codegen-only — no schema change); hash pins + drift trio +
carve_out_inventorygreen untouched. cargo test --workspacegreen; the three new.ailfixtures round-trip (Roundtrip Invariant holds for runnable loop/recur programs).
Cross-references
- Parent spec
docs/specs/2026-05-17-loop-recur.md(Component 5 + Positive E2E = this iter; this is the milestone's TERMINAL iteration — after it ships the milestone CLOSES and the Boss runsaudit, thenfieldtestsince loop/recur is user-visible Form-A surface; that pipeline is the Boss's post-iter call, NOT part of this plan). - mut.3 (
docs/specs/2026-05-15-mut-local.md) is the codegen precedent:pending_entry_allocasentry-block hoist,mut_var_allocassave/restore, the lambda-boundary save/reset/restore triple. loop-recur.3 reuses the mechanism; the loop-specific additions (loop-header block, back-edgebr,loop_framesstack, the parallelblock_terminatedSET site) are Boss calls 1-4 above. - iter-1 (
a179ec3) shipped thelower_termLoop/RecurCodegenError::Internalstub this iter replaces + thesynth_with_extraspass-through that stays. iter-2 (1566ce0) shipped the typecheck this iter relies on (binder typing pins the alloca types; the fourRecur*codes mean a codegen-side arity/type miss isCodegenError::Internal, the typecheck-already-rejected precedent). - Boss calls 1-4 (alloca-not-hand-phi;
mut_var_allocasreuse; emergent loop-exit via theif/matchjoin; singleblock_terminatedfield + parallel SET site) are recorded here and must be mirrored into the per-iter journal at iter close.