fieldtest: remove-mut-var-assign — 4 examples, all working; milestone CLOSED

Post-audit downstream-LLM-author field test of the post-removal
Form-A surface. 4 fresh real-world programs written from DESIGN.md +
form_a.md + public examples only (no compiler source): running
sum-of-squares accumulator (loop/recur → 385), grade cascade
(let-threaded if → 4/2/0), Horner polynomial straight-line build-up
(let-shadow chain → 73), multi-state bracket scanner threading
(rest,depth,ok) by tail recursion → true/false. 0 bugs, 0 friction,
0 spec_gap, 4 working — every task clean and correct on the first
try with only let/if/loop/recur; all parse|render|parse
byte-identical; zero mut/var/assign tokens produced (a doc-faithful
author did not reach for the removed construct).

The closest-to-friction case (multi-state machine restates the full
state tuple per tail-call) is correctly classified working, not a
spec_gap: that explicit-dataflow cost is the local-reasoning pillar
working as designed; mut's implicit cross-iteration persistence is
exactly the failure class the removal eliminates. mut-keyword
rejection is fail-closed with a diagnostic that enumerates the
surviving forms (no tombstone, no-nostalgia, as spec'd).

Removal thesis empirically CONFIRMED — mut was redundant with
let/if/loop/recur in 100% of the fresh tasks. The
remove-mut-var-assign milestone is fully ratified and CLOSED:
spec+plan+iter, audit clean (architect [high] form_a.md fixed in
.tidy, bench causally exonerated), fieldtest clean. Roadmap P0
flipped [~]→[x]; WhatsNew user-facing entry appended.
This commit is contained in:
2026-05-18 11:28:34 +02:00
parent 4837871ddf
commit 5bb721178f
8 changed files with 414 additions and 19 deletions
@@ -0,0 +1,199 @@
# Fieldtest — remove-mut-var-assign — 2026-05-18
**Status:** Draft — awaiting orchestrator triage
**Author:** ailang-fieldtester (dispatched by skills/fieldtest)
## Scope
The `mut` / `var` / `assign` local-mutable-state construct was
removed from AILang entirely (milestone `remove-mut-var-assign`,
single terminal iteration + one inline audit `.tidy`). The three
Form-A keywords no longer lex; `Term::Mut` / `Term::Assign` /
`struct MutVar` are gone from the AST; a JSON-AST carrying
`{"t":"mut"}` / `{"t":"assign"}` fails closed as a serde
unknown-variant. The surviving forms for everything that previously
used a mutable local are `let`, `if`, `loop`, `recur`. There is no
tombstone diagnostic by design (no-nostalgia). This field test
empirically probes the removal thesis — that `mut` was redundant —
by writing the four real-world tasks an imperative-trained author
would *instinctively* reach for a mutable accumulator / flag /
build-up / multi-state loop for, in the surface as it exists now,
and quantifying whether the absence of `mut` forced a materially
more awkward shape.
## Examples
### `examples/fieldtest/remove-mut_1_sum_of_squares.ail` — running numeric accumulator over a bounded range
- Computes `sum_sq(10) = Σ i² for i in 1..=10 = 385` via a `loop`
with two binders (`i` counter, `acc` total) and a tail `recur`.
- Fits the milestone's axis-1 (the canonical "imperative running
accumulator over a range" — `s = 0; for i: s += i*i`).
- Outcome: `ail check` clean first try (`ok (22 symbols across 2
modules)`); `ail build` clean; stdout `385` — matches expected.
### `examples/fieldtest/remove-mut_2_grade_cascade.ail` — conditional classification cascade
- `grade(score)` returns a letter-grade code (4=A … 0=F) via a
let-threaded `g` updated by a cascade of four `if`-expressions.
`grade(95)=4`, `grade(72)=2`, `grade(50)=0`.
- Fits axis-2 (the "set a code variable through a cascade of
if-branches" shape an imperative author writes with one mutable
flag).
- Outcome: `ail check` clean first try; `ail build` clean; stdout
`4`/`2`/`0` — matches expected.
### `examples/fieldtest/remove-mut_3_horner_poly.ail` — multi-step straight-line numeric build-up
- Horner-form evaluation of `2x⁴+3x³+0x²+5x+7` as a straight chain
of five `let acc` shadowings. `poly(2) = 73`.
- Fits axis-3 (the textbook straight-line "acc = ...; acc = acc*x +
c; ..." build-up).
- Outcome: `ail check` clean first try; `ail build` clean; stdout
`73` — matches expected.
### `examples/fieldtest/remove-mut_4_bracket_scanner.ail` — multi-state streaming state machine
- A bracket-balance scanner over a token stream (recursive `Int`
list, 1=`(`, 2=`)`, 0=other) threading `(rest, depth, ok)` via
tail recursion; each match arm updates a *subset* of the state.
`balanced "(()())" = true`, `balanced ")(" = false`.
- Fits axis-4 (the streaming-feel loop that threads more than one
piece of state and updates a subset per branch — the strongest
stress on the removal: an imperative author mutates one variable
and leaves the others implicitly alone).
- Outcome: `ail check` clean first try (`ok (24 symbols across 2
modules)`); `ail build` clean; stdout `true`/`false` — matches
expected.
All four `.ail` fixtures are additionally `ail parse | render |
parse` byte-identical (Roundtrip Invariant holds for the surviving
surface an author writes post-removal).
## Findings
### [working] Bounded-range accumulator: `loop`/`recur` is structurally equal to the imperative form, zero extra threading
- Surfaced in: `remove-mut_1_sum_of_squares.ail`.
- What happened: the imperative shape is two mutated locals (`s`,
`i`) inside a `for`. The AILang shape is one `loop` with two
binders + one `recur` — `(loop (i Int 1) (acc Int 0) (if (> i n)
acc (recur (+ i 1) (+ acc (* i i)))))`. Binder count, update
count, and exit-condition are 1:1 with the imperative loop. No
extra `let` threading, no extra `recur` argument beyond the two
values the imperative loop also carries. Compiled and produced
`385` on the first try.
- Why working: the milestone predicts `loop`/`recur` covers the
bounded-accumulator case with no expressivity loss. The example
is the affirmative evidence — the removed `mut` would have
produced an *identical* binder/update count; the construct added
nothing here. The diagnostic surface was never exercised because
nothing was wrong (clean first try).
- Recommended downstream action: carry-on.
### [working] Conditional cascade and straight-line build-up: `let`/`if` is the faithful, clean form
- Surfaced in: `remove-mut_2_grade_cascade.ail`,
`remove-mut_3_horner_poly.ail`.
- What happened: both express what an imperative author writes as
"one mutable variable, reassigned through a cascade" as a chain
of shadowing `let` bindings (`let g (if … g) …` ×4 / `let acc (+
(* acc x) c) …` ×4), each step a pure expression, the final
value unambiguous. Both checked clean first try, built, and
produced exactly the expected output (`4`/`2`/`0` and `73`).
- Why working: this is the milestone's clause-2 thesis made
executable from the author's chair — every `mut` accumulator/flag
in these shapes *is* a `let`-shadow chain with no behavioural
difference. The removal lost nothing; if anything the `let` form
is *safer* (the grade cascade's final value is structurally the
last `let` body, so the dead-write hazard `mut` permits — see the
spec's `mut_nested_shadow` example — cannot arise here).
- Recommended downstream action: carry-on.
### [working] Multi-state streaming machine: every branch must restate the full state tuple — and that is the local-reasoning pillar working as intended
- Surfaced in: `remove-mut_4_bracket_scanner.ail`.
- What happened: the imperative author writes `depth = depth + 1`
(one mutation; `ok` implicitly untouched) or `ok = false` (one
mutation; `depth` implicitly untouched). The surviving-surface
form is a tail-recursive `scan` threading `(rest, depth, ok)`
where every `tail-app` must restate *all three* arguments even in
a branch that changes only one — e.g. `(tail-app scan more (+
depth 1) ok)` re-passes the unchanged `ok` explicitly. Quantified:
5 tail-calls, each restating the ≥1 unchanged component (the
"implicit leave-alone" of mutable state becomes an explicit
pass-through). It checked clean on the *first* try and produced
`true`/`false` correctly.
- Why working (and explicitly *not* a spec_gap): this is the
closest any of the four tasks comes to friction — and it is the
decisive negative-control for the removal thesis, so it is worth
the careful classification. The "restate all state at each step"
cost is not awkwardness imposed by the removal; it is precisely
the explicit-dataflow property `docs/DESIGN.md`'s local-reasoning
pillar exists to enforce. `mut`'s "update one, the rest implicitly
persist across the loop back-edge" *is* the iterated-mutable-state
reasoning lines 99108 name as the canonical clause-3 failure: the
reader must mentally carry the unwritten state across iterations.
The tail-recursive form makes that state-flow syntactically
present and locally checkable — exactly the trade the language is
designed to make. Note `loop`/`recur` is *not* usable here (it
rebinds positionally with no structural pattern; an ADT traversal
needs `match`, so the surviving form is ordinary tail recursion) —
but that is a property of ADT traversal, not of the `mut` removal:
`mut` never offered ADT pattern-matching either. No expressivity
was lost; verbosity rose by the count of unchanged-but-restated
arguments, which is the intended cost of explicit dataflow.
- Recommended downstream action: carry-on. (If the orchestrator
wants a forward note: the only conceivable ergonomic sugar here —
a record/struct to bundle `(depth, ok)` so a branch updates one
field — is an *additive* future question entirely orthogonal to
`mut`; it must not be read as evidence against this removal.)
### [working] `mut` keyword rejection is fail-closed with an actively-guiding diagnostic
- Surfaced in: an out-of-tree probe (`/tmp/mut_probe.ail`, deleted
after the run — not left as a fixture; the milestone's own
`mut_removed_pin.rs` already pins this and the §"What you DO NOT
ship" rule forbids a rejection-only fixture with no `let`/`if`
equivalent).
- What happened: a doc-faithful-but-stale author who still emits
`(mut (var s (con Int) 0) (assign s (app + s 1)) s)` gets, at
`ail check`, exit code 1 and:
`error: [surface-parse-error] parse error in term: unknown term
head `mut`; expected one of `app`, `tail-app`, `lam`, `let`,
`let-rec`, `if`, `match`, `do`, `tail-do`, `seq`, `term-ctor`,
`clone`, `reuse-as`, `loop`, `recur`, `lit-unit` at byte 137`.
Identical structured payload via `--json` (`code:
surface-parse-error`, `severity: error`). `ail build` also exits
1. No panic, no tombstone, no degraded internal error.
- Why working: the removal spec mandates fail-closed with *no*
dedicated "construct removed" diagnostic (no-nostalgia). The
generic `unknown term head` path delivers exactly that — and as a
bonus it enumerates the surviving valid term heads *including*
`loop`, `recur`, `let`, so the very diagnostic an out-of-date
author hits points them at the replacement forms. This is the
clause-3 discriminator ("the wrong code fails to typecheck, not
merely advised against") working precisely as the spec's
"Removal made executable" criterion requires.
- Recommended downstream action: carry-on.
## Recommendation summary
| Finding | Class | Action |
|---|---|---|
| Bounded-range accumulator = `loop`/`recur`, zero extra threading | working | carry-on |
| Cascade + straight-line build-up = clean `let`/`if` | working | carry-on |
| Multi-state machine restates full tuple = local-reasoning pillar working | working | carry-on |
| `mut` keyword rejection fail-closed + guiding diagnostic | working | carry-on |
**Verdict on the removal thesis:** *Confirmed.* Across all four
imperative-instinct tasks the surviving `let`/`if`/`loop`/`recur`
surface expressed the program cleanly and correctly on the first
try; the removed `mut` would have added no expressivity (equal
binder/update counts in the loop cases, a redundant shadow chain in
the straight-line cases) and its only behavioural difference — the
implicit cross-iteration persistence of un-restated state — is the
exact clause-3 failure mode the removal exists to eliminate. No
`spec_gap`, no `friction`, no `bug` surfaced. The empirical signal
is fully consistent with the milestone's inverted feature-acceptance
argument.