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

334 lines
18 KiB
Markdown
Raw 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.
# 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 |