Files
AILang/docs/specs/0034-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

317 lines
14 KiB
Markdown

# Standalone `loop` / `recur` — Design Spec
**Date:** 2026-05-17
**Status:** Draft — awaiting user spec review
**Authors:** Brummel (orchestrator) + Claude
## Goal
Add `loop` / `recur` to AILang as a **standalone, strictly additive**
iteration surface that lowers to the same back-edge `tail-app`
already produces, with one closed rule: `recur` is valid only in
tail position of its enclosing `loop`; anything else is a compile
error.
This is **not** the reverted Iteration-discipline milestone. That
milestone bundled `loop`/`recur` (it.1) with a structural-recursion
guardedness checker + the `Diverge` effect (it.2) and a destructive
`tail-app` retirement + total-by-construction thesis (it.3); the
bundle was reverted on 2026-05-16 because the totality dichotomy
made the maximally-LLM-natural `build(d:Int)=Node(1,build(d-1),
build(d-1))` inexpressible, and because — per the revert spec —
"`loop`/`recur` (it.1) was never run through the feature-acceptance
gate for LLM-naturalness in its own right; it rode in on the
thesis." This spec does exactly that gate, standalone.
**Deliberate boundaries (in scope by their absence):**
- **No totality claim.** A `loop` with no non-`recur` exit branch
is legal and runs forever, exactly like `while(true)`. `loop`/
`recur` asserts nothing about termination. This is the precise
difference from the reverted milestone.
- **No guardedness pass.** `verify_structural_recursion`,
`term_contains_loop`, the `NonStructuralRecursion` diagnostic are
**not** part of this milestone.
- **No `Diverge` effect.** No effect is raised by `loop`/`recur`.
`Diverge` stays reserved-and-unimplemented exactly as today.
- **`tail-app` untouched.** `tail-app` / `tail-do` and
`verify_tail_positions`' tail-app role are byte-unchanged.
`loop`/`recur` coexists with them; mutual recursion and tail
calls into a *different* function (which `recur` cannot express)
continue to go through `tail-app`. No retirement, now or implied.
## Feature-acceptance verdict (the gate the revert spec demanded)
- **Clause 1 (an LLM author produces it).** Satisfied and
non-discriminating — the criterion itself states an LLM reaches
for every construct native to its imperative training
distribution. The evidence is the worked `.ail` in §"Concrete
code shapes", not a prose assertion.
- **Clause 2 (measurably improves correctness OR removes
redundancy).** Passes on both arms. *Correctness:* today
stack-safety hinges on an author-asserted tail mark on a
hand-written self-recursive helper; a forgotten/incorrect mark
silently degrades to a stack-growing call — correct on small
input, overflow on deep input, an input-size-dependent silent
failure. Strict `recur` turns a non-tail back-jump into a compile
error: an entire silent-failure class becomes a compile-time
diagnostic. *Redundancy:* removes the separate helper fn, the
invented name, and the explicit threading of all loop state as
parameters.
- **Clause 3 (reintroduces no bug class a core constraint
eliminates).** `recur` rebinds loop parameters to *new values*
per iteration — not mutation, no aliasing, no cycle; Decision 10
intact. `tail-app` and Decision 8 byte-unchanged. Approach A
keeps `loop`/`recur` a first-class node, so the round-trip
invariant holds and pre-existing canonical-JSON hashes stay
bit-stable (additive, skip-serialized). The clause-3 failure that
sank the *bundle* was it.2's half-enforced totality dichotomy (a
checker that rejected bare non-structural recursion while
grandfathering `tail`-marked calls — not a stable additive
stopping point). Strict standalone `loop`/`recur` has no such
disease: its only rule is closed and complete ("`recur` must be
in tail position"), rejects no pre-existing code, grandfathers
nothing, and depends on no future destructive iteration.
## Architecture
Three strictly-additive AST nodes, mirroring the *shape* the
reverted it.1 used, **minus** every it.2 / it.3 element:
- `Term::Loop { binders: Vec<LoopBinder>, body: Box<Term> }`
- `Term::Recur { args: Vec<Term> }`
- `struct LoopBinder { name: String, ty: Type, init: Term }`
Form-A surface: `(loop (NAME TYPE INIT) ... BODY)` and
`(recur ARG ...)`. The binder triple deliberately mirrors mut's
`(var NAME TYPE INIT)` — same author-facing shape, no new form
vocabulary. `loop` is an expression; its value is `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; `recur` itself has no value type (it does
not fall through — it transfers control), so its synthesised type
is a fresh metavar that unifies with whatever sibling branch the
enclosing `if`/`match` requires (this resolves the same open risk
it.1 recorded: `recur` appears in `if` branches that must unify
with the sibling type).
## Concrete code shapes
### The AILang program (clause-1 evidence — the headline)
What an LLM author writes for "sum 1..n" **with** `loop`/`recur`:
```
(fn sum_to
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body
(loop (acc (con Int) 0) (i (con Int) 1)
(if (app > i n)
acc
(recur (app + acc i) (app + i 1))))))
```
What the same author must write **today** (status quo it replaces)
— a separate helper, three threaded params, and a self-call that
*must* be tail-marked or it silently overflows on large `n`:
```
(fn sum_to_go
(type (fn-type (params (con Int) (con Int) (con Int)) (ret (con Int))))
(params acc i n)
(body (if (app > i n)
acc
(app sum_to_go (app + acc i) (app + i 1) n))))
(fn sum_to
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body (app sum_to_go 0 1 n)))
```
The clause-2 improvement is visible in the diff between the two:
one construct vs. helper+threading, and a structurally-guaranteed
back-jump vs. an author-asserted mark.
### Must-fail fixture (clause-3 discriminator — correct behaviour
is rejection)
`recur` outside tail position must fail to typecheck:
```
(fn bad_recur
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body
(loop (i (con Int) 0)
(app + 1 (recur (app + i 1)))))) ; recur is an argument to +,
; NOT tail → RecurNotInTailPosition
```
Expected: `ail check` exits 1 with diagnostic code
`recur-not-in-tail-position`. The other three negatives
(`recur` with wrong arity / wrong arg type / outside any `loop`)
each fire their own code.
### Implementation shape (secondary — supporting, not the point)
Before → after, load-bearing changes only; exact bytes/lines are
the planner's job.
`crates/ailang-core/src/ast.rs``Term` enum gains two variants;
serde skip-serializes nothing new on *existing* variants, and the
new variants are absent from every pre-existing fixture, so their
canonical JSON is unchanged (additive-extension pattern, same as
mut.1 / iter13a / iter18e):
```
// before: Term has no Loop/Recur
// after:
Term::Loop { binders: Vec<LoopBinder>, body: Box<Term> } // "t":"loop"
Term::Recur { args: Vec<Term> } // "t":"recur"
struct LoopBinder { name: String, #[serde(rename="type")] ty: Type, init: Term }
```
`crates/ailang-check/src/lib.rs` — a **new private** pass
`verify_loop_body` checks `recur`-in-tail-position; the existing
`pub fn verify_tail_positions` signature and its `tail-app` role
are **unchanged** (the reverted it.1 wrongly repurposed it; here it
is left alone and a sibling pass is added instead):
```
// before: verify_tail_positions only (tail-app role)
// after: verify_tail_positions UNCHANGED
// + fn verify_loop_body(...) // new, private; recur tail-position only
```
`crates/ailang-codegen/src/lib.rs` — a loop-header basic block,
one `phi` per binder seeded from the binder inits, and `recur`
lowered to a back-edge `br` updating the phis. The existing
tail-driven `block_terminated` seam is **extended** with a parallel
setter for the back-edge (it.1's shape) — `tail-app`'s use of the
seam is unchanged.
## Components
1. **Surface** (`crates/ailang-surface`): `parse_loop`,
`parse_recur`, print arms, keyword entries, EBNF block;
`crates/ailang-core/specs/form_a.md` grammar entries.
2. **Core** (`crates/ailang-core`): the two `Term` variants +
`LoopBinder`, additive serde, serde round-trip unit tests;
schema-drift / schema-coverage / spec-drift / carve-out-inventory
anchors extended with the `Term::Loop → "loop"` /
`Term::Recur → "recur"` entries (the planner re-derives the exact
`EXPECTED` carve-out delta from the current tree — no count is
hard-coded here).
3. **Prose** (`crates/ailang-prose`): read + rebuild arms for both
variants (lockstep with free-var / subst walkers).
4. **Check** (`crates/ailang-check`): `Term::Loop` binder typing;
`Term::Recur` positional arity + per-arg type unification against
the enclosing loop's binder types via a `loop_stack`-style frame
threaded exactly as mut.2's `mut_scope_stack`; the new private
`verify_loop_body` for tail-position. Four `CheckError` variants
(see §Error handling). No guardedness pass.
5. **Codegen** (`crates/ailang-codegen`): loop-header block, per-
binder `phi`, `recur` back-edge `br`, the parallel
`block_terminated` setter, lambda-boundary loop-frame
save/restore (mirroring mut.3's lambda-boundary handling).
6. **Walkers**: additive `Term::Loop`/`Term::Recur` arms in
`desugar.rs`, `lift.rs`, `mono.rs`, `linearity.rs`,
`uniqueness.rs`, `reuse_shape.rs`, `pre_desugar_validation.rs`,
and the `builtins.rs` test helpers (~25 sites — the it.1 blast
radius, additive).
## Data flow
`.ail``ailang_surface::parse` (`Term::Loop` / `Term::Recur`
nodes created) → typecheck (binder typing → `recur` arity/type
unification → `verify_loop_body` tail-position check) →
desugar / lift / mono (additive walker arms, no rewrite of
loop/recur) → `ailang-codegen` (loop-header + per-binder phi +
back-edge br) → LLVM text → `clang -O2`. The `tail-app` path runs
alongside, byte-unchanged; there is no interaction between the two
back-edge mechanisms.
## Error handling
Four `CheckError` variants, all from the reverted it.1, **none**
from it.2:
- `RecurOutsideLoop``recur` with no enclosing `loop`.
- `RecurArityMismatch``recur` arg count ≠ enclosing loop's
binder count.
- `RecurTypeMismatch` — a `recur` arg's type ≠ the corresponding
binder's type.
- `RecurNotInTailPosition``recur` appears anywhere other than
the tail position of its enclosing `loop` body.
Bracket-`[code]`-free `Display` (the F2 convention, consistent with
the mut-* variants). Each gets a `code()` arm; `ctx()` arms only
where a dedicated context string adds value (arity/type variants),
otherwise the catch-all — the planner determines the exact split
from the current `lib.rs` ctx structure. Explicitly **excluded**:
`NonStructuralRecursion` (that was the it.2 totality enforcement).
`UndeclaredEffect` is unchanged — `loop`/`recur` raises no effect.
## Testing strategy
- **Positive E2E.** A `sum_to`-class fixture builds and runs to the
correct printed value; plus a deep-`n` variant that, under the
status-quo hand-written form with a *forgotten* tail mark, would
overflow — here safe by construction (this is the clause-2
correctness claim made executable).
- **Negative fixtures.** Four `.ail.json` fixtures, one per
`CheckError` code, each asserting `ail check` exits 1 with exactly
that code (`.contains(code)`, non-vacuous). The
`RecurNotInTailPosition` fixture is the §"Concrete code shapes"
must-fail program.
- **Round-trip.** Every new fixture passes the existing
text→AST→JSON→AST→text byte-isomorphism gate
(`ailang-surface/tests/round_trip.rs`) — this is the concrete
proof Approach A preserves the Roundtrip Invariant (a desugaring
approach would fail exactly here, which is why it was rejected).
- **Hash stability.** A pin asserting a representative pre-existing
fixture's canonical-JSON bytes / content hash are unchanged by
the schema extension (same pattern as the mut.1 / iter13a /
iter18e hash pins).
- **Schema-drift.** `design_schema_drift.rs` / `schema_coverage.rs`
/ `spec_drift.rs` / `carve_out_inventory.rs` green with the new
anchors.
- **`tail-app` non-regression.** Existing `tail-app` IR-snapshot
and e2e fixtures byte-identical (proves coexistence, not
replacement).
- **Full suite.** `cargo test --workspace` green.
## Acceptance criteria
- `loop` / `recur` parse, print, and round-trip; `Term::Loop` /
`Term::Recur` / `LoopBinder` exist as additive nodes with
canonical JSON `"t":"loop"` / `"t":"recur"`.
- The four `Recur*` codes fire point-exactly on their negative
fixtures; `recur` outside tail position is a compile error.
- An infinite `loop` (no non-`recur` exit) typechecks and compiles
(no termination claim is made or enforced).
- `tail-app` / `tail-do` / `verify_tail_positions` / Decision 8 are
byte-unchanged; no `Diverge`, no `verify_structural_recursion`,
no `NonStructuralRecursion`.
- Pre-existing canonical-JSON hashes are bit-stable; the Roundtrip
Invariant holds for loop/recur-bearing modules.
- `cargo test --workspace` green; milestone-close `audit` bench
baseline pristine (additive surface; any non-pristine result is a
defect, not a ratifiable delta — though a check-time feature tax,
as seen with mut-*, may be ratified with a paired baseline update
if localised and non-hot-path).
## Cross-references
- `docs/DESIGN.md` — Decision 8 (verified tail calls; unchanged),
Roundtrip Invariant, Feature-acceptance criterion, §"Data model".
- `docs/specs/0032-iteration-discipline-revert.md` — why the
bundle was reverted; this spec is the de-bundled, gate-passed
re-attempt of its it.1 core only.
- `docs/specs/0031-iteration-discipline.md` — superseded
bundle spec; its it.1 surface is the *shape* reference (its it.2 /
it.3 content is explicitly out of scope here).
- `docs/specs/0033-llm-surface-discipline.md` §6.2 — the
principles entry this milestone discharges.