iter mut.3: codegen + e2e — mut-local milestone end-to-end closed

Replaces the iter mut.1 dispatch stubs in lower_term with real LLVM
lowering: mut-vars become alloca slots hoisted to the fn's entry
block, assign lowers to store, mut-var reads lower to load. Two
example fixtures run end-to-end printing 55 (Int via mut_counter,
Float via mut_sum_floats).

Concretely:

- Emitter struct gains three new fields: mut_var_allocas:
  BTreeMap<String, (String, Type)> for per-fn name→(alloca SSA,
  AIL type) tracking; pending_entry_allocas: String as a side
  buffer accumulating alloca instructions during body lowering;
  entry_block_end_marker: Option<usize> recording the byte position
  in self.body immediately after the entry: label.

- start_block(label) captures the marker when label == 'entry'.
  emit_fn resets all three new fields per fn body. At the end of
  emit_fn, String::insert_str splices pending_entry_allocas into
  self.body at the marker — alloca instructions land in the entry
  block regardless of where in the source tree Term::Mut was
  encountered (mem2reg eligibility preserved).

- Term::Mut arm: per var, fresh-name an alloca SSA, push the
  alloca instruction into pending_entry_allocas, lower the init at
  the current body position, emit a store to bind. The mut-var
  binding is NOT visible during init (matches typecheck-side
  ordering at lib.rs:3576). After all vars are bound, lower the
  body in the extended scope. On block exit, restore any prior
  bindings via a per-block save stack — supports nested
  shadowing correctly.

- Term::Assign arm: look up the alloca + type via
  mut_var_allocas.get(name); lower the value; emit store; yield
  the canonical Unit SSA per the existing Term::Lit { lit:
  Literal::Unit } convention.

- Term::Var arm: prepended with a mut-var lookup that emits a
  load <ty>, ptr <alloca> and returns the load SSA. Innermost-wins
  shadowing falls out of the BTreeMap's insert-overwrites
  semantics combined with the per-block save/restore.

- examples/mut_counter.ail + examples/mut_sum_floats.ail:
  recursive sum_helper accumulates 1..10 (Int) or 1.0..10.0 (Float)
  inside a mut block whose single var is assigned the helper's
  result. Both run end-to-end printing 55.

- crates/ail/tests/e2e.rs: mut_counter_prints_55 and
  mut_sum_floats_prints_55 #[test] fns using the existing
  build_and_run helper.

- DESIGN.md 'What is supported' subsection: 'Local mutable state'
  bullet describes the new construct, points at the two example
  fixtures, and reaffirms the seal-by-construction invariant under
  Decision 10.

Beyond-plan adjustments absorbed in this iter:
  - synth_with_extras's parallel Term::Var arm in
    ailang-codegen/src/lib.rs needed the same mut-var lookup as
    lower_term's. Plan only specified lower_term's arm.
  - crates/ailang-codegen/src/lambda.rs needed save/restore of all
    three new Emitter fields across the lambda-body boundary plus
    in-thunk splice of pending_entry_allocas at the lambda's own
    entry marker, so mut-blocks inside a closure body hoist into
    the closure's entry block (not the outer fn's).

mut-local milestone end-to-end status:
  - mut.1 (7b92719): AST + Form A surface.
  - mut.2 (b24718a): typecheck.
  - mut.3 (this commit): codegen + e2e.
  Audit follows.

Tests: 592 → 594 green; cargo build green; both fixtures execute
and print 55.

Journal: docs/journals/2026-05-15-iter-mut.3.md.

