# Fieldtest — mut-local — 2026-05-15 **Status:** Draft — awaiting orchestrator triage **Author:** ailang-fieldtester (dispatched by skills/fieldtest) ## Scope The mut-local milestone shipped local mutable state in fn bodies as a sealed, lexically-scoped construct: `(mut (var ) ... )` declares mutable bindings of types {Int, Float, Bool, Unit}; `(assign )` updates a binding; references read the current value. The seal-by-construction promise is enforced via four diagnostics, and the enclosing fn signature does not gain a `!Mut` effect. ## Examples ### `examples/fieldtest/mut-local_1_factorial.ail` — straight-line factorial 5! - What it does: declares `prod : Int` initially 1; runs five straight-line `(assign prod (app * prod K))` statements (K = 1..5); reads `prod` as the block's value and prints it. - Why this task fits the milestone's scope: straight-line accumulator unroll is the cleanest possible exercise of a mut-Int var. No iteration, no helper fn, no branching — just the unsugared imperative shape the milestone exists to enable. - Outcome: compiles clean (`ok (21 symbols across 2 modules)`), builds clean, runs and prints `120`. First-try correct. ### `examples/fieldtest/mut-local_2_classify_temp.ail` — temperature category via nested-if assigns - What it does: `classify(t : Int) -> Int` opens a mut block with `code : Int = 0`, then a nested `(if ...)` cascade where each branch is a single `(assign code N)`, and returns `code`. `main` calls `classify(22)` and prints the result. - Why this task fits the milestone's scope: tests composition of `mut` with `if`. Each branch is Unit-typed (because `(assign ...)` is Unit), so the outer `(if ...)` is itself Unit and serves as one of the mut-block's body statements before the final read of `code`. - Outcome: compiles clean (`ok (22 symbols across 2 modules)`), builds clean, runs and prints `2`. First-try correct. ### `examples/fieldtest/mut-local_3_horner.ail` — Horner polynomial in a Float mut-var - What it does: evaluates `2x³ − 3x² + 5x − 7` at x = 2.5 by Horner's method, with the running accumulator held in `acc : Float` initialised to the leading coefficient `2.0`, then three straight- line `(assign acc (app + (app * acc 2.5) ...))` statements, and a final read of `acc`. - Why this task fits the milestone's scope: exercises Float mut-vars and demonstrates the cleanest non-Int use case — Horner is a textbook example of "one running accumulator, repeated multiply- add" that maps 1:1 onto mut's straight-line shape. - Outcome: compiles clean, builds clean, runs and prints `18` (Float 18.0 rendered via the same `%g` convention as `examples/mut_sum_floats.ail`). First-attempt error: I miscomputed the expected value in my own head (`64.875` instead of the correct `18`) and the runtime gave me `18`. Tracing by hand confirmed the runtime is right and the comment in the fixture has been corrected. No language friction here — the language was right and I was wrong, which is itself a good sign about transparency of behaviour. ### `examples/fieldtest/mut-local_4_has_small_factor.ail` — Bool mut-var "found-a-factor" - What it does: `has_small_factor(n : Int) -> Bool` opens a mut block with `found : Bool = false`; runs four `(if ((n % p) == 0) (assign found true) (lit-unit))` checks for p ∈ {2,3,5,7}; reads `found`. `main` runs it on n = 91 (= 7×13). - Why this task fits the milestone's scope: tests Bool mut-vars plus the natural OR-flag idiom. AILang has no `||` operator on the surface, so the alternative shape (`if c1 true (if c2 true (if c3 true c4))`) is unpleasantly deeply nested. mut-Bool flattens it. - Outcome: compiles clean (`ok (22 symbols across 2 modules)`), builds clean, runs and prints `true`. First-try correct. ### `examples/fieldtest/mut-local_5_lambda_capture_probe.ail` — deliberate seal-probe (negative test) - What it does: tries to construct a closure that captures a mut-var (`count`) from an enclosing mut block. Expected to FAIL at typecheck with `[mut-var-captured-by-lambda]`. - Why this task fits the milestone's scope: directly probes the seal-by-construction promise. The shape ("a callback that references the running count") is the most natural way an LLM-author would think to *escape* a mut-var; the rejection enforces the seal. - Outcome: typecheck fails with the expected diagnostic, the message is actionable (names the var, says why it's forbidden, suggests passing the value as a parameter instead). One small wart: the diagnostic code is repeated inside the message (`[mut-var- captured-by-lambda] make_bumper: [mut-var-captured-by-lambda] mut-var ...`). See Finding F2. ### `examples/fieldtest/mut-local_6_diag_probe.ail` — diagnostic probe for `mut-var-unsupported-type` - What it does: declares `var s (con Str) "hello"`. Expected to FAIL at typecheck with `[mut-var-unsupported-type]`. (Sibling probe in the same shape — assigning a Float to an Int var — was run transiently to confirm `[assign-type-mismatch]` fires the same way; the fixture now hosts the Str variant.) - Why this task fits the milestone's scope: the milestone's most user-visible restriction is the four-supported-types list. An LLM-author who has not internalised this will reach for Str the first time they want to accumulate a string; the diagnostic must point at the right line and explain. - Outcome: typecheck fails with the expected diagnostic, the message is informative and names the unsupported type. Same double-bracket-code wart as F2. In addition to the six fixtures above, four transient probes were run against `/tmp/probe_*.ail` (not checked into the working tree): - `(assign y 5)` against a mut-frame containing only `x` — fires `[mut-assign-out-of-scope]` with the message naming the available mut-vars `[x]`. Diagnostic is helpful. - `(mut (var x (con Int) 0))` with no body — fires the parse-time `(mut ...) requires at least one body expression after vars`. Clean. - `(mut (var t ...) ... t)` *inside* a lambda body whose only free variable is a lambda parameter (not a mut-var) — typechecks and runs. The seal-of-capture is calibrated to mut-vars only, not to "no mut blocks inside lambdas at all". - `(assign x 5)` as the FINAL expression of a mut block (no trailing read) — typechecks and runs; the block's static type is Unit, the enclosing `main` accepts it. Confirms that `Term::Assign` is a first-class Unit-typed expression in body position, not just a "statement". ## Findings ### [friction] F1 — mut without iteration: the "accumulator over an iteration" shape never materialises The spec (§Goal) states that mut-local exists to remove friction from "accumulators inside a fn body that today must be expressed by tail- recursive helpers carrying explicit running total parameters". But the milestone explicitly defers `while` / `for` (§Out of scope: "Loops. No while, no for. Iteration in this milestone is still tail recursion"), and the seal-by-construction rule forbids capturing a mut-var into a lambda body. Together, these two constraints mean the LLM-author **cannot** write the canonical accumulator-in-a-loop shape — there is no surface form in which a mut-var is updated once per loop iteration. The author still has to write a tail-recursive helper that threads the running total as an explicit parameter, and the mut block at most stores the helper's *final return value* into a var that gets immediately read out. This is what the `examples/mut_counter.ail` shipped with the milestone does — the mut block adds no information that a plain `(app print (app sum_helper 1 10 0))` wouldn't have. The shapes that DO work clean with mut: 1. Straight-line unrolled accumulator (fieldtest #1, #3). 2. Cascade of `(if ... (assign x ...) ...)` branches (fieldtest #2, #4). Both are real wins — fieldtest #2 (no-`||`-operator OR flag) and fieldtest #4 (no-deep-nested-if classify) are measurably cleaner with mut than the alternative. But neither is the "running total over an iteration" shape the spec's motivation paragraph leads the LLM-author to expect. - Where it surfaced: I started writing fieldtest #1 as a factorial via tail-recursive helper that threads the partial product through a mut-var, before realising the helper couldn't write to the mut-var (lambda-capture rule) and the helper's result was being thrown away into a mut-var only to be read back out as the final expression. I rewrote it as a straight-line unroll. - Recommended downstream action: `plan` (tidy iteration) — either add at least one short-loop shape to the milestone (the most obvious being a `(for ...)` block whose body is a fragment that may write to enclosing mut-vars and may NOT capture them into closures), OR add a paragraph to DESIGN.md's "What is supported" entry that explicitly says "the accumulator-in-a-loop shape from the spec's motivation is not yet achievable; until `while` lands in the follow-on milestone, the workaround is the tail-recursive accumulator-parameter pattern in `examples/mut_counter.ail` and mut-local is useful only for straight-line and cascade shapes". ### [friction] F2 — diagnostic code is doubled (`[code] fn-name: [code] message`) All four mut-related diagnostics emit their code twice when rendered in the human-stderr formatter: once as the leading bracket prefix and once embedded inside the message body. The leading bracket is the canonical `[code]:` format set by the cli-diag-human iter (per DESIGN.md). The embedded second bracket appears to be the message text itself starting with `[code] ...`, which then gets concatenated with the prefix. Verbatim samples: ``` error: [mut-var-captured-by-lambda] make_bumper: [mut-var-captured-by-lambda] mut-var `count` cannot be captured by a lambda — ... error: [assign-type-mismatch] main: [assign-type-mismatch] cannot assign `Float` to mut-var `n : Int` — ... error: [mut-var-unsupported-type] main: [mut-var-unsupported-type] mut-var `s : Str` is not supported in this milestone — ... error: [mut-assign-out-of-scope] main: [mut-assign-out-of-scope] cannot assign to `y` here — not declared as a mut-var in the enclosing (mut ...) block. Available mut-vars in scope: [x] ``` The fn-name prefix (`make_bumper:`, `main:`) IS useful — it tells the LLM-author which fn the error is in. The second `[code]` is pure noise: the bracketed prefix already supplies it. An LLM reading the diagnostic without prior calibration will likely treat the doubled code as a structured signal it should pay attention to, slowing remediation rather than helping it. - Where it surfaced: all four diagnostic probes (fieldtest #5, #6, and the transient `/tmp/probe_oos.ail`). - Recommended downstream action: `debug` — the message bodies registered for these four codes evidently include the bracketed prefix in their own text (probably as a leftover from a pre- cli-diag-human format where messages carried their own bracketed prefix). The fix is mechanical: strip the leading `[code]` from each message body so the formatter's prefix appears exactly once. ### [friction] F3 — no surface form to call a zero-arg fn from AILang This is orthogonal to mut-local but surfaced while building the closure-factory probe for fieldtest #5. Form-a's grammar is `(app FN ARG+)` — at least one argument required. A fn declared as `(fn make_bumper (type (fn-type (params) (ret ...))) (params) ...)` cannot be invoked from another fn's body, because the call shape `(app make_bumper)` is rejected at parse with `expected at least one argument at byte ...`. Workaround: give every "factory" fn a dummy Int parameter and pass a sacrificial value. This is orthogonal to mut-local but it makes the *most natural* LLM-author shape for testing seal-by-construction — a closure factory — strictly inexpressible at zero arity. Fieldtest #5 had to be rewritten with a dummy `seed` parameter (then I could probe the capture rule with `count` initialised from `seed`). For the mut-local audience this manifests as: any closure-factory shape already needs a workaround unrelated to the mut work, which adds noise to authoring. - Where it surfaced: fieldtest #5, first attempt. - Recommended downstream action: `plan` (tidy iteration) or `brainstorm` — outside this milestone's scope, but worth filing on the roadmap. Allowing `(app f)` for zero-arity fns is a one- liner grammar change with no schema impact (the JSON-AST already has `args: [Term...]` and the empty array is well-formed). ### [spec_gap] F4 — DESIGN.md does not name the "use this instead" workaround for the rejected shapes When the `[mut-var-captured-by-lambda]` diagnostic fires, its message says: "deferring escape to a future milestone with ref-types and the !Mut effect". When `[mut-var-unsupported-type]` fires on `Str`, the message says "deferred to a follow-on milestone". Both are compiler-internal-reader phrasings. An LLM-author seeing these needs to know what to write *right now*. The DESIGN.md "What is supported" entry for local mutable state describes the seal but does not enumerate the working idioms (the straight-line unroll and the cascade-of-if; see F1) nor name the fallback shapes for the rejected ones (the tail-recursive accumulator-parameter pattern; see `examples/mut_counter.ail`). - Where it surfaced: while writing fieldtest #1 and #5, I had to derive the workarounds from re-reading the spec's §"Out of scope" paragraph and inferring backwards. An LLM-author with only DESIGN.md and the public corpus would land on `examples/mut_counter .ail` only by coincidence (the file IS in the corpus, but its comment doesn't say "this is the canonical pattern when mut alone can't iterate"). - Recommended downstream action: tighten DESIGN.md — add to the "Local mutable state" §"What is supported" entry a short enumeration of working idioms (straight-line unroll; cascade of if-assigns; cascade of match-arm-assigns) and a one-liner pointer to the tail-rec-helper pattern as the fallback when the per-iteration shape is wanted. ### [working] W1 — mut's surface is reachable on first read of DESIGN.md and form-a.md The four supported shapes (Int, Float, Bool, Unit) all parse and typecheck on first try given the spec and the `examples/mut.ail` fixture. The S-expression layout `(mut (var name (con T) init) ... body)` is consistent with the rest of the surface (no surprise positional rules, no new keywords beyond `mut`/`var`/`assign`). I did not hit any "what does the form actually look like?" friction. Both `mut_counter.ail` and `mut_sum_floats.ail` in the public corpus are good examples, and the form-a §Terms grammar entry documents the shape precisely. - Where it surfaced: every happy-path fieldtest example (#1, #2, #3, #4) compiled on first try. - Recommended downstream action: `carry-on`. This is a clear signal that the *surface design* of mut is sound. The friction recorded above is about *what mut can be used for*, not about *how mut is written*. ### [working] W2 — diagnostics fire correctly and pinpoint the cause All four mut-related diagnostics (`mut-var-captured-by-lambda`, `mut-var-unsupported-type`, `assign-type-mismatch`, `mut-assign-out-of-scope`) fire on the cases that should trip them, name the offending var, and supply a short rationale. The doubled code (F2) is noise on top of an otherwise solid signal. For `mut-assign-out-of-scope`, the message even enumerates the available mut-vars in scope (`Available mut-vars in scope: [x]`), which is exactly the kind of context-supplying-without-being-asked diagnostic the LLM-author benefits from most. - Where it surfaced: fieldtest #5 (capture-by-lambda), fieldtest #6 (unsupported-type), transient probe (assign-type-mismatch and assign-out-of-scope). - Recommended downstream action: `carry-on`. Fix F2 (doubled code) separately; the diagnostic *bodies* are good. ### [working] W3 — mut composes cleanly with `if`, with lambda bodies (no capture), and with the final-expression-position The two non-trivial composition cases — `if` cascades inside a mut block (fieldtest #2, #4) and a mut block inside a lambda body whose mut-vars do not escape (transient `/tmp/probe_mut_in_lam.ail`) — both work without surprise. Also: `(assign x 5)` is a legitimate final expression of a mut block (the block's type is then Unit), and the parser accepts a mut block whose body is one such final expression with no preceding statements. The composition story is consistent. - Where it surfaced: fieldtest #2, #4; transient probes. - Recommended downstream action: `carry-on`. The composition invariants ("mut-vars do not escape" / "mut-vars shadow let-bound names of the same identifier") are spec'd in `2026-05-15-mut-local.md` §Term::Var resolution and behave as specified. ## Recommendation summary | Finding | Class | Action | |---|---|---| | F1 — accumulator-in-a-loop shape inexpressible | friction | `plan` (add `while`/`for`) OR tighten DESIGN.md to name the gap (less work, more honest) | | F2 — diagnostic code doubled in render | friction | `debug` (mechanical message-body cleanup) | | F3 — zero-arg fn cannot be called from AILang code | friction | `plan` (tidy iteration, outside mut-local scope but on roadmap) | | F4 — DESIGN.md does not name working idioms / fallbacks | spec_gap | tighten DESIGN.md (add idioms + fallback pointer) | | W1 — mut surface reachable on first read | working | carry-on | | W2 — diagnostics fire correctly with useful context | working | carry-on (modulo F2) | | W3 — composition with if / lambda-body / final-position is consistent | working | carry-on |