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.
14 KiB
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
loopwith no non-recurexit branch is legal and runs forever, exactly likewhile(true).loop/recurasserts nothing about termination. This is the precise difference from the reverted milestone. - No guardedness pass.
verify_structural_recursion,term_contains_loop, theNonStructuralRecursiondiagnostic are not part of this milestone. - No
Divergeeffect. No effect is raised byloop/recur.Divergestays reserved-and-unimplemented exactly as today. tail-appuntouched.tail-app/tail-doandverify_tail_positions' tail-app role are byte-unchanged.loop/recurcoexists with them; mutual recursion and tail calls into a different function (whichrecurcannot express) continue to go throughtail-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
.ailin §"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
recurturns 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).
recurrebinds loop parameters to new values per iteration — not mutation, no aliasing, no cycle; Decision 10 intact.tail-appand Decision 8 byte-unchanged. Approach A keepsloop/recura 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 grandfatheringtail-marked calls — not a stable additive stopping point). Strict standaloneloop/recurhas no such disease: its only rule is closed and complete ("recurmust 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
- Surface (
crates/ailang-surface):parse_loop,parse_recur, print arms, keyword entries, EBNF block;crates/ailang-core/specs/form_a.mdgrammar entries. - Core (
crates/ailang-core): the twoTermvariants +LoopBinder, additive serde, serde round-trip unit tests; schema-drift / schema-coverage / spec-drift / carve-out-inventory anchors extended with theTerm::Loop → "loop"/Term::Recur → "recur"entries (the planner re-derives the exactEXPECTEDcarve-out delta from the current tree — no count is hard-coded here). - Prose (
crates/ailang-prose): read + rebuild arms for both variants (lockstep with free-var / subst walkers). - Check (
crates/ailang-check):Term::Loopbinder typing;Term::Recurpositional arity + per-arg type unification against the enclosing loop's binder types via aloop_stack-style frame threaded exactly as mut.2'smut_scope_stack; the new privateverify_loop_bodyfor tail-position. FourCheckErrorvariants (see §Error handling). No guardedness pass. - Codegen (
crates/ailang-codegen): loop-header block, per- binderphi,recurback-edgebr, the parallelblock_terminatedsetter, lambda-boundary loop-frame save/restore (mirroring mut.3's lambda-boundary handling). - Walkers: additive
Term::Loop/Term::Recurarms indesugar.rs,lift.rs,mono.rs,linearity.rs,uniqueness.rs,reuse_shape.rs,pre_desugar_validation.rs, and thebuiltins.rstest 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—recurwith no enclosingloop.RecurArityMismatch—recurarg count ≠ enclosing loop's binder count.RecurTypeMismatch— arecurarg's type ≠ the corresponding binder's type.RecurNotInTailPosition—recurappears anywhere other than the tail position of its enclosingloopbody.
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-nvariant 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.jsonfixtures, one perCheckErrorcode, each assertingail checkexits 1 with exactly that code (.contains(code), non-vacuous). TheRecurNotInTailPositionfixture 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.rsgreen with the new anchors. tail-appnon-regression. Existingtail-appIR-snapshot and e2e fixtures byte-identical (proves coexistence, not replacement).- Full suite.
cargo test --workspacegreen.
Acceptance criteria
loop/recurparse, print, and round-trip;Term::Loop/Term::Recur/LoopBinderexist as additive nodes with canonical JSON"t":"loop"/"t":"recur".- The four
Recur*codes fire point-exactly on their negative fixtures;recuroutside tail position is a compile error. - An infinite
loop(no non-recurexit) typechecks and compiles (no termination claim is made or enforced). tail-app/tail-do/verify_tail_positions/ Decision 8 are byte-unchanged; noDiverge, noverify_structural_recursion, noNonStructuralRecursion.- Pre-existing canonical-JSON hashes are bit-stable; the Roundtrip Invariant holds for loop/recur-bearing modules.
cargo test --workspacegreen; milestone-closeauditbench 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.