Files
AILang/docs/plans/0069-iter-mut.3.md
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

26 KiB

mut.3 — codegen + e2e — Implementation Plan

Parent spec: docs/specs/0029-mut-local.md

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Replace the iter mut.1 dispatch stubs at crates/ailang-codegen/src/lib.rs:1683-1691 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. Extend the codegen-side Term::Var resolution ladder to consult mut-var allocas before self.locals. Ship two end-to-end fixtures (examples/mut_counter.ail + examples/mut_sum_floats.ail) and two e2e test cases asserting expected stdout. Append a bullet to DESIGN.md §"What is supported".

Architecture: Mut-var allocas land in the fn's entry block via a side buffer pending_entry_allocas: String accumulated during body lowering and flushed once into self.body at a tracked byte position immediately after start_block("entry") emits the entry: label. This is a deliberate departure from the existing alloca sites (match_lower.rs:114, lambda.rs:240/266) which emit allocas at the current body position — those allocas are one-shot per fn invocation and don't suffer from being non-entry-block. Mut-var allocas in deeply-nested blocks (e.g. inside if/match/seq) must remain accessible across the whole fn body, so mem2reg eligibility (entry-block placement) matters. Mut-var lookup at codegen uses a per-fn mut_var_allocas: BTreeMap<String, (String, Type)> mapping name → (alloca SSA name, AIL type). Push entries on Term::Mut-entry, remove on exit; lexical scoping mirrors the mut.2 typecheck-side stack.

Tech Stack: ailang-codegen only. No ailang-core, ailang-surface, ailang-check, ailang-prose, or ail source changes (one new test fn in crates/ail/tests/e2e.rs). Two new example fixtures + one DESIGN.md amendment.


Files this plan creates or modifies:

  • Create: examples/mut_counter.ail — sum 1..10 via mut, prints 55.
  • Create: examples/mut_sum_floats.ail — Float twin (sum 1.0..10.0), prints the Float total.
  • Modify: crates/ailang-codegen/src/lib.rs:716-717 — add mut_var_allocas: BTreeMap<String, (String, Type)> and pending_entry_allocas: String and entry_block_end_marker: Option<usize> fields to Emitter.
  • Modify: crates/ailang-codegen/src/lib.rs:820-842 — initialise the three new fields in Emitter::new.
  • Modify: crates/ailang-codegen/src/lib.rs:1008-1034 — reset the three new fields per fn-body in emit_fn, and capture the entry-block byte marker after start_block("entry").
  • Modify: crates/ailang-codegen/src/lib.rs:837-842 — extend start_block (or its caller) to record entry_block_end_marker on entry-block emission.
  • Modify: crates/ailang-codegen/src/lib.rs:1292-1357 (Term::Var arm) — prepend a mut-var lookup that emits a load and returns the SSA name.
  • Modify: crates/ailang-codegen/src/lib.rs:1683-1691 — replace the Term::Mut/Term::Assign Internal stubs with real lowering.
  • Modify: crates/ailang-codegen/src/lib.rs (end of emit_fn) — flush pending_entry_allocas into self.body at the marker.
  • Modify: crates/ail/tests/e2e.rs — add mut_counter_prints_55 and mut_sum_floats_prints_55_dot_0 test fns.
  • Modify: docs/DESIGN.md — append a bullet to the "What is supported" subsection around line 2680.

Task 1 — Emitter field additions + per-fn reset

Files:

  • Modify: crates/ailang-codegen/src/lib.rs:716-717 (struct fields)

  • Modify: crates/ailang-codegen/src/lib.rs:820-842 (new)

  • Modify: crates/ailang-codegen/src/lib.rs:1008-1034 (emit_fn reset)

  • Step 1: Add three fields to Emitter

Locate the Emitter struct around crates/ailang-codegen/src/lib.rs:716. After the existing field current_param_modes: BTreeMap<String, ParamMode> (line 717), insert:

    /// 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 when leaving the block. Consulted by
    /// the `Term::Var` arm of `lower_term` before `self.locals`,
    /// matching the typecheck-side mut-scope-stack precedence.
    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.
    pending_entry_allocas: String,

    /// Iter mut.3: byte position in `self.body` immediately after
    /// the `entry:` 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>,
  • Step 2: Initialise in Emitter::new

Locate Emitter::new around crates/ailang-codegen/src/lib.rs:820. Near the existing closure_drops: BTreeMap::new(), line (around 831), add:

            mut_var_allocas: BTreeMap::new(),
            pending_entry_allocas: String::new(),
            entry_block_end_marker: None,
  • Step 3: Reset in emit_fn

