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.
This commit is contained in:
2026-05-17 23:57:46 +02:00
parent eae73bf320
commit edd2558d35
10 changed files with 434 additions and 3 deletions
+8
View File
@@ -142,6 +142,13 @@ impl<'a> Emitter<'a> {
// the three fields so the thunk emits its own hoisted-
// alloca block at the thunk's entry, not the outer fn's.
let saved_mut_allocas = std::mem::take(&mut self.mut_var_allocas);
// 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);
let saved_pending_allocas = std::mem::take(&mut self.pending_entry_allocas);
let saved_entry_marker = self.entry_block_end_marker.take();
// Iter 18d.4 fix: a lambda thunk is its own fn frame for
@@ -256,6 +263,7 @@ impl<'a> Emitter<'a> {
self.current_param_modes = saved_param_modes;
// Iter mut.3: restore outer fn's mut-var bookkeeping.
self.mut_var_allocas = saved_mut_allocas;
self.loop_frames = saved_loop_frames;
self.pending_entry_allocas = saved_pending_allocas;
self.entry_block_end_marker = saved_entry_marker;
+132 -3
View File
@@ -723,6 +723,16 @@ struct Emitter<'a> {
/// walks `mut_scope_stack` innermost-first before falling
/// through to locals/globals).
mut_var_allocas: BTreeMap<String, (String, Type)>,
/// 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)>)>,
/// Iter mut.3: side buffer for `alloca` instructions emitted
/// during body lowering but hoisted to the fn's entry block.
/// Flushed once into `self.body` at `entry_block_end_marker`
@@ -853,6 +863,7 @@ impl<'a> Emitter<'a> {
moved_slots: BTreeMap::new(),
current_param_modes: BTreeMap::new(),
mut_var_allocas: BTreeMap::new(),
loop_frames: Vec::new(),
pending_entry_allocas: String::new(),
entry_block_end_marker: None,
}
@@ -1842,9 +1853,127 @@ impl<'a> Emitter<'a> {
// iter stubs the dispatch so the workspace compiles; no
// loop/recur program is codegen'd in iter 1. Mirrors
// mut.1's lower_term stub.
Term::Loop { .. } | Term::Recur { .. } => Err(CodegenError::Internal(
"Term::Loop/Term::Recur lowering lands in loop-recur iter 3".into(),
)),
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
}
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()))
}
}
}