edd2558d35
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.
189 lines
10 KiB
Markdown
189 lines
10 KiB
Markdown
# iter loop-recur.3 — Codegen (Component 5) + run-to-value E2E
|
|
|
|
**Date:** 2026-05-17
|
|
**Started from:** eae73bf320440f6b031715e72c1f20b0ac86e024
|
|
**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.ail` → `55`;
|
|
`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
|