fieldtest: mut-local — 6 examples (4 positive + 2 negative probes), 0 bugs / 3 friction / 1 spec_gap

Boss-dispatched fieldtest after audit-mut-local closed. Six AIL
Surface (.ail) fixtures under examples/fieldtest/ exercise the
shipped mut-local surface from a downstream-LLM-author's perspective
(no compiler-source access; DESIGN.md + public examples only).

Positive fixtures all run end-to-end first-try:
- mut-local_1_factorial.ail: straight-line Int accumulator
  unroll (5!), prints 120.
- mut-local_2_classify_temp.ail: nested-if assigns into a Unit-
  typed mut block, prints classification code.
- mut-local_3_horner.ail: Float mut accumulator for polynomial
  evaluation, prints 18.
- mut-local_4_has_small_factor.ail: Bool mut flag via four
  if-then-assign checks, prints true.

Negative probes confirm diagnostics fire as documented:
- mut-local_5_lambda_capture_probe.ail: [mut-var-captured-by-lambda].
- mut-local_6_diag_probe.ail: [mut-var-unsupported-type].

Findings:

[friction] F1 — mut without iteration: the accumulator-over-an-
iteration shape never materializes. Without while/for, the LLM-
author still writes a tail-recursive helper (the very pattern
the milestone Goal said mut would replace). examples/mut_counter.ail
illustrates the degeneration. Routing: planner for a while/for
iteration OR tighten DESIGN.md to name the gap honestly.

[friction] F2 — all four mut-related diagnostics emit their
bracketed [code] twice ('error: [code] fn-name: [code] message').
Mechanical bug: the #[error('[code] ...')] Display attributes I
authored in mut.2/mut.4-tidy include the bracketed prefix in the
message body, and the cli-diag-human formatter adds another from
CheckError::code(). Routing: debug (mechanical message-body
cleanup).

[friction] F3 — no surface form to call a zero-arg fn ('(app f)'
rejected at parse with 'expected at least one argument').
Orthogonal to mut-local but surfaced building the closure-factory
probe. Routing: planner for a small tidy iter.

[spec_gap] F4 — DESIGN.md does not name the 'use a tail-rec
helper instead' workaround for the iteration-over-accumulator
shape that mut alone cannot express. Routing: ratify in DESIGN.md
alongside F1's resolution.

[working] W1/W2/W3 — surface reachable on first read; diagnostics
pinpoint cause (mut-assign-out-of-scope even lists available
vars); composes cleanly with if + lambda-without-capture +
final-expression position.

Spec: docs/specs/2026-05-15-fieldtest-mut-local.md (333 lines).

