Files
AILang/docs/journals/2026-05-17-iter-loop-recur.3.md
T
Brummel edd2558d35 iter loop-recur.3: codegen — real LLVM-IR lowering + run-to-value E2E (milestone terminal)
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.
2026-05-17 23:57:46 +02:00

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_to run-to-value E2E (RED → GREEN). RED observed verbatim: ail build on loop_sum_to_run.ail fails with the iter-1 stub internal: Term::Loop/Term::Recur lowering lands in loop-recur iter 3 (parse + iter-1/iter-2 typecheck pass; only codegen stubbed — confirmed via direct ail build). GREEN: loop_frames field + init, lambda-boundary save/reset/restore, the two real Term::Loop/Term::Recur arms replacing the single stub arm. loop_sum_to_run.ail55; cargo build -p ailang-codegen Finished; full e2e suite 88/0. Plan's literal arms compiled as-written — no plan-vs-reality substitution needed (pre-grounding confirmed mut_var_allocas: (String, Type) and lower_term -> (String /*ssa*/, String /*llvm_ty*/) matched the plan's assumptions exactly).
  • iter loop-recur.3.2: deep-n safe-by-construction + infinite-loop-compiles (test+fixture only; the codegen mechanism shipped in 3.1, so no separate RED). loop_sum_to_deep.ail runs 1e6 iterations via the back-edge → 500000500000 with 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-recur exit) compiles ail build exit 0 — recur sets block_terminated, propagating through emit_fn's if !self.block_terminated fall-through guard so spin emits no ret and 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_positions non-regression green (the parallel block_terminated SET site touched no existing SET/READ site — Boss call 4); loop_recur + iter13a hash pins + drift trio + carve_out_inventory green 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_trip green (the 3 new .ail fixtures 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:

  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 implementation-shape subsection. The established in-repo loop-carried-value mechanism is alloca-in-entry-block (mut.3 pending_entry_allocas) + clang -O2 mem2reg, which produces the identical optimized binary. 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 with no precedent (the if arm sidesteps this by opening its join block last — a loop header cannot be opened last). Rationale = architectural consistency + absence of a linear-emit phi precedent + identical -O2 output. Implemented verbatim; loop_sum_to_run.ail→55 and loop_sum_to_deep.ail→500000500000 are the operational proof mem2reg delivers the loop-carried-SSA intent.
  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. The Term::Loop arm inserts each binder into mut_var_allocas (value type (String, Type), identical to the Term::Mut precedent); the existing lib.rs Term::Var arm resolves them via its pre-existing mut_var_allocas.get(name)load path 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.
  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 body is typically (if c <exit> (recur …)); the existing one-branch-terminated logic at lib.rs:1606-1613 already drops the terminated (recur) branch and returns the non-recur branch's (ssa,ty) directly — iff recur sets block_terminated. The Term::Loop arm therefore returns lower_term(body)'s (ssa,ty) directly; no separate loop-result phi was built. An infinite loop (body = bare recur) compiles because recur sets block_terminated, propagating through the emit_fn if !self.block_terminated fall-through guard exactly as a tail-app-terminated function.
  4. Reuse the single block_terminated field; recur sets it at its OWN new emit site (a parallel SET call-site), zero edits to any existing SET or READ site. A back-edge br IS a block terminator with exactly block_terminated's semantics. recur's own self.block_terminated = true; is a NEW call-site parallel to (not replacing) tail-app's SET at lib.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_positions non-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 tail filters on the test-name substring tail, which matches no test in the workspace (the real tail-app guards are ailang-check::lib::tail_call_in_{non_,}tail_position_is_*, ailang-prose::lib *_tail_*, e2e iter14e_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. Recurring feedback_plan_pseudo_vs_reality class (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_positions byte-unchanged proven), no behaviour change. Observation, not a correctness risk — the full-suite count 619 = 616 + 3 is independent confirmation nothing regressed.

Known debt

  • None. The iteration's invariants (loop/recur lowers to real IR; run-to-value 55; deep-n 500000500000 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_frames field + init; the two real Term::Loop/Term::Recur arms replacing the iter-1 single stub arm), crates/ailang-codegen/src/lambda.rs (lambda-boundary loop_frames save/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_terminated SET/READ site + tail-app/tail-do lowering + verify_tail_positions; 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; hash_pin.rs (loop_recur + iter13a pins) + the drift trio + carve_out_inventory.rs (codegen-only iter; the 3 new .ail fixtures are round-trip-auto-covered, NOT carve-outs — inventory stays 8)

Stats

bench/orchestrator-stats/2026-05-17-iter-loop-recur.3.json