Files
AILang/docs/specs/0037-remove-mut-var-assign.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

247 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Remove `mut` / `var` / `assign` — Design Spec
**Date:** 2026-05-18
**Status:** Draft — awaiting user spec review
**Authors:** Brummel (orchestrator) + Claude
## Goal
Remove the local-mutable-state construct from AILang entirely. At
the AST there are exactly two nodes — `Term::Mut` (the block, which
declares the vars) and `Term::Assign` (the update); `var` is not a
first-class `Term`, only the declaration syntax inside a `mut`
block, and `Term::Assign` is statically illegal outside an enclosing
`Term::Mut` (`mut-assign-out-of-scope`). The three keywords are one
indivisible feature; the cut is atomic.
The feature-acceptance criterion, applied **inverted** (does the
*removed* feature fail the gate?):
- **Clause 1** (an LLM author reaches for it): met — and per
`docs/DESIGN.md` that counts *against*, not for, since clause 1 is
satisfied by exactly the imperative constructs the core refuses.
- **Clause 2** (removes redundancy / improves correctness):
violated. Every one of the shipped `mut`-bearing fixtures is
straight-line scalar mutation a `let`/`if` expresses; the
iteration ones delegate to a tail-recursive helper. The construct
*is* the redundancy. Proven executable below (`mut_counter.ail`
before→after).
- **Clause 3** (reintroduces no bug class the core exists to
eliminate): violated. `Term::Mut` + `Term::Assign` is the
iterated/sequenced mutable-state reasoning `docs/DESIGN.md`
lines 99108 name as the canonical clause-3 failure. Scalar
sealing bounds the blast radius, not the reasoning burden — and
nothing in-tree needs it.
`loop` / `recur` shipped and closed as a standalone milestone
(2026-05-18) and is the surviving first-class iteration construct;
`let` / `if` cover the straight-line cases. This milestone is
explicitly **decoupled** from the open "Stateful islands" roadmap
item, which is a separate, later, independent decision and is **not**
in scope here.
## Architecture
Atomic hard-removal in one milestone, no deprecation window
(Approach A). Surface, AST, checker, codegen, DESIGN.md, fixtures
and the drift trio are all cut together so the schema is honest at
every commit.
After removal the Form-A keywords `mut` / `var` / `assign` no longer
lex; a JSON-AST carrying `{"t":"mut"}` or `{"t":"assign"}` fails
closed as a generic serde unknown-variant error. There is **no**
dedicated "construct removed" diagnostic — a tombstone diagnostic is
permanent clutter for a language whose sole author is an LLM that
simply stops emitting `mut` once `docs/DESIGN.md` no longer
documents it (no-nostalgia).
One delicate interaction governs the whole milestone: **the codegen
alloca machinery is shared with `loop`.** `loop-recur.3` lowered
loop binders by reusing the mut.3 `pending_entry_allocas` mechanism
and registering them in the existing `mut_var_allocas` map, and
`loop-recur.tidy` widened the shared `Term::Lam` escape guard to
cover both. The machinery therefore **survives** the cut (loop needs
it); only its `mut` half is removed, and the now-misnamed
`mut_var_allocas` is renamed `binder_allocas` for honesty (its
"mut-var" name would otherwise be a lie in the source).
## Concrete code shapes
### The AILang program — inverted clause-1 faithfulness evidence
The headline is the proof that removal loses **no** expressivity:
every shipped `mut` program rewrites to an observably identical
`let`/`if` program. Two real fixtures, before → after:
`examples/mut_counter.ail` (the strongest evidence — the `mut` block
computed *nothing*; the iteration was always the tail-recursive
helper):
```clojure
;; BEFORE — ships today (mut.3 fixture), prints 55
(fn main
(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))))
;; AFTER — observably identical, prints 55
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print (app sum_helper 1 10 0))))
;; sum_helper (tail-recursive accumulator) is unchanged.
```
`examples/mut.ail` :: `mut_single_var` (the minimal scalar case):
```clojure
;; BEFORE — value is 1
(body
(mut
(var x (con Int) 0)
(assign x (app + x 1))
x))
;; AFTER — value is 1, faithful
(body
(let x 0 (app + x 1)))
```
`mut.ail` :: `mut_nested_shadow` shows the rewrite also *exposes*
the redundancy — the outer `var`+`assign` was dead code `mut`
permitted (only the inner block is the body's value):
```clojure
;; BEFORE — value 101 (outer x=10,x+1 discarded; inner x=100,x+1 returned)
(body
(mut (var x (con Int) 10) (assign x (app + x 1))
(mut (var x (con Int) 100) (assign x (app + x 1)) x)))
;; AFTER — value 101, the dead outer state simply gone
(body
(let x 100 (app + x 1)))
```
The must-fail side (the removal made executable): a module carrying
`{"t":"mut"}` / a `.ail` with the `mut` keyword must now be
rejected (serde unknown-variant / Form-A parse error). This is a new
pin fixture, the clause-3 discriminator (the wrong code fails to
typecheck, not merely advised against).
### Implementation shape (secondary — supporting, not the point)
| Site | Before | After |
|---|---|---|
| `ailang-core/src/ast.rs` (~529-638) | `Term::Mut { vars: Vec<MutVar>, body }`, `Term::Assign { name, value }`, `struct MutVar { name, ty, init }` | all three deleted; every exhaustive no-`_`-wildcard `Term` match across the 6 crates loses its `Mut`/`Assign` arms |
| `ailang-surface` (`lex`/`parse`/`print`/`loader.rs`, `form_a.md`) | `mut`/`var`/`assign` keywords, `parse_mut`/`parse_assign`, print arms | deleted; the keywords no longer lex |
| `ailang-check/src/lib.rs` | `Term::Mut`/`Term::Assign` synth arms; `mut_scope_stack` threaded through every synth caller; 4 variants `MutVarCapturedByLambda`, `UnsupportedMutVarType`[`mut-var-unsupported-type`], `MutAssignOutOfScope`[`mut-assign-out-of-scope`], `AssignTypeMismatch`[`assign-type-mismatch`] + their `code()`/`ctx()` arms; shared `Term::Lam` escape guard gate `!mut_scope_stack.is_empty() \|\| !loop_stack.is_empty()` | synth arms + `mut_scope_stack` + the 4 variants deleted; escape-guard gate simplifies to `!loop_stack.is_empty()`, the `mut_scope_stack` free-var pass removed — **the `loop_stack` half MUST survive byte-equivalent** (loop-binder-captured-by-lambda still rejected) |
| `ailang-codegen` (`lib.rs` ~1779-1855, `escape.rs` ~193-417, `lambda.rs`) | `Term::Mut`/`Term::Assign` `lower_term` arms; `mut_var_allocas`/`pending_entry_allocas` save/restore | the two `lower_term` arms + the `mut`/`assign` escape arms deleted; the alloca machinery **kept** (loop reuses it) and `mut_var_allocas` renamed `binder_allocas`, doc-comments made truthful |
| `docs/DESIGN.md` | `Term::Mut`/`Term::Assign` data-model entries (~2404-2422) + mut.1/mut.3/mut.4 prose (~2448-2458) + the symmetric lambda-capture note | the `mut` data-model + prose hard-deleted; the symmetric-rule note keeps only its `loop`-binder half; present-tense, zero nostalgia |
| drift trio + `carve_out_inventory.rs` + roadmap | mut anchors in `design_schema_drift`/`spec_drift`/`schema_coverage`; mut carve-out fixtures counted in `EXPECTED` (18 post-loop-recur.tidy); roadmap "mut-local is the foundation for Stateful islands" sub-bullet | mut drift anchors removed in lockstep with the schema deletion; `EXPECTED` decremented by the mut carve-out count (exact integer = planner's to pin); the false foundation sub-bullet removed |
## Components
1. **AST** — delete the two variants and `MutVar`; sweep every
exhaustive `Term` match (the same ~30-site no-wildcard pattern
`loop-recur.1` swept additively, now in reverse).
2. **Surface** — delete keyword lexing, the two parsers, the print
arms, the loader handling, the `form_a.md` grammar lines.
3. **Checker** — delete synth arms, `mut_scope_stack` threading,
the 4 `CheckError` variants + registry/ctx arms; simplify the
*shared* escape guard to `loop_stack`-only, preserving the
loop-binder rejection exactly.
4. **Codegen** — delete the two `lower_term` arms + `escape.rs`/
`lambda.rs` `mut` handling; **keep** the alloca machinery
(loop-carried), rename `mut_var_allocas``binder_allocas`.
5. **Fixtures** — rewrite value-computing fixtures to `let`/`if`
(`examples/mut.ail` 6 fns, `mut_counter.ail`, `mut_sum_floats.ail`,
`fieldtest/mut-local_1..4`), repoint the e2e assertions
(`mut_counter`→55, `mut_sum_floats`→55); **delete** the
rejection-probe fixtures whose entire purpose is the removed
mut-specific rejection (`fieldtest/mut-local_5_lambda_capture_probe.ail`,
`mut-local_6_diag_probe.ail`, `examples/test_mut_var_captured_by_lambda.ail.json`)
— no `let`/`if` equivalent exists for them.
6. **DESIGN.md** — hard-delete the `mut` data-model + prose;
present-tense; the file must read as the *current* language, with
no trace of `mut`/`var`/`assign` and no post-mortem.
7. **Drift trio + roadmap** — remove mut anchors in lockstep with
the schema deletion (proves removal is matched by pin removal);
decrement `carve_out_inventory` `EXPECTED`; delete the false
Stateful-islands "foundation" sub-bullet.
## Data flow
Author writes `.ail` with `let`/`if`/`loop`/`recur` only → surface
no longer lexes `mut`/`var`/`assign` (Form-A parse error on the
keyword) → JSON-AST has no `"t":"mut"`/`"t":"assign"` (serde
unknown-variant rejection, fail-closed) → checker/codegen never see
the nodes. Non-`mut` modules are byte-stable end-to-end: removal is
symmetric to the additive mut.1 introduction and serde is
tag-based, so no canonical-JSON hash of any surviving module moves.
`loop`/`recur` is unaffected — it shares the renamed alloca
machinery, and its lowering is byte-identical (rename is
representation-only).
## Error handling
The 4 `mut` `CheckError` variants are deleted. `{"t":"mut"}` /
`{"t":"assign"}` produce the generic serde unknown-variant error —
no dedicated diagnostic, no tombstone (fail-closed, no-nostalgia).
`loop`/`recur`'s five `Recur*` codes and `LoopBinderCapturedByLambda`
are byte-unchanged. The shared `Term::Lam` escape guard must keep
rejecting a lambda that captures a `loop` binder — this is a
non-regression pin, since the guard is the exact code being
simplified.
## Testing strategy
- **Behaviour-preservation (inverted clause-1, executable):** the
rewritten fixtures' e2e value assertions stay green —
`mut_counter``55`, `mut_sum_floats``55` — proving the `let`/`if`
rewrite is faithful.
- **loop/recur non-regression:** the full `loop_recur_*` /
`loop_sum_to*` fixture suite byte-identical (the shared
alloca-machinery rename must not move loop codegen); the
`loop-binder-captured-by-lambda` rejection still fires
point-exactly (shared escape guard, loop half survives);
`tail-app`/`tail-do` byte-frozen.
- **Schema honesty:** drift trio (`design_schema_drift`,
`spec_drift`, `schema_coverage`) + `carve_out_inventory` green at
the new `EXPECTED` — proving the schema-region deletion is
matched by the drift-anchor deletion.
- **Removal made executable:** a new must-fail pin — a `.ail` with
the `mut` keyword and a module with `{"t":"mut"}` are now rejected
(Form-A parse error / serde unknown-variant).
- **Round-trip invariant:** all surviving non-`mut` modules
`ail parse|render|parse` byte-identical; pre-existing hash pins
byte-stable.
- `cargo test --workspace` green.
## Acceptance criteria
- `mut`/`var`/`assign` do not lex in Form A; `{"t":"mut"}` /
`{"t":"assign"}` are rejected as unknown serde variants; no
`Term::Mut`/`Term::Assign`/`MutVar` symbol remains in any crate.
- The 4 `mut` `CheckError` variants are gone; `loop`/`recur`'s
codes and the loop-binder escape-guard behaviour are
byte-unchanged (non-regression pinned).
- Every value-computing fixture is rewritten faithfully and still
produces its original value (`mut_counter`/`mut_sum_floats`
print 55); the three rejection-probe fixtures are deleted.
- `docs/DESIGN.md` is present-tense and contains zero
`mut`/`var`/`assign` mention and zero nostalgia/aspiration; the
drift trio + `carve_out_inventory` are green at the new
`EXPECTED`.
- The Roundtrip Invariant holds byte-identically for all surviving
modules; the `loop`/`recur` e2e (`55` / `500000500000` /
infinite-compiles) stay green.
- `cargo test --workspace` green; milestone-close `audit` bench
baseline pristine (pure removal, no hot-path touched — any
non-pristine result is a defect, not a ratifiable delta).