Files
AILang/docs/specs/0038-fieldtest-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

10 KiB
Raw Permalink Blame History

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.