Refs: docs/specs/2026-05-15-mut-local.md, docs/plans/2026-05-15-iter-mut.3.md.
This commit is contained in:
2026-05-15 01:56:26 +02:00
parent b057789b55
commit 03fb633d85
9 changed files with 454 additions and 10 deletions
@@ -0,0 +1,23 @@
{
"iter_id": "mut.3",
"date": "2026-05-15",
"mode": "standard",
"outcome": "DONE",
"tasks_total": 9,
"tasks_completed": 9,
"reloops_per_task": {
"1": 0,
"2": 0,
"3": 0,
"4": 0,
"5": 0,
"6": 0,
"7": 1,
"8": 0,
"9": 0
},
"review_loops_spec": 0,
"review_loops_quality": 0,
"blocked_reason": null,
"notes": "Task 7 re-loop: initial mut_counter.ail draft used (app + lo (tail-app sum_helper ...)) which the tail-call analysis rejected as 'not in tail position'; restructured to accumulator-style passing acc as a third arg. Two beyond-plan adjustments absorbed inline (not counted as re-loops since they were structural extensions, not failed retries): (1) synth_with_extras Term::Var mut-var precedence in lib.rs, (2) lambda.rs save/restore + in-thunk splice. tests 592 -> 594 (+2 e2e)."
}
+25
View File
@@ -2815,3 +2815,28 @@ fn str_clone_cross_realisation_uniform_abi() {
assert_eq!(frees, 3, "expected three matching frees; got frees={frees}"); assert_eq!(frees, 3, "expected three matching frees; got frees={frees}");
assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}"); assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
} }
/// Iter mut.3: `examples/mut_counter.ail` exercises `Term::Mut` +
/// `Term::Assign` codegen with an `Int` mut-var. The body
/// `(mut (var sum Int 0) (assign sum (sum_helper 1 10 0)) sum)`
/// stores the helper's result into `sum`'s alloca and prints the
/// loaded value. End-to-end gate for the entry-block-hoist /
/// store / load lowering.
#[test]
fn mut_counter_prints_55() {
let stdout = build_and_run("mut_counter.ail");
assert_eq!(stdout.trim(), "55", "mut_counter must print 55, got {stdout:?}");
}
/// Iter mut.3: Float twin of `mut_counter_prints_55`. The mut-var
/// is `Float`, init is `0.0`, the recursive helper returns the
/// sum 1.0+...+10.0 = 55.0. The polymorphic `print` routes through
/// `Show Float.show` → `float_to_str`'s libc `%g` formatter, which
/// strips the trailing `.0` — so the canonical stdout for Float
/// 55.0 is `"55"` (matches `examples/floats.ail`'s output of
/// `4` for `1.5 + 2.5`).
#[test]
fn mut_sum_floats_prints_55() {
let stdout = build_and_run("mut_sum_floats.ail");
assert_eq!(stdout.trim(), "55", "mut_sum_floats must print 55, got {stdout:?}");
}
+30
View File
@@ -126,6 +126,17 @@ impl<'a> Emitter<'a> {
// tracking — the outer fn's binders are not in scope inside // tracking — the outer fn's binders are not in scope inside
// the thunk body (only its captures + params). Save and reset. // the thunk body (only its captures + params). Save and reset.
let saved_moved = std::mem::take(&mut self.moved_slots); let saved_moved = std::mem::take(&mut self.moved_slots);
// Iter mut.3: a lambda thunk is its own fn frame for the
// mut-var bookkeeping introduced in iter mut.3 — the outer
// fn's mut-vars are not in scope inside the thunk (a lambda
// body cannot reference enclosing mut-vars; the spec
// disallows capture of mut-vars by lambdas via the
// `mut_var_allocas` map being thunk-local). Save and reset
// 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);
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 // Iter 18d.4 fix: a lambda thunk is its own fn frame for
// param-mode lookup. The outer fn's params are not in scope // param-mode lookup. The outer fn's params are not in scope
// inside the thunk; the thunk's own params are pushed below // inside the thunk; the thunk's own params are pushed below
@@ -208,6 +219,21 @@ impl<'a> Emitter<'a> {
self.body.push_str("}\n\n"); self.body.push_str("}\n\n");
} }
// Iter mut.3: splice any hoisted mut-var allocas into the
// thunk's entry block at the marker captured during the
// thunk's `start_block("entry")` above. Empty `pending` is
// a no-op (the thunk had no `Term::Mut` in its body).
if !self.pending_entry_allocas.is_empty() {
let marker = self.entry_block_end_marker.ok_or_else(|| {
CodegenError::Internal(
"lambda thunk: pending_entry_allocas accumulated but no entry-block \
marker was captured".into(),
)
})?;
let allocas = std::mem::take(&mut self.pending_entry_allocas);
self.body.insert_str(marker, &allocas);
}
// Park the thunk text in the deferred queue and restore outer // Park the thunk text in the deferred queue and restore outer
// emitter state. // emitter state.
let thunk_text = std::mem::take(&mut self.body); let thunk_text = std::mem::take(&mut self.body);
@@ -221,6 +247,10 @@ impl<'a> Emitter<'a> {
self.non_escape = saved_non_escape; self.non_escape = saved_non_escape;
self.moved_slots = saved_moved; self.moved_slots = saved_moved;
self.current_param_modes = saved_param_modes; 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.pending_entry_allocas = saved_pending_allocas;
self.entry_block_end_marker = saved_entry_marker;
// 3. Emit allocation + capture filling + closure-pair packing // 3. Emit allocation + capture filling + closure-pair packing
// in the OUTER body. Captures use 8 bytes each; closure-pair // in the OUTER body. Captures use 8 bytes each; closure-pair
+174 -10
View File
@@ -714,6 +714,27 @@ struct Emitter<'a> {
/// dec'ing, because Implicit and Borrow do not carry the "caller /// dec'ing, because Implicit and Borrow do not carry the "caller
/// handed off ownership" signal that makes the dec safe. /// handed off ownership" signal that makes the dec safe.
current_param_modes: BTreeMap<String, ParamMode>, current_param_modes: BTreeMap<String, ParamMode>,
/// Iter mut.3: per-fn map of mut-var name → (alloca SSA name,
/// AIL element type). Populated when entering a `Term::Mut`
/// block; entries removed (or restored to their prior binding)
/// when leaving the block. Consulted by the `Term::Var` arm of
/// `lower_term` before `self.locals`, matching the typecheck-
/// side mut-scope-stack precedence (`synth` in `ailang-check`
/// walks `mut_scope_stack` innermost-first before falling
/// through to locals/globals).
mut_var_allocas: BTreeMap<String, (String, Type)>,
/// 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`
/// after `lower_term` completes. Entry-block placement is
/// what makes the allocas mem2reg-eligible regardless of how
/// deeply nested the originating `Term::Mut` block is.
pending_entry_allocas: String,
/// Iter mut.3: byte position in `self.body` immediately after
/// the `entry:\n` label, captured during `start_block("entry")`
/// at fn-body emission. Used to splice `pending_entry_allocas`
/// into the entry block once body lowering completes.
entry_block_end_marker: Option<usize>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -831,12 +852,23 @@ impl<'a> Emitter<'a> {
closure_drops: BTreeMap::new(), closure_drops: BTreeMap::new(),
moved_slots: BTreeMap::new(), moved_slots: BTreeMap::new(),
current_param_modes: BTreeMap::new(), current_param_modes: BTreeMap::new(),
mut_var_allocas: BTreeMap::new(),
pending_entry_allocas: String::new(),
entry_block_end_marker: None,
} }
} }
pub(crate) fn start_block(&mut self, label: &str) { pub(crate) fn start_block(&mut self, label: &str) {
self.body.push_str(label); self.body.push_str(label);
self.body.push_str(":\n"); self.body.push_str(":\n");
// Iter mut.3: capture the entry-block splice point for
// `pending_entry_allocas`. The marker points to the byte
// position immediately after the `entry:\n` label — the
// splice (in `emit_fn`'s tail) inserts the hoisted allocas
// there, before any subsequent body emission.
if label == "entry" {
self.entry_block_end_marker = Some(self.body.len());
}
self.current_block = label.to_string(); self.current_block = label.to_string();
self.block_terminated = false; self.block_terminated = false;
} }
@@ -1021,6 +1053,13 @@ impl<'a> Emitter<'a> {
// consulted by `lower_match`'s Iter A gate to skip arm-close // consulted by `lower_match`'s Iter A gate to skip arm-close
// pattern-binder dec when the scrutinee is a non-Own param. // pattern-binder dec when the scrutinee is a non-Own param.
self.current_param_modes.clear(); self.current_param_modes.clear();
// Iter mut.3: per-fn-body mut-var bookkeeping. The map is
// populated/restored by the `Term::Mut` arm of `lower_term`;
// the side buffer collects alloca instructions for hoisting;
// the marker is captured by `start_block("entry")` below.
self.mut_var_allocas.clear();
self.pending_entry_allocas.clear();
self.entry_block_end_marker = None;
for (i, pname) in f.params.iter().enumerate() { for (i, pname) in f.params.iter().enumerate() {
let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Implicit); let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Implicit);
self.current_param_modes.insert(pname.clone(), mode); self.current_param_modes.insert(pname.clone(), mode);
@@ -1203,6 +1242,27 @@ impl<'a> Emitter<'a> {
} }
} // close `if !body_was_intercepted` } // close `if !body_was_intercepted`
// Iter mut.3: splice any deferred entry-block allocas at the
// captured marker (immediately after the `entry:\n` label).
// If no mut-vars were emitted for this fn,
// `pending_entry_allocas` is empty and this is a no-op. The
// splice runs after body lowering finalises but BEFORE the
// deferred-thunks flush — thunks are already self-contained
// strings parked in `deferred_thunks`, with their own splice
// already performed inside the lambda emission boundary in
// `lambda.rs`.
if !self.pending_entry_allocas.is_empty() {
let marker = self.entry_block_end_marker.ok_or_else(|| {
CodegenError::Internal(format!(
"fn `{}`: pending_entry_allocas accumulated but no entry-block \
marker was captured",
f.name
))
})?;
let allocas = std::mem::take(&mut self.pending_entry_allocas);
self.body.insert_str(marker, &allocas);
}
// Iter 8b: flush lambda thunks collected while lowering this fn's // Iter 8b: flush lambda thunks collected while lowering this fn's
// body. They go after the closing `}` of the parent fn, before // body. They go after the closing `}` of the parent fn, before
// the adapter, so the parent fn is contiguous. // the adapter, so the parent fn is contiguous.
@@ -1317,6 +1377,23 @@ impl<'a> Emitter<'a> {
self.block_terminated = true; self.block_terminated = true;
return Ok(("0".into(), "i8".into())); return Ok(("0".into(), "i8".into()));
} }
// Iter mut.3: mut-vars take precedence over let-bound
// / param resolutions, symmetric to the typecheck-side
// mut-scope-stack walk in `synth`'s `Term::Var` arm.
// Shadowing across nested mut blocks is handled by the
// `Term::Mut` arm's push-and-restore on the BTreeMap
// entry, so a single `get` suffices here (the entry
// visible at this point is the innermost binding).
if let Some((alloca_name, ail_ty)) =
self.mut_var_allocas.get(name).cloned()
{
let llvm_ty = llvm_type(&ail_ty)?;
let load_ssa = self.fresh_ssa();
self.body.push_str(&format!(
" {load_ssa} = load {llvm_ty}, ptr {alloca_name}\n"
));
return Ok((load_ssa, llvm_ty));
}
if let Some((_, ssa, ty, _)) = if let Some((_, ssa, ty, _)) =
self.locals.iter().rev().find(|(n, _, _, _)| n == name) self.locals.iter().rev().find(|(n, _, _, _)| n == name)
{ {
@@ -1679,15 +1756,96 @@ impl<'a> Emitter<'a> {
// at `synth` (iter mut.1, same spec subsection), so the // at `synth` (iter mut.1, same spec subsection), so the
// codegen stub is a defense-in-depth — under normal // codegen stub is a defense-in-depth — under normal
// flow, typecheck has already rejected. // flow, typecheck has already rejected.
Term::Mut { .. } => { // Iter mut.3: lowering of a `Term::Mut` block. Each var
Err(CodegenError::Internal( // gets an `alloca` emitted into the entry-block side
"Term::Mut not yet supported in codegen (deferred to iter mut.3)".into(), // buffer (hoisted; mem2reg-eligible regardless of nesting
)) // depth) plus a `store` of its init at the current body
// position. The map binding (name → alloca, AIL type) is
// installed AFTER the init is lowered, mirroring the
// typecheck-side ordering in `synth`'s `Term::Mut` arm:
// a var's own init does NOT see its own binding (uses
// the outer scope plus any earlier vars of the same
// block, which ARE bound because we install them as we
// go through the loop). On block exit we restore the
// prior binding (if any) — innermost-wins shadowing
// across nested mut blocks.
Term::Mut { vars, body } => {
let mut saved: Vec<(String, Option<(String, Type)>)> = Vec::new();
for v in vars {
let alloca_name = format!("%mut_{}_{}", v.name, self.fresh_id());
let llvm_ty = llvm_type(&v.ty)?;
// Hoisted alloca — goes into the side buffer,
// spliced into the entry block at the end of
// emit_fn.
self.pending_entry_allocas.push_str(&format!(
" {alloca_name} = alloca {llvm_ty}\n"
));
// Init runs in the outer scope plus any vars
// already pushed into `saved` (i.e. earlier vars
// of this same `Term::Mut`), mirroring the
// typecheck-side ordering at lib.rs:3578.
let (init_ssa, init_ty) = self.lower_term(&v.init)?;
if init_ty != llvm_ty {
return Err(CodegenError::Internal(format!(
"Term::Mut var `{}`: init LLVM type {init_ty} != declared {llvm_ty}",
v.name
)));
}
self.body.push_str(&format!(
" store {llvm_ty} {init_ssa}, ptr {alloca_name}\n"
));
let prior = self
.mut_var_allocas
.insert(v.name.clone(), (alloca_name, v.ty.clone()));
saved.push((v.name.clone(), prior));
}
let body_result = self.lower_term(body);
// Restore prior bindings — done unconditionally so an
// error during body lowering doesn't leak inner-frame
// entries into the outer scope (defensive; `lower_term`
// returns `Result`, and we propagate after restoring).
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::Assign { .. } => { // Iter mut.3: lowering of a `Term::Assign`. Resolves the
Err(CodegenError::Internal( // target name through `mut_var_allocas` (the typecheck
"Term::Assign not yet supported in codegen (deferred to iter mut.3)".into(), // pass has already pinned the lexical-scope invariant,
)) // so a miss here is an internal error — the assign would
// have been rejected with `MutAssignOutOfScope`). The
// assign's static type is Unit; we return the canonical
// Unit SSA form `("0", "i8")` matching `Literal::Unit`
// in this same `match` (line ~1289).
Term::Assign { name, value } => {
let (alloca_name, ail_ty) = self
.mut_var_allocas
.get(name)
.cloned()
.ok_or_else(|| {
CodegenError::Internal(format!(
"Term::Assign `{name}` reached codegen without a mut-var alloca \
— typecheck should have rejected this earlier"
))
})?;
let llvm_ty = llvm_type(&ail_ty)?;
let (value_ssa, value_ty) = self.lower_term(value)?;
if value_ty != llvm_ty {
return Err(CodegenError::Internal(format!(
"Term::Assign `{name}`: value LLVM type {value_ty} != declared {llvm_ty}"
)));
}
self.body.push_str(&format!(
" store {llvm_ty} {value_ssa}, ptr {alloca_name}\n"
));
Ok(("0".into(), "i8".into()))
} }
} }
} }
@@ -2710,13 +2868,19 @@ impl<'a> Emitter<'a> {
}), }),
Term::Var { name } => { Term::Var { name } => {
// Lookup precedence: extras (let-bindings introduced // Lookup precedence: extras (let-bindings introduced
// during this synth walk) → emitter locals → globals // during this synth walk) → mut-var allocas → emitter
// → builtins. Mirrors typechecker shadowing. // locals → globals → builtins. Mirrors typechecker
// shadowing and the lower_term-side precedence
// (mut.3: mut-vars in scope take precedence over
// outer let-bound / param resolutions).
for (n, ty) in extras.iter().rev() { for (n, ty) in extras.iter().rev() {
if n == name { if n == name {
return Ok(ty.clone()); return Ok(ty.clone());
} }
} }
if let Some((_, ail_ty)) = self.mut_var_allocas.get(name) {
return Ok(ail_ty.clone());
}
if let Some((_, _, _, ail)) = if let Some((_, _, _, ail)) =
self.locals.iter().rev().find(|(n, _, _, _)| n == name) self.locals.iter().rev().find(|(n, _, _, _)| n == name)
{ {
+20
View File
@@ -2831,6 +2831,26 @@ What **is** supported (and used as the smoke test for the pipeline):
arg types (ctor) or the scrutinee's `Type::Con.args` (match). An arg types (ctor) or the scrutinee's `Type::Con.args` (match). An
unresolved `Type::Var` reaching `llvm_type` is a hard error unresolved `Type::Var` reaching `llvm_type` is a hard error
rather than a silent fallback to `ptr`. rather than a silent fallback to `ptr`.
- **Local mutable state.** `(mut (var <name> <type> <init>) ... <body>)`
declares lexically-scoped mutable bindings whose types are restricted
to the stack-resident scalars `Int`, `Float`, `Bool`, `Unit`. Inside
the block, `(assign <name> <value>)` updates a mut-var; references
to a mut-var name resolve to a `load` at codegen. Mut-vars are
alloca-resident — the `alloca` instructions are hoisted to the fn's
entry block via a per-fn side buffer spliced after `lower_term` so
mem2reg eligibility holds regardless of how deeply nested the
originating `Term::Mut` block sits. Exercised end-to-end by
`examples/mut_counter.ail` (Int) and `examples/mut_sum_floats.ail`
(Float). Mut-vars do not escape the enclosing mut block and do not
introduce a `!Mut` effect onto the surrounding fn signature.
Sealed-by-construction: the spec restricts var element types to
non-RC-managed primitives and forbids first-class references, so
Decision 10's "no shared mutable refs" invariant is preserved (each
mut-var is uniquely held by its enclosing block). Future milestones
on the Stateful-islands path lift the type restriction (heap-RC-
managed Str + ADTs), add escaping references (`ref a` + `!Mut`
effect), and introduce composable transducers (`Stateful a b` +
`pipe`). Spec: `docs/specs/2026-05-15-mut-local.md`.
Pipeline regression smoke tests: Pipeline regression smoke tests:
+139
View File
@@ -0,0 +1,139 @@
# iter mut.3 — codegen + e2e for Term::Mut / Term::Assign
**Date:** 2026-05-15
**Started from:** b057789b55a86cd664e2d7fee2c998bb855cad98
**Status:** DONE
**Tasks completed:** 9 of 9
## Summary
Replaced the iter mut.1 codegen stubs for `Term::Mut` and `Term::Assign`
with real LLVM lowering: mut-vars become `alloca` slots hoisted to the
fn's entry block via a per-fn side buffer `pending_entry_allocas`
spliced into `self.body` at a byte marker captured during
`start_block("entry")`; `Term::Assign` lowers to `store`; references to
a mut-var name lower to `load` through a new lookup precedence in both
`lower_term`'s `Term::Var` arm and `synth_with_extras`'s `Term::Var`
arm (precedence: extras → mut-vars → locals → globals → builtins,
symmetric to the typecheck-side `mut_scope_stack` walk in
`ailang-check`). Lambda emission saves/restores the three new
`Emitter` fields across the thunk boundary and splices the thunk's own
hoisted allocas at its own entry marker so nested mut blocks inside
lambdas hoist into the lambda's entry, not the outer fn's. Two e2e
fixtures shipped (`mut_counter.ail` Int / `mut_sum_floats.ail` Float),
both printing `55` end-to-end (Float's `%g` formatter strips the `.0`).
DESIGN.md gains a "Local mutable state" bullet under "What **is**
supported". Closes the mut-local milestone end-to-end; the audit step
follows.
## Per-task notes
- iter mut.3.1: three `Emitter` fields (`mut_var_allocas`,
`pending_entry_allocas`, `entry_block_end_marker`) added with
doc comments; initialised in `Emitter::new`; cleared per-fn at the
top of `emit_fn` next to the existing per-fn-reset block.
- iter mut.3.2: `start_block` extended — when `label == "entry"` it
captures `self.body.len()` as `entry_block_end_marker` (the splice
point for `pending_entry_allocas`).
- iter mut.3.3: `Term::Var` arm of `lower_term` gained a mut-var
lookup before the `self.locals` walk — on hit, emits
`%v_N = load <llvm_ty>, ptr <alloca_ssa>` and returns the load SSA
+ LLVM type.
- iter mut.3.4: `Term::Mut` arm of `lower_term` real lowering — for
each var: alloca into the side buffer, lower init in outer-plus-
earlier-vars scope (matches typecheck ordering at lib.rs:3578),
store init into alloca, then install the binding (saving any prior
entry for restoration on block exit). Lowers body, restores saved
bindings (unconditional restoration even on error path).
- iter mut.3.5: `Term::Assign` arm of `lower_term` real lowering —
look up the alloca in `mut_var_allocas`, lower value, emit store,
return the canonical Unit SSA form `("0", "i8")` matching
`Literal::Unit` in the same `match`. Tasks 4 and 5 were applied
in one `Edit` call because both arms were adjacent stubs that
needed simultaneous replacement (the `match` arm signature changed
from `{ .. }` to `{ name, value }` / `{ vars, body }`); the review
separation still held — each arm was spec-checked independently.
- iter mut.3.6: splice of `pending_entry_allocas` at the marker —
inserted into `self.body` at the end of `emit_fn` (after body
finalisation, before the deferred-thunks flush). Marker absence
while `pending` is non-empty fires `CodegenError::Internal` with
the fn name.
- iter mut.3.7: `examples/mut_counter.ail` (Int) and
`examples/mut_sum_floats.ail` (Float) authored; both use an
accumulator-style tail-recursive helper since while-loops are
deferred. Initial draft used the natural `(app + lo (tail-app ...))`
shape and was rejected by the tail-call analysis ("call marked
`tail` is not in tail position") — restructured to pass `acc` as
a third arg and yield it directly in the base case. Both fixtures
typecheck clean and print `55` (Float via `%g` strips `.0`).
- iter mut.3.8: `mut_counter_prints_55` + `mut_sum_floats_prints_55`
appended to `crates/ail/tests/e2e.rs`. The plan's placeholder
`expected = "55"` resolved by inspection of `examples/floats.ail`
+ `crates/ail/tests/floats_e2e.rs` (where `1.5 + 2.5` prints as
`4`, confirming `%g`-driven trailing-zero strip — so `55.0`
prints as `55`); test pinned to `"55"`.
- iter mut.3.9: DESIGN.md "Local mutable state" bullet appended
after the "Parameterised ADTs" bullet at the end of the "What
**is** supported" subsection. Schema-drift tests pass (the bullet
lives outside §"Data model").
Two beyond-plan adjustments emerged during execution and were
absorbed inline (recorded as Concerns below):
- `synth_with_extras`'s `Term::Var` arm also needed mut-var lookup
precedence. The plan's Task 3 only touched `lower_term`'s
`Term::Var` arm; without the parallel `synth_with_extras` update,
every polymorphic-call-site arg-type derivation that reaches a
mut-var reference fails with `UnknownVar`. Discovered by running
`examples/mut.ail` through codegen and observing the
`mut_single_var` case fail at `(app + x 1)`.
- Lambda emission (`lambda.rs`) needed save/restore of the three
new fields PLUS an in-thunk splice of `pending_entry_allocas` at
the thunk's own entry marker. Without this, a `Term::Mut` inside
a lambda body would push its alloca into the outer fn's side
buffer (or worse, leak into whichever fn's body the splice runs
on). Mirrors how `lambda.rs` already saves/restores
`non_escape` / `moved_slots` / `current_param_modes` across the
thunk boundary.
## Concerns
- Synth-side `Term::Var` mut-var-lookup parallel was not in the
plan but is structurally required. Recorded as a plan defect to
feed into the iter-mut.4 lessons (if any) and into the milestone-
close audit.
- Lambda-boundary save/restore + in-thunk splice was not in the
plan but is structurally required for correctness of any
`Term::Mut` inside a lambda body. Same defect category as above.
## Known debt
- None for the milestone-local scope. The spec's deferred items
(while-loops, heap-RC mut-var element types, `ref a` + `!Mut`
effect, `Stateful a b` + `pipe`) are explicitly named as future
milestones in the new DESIGN.md bullet.
## Blocked detail
n/a — Status: DONE.
## Files touched
- `crates/ailang-codegen/src/lib.rs` — three `Emitter` fields, init
+ per-fn reset, `start_block` marker capture, `Term::Var` arm
mut-var precedence (both `lower_term` and `synth_with_extras`),
`Term::Mut` + `Term::Assign` arms in `lower_term`, splice at end
of `emit_fn`.
- `crates/ailang-codegen/src/lambda.rs` — save/restore of the three
new fields across the thunk boundary, in-thunk splice of
`pending_entry_allocas`.
- `crates/ail/tests/e2e.rs` — two new tests pinning
`mut_counter.ail``"55"` and `mut_sum_floats.ail``"55"`.
- `docs/DESIGN.md` — "Local mutable state" bullet at end of
"What **is** supported" subsection.
- `examples/mut_counter.ail` — new Int fixture.
- `examples/mut_sum_floats.ail` — new Float fixture.
## Stats
bench/orchestrator-stats/2026-05-15-iter-mut.3.json
+1
View File
@@ -72,3 +72,4 @@
- 2026-05-14 — audit-pd: milestone close (prelude-decouple) — architect drift fixed inline as audit-pd-tidy (DESIGN.md §"Roundtrip Invariant" carve-out count 8→7 + main.rs migrate-subcommand dead prelude fallback removed); bench mixed (check.py + compile_check.py established noise envelope, 15th consecutive observation, baseline pristine; cross_lang clean) → 2026-05-14-audit-pd.md - 2026-05-14 — audit-pd: milestone close (prelude-decouple) — architect drift fixed inline as audit-pd-tidy (DESIGN.md §"Roundtrip Invariant" carve-out count 8→7 + main.rs migrate-subcommand dead prelude fallback removed); bench mixed (check.py + compile_check.py established noise envelope, 15th consecutive observation, baseline pristine; cross_lang clean) → 2026-05-14-audit-pd.md
- 2026-05-15 — iter mut.1: AST extension `Term::Mut` + `Term::Assign` + `MutVar` struct; Form A `(mut ...) / (var ...) / (assign ...)` productions parse + print + round-trip; ~25 substantive Term-walker arms across the codebase; dispatch stubs in `synth` (typecheck) + `lower_term` (codegen) return `CheckError::Internal` / `CodegenError::Internal` per the spec's out-of-iteration boundary; `examples/mut.ail` six-fn fixture (Int/Float/Bool/Unit + empty + nested-shadow); drift + coverage + spec-drift tests + DESIGN.md §"Term (expression)" + `crates/ailang-core/specs/form_a.md` amendments; tests 564 → 579 → 2026-05-15-iter-mut.1.md - 2026-05-15 — iter mut.1: AST extension `Term::Mut` + `Term::Assign` + `MutVar` struct; Form A `(mut ...) / (var ...) / (assign ...)` productions parse + print + round-trip; ~25 substantive Term-walker arms across the codebase; dispatch stubs in `synth` (typecheck) + `lower_term` (codegen) return `CheckError::Internal` / `CodegenError::Internal` per the spec's out-of-iteration boundary; `examples/mut.ail` six-fn fixture (Int/Float/Bool/Unit + empty + nested-shadow); drift + coverage + spec-drift tests + DESIGN.md §"Term (expression)" + `crates/ailang-core/specs/form_a.md` amendments; tests 564 → 579 → 2026-05-15-iter-mut.1.md
- 2026-05-15 — iter mut.2: typecheck for `Term::Mut` + `Term::Assign` replacing the iter mut.1 dispatch stubs; three new `CheckError` variants (`MutAssignOutOfScope`, `AssignTypeMismatch`, `UnsupportedMutVarType`) with bracketed-`[code]:` Display + `code()` + `ctx()` arms; `synth` signature threads `mut_scope_stack: &mut Vec<IndexMap<String, Type>>` after `locals`, propagated through all recursive call sites in `lib.rs` plus `mono.rs:712/1337` + `lift.rs:723` + `builtins.rs` test helpers + the mq.3 in-test helper at `lib.rs:6663`; `Term::Var` resolution prepended with innermost-first mut-scope lookup; `Term::Mut` arm gates var types to {Int,Float,Bool,Unit} and push/pops a fresh frame; `Term::Assign` arm walks the stack innermost-first, emits `AssignTypeMismatch` on type mismatch and `MutAssignOutOfScope` (with `available` flattened across all frames) on miss; five `.ail.json` fixtures under `examples/` (four negative + one positive nested-shadow) + driver `crates/ailang-check/tests/mut_typecheck_pin.rs`; `carve_out_inventory.rs` EXPECTED extended 7→12 for the new negative-fixture set; `examples/mut.ail` typechecks clean (`ok (26 symbols across 2 modules)`); tests 579 → 592 → 2026-05-15-iter-mut.2.md - 2026-05-15 — iter mut.2: typecheck for `Term::Mut` + `Term::Assign` replacing the iter mut.1 dispatch stubs; three new `CheckError` variants (`MutAssignOutOfScope`, `AssignTypeMismatch`, `UnsupportedMutVarType`) with bracketed-`[code]:` Display + `code()` + `ctx()` arms; `synth` signature threads `mut_scope_stack: &mut Vec<IndexMap<String, Type>>` after `locals`, propagated through all recursive call sites in `lib.rs` plus `mono.rs:712/1337` + `lift.rs:723` + `builtins.rs` test helpers + the mq.3 in-test helper at `lib.rs:6663`; `Term::Var` resolution prepended with innermost-first mut-scope lookup; `Term::Mut` arm gates var types to {Int,Float,Bool,Unit} and push/pops a fresh frame; `Term::Assign` arm walks the stack innermost-first, emits `AssignTypeMismatch` on type mismatch and `MutAssignOutOfScope` (with `available` flattened across all frames) on miss; five `.ail.json` fixtures under `examples/` (four negative + one positive nested-shadow) + driver `crates/ailang-check/tests/mut_typecheck_pin.rs`; `carve_out_inventory.rs` EXPECTED extended 7→12 for the new negative-fixture set; `examples/mut.ail` typechecks clean (`ok (26 symbols across 2 modules)`); tests 579 → 592 → 2026-05-15-iter-mut.2.md
- 2026-05-15 — iter mut.3: codegen + e2e for `Term::Mut` + `Term::Assign` closing the mut-local milestone end-to-end; mut-var allocas hoisted to fn entry block via a per-`Emitter` side buffer `pending_entry_allocas: String` + a captured `entry_block_end_marker: Option<usize>` byte position spliced via `String::insert_str` at the end of `emit_fn`; `Term::Mut` arm in `lower_term` emits one alloca per var into the side buffer, lowers each init at the current position, emits a `store` to bind, push/pops a per-block save stack for proper shadowing of outer mut-vars; `Term::Assign` arm emits `store <ty> <value_ssa>, ptr <alloca>` and yields the canonical Unit SSA; `Term::Var` arm prepends mut-var lookup with a `load` emission. Two beyond-plan adjustments: (1) `synth_with_extras`'s parallel `Term::Var` arm needed the same mut-var lookup precedence as `lower_term`, (2) `lambda.rs` thunk emission needed save/restore of all three new `Emitter` fields across the lambda-body boundary plus an in-thunk splice at the lambda's own entry marker so mut-blocks inside a closure body hoist to the closure's entry not the outer fn's. Two e2e fixtures `examples/mut_counter.ail` (Int) + `examples/mut_sum_floats.ail` (Float); both run end-to-end and print `55`. DESIGN.md "What **is** supported" subsection gains a "Local mutable state" bullet. mut-local milestone end-to-end closed. Tests 592 → 594 → 2026-05-15-iter-mut.3.md
+21
View File
@@ -0,0 +1,21 @@
(module mut_counter
(fn main
(doc "Iter mut.3 — sum 1..10 via mut + recursive helper. Expected stdout: 55.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print
(mut
(var sum (con Int) 0)
(assign sum (app sum_helper 1 10 0))
sum))))
(fn sum_helper
(doc "Accumulator-style tail-recursive helper — sum from lo through hi inclusive.")
(type (fn-type (params (con Int) (con Int) (con Int)) (ret (con Int))))
(params lo hi acc)
(body
(if (app > lo hi)
acc
(tail-app sum_helper (app + lo 1) hi (app + acc lo))))))
+21
View File
@@ -0,0 +1,21 @@
(module mut_sum_floats
(fn main
(doc "Iter mut.3 — Float twin of mut_counter. Expected stdout: 55 (Float-55.0 via %g).")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print
(mut
(var sum (con Float) 0.0)
(assign sum (app sum_helper 1.0 10.0 0.0))
sum))))
(fn sum_helper
(doc "Accumulator-style tail-recursive Float helper — sum from lo through hi inclusive.")
(type (fn-type (params (con Float) (con Float) (con Float)) (ret (con Float))))
(params lo hi acc)
(body
(if (app > lo hi)
acc
(tail-app sum_helper (app + lo 1.0) hi (app + acc lo))))))