Files
AILang/docs/plans/0078-loop-recur.3.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

28 KiB

loop-recur.3 — Codegen (Component 5) + run-to-value E2E — Implementation Plan

Parent spec: docs/specs/0034-loop-recur.md

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to 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):

  1. Loop binders → entry-block alloca + mut_var_allocas reuse, NOT hand-emitted phi. 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 literal phi in the emitted text). The established in-repo loop-carried-value mechanism is alloca-in-entry-block + clang -O2 mem2reg (mut.3 — which the spec explicitly invokes for the lambda-boundary handling). The linear-emit codegen architecture structurally cannot hand-emit a header phi whose back-edge predecessors are discovered during body lowering without a string-splice hack that has no precedent (the if arm 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-emit phi precedent + identical -O2 output; effort is explicitly NOT the reason.
  2. Binders ride the existing mut_var_allocas map + the existing Term::Var load path (representation-sharing). A loop binder's codegen representation — a named alloca slot, loaded on each Term::Var use, stored on recur — is structurally identical to a mut-var's. Reusing the map keeps Term::Var lowering 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 in locals, not mut_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.
  3. The loop's exit value is the emergent product of the existing if/match join once recur sets block_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 existing if-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 sets block_terminated. The Term::Loop arm therefore returns lower_term(body)'s (ssa,ty) directly. Building a separate loop-result phi would duplicate machinery the if/match arm already provides and risk a double-terminator. The emergent interaction is correct by the existing block_terminated lockstep (Boss call 4), not by accident. An infinite loop ((loop (i …) (recur …)), body = bare recur) compiles because recur sets block_terminated → it propagates through the emit_fn fall-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.
  4. Reuse the single block_terminated field; recur sets it at its own emit site (a new SET call-site), zero edits to any existing SET or READ site. block_terminated models exactly "this block emitted its terminator — skip a fall-through, exclude from a surrounding join." A back-edge br is 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 own self.block_terminated = true call-site, parallel to (not replacing) the tail-app SET site at lib.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 the loop_frames struct field (next to mut_var_allocas: BTreeMap<String,(String,Type)>).
    • :855-adjacent — init loop_frames: Vec::new() (next to mut_var_allocas: BTreeMap::new()).
    • :1845-1847 — replace the iter-1 Term::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 to saved_mut_allocas).
    • :260-adjacent — self.loop_frames = saved_loop_frames; (next to the self.mut_var_allocas = saved_mut_allocas; restore).
  • Create: examples/loop_sum_to_run.ail — runnable sum_to (loop/recur), main prints sum_to 1055.
  • Create: examples/loop_sum_to_deep.ail — same, sum_to 1000000500000500000 (deep-n safe-by-construction).
  • Create: examples/loop_forever_build.ailmain + an infinite loop (no non-recur exit); 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 existing block_terminated SET/READ site + the tail-app/tail-do lowering (Decision 8); the iter-2 typecheck (synth Loop/Recur arms, verify_loop_body, loop_stack, the four Recur* variants); the iter-1 synth_with_extras Loop→body-type / Recur→Unit pass-through (codegen type synthesis, a separate path from lower_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_frames codegen 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::Loop lowering 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::Recur lowering 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-n fixture

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-n E2E 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.rsuse 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_hashes green. (iter-3 changed only ailang-codegen + examples/*.ail + e2e tests — no schema/AST/serde change; the recon confirmed the hash pins and the drift trio + carve_out_inventory are not codegen-dependent and stay green untouched. The new fixtures are .ail not .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 .ail fixtures

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_term CodegenError::Internal stub is gone; Term::Loop/Term::Recur lower to real LLVM IR.
  • examples/loop_sum_to_run.ail builds and runs to 55; examples/loop_sum_to_deep.ail runs 1e6 iterations to 500000500000 (deep-n safe by construction — the clause-2 correctness claim made executable).
  • An infinite loop (examples/loop_forever_build.ail) compiles (ail build exit 0); it is never executed (no termination claim is made or enforced).
  • tail-app/tail-do/verify_tail_positions/Decision 8 byte-unchanged; every existing block_terminated SET/READ site byte-unchanged (recur adds a parallel SET call-site only); the iter-2 typecheck (synth arms, verify_loop_body, loop_stack, the four Recur* variants) byte-unchanged; the iter-1 synth_with_extras pass-through byte-unchanged.
  • Pre-existing canonical-JSON hashes bit-stable (iter-3 is codegen-only — no schema change); hash pins + drift trio + carve_out_inventory green untouched.
  • cargo test --workspace green; the three new .ail fixtures round-trip (Roundtrip Invariant holds for runnable loop/recur programs).

Cross-references

  • Parent spec docs/specs/0034-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 runs audit, then fieldtest since 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/0029-mut-local.md) is the codegen precedent: pending_entry_allocas entry-block hoist, mut_var_allocas save/restore, the lambda-boundary save/reset/restore triple. loop-recur.3 reuses the mechanism; the loop-specific additions (loop-header block, back-edge br, loop_frames stack, the parallel block_terminated SET site) are Boss calls 1-4 above.
  • iter-1 (a179ec3) shipped the lower_term Loop/Recur CodegenError::Internal stub this iter replaces + the synth_with_extras pass-through that stays. iter-2 (1566ce0) shipped the typecheck this iter relies on (binder typing pins the alloca types; the four Recur* codes mean a codegen-side arity/type miss is CodegenError::Internal, the typecheck-already-rejected precedent).
  • Boss calls 1-4 (alloca-not-hand-phi; mut_var_allocas reuse; emergent loop-exit via the if/match join; single block_terminated field + parallel SET site) are recorded here and must be mirrored into the per-iter journal at iter close.