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.
317 lines
14 KiB
Markdown
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.
|