# 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) |
