Files
AILang/docs/specs/0030-fieldtest-mut-local.md
T
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

18 KiB
Raw Blame History

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 <name> <type> <init>) ... <body>) declares mutable bindings of types {Int, Float, Bool, Unit}; (assign <name> <value>) 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