Locate the per-fn reset block in emit_fn (around crates/ailang-codegen/src/lib.rs:1008-1034 — where self.locals.clear(), self.counter = 0, self.moved_slots.clear(), self.current_param_modes.clear() already live). Append:

        self.mut_var_allocas.clear();
        self.pending_entry_allocas.clear();
        self.entry_block_end_marker = None;
  • Step 4: Verify build green

Run: cargo build --workspace 2>&1 | tail -10 Expected: green (the three new fields are added but not yet consumed; the only consumers are added in Tasks 2-3).


Task 2 — start_block records the entry-block marker

Files:

  • Modify: crates/ailang-codegen/src/lib.rs:837-842 (start_block)

start_block is the canonical helper that emits <label>:\n and positions the body for subsequent instruction emission. The entry block is the first one emitted per fn. Marker capture happens here.

  • Step 1: Locate start_block

Around crates/ailang-codegen/src/lib.rs:837, find:

    fn start_block(&mut self, label: &str) {
        self.body.push_str(label);
        self.body.push_str(":\n");
    }

(Exact body may differ; read the existing form first.)

  • Step 2: Capture the entry-block marker

Replace with (or extend) the body so that when label == "entry", the byte position immediately after the entry:\n is captured:

    fn start_block(&mut self, label: &str) {
        self.body.push_str(label);
        self.body.push_str(":\n");
        if label == "entry" {
            self.entry_block_end_marker = Some(self.body.len());
        }
    }

The marker points to the byte index where the next character would be written — i.e. the splice point for pending_entry_allocas. Splicing at this position inserts the alloca block immediately after the entry:\n label and before any subsequent body emission.

  • Step 3: Verify build green

Run: cargo build --workspace 2>&1 | tail -5 Expected: green.


Task 3 — Term::Var resolution prepends mut-var lookup

Files:

  • Modify: crates/ailang-codegen/src/lib.rs:1292-1357 (Term::Var arm of lower_term)

  • Step 1: Locate the Term::Var arm

Around crates/ailang-codegen/src/lib.rs:1292, the existing arm walks: __unreachable__self.locals.iter().rev().find(...)resolve_top_level_fnresolve_const → emit UnknownVar. The mut-var lookup slots immediately before self.locals.

  • Step 2: Prepend mut-var lookup

Insert before the self.locals.iter().rev().find(...) call:

            // Iter mut.3: mut-vars take precedence over let-bound
            // / param resolutions. Innermost-first (BTreeMap iterates
            // in sorted order, but per-fn entries are unique per
            // name with innermost-wins shadowing already enforced
            // at typecheck time, so a single lookup suffices here).
            if let Some((alloca_name, ail_ty)) = self.mut_var_allocas.get(name).cloned() {
                let llvm_ty = self.llvm_type(&ail_ty);
                let load_ssa = self.fresh_ssa_name("mut_load");
                self.body.push_str(&format!(
                    "  {load_ssa} = load {llvm_ty}, ptr {alloca_name}\n"
                ));
                return Ok(load_ssa);
            }

The exact helper names (self.llvm_type, self.fresh_ssa_name, self.body, the return-tuple shape) must match the existing arms in lower_term — the implementer reads adjacent arms (e.g. the self.locals.iter().rev().find() branch immediately below) to copy the canonical form. The example above is shape-correct but the function-call sites (llvm_type / fresh_ssa_name) may be different in this codebase; verify before writing.

Note: shadowing across nested mut blocks is handled by the push/remove in Task 4, not by stack ordering here — when an inner mut block shadows an outer's name, the inner's push overwrites the BTreeMap entry; the outer's restored on inner-exit.

  • Step 3: Verify build green

Run: cargo build --workspace 2>&1 | tail -5 Expected: green.


Task 4 — Term::Mut codegen arm

Files:

  • Modify: crates/ailang-codegen/src/lib.rs:1683-1687 — replace the Term::Mut stub.

  • Step 1: Replace the stub

Around crates/ailang-codegen/src/lib.rs:1683, replace:

            Term::Mut { .. } => {
                Err(CodegenError::Internal(
                    "Term::Mut not yet supported in codegen (deferred to iter mut.3)".into(),
                ))
            }

