Files
AILang/docs/specs/0035-fieldtest-loop-recur.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

13 KiB

Fieldtest — loop-recur — 2026-05-18

Status: Draft — awaiting orchestrator triage Author: ailang-fieldtester (dispatched by skills/fieldtest)

Scope

The loop-recur milestone shipped a standalone strict-iteration surface: the Form-A (loop (NAME TYPE INIT)* BODY) block and (recur ARG*) re-entry. loop is an expression whose value is the body's value on the iteration that exits via a non-recur branch; recur re-enters the lexically-innermost enclosing loop, rebinding its binders positionally. The single closed rule is that recur must be in tail position of its enclosing loop body. No totality is claimed — an infinite loop is legal and compiles. Four rejection diagnostics (recur-outside-loop, recur-arity-mismatch, recur-type-mismatch, recur-not-in-tail-position) plus the symmetric loop-binder-captured-by-lambda enforce the contract. The surface is fully shipped (parse + typecheck + native codegen + run + Roundtrip Invariant).

Examples

examples/fieldtest/loop_recur_1_isqrt_newton.ail — integer sqrt via Newton's method

  • Computes floor(sqrt(152399025)) = 12345, then prints r and r - 12345. Two binders (x current guess, prev previous guess); the recur is buried two levels deep: inside an if, inside a let next ..., inside another if. The whole (loop …) is itself wrapped in an outer if (< n 2) guard, and its result feeds a let r … (seq (print r) (print (- r 12345))).
  • Fits scope: axis 1 (multi-binder, non-trivial body, recur in a branch tail not at body toplevel) and axis 4 (loop result consumed by an enclosing let/seq, not as a whole fn body).
  • Outcome: ail check ok, ail build ok, runs, stdout 12345\n0 — matches expected on first try.

examples/fieldtest/loop_recur_2_collatz.ail — Collatz step counter

  • collatz_len(27)=111, collatz_len(97)=118, collatz_len(1)=0. Two binders (n current value, steps accumulator). Parity dispatch is a match (% n 2) with pat-lit 0 / _ arms; the recur lives in the tail of each match arm, and the match is itself the tail of an if (== n 1).
  • Fits scope: axis 1 — exercises recur tail-position through a match (the spec only spells out if; this confirms match-arm tails are handled identically).
  • Outcome: ail check ok, ail build ok, runs, stdout 111\n118\n0 — matches expected on first try.

examples/fieldtest/loop_recur_4_gcd_value_pos.ail — Euclidean gcd as a value sub-expression

  • gcd(48,18)=6, gcd(1071,462)=21; prints their sum 27. Two binders (a, b); recur in the tail of the else branch. The two (loop …)-bearing gcd calls are nested inside (app print (app + (app gcd …) (app gcd …)))loop results flow as function arguments.
  • Fits scope: axis 4 — loop as a value sub-expression in argument position, two independent loop instantiations composed arithmetically.
  • Outcome: ail check ok, ail build ok, runs, stdout 27 — matches expected on first try.

examples/fieldtest/loop_recur_3a..3e — the five rejection diagnostics

Each is the plausible wrong program an LLM author would actually write, not a contrived minimal repro:

  • 3a_recur_outside_loop — a self-recursive countdown helper that reaches for recur as a generic self-tail-call with no enclosing loop. → [recur-outside-loop] countdown: …
  • 3b_recur_arity — a two-binder (acc, i) loop where the author writes (recur (+ acc i)) and forgets to advance i. → [recur-arity-mismatch] sum_to: passes 1 argument(s) but … 2 binder(s)
  • 3c_recur_type — the author passes the Bool comparison result into the first Int binder slot. → [recur-type-mismatch] count_down: argument 0 has type \Bool` but … is `Int``
  • 3d_recur_not_tail — factorial written as n * recur(n-1), treating recur as value-returning. → [recur-not-in-tail- position] factorial: it is a back-jump, not a value-producing sub-expression
  • 3e_binder_captured — inside the loop body the author builds a \d. acc + d adder closing over loop binder acc and applies it. → [loop-binder-captured-by-lambda] sum_to: … Either move the lambda outside the loop, or restructure to pass \acc` as a lambda parameter …`
  • Fits scope: axes 2 and 3. Outcome: all five ail check exit 1 with exactly the promised code; the function is named and the fix is described in the message text.

examples/fieldtest/loop_recur_5b_event_loop_noterm.ail — non-terminating event loop (loop-axis-covering)

  • A two-binder tick/parity event loop with no non-recur branch. ail check ok, ail build ok. Not executed (infinite by design).
  • Fits scope: axis 5 — the no-termination boundary. Confirms an infinite loop typechecks and compiles cleanly with zero diagnostics, exactly as the spec promises.

examples/fieldtest/loop_recur_5_event_loop_noterm.ail — friction artefact (kept, not worked around)

  • Same algorithm written the maximally-natural way for an infinite loop: a niladic run_forever : fn() -> Int, invoked as (app run_forever). This is the LLM-natural shape — an event loop has no seed to thread — but it never reaches loop/recur semantics: it fails at parse with surface-parse-error: parse error in app-term: expected at least one argument. Retained verbatim as the finding artefact; 5b is the variant that adds an honest start parameter so the no-termination loop axis is still genuinely exercised.

Findings

