plan: loop-recur.3 — codegen (Component 5) + run-to-value E2E
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.
This commit is contained in:
@@ -0,0 +1,626 @@
|
|||||||
|
# loop-recur.3 — Codegen (Component 5) + run-to-value E2E — Implementation Plan
|
||||||
|
|
||||||
|
> **Parent spec:** `docs/specs/2026-05-17-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 10` → `55`.
|
||||||
|
- Create: `examples/loop_sum_to_deep.ail` — same, `sum_to 1000000`
|
||||||
|
→ `500000500000` (deep-`n` safe-by-construction).
|
||||||
|
- Create: `examples/loop_forever_build.ail` — `main` + 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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// 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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[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"):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[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_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/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
|
||||||
|
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/2026-05-15-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.
|
||||||
Reference in New Issue
Block a user