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.
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 printsrandr - 12345. Two binders (xcurrent guess,prevprevious guess); therecuris buried two levels deep: inside anif, inside alet next ..., inside anotherif. The whole(loop …)is itself wrapped in an outerif (< n 2)guard, and its result feeds alet r … (seq (print r) (print (- r 12345))). - Fits scope: axis 1 (multi-binder, non-trivial body,
recurin a branch tail not at body toplevel) and axis 4 (loopresult consumed by an enclosinglet/seq, not as a whole fn body). - Outcome:
ail checkok,ail buildok, runs, stdout12345\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 (ncurrent value,stepsaccumulator). Parity dispatch is amatch (% n 2)withpat-lit 0/_arms; therecurlives in the tail of eachmatcharm, and thematchis itself the tail of anif (== n 1).- Fits scope: axis 1 — exercises
recurtail-position through amatch(the spec only spells outif; this confirmsmatch-arm tails are handled identically). - Outcome:
ail checkok,ail buildok, runs, stdout111\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 sum27. Two binders (a,b);recurin the tail of the else branch. The two(loop …)-bearinggcdcalls are nested inside(app print (app + (app gcd …) (app gcd …)))—loopresults flow as function arguments.- Fits scope: axis 4 —
loopas a value sub-expression in argument position, two independent loop instantiations composed arithmetically. - Outcome:
ail checkok,ail buildok, runs, stdout27— 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-recursivecountdownhelper that reaches forrecuras a generic self-tail-call with no enclosingloop. →[recur-outside-loop] countdown: …3b_recur_arity— a two-binder(acc, i)loop where the author writes(recur (+ acc i))and forgets to advancei. →[recur-arity-mismatch] sum_to: passes 1 argument(s) but … 2 binder(s)3c_recur_type— the author passes theBoolcomparison result into the firstIntbinder slot. →[recur-type-mismatch] count_down: argument 0 has type \Bool` but … is `Int``3d_recur_not_tail— factorial written asn * recur(n-1), treatingrecuras value-returning. →[recur-not-in-tail- position] factorial: it is a back-jump, not a value-producing sub-expression3e_binder_captured— inside the loop body the author builds a\d. acc + dadder closing over loop binderaccand 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 checkexit 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-
recurbranch.ail checkok,ail buildok. Not executed (infinite by design). - Fits scope: axis 5 — the no-termination boundary. Confirms an
infinite
looptypechecks 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 withsurface-parse-error: parse error in app-term: expected at least one argument. Retained verbatim as the finding artefact;5bis the variant that adds an honeststartparameter 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_3a…loop_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 —
3bsays "recur must rebind every binder positionally",3dsays "it is a back-jump, not a value-producing sub-expression",3espells 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 insideif/let/if, loop inside an outerif),loop_recur_2_collatz(recur inmatch-arm tails). - What happened: DESIGN.md and the §"Concrete code shapes" only
demonstrate
recurinifbranches. I reached formatch-arm tails and alet-then-ifnest 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 amatcharm body and aletbody as tail when the enclosing form is tail. - Why working: confirms the closed rule generalises exactly as an
author would assume from the
ifexamples — 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 intolet/seq),loop_recur_4(two loop results into(+ … …)intoprint). - What happened:
loopused purely as an expression — fed to a binding, summed with anotherloop, passed as a call argument — worked with no ceremony. Separately,ail parse | ail render | ail parseis 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 arecur)ail checks andail 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() -> Intcalled as(app run_forever). The surface parser rejects this witherror: [surface-parse-error] parse error in app-term: expected at least one argument. DESIGN.md'sTerm::Appschema is{ "t":"app", "fn":Term, "args":[Term...] }—[Term...]does not state a minimum, and the entire publicexamples/*.ailcorpus contains no AIL-level call of a niladic function (mainis the only zero-arg fn and is never called from source;loop_forever_build.aildeliberately gavespinanIntparameter 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 readingcrates/. - 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 -> athunk 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)forfn() -> 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, sincefnanddataboth accept adocchild, so an author reasonably assumes the module node does too. The diagnostic iserror: [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 thatdocis 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
docinside 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/datadefs, 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) |