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
+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
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:
+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-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