[working] All five rejection diagnostics are point-exact and self-fixing

  • Examples: loop_recur_3aloop_recur_3e.
  • What happened: each plausible-mistake program produced exactly the promised code with no false positives and no crash. The wording goes beyond naming the rule — 3b says "recur must rebind every binder positionally", 3d says "it is a back-jump, not a value-producing sub-expression", 3e spells out two concrete remediations. Every message names the enclosing function.
  • Why working: this is the milestone's clause-2 correctness claim (an entire silent-failure class becomes a compile error) made real. An author who makes the natural mistake is told what is wrong, where, and how to fix it, without reading DESIGN.md.
  • Recommended downstream action: carry-on. These diagnostics are a feature-defining strength; protect them against future rewording drift (a snapshot test on the message bodies, if not already present, would be cheap insurance — but that is an internal call, not a fieldtest demand).

[working] recur tail-position threads correctly through match, nested let, and outer if

  • Examples: loop_recur_1_isqrt_newton (recur inside if/let/if, loop inside an outer if), loop_recur_2_collatz (recur in match-arm tails).
  • What happened: DESIGN.md and the §"Concrete code shapes" only demonstrate recur in if branches. I reached for match-arm tails and a let-then-if nest unprompted (both are the natural shapes for parity dispatch and Newton's fixpoint test). All compiled and ran correctly on the first try. The tail-position analysis correctly treats a match arm body and a let body as tail when the enclosing form is tail.
  • Why working: confirms the closed rule generalises exactly as an author would assume from the if examples — no surprise, no special-casing needed in author-side mental model.
  • Recommended downstream action: carry-on.

[working] loop composes as a value sub-expression and round-trips

  • Examples: loop_recur_1 (loop result into let/seq), loop_recur_4 (two loop results into (+ … …) into print).
  • What happened: loop used purely as an expression — fed to a binding, summed with another loop, passed as a call argument — worked with no ceremony. Separately, ail parse | ail render | ail parse is canonical-JSON byte-identical for every loop/recur-bearing fixture (1, 2, 4, 5b), so the spec's Roundtrip Invariant acceptance criterion holds in practice.
  • Why working: the spec's "loop is an expression" claim is not just true for the whole-fn-body case the reference corpus shows; it holds in arbitrary expression position.
  • Recommended downstream action: carry-on.

[working] No-termination boundary is exactly as specified

  • Example: loop_recur_5b_event_loop_noterm.
  • What happened: an infinite loop (every branch a recur) ail checks and ail builds with zero diagnostics. No spurious "unreachable", no "loop never exits", no totality complaint.
  • Why working: this is the precise difference from the reverted Iteration-discipline milestone, and it behaves as the spec's deliberate-boundary section promises.
  • Recommended downstream action: carry-on.

[spec_gap] A niladic application (app f) is unrepresentable in Form A; DESIGN.md does not constrain it

  • Example: loop_recur_5_event_loop_noterm (kept as the artefact).
  • What happened: the maximally-natural infinite-loop shape is a zero-argument run_forever : fn() -> Int called as (app run_forever). The surface parser rejects this with error: [surface-parse-error] parse error in app-term: expected at least one argument. DESIGN.md's Term::App schema is { "t":"app", "fn":Term, "args":[Term...] }[Term...] does not state a minimum, and the entire public examples/*.ail corpus contains no AIL-level call of a niladic function (main is the only zero-arg fn and is never called from source; loop_forever_build.ail deliberately gave spin an Int parameter to sidestep exactly this). So two readings are equally plausible from the spec alone: (a) args:[] is legal AST that the surface should be able to express, or (b) niladic application is forbidden by design and DESIGN.md should say so. The compiler picked (b) at the surface layer; I could not tell which reading is intended without reading crates/.
  • Why spec_gap: DESIGN.md neither permits nor forbids the empty-args application; the surface diagnostic ("expected at least one argument") states a rule that appears nowhere in DESIGN.md, and a downstream author writing an event loop / unit -> a thunk hits it with no spec guidance. It is orthogonal to loop/recur but was surfaced by the loop/recur no-termination axis, because an infinite loop is the canonical case with no state worth a parameter.
  • Recommended downstream action: ratify a decision and tighten DESIGN.md — either (a) state that niladic applications are forbidden in Form A and document the unit-param convention as the workaround, or (b) extend the surface to accept (app f) for fn() -> a. Until then, an author cannot express a niladic-call thunk and the diagnostic gives no DESIGN-backed remedy.

[friction] Module-level (doc …) is rejected with a diagnostic that omits the actual location of doc

  • Examples: surfaced on the first draft of loop_recur_3a..3e (corrected in the committed fixtures to a leading ; comment).
  • What happened: I wrote a module-level (module NAME (doc "…") (fn …)) — a natural reach, since fn and data both accept a doc child, so an author reasonably assumes the module node does too. The diagnostic is error: [surface-parse-error] parse error in module: unknown def head \doc`; expected `data`, `fn`, `const`, `class`, `instance`, or `import`. It correctly rejects, but the message lists the valid def heads **without** mentioning that docis valid *inside*fn/data` — an author can read this message and still not know where the doc string should go, and may conclude doc strings are unsupported entirely.
  • Why friction: it compiles-or-rejects correctly (no bug), but the verbose-by-design AILang diagnostic philosophy is not met here: the message names what is expected but not the one thing the author needs (move doc inside the def). Orthogonal to loop/recur; surfaced while writing the negative fixtures.
  • Recommended downstream action: plan a one-line tidy — append to the message a hint such as "(doc strings attach inside fn/data defs, not at module level)". Low cost, removes a dead-end for the author.

Recommendation summary

Finding Class Action
Five rejection diagnostics point-exact & self-fixing working carry-on
recur tail-position threads through match/let/outer-if working carry-on
loop composes as a value sub-expression & round-trips working carry-on
No-termination boundary exactly as specified working carry-on
Niladic (app f) unrepresentable; DESIGN.md silent spec_gap ratify + tighten DESIGN.md
Module-level (doc …) diagnostic omits where doc belongs friction plan (one-line tidy)