with the real lowering:

            Term::Mut { vars, body } => {
                // Saved-binding map for proper shadowing on exit.
                let mut saved: Vec<(String, Option<(String, Type)>)> = Vec::new();

                for v in vars {
                    let alloca_name = self.fresh_ssa_name(&format!("mut_{}", v.name));
                    let llvm_ty = self.llvm_type(&v.ty);

                    // Emit the alloca into the entry-block side
                    // buffer (hoisted, not at current body position).
                    self.pending_entry_allocas.push_str(&format!(
                        "  {alloca_name} = alloca {llvm_ty}\n"
                    ));

                    // Lower the init expression at the current body
                    // position. The mut-var binding is NOT yet
                    // visible during init (matches typecheck-side
                    // ordering at lib.rs:3576).
                    let init_ssa = self.lower_term(&v.init, /* whatever existing params */)?;

                    // Store the init result into the alloca at the
                    // current body position.
                    self.body.push_str(&format!(
                        "  store {llvm_ty} {init_ssa}, ptr {alloca_name}\n"
                    ));

                    // Now bind the name → alloca, saving any prior
                    // entry for restoration on block exit.
                    let prior = self.mut_var_allocas.insert(
                        v.name.clone(),
                        (alloca_name, v.ty.clone()),
                    );
                    saved.push((v.name.clone(), prior));
                }

                // Lower the body in the extended scope.
                let body_ssa = self.lower_term(body, /* whatever existing params */)?;

                // Restore bindings on exit.
                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); }
                    }
                }

                Ok(body_ssa)
            }

The exact lower_term call signature must match the existing arms in the same match — the implementer reads an adjacent arm (e.g. Term::Let or Term::Seq) for the canonical form. The init/store ordering inside the loop is the load-bearing semantic: the spec demands "vars are checked in order with the in-progress scope" at typecheck (lib.rs:3576), and codegen mirrors this by binding only AFTER the init is lowered.

  • Step 2: Build + verify the mut.ail typecheck-clean fns now don't reach codegen stub for Mut

Run: cargo build --workspace 2>&1 | tail -5 Expected: green.

Run: cargo run --quiet --bin ail -- build examples/mut.ail 2>&1 | head -20 Expected: this command may fail at runtime for some of the six fns that use Bool/Unit (not yet covered by Task 5 in lower_term's Term::Assign arm) — but the build should NOT produce the "Term::Mut not yet supported" error. If it does, the Task 4 arm isn't reached; debug before continuing.


Task 5 — Term::Assign codegen arm

Files:

  • Modify: crates/ailang-codegen/src/lib.rs:1688-1691 — replace the Term::Assign stub.

  • Step 1: Replace the stub

Around crates/ailang-codegen/src/lib.rs:1688, replace:

            Term::Assign { .. } => {
                Err(CodegenError::Internal(
                    "Term::Assign not yet supported in codegen (deferred to iter mut.3)".into(),
                ))
            }

with the real lowering:

            Term::Assign { name, value } => {
                // Look up the mut-var's alloca and AIL type.
                let (alloca_name, ail_ty) = self.mut_var_allocas
                    .get(name)
                    .cloned()
                    .ok_or_else(|| CodegenError::Internal(
                        format!("Term::Assign {{ name: {name:?} }} reached codegen without a mut-var alloca — typecheck should have rejected this earlier")
                    ))?;

                let llvm_ty = self.llvm_type(&ail_ty);
                let value_ssa = self.lower_term(value, /* existing params */)?;

                self.body.push_str(&format!(
                    "  store {llvm_ty} {value_ssa}, ptr {alloca_name}\n"
                ));

                // Term::Assign's static type is Unit. Yield the
                // canonical Unit SSA value per the codegen
                // convention for Unit (verify against an adjacent
                // arm — likely a literal "i1 0" or similar; the
                // mut.ail mut_returns_unit fixture exercises this).
                Ok(self.unit_ssa()) // or whichever helper returns the canonical Unit SSA
            }

The self.unit_ssa() helper name is a placeholder; the implementer identifies the canonical "Unit value" SSA emission convention from an adjacent codegen arm (e.g. Term::Lit { lit: Literal::Unit }) and uses whichever form matches.

  • Step 2: Verify build + mut.ail full pipeline

Run: cargo build --workspace 2>&1 | tail -5 Expected: green.

Run: cargo run --quiet --bin ail -- build examples/mut.ail 2>&1 | head -10 Expected: build succeeds. (Whether the binary runs successfully is exercised in Task 7 with the dedicated e2e fixtures.)


Task 6 — Flush pending_entry_allocas at the end of emit_fn

Files:

  • Modify: crates/ailang-codegen/src/lib.rs — the end of emit_fn (just before the fn body is written out / } is emitted).

  • Step 1: Locate the emit_fn end

Around crates/ailang-codegen/src/lib.rs:1008+, find where emit_fn finishes assembling self.body and is about to flush it to self.ir (or wherever the final IR text accumulates). This is the splice point.

  • Step 2: Splice pending_entry_allocas at the marker

