Third and terminal iteration of the standalone loop/recur
milestone (plan eae73bf). Replaces the iter-1 lower_term
CodegenError::Internal stub for Term::Loop/Term::Recur with real
LLVM-IR lowering: loop binders as entry-block allocas (mut.3
pending_entry_allocas, reusing mut_var_allocas so the existing
Term::Var load path is byte-unchanged), a fresh loop-header block,
recur stores + back-edge br, a loop_frames stack saved/restored at
the lambda boundary. clang -O2 mem2reg promotes the allocas to
phi. Four Boss design calls implemented verbatim and journalled
(alloca-not-hand-phi; mut_var_allocas reuse; emergent loop-exit
via the if/match join; single block_terminated field + parallel
SET site). Diff confirmed surgical: codegen/lib.rs 3 hunks (field
+ init + stub->2-arms), lambda.rs 2 hunks (save+restore); zero
edits to any existing block_terminated SET/READ site, tail-app
lowering, or verify_tail_positions (Boss call 4, the spec-pinned
invariant). Three new .ail fixtures: sum_to->55,
deep-n 1e6->500000500000 (clause-2 correctness made executable),
infinite-loop build-only. Codegen-only: no schema/typecheck
change; hash pins + drift trio stay green untouched.
One DONE_WITH_CONCERNS (T3): the plan's `cargo test ... tail`
filter resolved to no tests; ran via real names + the full 619/0
which subsumes it (feedback_plan_pseudo_vs_reality class, no
behaviour change). Boss systemic fix folded in: planner SKILL.md
Step-5 gains item 8 (verification-command filter strings must
resolve) — the second planner-meta-gap this milestone surfaced.
cargo test --workspace 616 -> 619 / 0 red (Boss-reran
independently); the 3 loop/recur e2e explicitly green. All three
components shipped: the loop/recur milestone is structurally
complete. Milestone-close audit + fieldtest is the next step.
10 KiB
iter loop-recur.3 — Codegen (Component 5) + run-to-value E2E
Date: 2026-05-17
Started from: eae73bf320
Status: DONE
Tasks completed: 3 of 3
Summary
The terminal iteration of the standalone-loop/recur milestone:
the iter-1 lower_term CodegenError::Internal stub for
Term::Loop/Term::Recur is replaced 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. Loop binders are loop-carried values lowered as
entry-block allocas (the 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 code
(representation-sharing); a fresh loop.header.<id> block is reached
by an unconditional br from the pre-header, recur lowers each arg
to SSA before any store (simultaneous positional rebind), stores
into the binder allocas, back-edges br to the header, and sets the
single existing block_terminated field at its OWN new emit site so
the loop's exit value is the emergent product of the existing
if/match join (no separate loop-result phi/exit-block). A new
loop_frames codegen stack lets recur find its target; it is
saved/reset/restored at the single lambda-lowering boundary exactly
as mut.3's mut_var_allocas triple. clang -O2 mem2reg promotes
the binder allocas to the phi nodes the spec's implementation-shape
describes. This is a codegen-only iteration: no schema/AST/serde
change, no typecheck change; hash pins (loop_recur + iter13a) and
the drift trio + carve_out_inventory stay green untouched
(confirmed, not modified — empirical proof none scans the codegen
region). cargo test --workspace 616 → 619 / 0 red (the 3 new e2e
tests). After this iter the milestone is structurally CLOSED;
milestone-close (audit/fieldtest) is the Boss's post-iter call.
Per-task notes
- iter loop-recur.3.1: positive
sum_torun-to-value E2E (RED → GREEN). RED observed verbatim:ail buildonloop_sum_to_run.ailfails with the iter-1 stubinternal: Term::Loop/Term::Recur lowering lands in loop-recur iter 3(parse + iter-1/iter-2 typecheck pass; only codegen stubbed — confirmed via directail build). GREEN:loop_framesfield + init, lambda-boundary save/reset/restore, the two realTerm::Loop/Term::Recurarms replacing the single stub arm.loop_sum_to_run.ail→55;cargo build -p ailang-codegenFinished; full e2e suite 88/0. Plan's literal arms compiled as-written — no plan-vs-reality substitution needed (pre-grounding confirmedmut_var_allocas: (String, Type)andlower_term -> (String /*ssa*/, String /*llvm_ty*/)matched the plan's assumptions exactly). - iter loop-recur.3.2: deep-
nsafe-by-construction + infinite-loop-compiles (test+fixture only; the codegen mechanism shipped in 3.1, so no separate RED).loop_sum_to_deep.ailruns 1e6 iterations via the back-edge →500000500000with no stack growth (the spec clause-2 correctness claim made executable — mem2reg promoted the binder allocas so the loop is an iterative back-edge, not a stack-growing recursion).loop_forever_build.ail(spin's loop has no non-recurexit) compilesail buildexit 0 —recursetsblock_terminated, propagating throughemit_fn'sif !self.block_terminatedfall-through guard sospinemits noretand is a well-formed never-returning fn; the binary is never executed (spec "typechecks AND compiles, no termination claim"). Full e2e suite 90/0. - iter loop-recur.3.3: regression gates — pure verification, ZERO
source changes (confirmed
git diff --name-only HEAD= only the 3 T1/T2 source files). tail-app/tail-do/verify_tail_positionsnon-regression green (the parallelblock_terminatedSET site touched no existing SET/READ site — Boss call 4);loop_recur+ iter13a hash pins + drift trio +carve_out_inventorygreen untouched (codegen-only iter, no schema change); full workspace 619 passed = exactly the loop-recur.2 baseline (616) + the 3 new e2e tests, zero FAILED/error;round_tripgreen (the 3 new.ailfixtures auto-discovered, parse→print→parse idempotent — Roundtrip Invariant holds for runnable loop/recur programs).
Boss design calls (mirrored from the plan header at iter close)
All four were settled in the plan header on architectural-consistency / spec-pinned-invariant grounds (NOT effort); each verified against actual read source before implementation; none reopened, re-derived, or "improved" in any phase:
- Loop binders → entry-block alloca +
mut_var_allocasreuse, NOT hand-emittedphi. The spec's "phi" wording lives only in the explicitly-secondary implementation-shape subsection. The established in-repo loop-carried-value mechanism is alloca-in-entry-block (mut.3pending_entry_allocas) +clang -O2mem2reg, which produces the identical optimized binary. The linear-emit codegen architecture structurally cannot hand-emit a headerphiwhose back-edge predecessors are discovered during body lowering without a string-splice hack with no precedent (theifarm sidesteps this by opening its join block last — a loop header cannot be opened last). Rationale = architectural consistency + absence of a linear-emitphiprecedent + identical -O2 output. Implemented verbatim;loop_sum_to_run.ail→55 andloop_sum_to_deep.ail→500000500000 are the operational proof mem2reg delivers the loop-carried-SSA intent. - 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. TheTerm::Looparm inserts each binder intomut_var_allocas(value type(String, Type), identical to theTerm::Mutprecedent); the existinglib.rsTerm::Vararm resolves them via its pre-existingmut_var_allocas.get(name)→loadpath byte-unchanged (zero edits to Var-resolution; no parallel map). Verified against real source before implementing. mut.3's shipped+E2E-green mut-var read path is the proof the load works for loop binders. - The loop's exit value is the emergent product of the existing
if/matchjoin oncerecursetsblock_terminated— no separate loop-result phi/exit-block. The body is typically(if c <exit> (recur …)); the existing one-branch-terminated logic atlib.rs:1606-1613already drops the terminated (recur) branch and returns the non-recur branch's(ssa,ty)directly — iffrecursetsblock_terminated. TheTerm::Looparm therefore returnslower_term(body)'s(ssa,ty)directly; no separate loop-result phi was built. An infinite loop (body = barerecur) compiles becauserecursetsblock_terminated, propagating through theemit_fnif !self.block_terminatedfall-through guard exactly as a tail-app-terminated function. - Reuse the single
block_terminatedfield;recursets it at its OWN new emit site (a parallel SET call-site), zero edits to any existing SET or READ site. A back-edgebrIS a block terminator with exactlyblock_terminated's semantics.recur's ownself.block_terminated = true;is a NEW call-site parallel to (not replacing) tail-app's SET atlib.rs:2279. The diff confirms tail-app's SET and every READ site (:1124/1581/1594/1600-1613/...) are byte-identical; the tail-app/verify_tail_positionsnon-regression gate (T3 Step 1) is the operational proof.
Concerns
- DONE_WITH_CONCERNS (iter loop-recur.3.3): the plan's literal Task-3
Step-1 command
cargo test --workspace tailfilters on the test-name substringtail, which matches no test in the workspace (the real tail-app guards areailang-check::lib::tail_call_in_{non_,}tail_position_is_*,ailang-prose::lib*_tail_*, e2eiter14e_print_list_recursion_emits_musttail). Ran the gate via the real test names instead (2 + 3 + 1, all green); the authoritative non-regression gate is Step 3's full-workspace 619/0 which subsumes them. Recurringfeedback_plan_pseudo_vs_realityclass (a literal verification command whose filter doesn't resolve to its evident target); resolved by preserving the gate's evident intent (tail-app/verify_tail_positionsbyte-unchanged proven), no behaviour change. Observation, not a correctness risk — the full-suite count619 = 616 + 3is independent confirmation nothing regressed.
Known debt
- None. The iteration's invariants (loop/recur lowers to real IR;
run-to-value 55; deep-
n500000500000 safe-by-construction; infinite loop compiles; tail-app + hash pins + drift trio byte-unchanged; round-trip on the 3 new fixtures) are fully pinned by the 3 new e2e tests + the unchanged regression suite.
Files touched
- Codegen:
crates/ailang-codegen/src/lib.rs(loop_framesfield + init; the two realTerm::Loop/Term::Recurarms replacing the iter-1 single stub arm),crates/ailang-codegen/src/lambda.rs(lambda-boundaryloop_framessave/reset + restore, alongside the mut.3 triple) - Tests:
crates/ail/tests/e2e.rs(3 new test fns: 2 build+run, 1 build-only) - New fixtures:
examples/loop_sum_to_run.ail,examples/loop_sum_to_deep.ail,examples/loop_forever_build.ail - No edit / byte-frozen (confirmed, not modified): every existing
block_terminatedSET/READ site + tail-app/tail-do lowering +verify_tail_positions; 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;hash_pin.rs(loop_recur+ iter13a pins) + the drift trio +carve_out_inventory.rs(codegen-only iter; the 3 new.ailfixtures are round-trip-auto-covered, NOT carve-outs — inventory stays 8)
Stats
bench/orchestrator-stats/2026-05-17-iter-loop-recur.3.json