From 03fb633d8531541ffe8db8d4eb776a483b5a840f Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 15 May 2026 01:56:26 +0200 Subject: [PATCH] =?UTF-8?q?iter=20mut.3:=20codegen=20+=20e2e=20=E2=80=94?= =?UTF-8?q?=20mut-local=20milestone=20end-to-end=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 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 , ptr 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. --- .../2026-05-15-iter-mut.3.json | 23 +++ crates/ail/tests/e2e.rs | 25 +++ crates/ailang-codegen/src/lambda.rs | 30 +++ crates/ailang-codegen/src/lib.rs | 184 +++++++++++++++++- docs/DESIGN.md | 20 ++ docs/journals/2026-05-15-iter-mut.3.md | 139 +++++++++++++ docs/journals/INDEX.md | 1 + examples/mut_counter.ail | 21 ++ examples/mut_sum_floats.ail | 21 ++ 9 files changed, 454 insertions(+), 10 deletions(-) create mode 100644 bench/orchestrator-stats/2026-05-15-iter-mut.3.json create mode 100644 docs/journals/2026-05-15-iter-mut.3.md create mode 100644 examples/mut_counter.ail create mode 100644 examples/mut_sum_floats.ail diff --git a/bench/orchestrator-stats/2026-05-15-iter-mut.3.json b/bench/orchestrator-stats/2026-05-15-iter-mut.3.json new file mode 100644 index 0000000..eac8ff8 --- /dev/null +++ b/bench/orchestrator-stats/2026-05-15-iter-mut.3.json @@ -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)." +} diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index efd062d..9950cb6 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -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:?}"); +} diff --git a/crates/ailang-codegen/src/lambda.rs b/crates/ailang-codegen/src/lambda.rs index 34a52a1..622ff75 100644 --- a/crates/ailang-codegen/src/lambda.rs +++ b/crates/ailang-codegen/src/lambda.rs @@ -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 diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 7cce8bd..d92ecca 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -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, + /// 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, + /// 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, } #[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) { diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1ef2d75..3923048 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 ) ... )` + declares lexically-scoped mutable bindings whose types are restricted + to the stack-resident scalars `Int`, `Float`, `Bool`, `Unit`. Inside + the block, `(assign )` 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: diff --git a/docs/journals/2026-05-15-iter-mut.3.md b/docs/journals/2026-05-15-iter-mut.3.md new file mode 100644 index 0000000..d0752f1 --- /dev/null +++ b/docs/journals/2026-05-15-iter-mut.3.md @@ -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 , ptr ` 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 diff --git a/docs/journals/INDEX.md b/docs/journals/INDEX.md index 1fbdb78..eda02a5 100644 --- a/docs/journals/INDEX.md +++ b/docs/journals/INDEX.md @@ -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>` 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` 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 , ptr ` 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 diff --git a/examples/mut_counter.ail b/examples/mut_counter.ail new file mode 100644 index 0000000..93d8a35 --- /dev/null +++ b/examples/mut_counter.ail @@ -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)))))) diff --git a/examples/mut_sum_floats.ail b/examples/mut_sum_floats.ail new file mode 100644 index 0000000..13840fd --- /dev/null +++ b/examples/mut_sum_floats.ail @@ -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))))))