Immediately before the body is written out:

        // 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.
        if !self.pending_entry_allocas.is_empty() {
            if let Some(marker) = self.entry_block_end_marker {
                let allocas = std::mem::take(&mut self.pending_entry_allocas);
                self.body.insert_str(marker, &allocas);
            } else {
                return Err(CodegenError::Internal(
                    "pending_entry_allocas accumulated but no entry-block marker was captured".into(),
                ));
            }
        }

This splices the alloca instructions into self.body at the byte position recorded by Task 2's start_block extension. The result is an entry block whose first instructions are alloca for each mut-var encountered anywhere in the fn body, followed by whatever the original entry-block instructions are.

  • Step 3: Verify build green

Run: cargo build --workspace 2>&1 | tail -5 Expected: green.


Task 7 — examples/mut_counter.ail + examples/mut_sum_floats.ail

Files:

  • Create: examples/mut_counter.ail

  • Create: examples/mut_sum_floats.ail

  • Step 1: Write examples/mut_counter.ail

Create examples/mut_counter.ail:

(module mut_counter

  (fn main
    (doc "Iter mut.3 — sum 1..10 via mut/var/assign. Expected stdout: 55.")
    (type (fn-type (params) (ret (con Unit)) (effects IO)))
    (params)
    (body
      (app print
        (mut
          (var i (con Int) 1)
          (var sum (con Int) 0)
          (assign sum (loop_body i sum)))))))

(fn loop_body
  (doc "Helper — recursive sum 1..10. Replaced by a real loop in a future milestone.")
  (type (fn-type (params (con Int) (con Int)) (ret (con Int))))
  (params i acc)
  (body
    (if (app > i 10)
      acc
      (app loop_body (app + i 1) (app + acc i))))))

Wait — this requires mut to call a helper, which the mut codegen supports (init / body / assign-value can be any Term). But the example's idiomatic form is harder to express without while (deferred). The simplest accurate form:

(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))
          sum))))

  (fn sum_helper
    (doc "Recursive helper — sum from lo through hi inclusive.")
    (type (fn-type (params (con Int) (con Int)) (ret (con Int))))
    (params lo hi)
    (body
      (if (app > lo hi)
        0
        (app + lo (tail-app sum_helper (app + lo 1) hi))))))

The mut block exercises one var (sum) with a real assign of a non-trivial Term result. The recursive helper is the standing tail-call iteration shape per Decision 8. Expected stdout: 55.

  • Step 2: Write examples/mut_sum_floats.ail

Create examples/mut_sum_floats.ail:

(module mut_sum_floats

  (fn main
    (doc "Iter mut.3 — Float twin of mut_counter. Expected stdout: 55.0 (or whatever the polymorphic-print form emits for Float 55.0).")
    (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))
          sum))))

  (fn sum_helper
    (doc "Recursive Float helper — sum from lo through hi inclusive.")
    (type (fn-type (params (con Float) (con Float)) (ret (con Float))))
    (params lo hi)
    (body
      (if (app > lo hi)
        0.0
        (app + lo (tail-app sum_helper (app + lo 1.0) hi))))))

Expected stdout: whatever the polymorphic Float print emits for the value 55.0. The implementer verifies against an existing Float-print fixture (e.g. examples/floats.ail's known output) to fix the expected string.

  • Step 3: Verify both fixtures round-trip + typecheck clean

Run: cargo run --quiet --bin ail -- check examples/mut_counter.ail 2>&1 Expected: ok (...).

Run: cargo run --quiet --bin ail -- check examples/mut_sum_floats.ail 2>&1 Expected: ok (...).

Run: cargo test --workspace -p ailang-surface round_trip 2>&1 | tail -5 Expected: PASS — the auto-glob round-trip picks up both new fixtures.


Task 8 — e2e tests in crates/ail/tests/e2e.rs

Files:

  • Modify: crates/ail/tests/e2e.rs

  • Step 1: Read the canonical e2e test pattern

Open crates/ail/tests/e2e.rs and locate the build_and_run helper (around lines 13-38 per the recon). Read the convention for a #[test] that invokes it and asserts on stdout.

  • Step 2: Add the two test fns

At the end of crates/ail/tests/e2e.rs, append:

#[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:?}");
}

#[test]
fn mut_sum_floats_prints_55_dot() {
    let stdout = build_and_run("mut_sum_floats.ail");
    // The exact float-print form is whatever the polymorphic print
    // emits for 55.0 — likely "55" if the formatter drops trailing
    // zeros, "55.0" if it preserves them. Verify against
    // examples/floats.ail's expected output before pinning.
    let expected = "55"; // placeholder — implementer reads the floats.ail e2e for the canonical form
    assert_eq!(stdout.trim(), expected, "mut_sum_floats must print {expected}, got {stdout:?}");
}

