spec: mut-local — local mutable state (mut/var/assign) — foundation milestone for stateful islands
First shippable milestone on the Stateful-islands roadmap path: sealed-by-construction local mutable bindings in fn bodies, with no ref-types, no effect handlers, no escape. Lowers entirely to LLVM allocas with statically-bounded lifetime; fn signatures stay pure (no !Mut effect leakage). Three iterations: mut.1 (schema + surface), mut.2 (typecheck + diagnostics), mut.3 (codegen + e2e). Var element types restricted to Int/Float/Bool/Unit (non-RC-managed scalars) in this milestone; Str/ADT/fn-value vars deferred. Grounding-check PASS on iteration mut.1 assumptions: Term enum extension is gated by design_schema_drift, round-trip by ailang-surface round_trip auto-discovery, DESIGN.md amendment by the same drift test, schema_coverage forces a corpus fixture.
This commit is contained in:
@@ -0,0 +1,604 @@
|
||||
# Local mutable state — `mut` / `var` / `assign` — Design Spec
|
||||
|
||||
**Date:** 2026-05-15
|
||||
**Status:** Draft — awaiting user spec review
|
||||
**Authors:** Brummel (orchestrator) + Claude
|
||||
|
||||
## Goal
|
||||
|
||||
Introduce a sealed, lexically-scoped mutable-state construct into
|
||||
AILang as the **foundational first step** on the Stateful-islands
|
||||
roadmap path. After this milestone:
|
||||
|
||||
1. The AST has three new shapes — `Term::Mut` (the block), the
|
||||
`MutVar` struct inside it (one mutable binding), and
|
||||
`Term::Assign` (an in-block update). Form A surface syntax for
|
||||
all three round-trips canonically.
|
||||
2. Inside a `mut` block, the LLM author may declare mutable bindings
|
||||
with `var`, update them with `assign`, and read them as ordinary
|
||||
`Term::Var` references. Each mut block evaluates to its final
|
||||
expression's value.
|
||||
3. mut blocks are **sealed by construction**: a `var` is a
|
||||
lexically-scoped first-order binding whose only observable
|
||||
behaviour outside the block is the final value the block returns.
|
||||
No `var` reference can escape (vars are not first-class values),
|
||||
so the surrounding fn signature stays pure — no `!Mut` effect
|
||||
leaks, no reference-typed return.
|
||||
4. The supported `var` element types are restricted to the
|
||||
**stack-resident primitives** Int, Float, Bool, Unit. Str
|
||||
(heap-RC-managed) and ADTs are deferred to the next milestone on
|
||||
the Stateful-islands path, alongside ref-types that can escape.
|
||||
|
||||
The motivation is two-fold. First, **the roadmap path**: the full
|
||||
Stateful-islands milestone (`Stateful a b` + `!Mut` effect + pipe
|
||||
combinator) has ten identified blockers; the cheapest of those —
|
||||
the surface forms and codegen path for local mutation, with
|
||||
sealed-by-construction semantics — can ship as a self-contained
|
||||
milestone *before* the effect-handler infrastructure lands.
|
||||
Decoupling them keeps each milestone small, reviewable, and
|
||||
auditable. Second, **direct LLM-author utility**: even without the
|
||||
escape-permitting layers above it, local mutable state immediately
|
||||
removes friction from one recurring authoring shape — accumulators
|
||||
inside a fn body that today must be expressed by tail-recursive
|
||||
helpers carrying explicit "running total" parameters. The
|
||||
imperative form is what an LLM-author reaches for unprompted; this
|
||||
milestone supplies it under static-safety constraints stronger than
|
||||
myc's (vars are alloca-resident with statically-bounded lifetime,
|
||||
no aliasing possible).
|
||||
|
||||
The bet is that an `(mut (var sum 0) (assign sum (+ sum x)) sum)`
|
||||
shape will appear in LLM-authored AILang code naturally, and that
|
||||
its presence will measurably reduce the line-count and the
|
||||
helper-fn count compared to the tail-recursive accumulator form
|
||||
that the same author would write today. The fieldtest after this
|
||||
milestone closes is exactly that LLM-utility measurement.
|
||||
|
||||
### Why this is the right first milestone on the path
|
||||
|
||||
The full Stateful-islands roadmap entry identifies effect-handler
|
||||
infrastructure as the heaviest prerequisite. But mut-local is
|
||||
**orthogonal** to effect handlers: it lowers entirely to LLVM
|
||||
allocas with no effect annotation, no handler, no escape. It can
|
||||
ship today against the existing typechecker and codegen with three
|
||||
new AST nodes and one new pass invariant ("Term::Assign is legal
|
||||
only inside Term::Mut"). The follow-on milestone (ref-types +
|
||||
effect handlers + `!Mut`) builds on top of this without invalidating
|
||||
it — the seal-at-boundary semantics of this milestone become the
|
||||
"no escape" leg of the future ref-discharge semantics.
|
||||
|
||||
If we ship effect handlers first, mut-local still has to be
|
||||
written; if we ship mut-local first, effect handlers benefit from
|
||||
the surface forms it establishes. The dependency goes one way.
|
||||
|
||||
### Out of scope (deferred to follow-on milestones)
|
||||
|
||||
The following are explicitly deferred. Each is named so the
|
||||
boundary is unambiguous to the planner and to anyone reading the
|
||||
spec in retrospect.
|
||||
|
||||
- **`ref a` as a first-class type.** No reference types in this
|
||||
milestone. Vars are *not* references — they are alloca slots
|
||||
with lexically-bounded lifetime. Reading a var yields a value,
|
||||
not a reference; the var's address is never observed.
|
||||
- **`!Mut` as an effect annotation.** No effect leakage from mut
|
||||
blocks in this milestone. Fns containing mut blocks retain their
|
||||
pure signature. The `!Mut` effect is reserved for the follow-on
|
||||
milestone that introduces escaping refs.
|
||||
- **Effect-handler infrastructure.** No `effect E { ... }` top-
|
||||
level declaration form, no `handle ... with ...` construct.
|
||||
mut is lowered as a syntactic-only construct without going
|
||||
through an effect-handler dispatch.
|
||||
- **`MutArray a` / mutable arrays.** No mutable container types.
|
||||
Future milestone.
|
||||
- **`Stateful a b`** sealed-callable type and `pipe` combinator.
|
||||
Future milestone (the full Stateful-islands target).
|
||||
- **Var element types beyond Int / Float / Bool / Unit.** Str
|
||||
(heap-Str ABI, RC-managed) and ADTs are deferred because their
|
||||
reassign semantics (drop old, retain new, RC bookkeeping) need
|
||||
separate design. This milestone covers the four scalar primitives
|
||||
whose reassign is a single LLVM `store`.
|
||||
- **Loops.** No `while`, no `for`. Iteration in this milestone is
|
||||
still tail recursion (Decision 8). A future milestone may add
|
||||
`while` *inside* mut blocks once the foundation here is solid.
|
||||
- **Multi-arm mut blocks / nested mut.** Nested mut is *allowed*
|
||||
(a mut block whose body contains another mut block); the inner
|
||||
block introduces a fresh scope. This is not "out of scope" —
|
||||
it falls out of the design — but it is called out explicitly so
|
||||
the typecheck pass treats it correctly.
|
||||
|
||||
## Architecture
|
||||
|
||||
The cut is **three new AST nodes** plus **one new typecheck pass
|
||||
invariant**, with codegen extending the existing `escape.rs` and
|
||||
`lower_term` paths to handle allocas and stores.
|
||||
|
||||
### Phase split
|
||||
|
||||
**Today (no mut concept):**
|
||||
|
||||
```
|
||||
ailang-surface::parse
|
||||
-> Module { defs: [FnDef, ...] }
|
||||
|
||||
ailang-check::check
|
||||
-> typecheck per FnDef body (pure Term tree, no mutation)
|
||||
|
||||
ailang-codegen
|
||||
-> SSA-only lowering, no allocas except per-fn arena
|
||||
(escape.rs decides between alloca and GC_malloc for ADT boxes)
|
||||
```
|
||||
|
||||
**After this milestone:**
|
||||
|
||||
```
|
||||
ailang-surface::parse
|
||||
-> recognises (mut ...) / (var ...) / (assign ...) Form-A
|
||||
constructs, builds Term::Mut / Term::Assign / MutVar nodes.
|
||||
|
||||
ailang-check::check
|
||||
-> existing typecheck extended with mut-scope tracking:
|
||||
a stack of "current mut scope" entered on Term::Mut, exited
|
||||
on Term::Mut completion. Term::Var resolves to either a
|
||||
lexical mut-var (if in scope) or a regular let-bound /
|
||||
top-level identifier. Term::Assign requires its target name
|
||||
to be a mut-var in the current scope (else MutAssignOutOfScope
|
||||
diagnostic).
|
||||
|
||||
ailang-codegen
|
||||
-> Term::Mut: emit alloca per mut-var at fn entry block
|
||||
(hoisted), emit init stores at the mut block's source
|
||||
location, lower body statements in order, lower final
|
||||
expression and return its SSA value.
|
||||
-> Term::Assign: emit store-to-alloca for the named var.
|
||||
-> Term::Var resolution at codegen: lookup-in-scope returns
|
||||
either an SSA (for let-bound / param) or a load-from-alloca
|
||||
(for a mut-var in scope).
|
||||
```
|
||||
|
||||
### Why three AST nodes, not one fused construct
|
||||
|
||||
A single fused `Term::Mut { vars, stmts, final }` would conflate
|
||||
two things that the typechecker and codegen need to distinguish:
|
||||
|
||||
1. The block-scope (when does a var enter scope, when does it
|
||||
leave) — a property of `Term::Mut`.
|
||||
2. The mutation operation (which var is being updated, with what
|
||||
value) — a property of each `Term::Assign`.
|
||||
|
||||
Keeping them as separate nodes lets `Term::Assign` appear anywhere
|
||||
in the body of a `Term::Mut` (including inside `Term::If` branches,
|
||||
`Term::Match` arms, and so on), without the schema having to model
|
||||
a flat "list of mutation events". The lexical-scope check is
|
||||
"Term::Assign is reachable only when a `Term::Mut` is on the
|
||||
ancestor stack and the assigned name is one of its `vars`" — a
|
||||
single pass with a name-stack.
|
||||
|
||||
`MutVar` (the var-declaration sub-shape) lives as a struct field of
|
||||
`Term::Mut`, not as a standalone Term variant, because vars cannot
|
||||
appear outside mut blocks. They are not first-class values; making
|
||||
them a Term variant would imply they can.
|
||||
|
||||
### Form A surface
|
||||
|
||||
```
|
||||
;; the mut form: vars listed first, statements in sequence, final
|
||||
;; expression last. Sequence is the natural list of (assign ...)
|
||||
;; and other Unit-typed steps; the final element is the block's
|
||||
;; result.
|
||||
|
||||
(mut
|
||||
(var sum (con Float) 0.0)
|
||||
(var count (con Int) 0)
|
||||
(assign sum (app + sum 1.0))
|
||||
(assign count (app + count 1))
|
||||
(app + sum (app int_to_float count)))
|
||||
```
|
||||
|
||||
`(var <name> <type> <init>)` declares a mut-var with the given
|
||||
type and initial value. The type is explicit (no inference inside
|
||||
mut, consistent with Decision 6's "explicit annotations").
|
||||
|
||||
`(assign <name> <value>)` updates the var named `<name>`. The
|
||||
var must be in lexical mut-scope. The expression's static type is
|
||||
Unit.
|
||||
|
||||
The mut block's body is the flat tail of the form, after the
|
||||
trailing `var` declarations: a sequence of zero or more Unit-typed
|
||||
statements followed by exactly one final expression of any
|
||||
supported type. This is desugared by the surface parser into a
|
||||
`Term::Mut` whose `body` field is a single Term obtained by
|
||||
right-folding the sequence through `Term::Seq` (with the final
|
||||
element as the seed). The JSON-AST never sees a "list of
|
||||
statements" — it sees a `Term::Mut` containing exactly one body
|
||||
Term.
|
||||
|
||||
### JSON-AST schema
|
||||
|
||||
```jsonc
|
||||
{ "t": "mut",
|
||||
"vars": [ { "name": "<id>", "type": Type, "init": Term }, ... ],
|
||||
"body": Term }
|
||||
```
|
||||
|
||||
The `vars` array MAY be empty (a `(mut <expr>)` form with no
|
||||
var declarations is legal; it is equivalent to a scope-introduction
|
||||
that does nothing — useful uniformity, not a feature in itself).
|
||||
|
||||
```jsonc
|
||||
{ "t": "assign",
|
||||
"name": "<id>",
|
||||
"value": Term }
|
||||
```
|
||||
|
||||
`Term::Assign`'s static type is Unit. Its only legal context is
|
||||
the body sub-tree of a `Term::Mut` whose `vars` includes a var with
|
||||
the same `name`. The typechecker enforces this; codegen relies on
|
||||
it (an Assign outside a mut scope is a panic).
|
||||
|
||||
### Canonical-form invariants
|
||||
|
||||
- The `vars` array is **not** sorted; ordering follows lexical
|
||||
declaration order (consistent with the surface). Reordering would
|
||||
affect codegen alloca ordering and the surface print.
|
||||
- The `vars` array MAY be empty; the empty-array form is the
|
||||
canonical encoding (the field stays present rather than being
|
||||
omitted, since `Term::Mut`'s shape is identified by its `t` tag
|
||||
and we want the schema to be uniform).
|
||||
- `Term::Assign.name` MUST refer to a mut-var of an ancestor
|
||||
`Term::Mut`. Out-of-scope Assigns are rejected at typecheck.
|
||||
- A `Term::Mut` with `vars: []` and `body` containing no `Assign`
|
||||
is **semantically a no-op** identical to its body alone. The
|
||||
parser does NOT collapse it; canonical form is preserved
|
||||
(round-trip determinism wins over a one-shot optimisation).
|
||||
|
||||
### Term::Var resolution
|
||||
|
||||
Today: `Term::Var.name` resolves to a let-bound / param / top-
|
||||
level def / builtin / class-method. The resolver order is
|
||||
documented at `crates/ailang-check/src/lib.rs` (in the typechecker)
|
||||
and at `crates/ailang-codegen/src/lib.rs` (in the lowering).
|
||||
|
||||
After this milestone: resolution gains one more layer at the front
|
||||
— **mut-var in current lexical scope**. The resolver checks the
|
||||
mut-scope stack first; if the name matches a mut-var of any
|
||||
enclosing `Term::Mut`, the reference resolves to that var.
|
||||
Otherwise the existing resolution proceeds.
|
||||
|
||||
A mut-var shadows an outer let-bound / param / top-level name of
|
||||
the same identifier (legitimate user choice; the shadow is
|
||||
documented but not warned). Within nested mut blocks, the
|
||||
innermost mut-var of the same name wins (lexical shadowing).
|
||||
|
||||
## Components (iterations)
|
||||
|
||||
Three iterations, each shippable as a standalone commit:
|
||||
|
||||
### Iteration mut.1 — Schema + surface
|
||||
|
||||
- Add `Term::Mut { vars: Vec<MutVar>, body: Box<Term> }` variant
|
||||
to `ailang-core::ast::Term` (file: `crates/ailang-core/src/ast.rs`).
|
||||
- Add `Term::Assign { name: String, value: Box<Term> }` variant.
|
||||
- Add `MutVar { name: String, ty: Type, init: Term }` struct.
|
||||
- Wire canonical-JSON serde for both variants (`crates/ailang-core/src/hash.rs`-area).
|
||||
- Add `(mut ...)` / `(var ...)` / `(assign ...)` Form A productions
|
||||
to `ailang-surface::parse` and the form-A printer.
|
||||
- Add `tests/round_trip.rs` coverage with a `mut.ail` fixture.
|
||||
- DESIGN.md §"Data model" — append the two new Term variants and
|
||||
the MutVar sub-shape. DESIGN.md §"Term (expression)" gets the
|
||||
jsonc-blocked schema additions.
|
||||
|
||||
Out of iteration: no typechecker recognition of the new nodes
|
||||
beyond the validator that round-trips them schema-side; no codegen.
|
||||
A `Term::Mut` reaching typecheck in iteration mut.1 produces a
|
||||
`CheckError::Internal("Term::Mut not yet supported in typecheck")`
|
||||
diagnostic and the test fixture is gated on iteration mut.2.
|
||||
|
||||
Round-trip is the iteration's gate: surface → JSON-AST → surface
|
||||
must reproduce the source byte-for-byte for the `mut.ail` fixture.
|
||||
|
||||
### Iteration mut.2 — Typecheck
|
||||
|
||||
- Extend `ailang-check::check_term` with the `Term::Mut` arm:
|
||||
- Push a fresh mut-scope frame onto a `mut_scope_stack: Vec<HashMap<String, Type>>`.
|
||||
- For each MutVar in order: typecheck `init` in the outer scope
|
||||
plus already-declared vars; bind name → type in the current
|
||||
frame; check the type is one of {Int, Float, Bool, Unit}
|
||||
(else `CheckError::UnsupportedMutVarType`).
|
||||
- Typecheck `body` in scope of all vars + the mut-frame stack.
|
||||
- Pop the mut-scope frame.
|
||||
- The `Term::Mut`'s static type is `body`'s static type.
|
||||
- Extend `Term::Assign` arm:
|
||||
- Walk up the mut_scope_stack for a frame containing `name`.
|
||||
- If found: typecheck `value` against the var's type; produce
|
||||
Unit. If types mismatch: `CheckError::AssignTypeMismatch`.
|
||||
- If not found: `CheckError::MutAssignOutOfScope`.
|
||||
- Extend `Term::Var` resolution to consult the mut-scope stack
|
||||
first (innermost-wins shadowing).
|
||||
- Add unit tests under `crates/ailang-check/tests/` covering: legal
|
||||
mut, assign out of scope, type mismatch, unsupported var type
|
||||
(e.g. `var s : Str`), nested mut shadow, mut-var-shadows-let.
|
||||
|
||||
Out of iteration: no codegen lowering. A `Term::Mut` reaching
|
||||
codegen in mut.2 produces `CodegenError::Internal("Term::Mut not
|
||||
yet supported in codegen")` and the e2e fixture is gated on
|
||||
iteration mut.3.
|
||||
|
||||
### Iteration mut.3 — Codegen + e2e
|
||||
|
||||
- Extend `ailang-codegen::lower_term` with `Term::Mut`:
|
||||
- Hoist a `LLVMBuildAlloca` for each MutVar to the fn's entry
|
||||
block. Track the alloca pointer in a per-fn `mut_var_allocas:
|
||||
HashMap<String, (LLVMValueRef alloca, Type ail_ty)>`.
|
||||
- At the current source position, lower each init and emit a
|
||||
`LLVMBuildStore` into the corresponding alloca.
|
||||
- Lower the body Term with `mut_var_allocas` extended; `Term::Var`
|
||||
resolution emits `LLVMBuildLoad` for a mut-var name.
|
||||
- On block exit, remove the introduced mut-vars from the map
|
||||
(lexical scope).
|
||||
- Extend `lower_term` with `Term::Assign`:
|
||||
- Lookup the alloca for `name` in `mut_var_allocas`.
|
||||
- Lower `value` to an SSA, emit `LLVMBuildStore`.
|
||||
- Yield a Unit SSA value.
|
||||
- Extend the escape-analysis pass `crates/ailang-codegen/src/escape.rs`
|
||||
to traverse `Term::Mut` (visit vars' inits + body) and `Term::Assign`
|
||||
(visit value). Mut blocks do not introduce escape edges by
|
||||
themselves; mut-vars are alloca-resident, not heap.
|
||||
- Add `examples/mut_counter.ail`:
|
||||
- A fn that uses `mut` to compute the sum of 1..10 and prints
|
||||
the result. Expected stdout: `55`.
|
||||
- Round-trips through `parse` ↔ render.
|
||||
- Builds and runs end-to-end via the e2e test harness.
|
||||
- Add `examples/mut_sum_floats.ail`:
|
||||
- Mirrors `mut_counter` for Float to exercise the second
|
||||
supported scalar.
|
||||
- Append a §"Local mutable state" entry to DESIGN.md's "What is
|
||||
supported" enumeration.
|
||||
|
||||
Acceptance gate for the iteration: both example fixtures build +
|
||||
run + match expected stdout under the existing e2e harness; no
|
||||
new build-bench regressions (the bench corpus is pure-fn and is
|
||||
not touched).
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
.ail source
|
||||
|
|
||||
v ailang-surface::parse
|
||||
| - (mut ...) production -> Term::Mut { vars, body }
|
||||
| - (var <name> <type> <init>) entries -> MutVar
|
||||
| - (assign <name> <value>) -> Term::Assign
|
||||
| - body sequence -> right-folded into Term::Seq if multi-step
|
||||
v
|
||||
canonical JSON-AST (Term::Mut + Term::Assign + MutVar nodes)
|
||||
|
|
||||
v ailang-check::check
|
||||
| - check_term arm for Term::Mut pushes mut-scope frame
|
||||
| - check_term arm for Term::Assign verifies in-scope + types
|
||||
| - Term::Var consults mut_scope_stack first
|
||||
v
|
||||
typed Term tree (mut-scope information no longer needed —
|
||||
the typecheck pass discards the stack on exit; codegen
|
||||
re-derives lexical scope from the tree)
|
||||
|
|
||||
v ailang-codegen::lower_term
|
||||
| - Term::Mut: hoist allocas to fn entry, emit init stores,
|
||||
| lower body, scope-exit removes vars from map
|
||||
| - Term::Assign: store-to-alloca
|
||||
| - Term::Var: load-from-alloca if mut-var, else existing
|
||||
v
|
||||
LLVM IR with alloca/store/load instructions on a per-fn basis
|
||||
|
|
||||
v clang -O2
|
||||
|
|
||||
v
|
||||
native binary
|
||||
```
|
||||
|
||||
The `mut_scope_stack` lives only inside the typecheck pass; it is
|
||||
not stored on the AST. Codegen re-derives the same information by
|
||||
walking the tree top-down with its own `mut_var_allocas` map. The
|
||||
correspondence between the two is enforced by the lexical-scope
|
||||
invariant in the AST: an Assign whose name is not lexically bound
|
||||
in an ancestor Mut would have failed typecheck.
|
||||
|
||||
## Error handling
|
||||
|
||||
Three new diagnostic categories, one new compiler-internal
|
||||
invariant:
|
||||
|
||||
### CheckError::MutAssignOutOfScope
|
||||
|
||||
Raised when `Term::Assign.name` does not match any mut-var in the
|
||||
current lexical mut-scope stack.
|
||||
|
||||
```
|
||||
error[mut-assign-out-of-scope]: cannot assign to `foo` here
|
||||
-> the name `foo` is not declared as a mut-var in the enclosing
|
||||
(mut ...) block. Either declare it with (var foo <type> <init>)
|
||||
at the top of the block, or move the assign inside the block
|
||||
that declares it.
|
||||
```
|
||||
|
||||
The diagnostic includes the surface span of the Assign and (if any
|
||||
mut block is on the ancestor stack) the spans of the enclosing
|
||||
blocks' vars-lists, with the available mut-var names listed.
|
||||
|
||||
### CheckError::AssignTypeMismatch
|
||||
|
||||
Raised when `Term::Assign.value`'s inferred type does not match the
|
||||
mut-var's declared type.
|
||||
|
||||
```
|
||||
error[assign-type-mismatch]: cannot assign Float to mut-var `count : Int`
|
||||
-> `count` was declared as Int at <span>; the assign at <span>
|
||||
has a Float value. The two types must match.
|
||||
```
|
||||
|
||||
### CheckError::UnsupportedMutVarType
|
||||
|
||||
Raised when a `MutVar.ty` is not one of {Int, Float, Bool, Unit}.
|
||||
|
||||
```
|
||||
error[mut-var-unsupported-type]: mut-var `s : Str` is not supported in this milestone
|
||||
-> only stack-resident primitive types (Int, Float, Bool, Unit)
|
||||
may be declared as mut-vars. Heap-RC-managed types (Str, ADTs,
|
||||
fn-values) are deferred to a follow-on milestone. Move the
|
||||
binding to a `(let ... )` outside the mut block, or
|
||||
restructure the code to use a primitive accumulator.
|
||||
```
|
||||
|
||||
This is a *temporary* restriction; the diagnostic text says so
|
||||
explicitly. Future milestones lift it.
|
||||
|
||||
### Compiler invariant (codegen)
|
||||
|
||||
A `Term::Assign` reaching `lower_term` with `mut_var_allocas`
|
||||
empty (i.e. outside any Mut frame) is a `CodegenError::Internal`.
|
||||
The typecheck pass is the gate; codegen does not re-validate, but
|
||||
asserts via the internal error if the invariant is broken.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
Three test tiers, all of which already exist in the project and
|
||||
get extended:
|
||||
|
||||
### Round-trip (gate for iteration mut.1)
|
||||
|
||||
- `examples/mut.ail` — a minimal but representative fixture
|
||||
exercising:
|
||||
- empty `(mut)` (vars empty, body is a single literal expression);
|
||||
- single-var mut with one assign + final expr;
|
||||
- two-var mut with multiple assigns;
|
||||
- nested mut (outer var of one name, inner var shadowing it);
|
||||
- mut returning each of the supported var types (Int, Float, Bool, Unit).
|
||||
- `crates/ailang-surface/tests/round_trip.rs` picks up the fixture
|
||||
automatically (the test globs `examples/*.ail`).
|
||||
|
||||
### Typecheck negative tests (gate for iteration mut.2)
|
||||
|
||||
Five negative fixtures under `crates/ailang-check/tests/` covering
|
||||
each diagnostic:
|
||||
|
||||
- `test_mut_assign_out_of_scope.ail.json` — assign to a name that
|
||||
has not been declared as a mut-var.
|
||||
- `test_mut_assign_type_mismatch.ail.json` — assign Float to a
|
||||
mut-var of type Int.
|
||||
- `test_mut_var_unsupported_type.ail.json` — `var s : Str` (lifted
|
||||
by a future milestone).
|
||||
- `test_mut_assign_outside_mut.ail.json` — `Term::Assign` directly
|
||||
in a fn body with no `Term::Mut` ancestor.
|
||||
- `test_mut_nested_shadow_legal.ail.json` — positive test
|
||||
asserting the nested-shadow case is legal (sanity).
|
||||
|
||||
Each fixture is a `.ail.json` (canonical-form-rejection style) so
|
||||
the test harness can assert the exact diagnostic code emitted.
|
||||
|
||||
### End-to-end (gate for iteration mut.3)
|
||||
|
||||
- `examples/mut_counter.ail` — sum of 1..10 via mut, prints 55.
|
||||
- `examples/mut_sum_floats.ail` — sum of 1.0..10.0 via mut, prints
|
||||
the Float total.
|
||||
- Both are exercised by the e2e harness
|
||||
(`crates/ailang-codegen/tests/e2e.rs` or wherever the existing
|
||||
end-to-end runner sits), which builds the example, runs the
|
||||
resulting binary, captures stdout, and asserts the expected
|
||||
output.
|
||||
|
||||
### Bench corpus
|
||||
|
||||
This milestone does NOT add streaming-specific benches; that is
|
||||
deferred to the future Stateful-islands milestone. The existing
|
||||
bench corpus is unaffected by this milestone (mut blocks are not
|
||||
introduced into bench fixtures); a regression check on the
|
||||
existing baseline runs as part of audit close.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
The milestone closes when **all** of the following are true:
|
||||
|
||||
1. Iteration mut.1 has shipped: `Term::Mut` / `Term::Assign` /
|
||||
`MutVar` are in the schema, the form-A surface parses + prints
|
||||
them, round-trip is green for `examples/mut.ail`.
|
||||
2. Iteration mut.2 has shipped: the typecheck pass recognises all
|
||||
three nodes with the documented diagnostics; all five negative
|
||||
fixtures fail with the expected error code; nested-shadow is
|
||||
legal.
|
||||
3. Iteration mut.3 has shipped: `examples/mut_counter.ail` and
|
||||
`examples/mut_sum_floats.ail` build + run + print the expected
|
||||
stdout under the e2e harness.
|
||||
4. DESIGN.md is updated: "Data model" §Term lists the two new
|
||||
variants and their schemas; "What is (yet) supported" gains a
|
||||
"Local mutable state" bullet naming the milestone close date.
|
||||
5. `ailang-architect` audit at milestone close: no drift; the
|
||||
three new AST nodes are documented in DESIGN.md and behave as
|
||||
spec'd.
|
||||
6. Bench regression: existing `bench/run.sh` numbers within their
|
||||
tolerance bands (this milestone should not move them; the
|
||||
regression check confirms nothing inadvertently broke).
|
||||
7. Fieldtest (post-audit, Boss-dispatched): the LLM-utility test —
|
||||
an LLM author given DESIGN.md plus public examples produces
|
||||
AILang code that reaches for `mut` unprompted when an
|
||||
accumulator is the natural shape. Friction or absence of
|
||||
reach-for is logged for the follow-on milestone.
|
||||
|
||||
## Load-bearing assumptions about current behaviour
|
||||
|
||||
Statements this spec relies on as currently true. The
|
||||
grounding-check agent (Step 7.5) verifies each against a green
|
||||
test in the workspace.
|
||||
|
||||
1. **`ailang-core::ast::Term` is a Rust enum and adding a new
|
||||
variant is mechanical.** Recent precedent: `Term::ReuseAs`
|
||||
shipped post-Decision-10 (DESIGN.md §"Term"). The schema-drift
|
||||
test at `crates/ailang-core/tests/design_schema_drift.rs`
|
||||
already enumerates Term variants and is the gating mechanism
|
||||
for additions.
|
||||
2. **The form-A parser/printer in `ailang-surface` is the sole
|
||||
text projection** (per the post-form-a-default-authoring state)
|
||||
and round-trip is gated by
|
||||
`crates/ailang-surface/tests/round_trip.rs` over every shipped
|
||||
`.ail` fixture (DESIGN.md §"Roundtrip Invariant").
|
||||
3. **Typecheck operates by structural walk over Term with a fresh
|
||||
environment per fn body** (DESIGN.md §"What is supported": "HM
|
||||
inference inside bodies … lambdas check monomorphically
|
||||
against their declared type"). Adding a per-scope side-stack
|
||||
for mut-vars is consistent with this shape.
|
||||
4. **Codegen lowers per fn with `LLVMBuildAlloca` already used for
|
||||
the per-fn arena** (DESIGN.md §"Per-fn arena via stack `alloca`").
|
||||
The alloca path is exercised end-to-end by
|
||||
`examples/escape_local_demo.ail`. Hoisting mut-var allocas to
|
||||
the fn entry block follows the same LLVM convention.
|
||||
5. **Diagnostics in `ailang-check` follow the bracketed-code
|
||||
format `[diag-code]: message`** with rendered spans (the
|
||||
cli-diag-human iter, 2026-05-14, made this uniform across all
|
||||
`WorkspaceLoadError` paths; per-iter journal
|
||||
`docs/journals/2026-05-14-iter-cli-diag-human.md` is the
|
||||
reference).
|
||||
6. **Existing `Term::Var` resolution at typecheck consults a
|
||||
stacked scope** (let-bound, param, top-level def, builtin,
|
||||
class-method, in that order). Prepending one more layer
|
||||
(mut-var, innermost-wins) is additive.
|
||||
7. **`Term::Seq` already exists and types correctly** when its
|
||||
lhs is Unit-typed (DESIGN.md §"Term": `seq` semantically is
|
||||
`let _ = lhs in rhs`). `Term::Assign` produces Unit, so a
|
||||
sequence of Assigns chained via Seq is well-typed by existing
|
||||
machinery.
|
||||
8. **The four supported scalar primitives (Int, Float, Bool,
|
||||
Unit) are stack-resident with trivial value semantics** —
|
||||
they are not RC-managed and a store/load pair is the full
|
||||
mutation primitive at codegen. Str is heap-RC-managed and
|
||||
adds drop-old / retain-new bookkeeping; restricting this
|
||||
milestone to non-RC types keeps the codegen story to a
|
||||
single LLVM `store`.
|
||||
9. **The schema-drift test catches additions** of Term variants
|
||||
that are not documented in DESIGN.md
|
||||
(`crates/ailang-core/tests/design_schema_drift.rs:111`-area
|
||||
compares the enum's variants against DESIGN.md anchors). The
|
||||
DESIGN.md amendment in iteration mut.1 is gated by this test.
|
||||
10. **The escape-analysis pass `crates/ailang-codegen/src/escape.rs`
|
||||
is structured as a Term-tree walk** that decides per
|
||||
allocating-site between `alloca` and `GC_malloc`. Extending
|
||||
it for `Term::Mut` and `Term::Assign` is mechanical (visit
|
||||
children); mut-var allocas are always stack-resident, never
|
||||
heap.
|
||||
Reference in New Issue
Block a user