832375f2ac
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.
234 lines
13 KiB
Markdown
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) |
|