The "placeholder" comment marks a piece of work the implementer must resolve at execution time by reading whichever existing Float-print e2e test pins the expected stdout — the placeholder MUST NOT survive into the committed file. Replace with the correct expected string before the test passes.

  • Step 3: Run the new tests

Run: cargo test --workspace --test e2e mut_counter_prints_55 mut_sum_floats_prints_55_dot 2>&1 | tail -10 Expected: both PASS.

Run: cargo test --workspace 2>&1 | tail -5 Expected: full workspace green.


Task 9 — DESIGN.md amendment

Files:

  • Modify: docs/DESIGN.md — append to "What is supported" subsection (around line 2680-2800).

  • Step 1: Locate the section

Read docs/DESIGN.md:2680-2800. The section starts with:

What **is** supported (and used as the smoke test for the pipeline):

- Int, Bool, Unit, **Str**, **Float** as primitive types.
- `if`, `let`, function calls, recursion.
...

Find a suitable insertion point — likely after the "Anonymous lambdas with capture" bullet (around line 2776) or after "Parameterised ADTs" (around line 2788), depending on where the implementer judges the new bullet thematically fits.

  • Step 2: Append the bullet

Insert (with appropriate context):

- **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 (hoisted to the fn's entry block, exercised by
  `examples/mut_counter.ail` and `examples/mut_sum_floats.ail`); they
  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/0029-mut-local.md`.
  • Step 3: Verify schema-drift tests still pass

Run: cargo test --workspace -p ailang-core schema_drift 2>&1 | tail -5 Expected: PASS — the drift tests scope themselves to §"Data model" (which is the §"Term (expression)" jsonc-blocked area, already amended in mut.1), and this new bullet lives outside that scope.


Self-review checklist

  • Spec coverage: every section of spec §"Iteration mut.3 — Codegen + e2e" has a matching task. Cross-check: codegen arms for Term::Mut (Task 4) and Term::Assign (Task 5), entry-block- alloca-hoist mechanism (Tasks 1+2+6), Term::Var resolution prepending (Task 3), two example fixtures (Task 7), e2e tests (Task 8), DESIGN.md amendment (Task 9). All covered.

  • Placeholder scan: grep for "TBD" / "TODO" / "implement later" / "similar to Task" / "appropriate error handling" / "fill in later". Task 8 Step 2 contains a deliberate "implementer reads the floats.ail e2e for the canonical form" placeholder that must be resolved before the test file is committed — the step explicitly names this. No other placeholders.

  • Type-name consistency: mut_var_allocas, pending_entry_allocas, entry_block_end_marker, CodegenError::Internal, LLVMBuildAlloca (referenced in spec but in this codebase emitted as text via push_str), Term::Mut, Term::Assign, MutVar. Each appears identically across tasks.

  • Step granularity: each step is 2-5 minutes (a single field addition; a single arm replacement; a single splice point).

  • No commit steps: verified — no git commit instructions.

  • Boss decisions named: the five recon open questions are resolved: (1) entry-block-hoist via side buffer + String::insert_str splice at marker (Tasks 1+2+6); (2) no LLVM helper wrapper — direct push_str (Task 4+5 code shape); (3) e2e in crates/ail/tests/e2e.rs (Task 8); (4) DESIGN.md bullet in "What is supported" subsection (Task 9 Step 1); (5) Unit codegen via the existing Term::Lit { lit: Unit } arm's canonical SSA emission (Task 5 Step 1 instruction).


Acceptance gate for iteration mut.3

  • cargo build --workspace green.
  • cargo test --workspace green.
  • cargo run --bin ail -- check examples/mut.ail clean.
  • cargo run --bin ail -- check examples/mut_counter.ail clean.
  • cargo run --bin ail -- check examples/mut_sum_floats.ail clean.
  • mut_counter.ail builds and runs end-to-end via the e2e harness, printing 55.
  • mut_sum_floats.ail builds and runs end-to-end, printing the canonical Float-print form for 55.0 (resolved per Task 8 Step 2).
  • DESIGN.md has the "Local mutable state" bullet under "What is supported".

After this iteration closes, the mut-local milestone is closed end-to-end:

  • Schema + surface from mut.1.
  • Typecheck from mut.2.
  • Codegen + e2e from mut.3. The follow-on Stateful-islands milestones (ref-types, !Mut effect, MutArray, Stateful + pipe) build on top of this. Audit (architect drift + bench regression) follows.