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

234 lines
13 KiB
Markdown

# 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_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 — `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 check`s and `ail build`s 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
`doc` is 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) |