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:
@@ -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)."
|
||||
}
|
||||
@@ -2815,3 +2815,28 @@ fn str_clone_cross_realisation_uniform_abi() {
|
||||
assert_eq!(frees, 3, "expected three matching frees; got frees={frees}");
|
||||
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:?}");
|
||||
}
|
||||
|
||||
@@ -126,6 +126,17 @@ impl<'a> Emitter<'a> {
|
||||
// tracking — the outer fn's binders are not in scope inside
|
||||
// the thunk body (only its captures + params). Save and reset.
|
||||
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
|
||||
// param-mode lookup. The outer fn's params are not in scope
|
||||
// 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");
|
||||
}
|
||||
|
||||
// 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
|
||||
// emitter state.
|
||||
let thunk_text = std::mem::take(&mut self.body);
|
||||
@@ -221,6 +247,10 @@ impl<'a> Emitter<'a> {
|
||||
self.non_escape = saved_non_escape;
|
||||
self.moved_slots = saved_moved;
|
||||
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
|
||||
// in the OUTER body. Captures use 8 bytes each; closure-pair
|
||||
|
||||
@@ -714,6 +714,27 @@ struct Emitter<'a> {
|
||||
/// dec'ing, because Implicit and Borrow do not carry the "caller
|
||||
/// handed off ownership" signal that makes the dec safe.
|
||||
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)]
|
||||
@@ -831,12 +852,23 @@ impl<'a> Emitter<'a> {
|
||||
closure_drops: BTreeMap::new(),
|
||||
moved_slots: 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) {
|
||||
self.body.push_str(label);
|
||||
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.block_terminated = false;
|
||||
}
|
||||
@@ -1021,6 +1053,13 @@ impl<'a> Emitter<'a> {
|
||||
// consulted by `lower_match`'s Iter A gate to skip arm-close
|
||||
// pattern-binder dec when the scrutinee is a non-Own param.
|
||||
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() {
|
||||
let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Implicit);
|
||||
self.current_param_modes.insert(pname.clone(), mode);
|
||||
@@ -1203,6 +1242,27 @@ impl<'a> Emitter<'a> {
|
||||
}
|
||||
} // 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
|
||||
// body. They go after the closing `}` of the parent fn, before
|
||||
// the adapter, so the parent fn is contiguous.
|
||||
@@ -1317,6 +1377,23 @@ impl<'a> Emitter<'a> {
|
||||
self.block_terminated = true;
|
||||
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, _)) =
|
||||
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
|
||||
// codegen stub is a defense-in-depth — under normal
|
||||
// flow, typecheck has already rejected.
|
||||
Term::Mut { .. } => {
|
||||
Err(CodegenError::Internal(
|
||||
"Term::Mut not yet supported in codegen (deferred to iter mut.3)".into(),
|
||||
))
|
||||
// Iter mut.3: lowering of a `Term::Mut` block. Each var
|
||||
// gets an `alloca` emitted into the entry-block side
|
||||
// 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 { .. } => {
|
||||
Err(CodegenError::Internal(
|
||||
"Term::Assign not yet supported in codegen (deferred to iter mut.3)".into(),
|
||||
))
|
||||
// Iter mut.3: lowering of a `Term::Assign`. Resolves the
|
||||
// target name through `mut_var_allocas` (the typecheck
|
||||
// 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 } => {
|
||||
// Lookup precedence: extras (let-bindings introduced
|
||||
// during this synth walk) → emitter locals → globals
|
||||
// → builtins. Mirrors typechecker shadowing.
|
||||
// during this synth walk) → mut-var allocas → emitter
|
||||
// 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() {
|
||||
if n == name {
|
||||
return Ok(ty.clone());
|
||||
}
|
||||
}
|
||||
if let Some((_, ail_ty)) = self.mut_var_allocas.get(name) {
|
||||
return Ok(ail_ty.clone());
|
||||
}
|
||||
if let Some((_, _, _, ail)) =
|
||||
self.locals.iter().rev().find(|(n, _, _, _)| n == name)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
unresolved `Type::Var` reaching `llvm_type` is a hard error
|
||||
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:
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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-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.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
|
||||
|
||||
@@ -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))))))
|
||||
@@ -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))))))
|
||||
Reference in New Issue
Block a user