Refs: docs/specs/2026-05-15-mut-local.md, audit-mut-local close
at 8685e96.
This commit is contained in:
2026-05-15 09:52:43 +02:00
parent 8685e96970
commit 1faee673f7
7 changed files with 530 additions and 0 deletions
@@ -0,0 +1,30 @@
; Fieldtest mut-local #1 — factorial 5! via straight-line mut updates.
;
; Task: print 5! (= 120) using a `mut` block that names a running
; product and unrolls five multiplications as straight-line statements.
; This is the most direct possible use of mut-local: no helper fn, no
; iteration, just a sequence of in-block updates terminated by reading
; the var. The LLM-author's mental model of "I want a local accumulator"
; maps 1:1 onto the surface here.
;
; Why this fits mut-local's scope: the milestone supplies only sealed
; lexically-scoped mutables, with no `while` or `for`. Straight-line
; unroll is the *only* shape inside one mut block that needs no helper.
;
; Expected stdout: 120
(module mut-local_1_factorial
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print
(mut
(var prod (con Int) 1)
(assign prod (app * prod 1))
(assign prod (app * prod 2))
(assign prod (app * prod 3))
(assign prod (app * prod 4))
(assign prod (app * prod 5))
prod)))))
@@ -0,0 +1,41 @@
; Fieldtest mut-local #2 — classify a temperature into a band using
; nested if-branches that each update a mut-Int "category code".
;
; Task: given a temperature value, set a category-code mut-var to
; 0 (freezing), 1 (cold), 2 (warm), 3 (hot) by walking through a
; cascade of if-branches. Print the resulting code.
;
; Why this fits mut-local's scope: this exercises mut composed with
; `if` — each branch contains a single `(assign ...)`. The seal-by-
; construction promise says the if-branch can write to the var, and
; the var's value flows out of the branch as the latest store. This is
; a use of mut that *replaces* what a chain of let-rebinds would
; otherwise do, and a chain of let-rebinds is the AILang author's
; usual workaround for "set this variable conditionally" — so the
; mut form should be measurably cleaner here.
;
; Expected stdout: 2 (room temperature 22 = "warm")
(module mut-local_2_classify_temp
(fn classify
(doc "Return category code 0..3 for temperature t in degrees C.")
(type (fn-type (params (con Int)) (ret (con Int))))
(params t)
(body
(mut
(var code (con Int) 0)
(if (app < t 0)
(assign code 0)
(if (app < t 15)
(assign code 1)
(if (app < t 28)
(assign code 2)
(assign code 3))))
code)))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print (app classify 22)))))
+34
View File
@@ -0,0 +1,34 @@
; Fieldtest mut-local #3 — evaluate the polynomial
; p(x) = 2 x^3 - 3 x^2 + 5 x - 7
; at x = 2.5 by Horner's method, using a Float mut-var as the running
; accumulator and unrolling the four Horner steps as straight-line
; assigns.
;
; Why this fits mut-local's scope: the accumulator is a Float, the
; updates are straight-line (no iteration), and the mut form removes
; the four nested let-rebinds an LLM-author would otherwise write
; ("p1 = ..., p2 = p1*x + ..., p3 = p2*x + ...") — each rebind needing
; a fresh name. Reusing one name for the running accumulator is the
; natural shape, and mut supplies it.
;
; Hand-check (Horner): start with leading coeff 2.0, then for each
; lower coefficient do acc = acc * x + c:
; 2.0 * 2.5 + (-3) = 5.0 - 3 = 2.0
; 2.0 * 2.5 + 5 = 5.0 + 5 = 10.0
; 10.0 * 2.5 + (-7) = 25.0 - 7 = 18.0
; Expected stdout: 18 (Float 18.0 via %g; print drops the trailing
; ".0" the same way it does for the Float fixture mut_sum_floats.ail.)
(module mut-local_3_horner
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print
(mut
(var acc (con Float) 2.0)
(assign acc (app - (app * acc 2.5) 3.0))
(assign acc (app + (app * acc 2.5) 5.0))
(assign acc (app - (app * acc 2.5) 7.0))
acc)))))
@@ -0,0 +1,35 @@
; Fieldtest mut-local #4 — Bool mut-var "found-a-factor" flag.
;
; Task: probe whether n has a small prime factor (2, 3, 5, or 7) by
; running four straight-line checks; if any check matches, set a Bool
; mut-var to true. Print the flag at the end.
;
; The straight-line form here is the natural shape: an LLM-author asked
; to "test these four conditions and OR the results" would otherwise
; write a chain of `||` operators (no such operator in AILang surface)
; or a nested chain of `(if ... (if ... ))`. The mut form replaces both
; with a flat sequence whose intent ("set this flag if any of these
; matches") reads top-to-bottom.
;
; Test against n = 91 = 7 * 13 — only the divisible-by-7 check fires.
; Expected stdout: true
(module mut-local_4_has_small_factor
(fn has_small_factor
(type (fn-type (params (con Int)) (ret (con Bool))))
(params n)
(body
(mut
(var found (con Bool) false)
(if (app == (app % n 2) 0) (assign found true) (lit-unit))
(if (app == (app % n 3) 0) (assign found true) (lit-unit))
(if (app == (app % n 5) 0) (assign found true) (lit-unit))
(if (app == (app % n 7) 0) (assign found true) (lit-unit))
found)))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print (app has_small_factor 91)))))
@@ -0,0 +1,39 @@
; Fieldtest mut-local #5 — deliberate probe of the seal-by-construction
; promise: try to lift a mut-var into a lambda closure.
;
; Spec §"Out of scope": "Lambda capture of a mut-var. A lambda body
; whose free vars include a mut-var of an enclosing Term::Mut is
; rejected at typecheck with CheckError::MutVarCapturedByLambda."
;
; This file is EXPECTED TO FAIL at `ail check` with the
; `mut-var-captured-by-lambda` diagnostic. The purpose is to probe:
; - that the diagnostic actually fires
; - that its rendered text is actionable
; - that it points at the lambda site, not somewhere else
;
; The shape: a mut block declares `count`, builds a closure that would
; close over `count`, and tries to return the closure. An LLM-author
; might write this naïvely thinking "I just need a small callback that
; updates the running count" — exactly the shape the seal forbids.
(module mut-local_5_lambda_capture_probe
(fn make_bumper
(type
(fn-type
(params (con Int))
(ret (fn-type (params (con Int)) (ret (con Int))))))
(params seed)
(body
(mut
(var count (con Int) 0)
(assign count seed)
(lam (params (typed n (con Int)))
(ret (con Int))
(body (app + n count))))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print (app (app make_bumper 10) 5)))))
@@ -0,0 +1,18 @@
; Fieldtest mut-local #6 — deliberate diagnostic probe. EXPECTED TO
; FAIL at `ail check`.
;
; Probes `mut-var-unsupported-type` — declaring a Str mut-var.
; (A sibling fixture used to probe `assign-type-mismatch` by assigning
; 1.5 to an Int var; on the same surface the diagnostic also fires
; with the same double-bracket-prefix shape.)
(module mut-local_6_diag_probe
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print
(mut
(var s (con Str) "hello")
s)))))