iter it.1: loop/recur additive — Term::Loop/Term::Recur/LoopBinder end-to-end

Iteration-discipline milestone, 1 of 3. Adds named loop + recur as
strictly-additive first-class AST nodes: parse/print/prose/serde/
round-trip/schema lockstep, typecheck (binder typing + recur
arity/type unification via loop_stack threaded as mut.2's
mut_scope_stack; recur-tail-position via verify_loop_body), codegen
(loop-header + one phi per binder; recur back-edge br with a NEW
parallel block_terminated setter; lambda-boundary loop_frames
save/restore). Four Recur* CheckError variants. Strictly additive:
zero deletions touch tail-app/tail-do or the seven existing
block_terminated sites — this is what makes the destructive it.3
safe. recur synth = fresh metavar (resolves the plan's flagged
Type::unit() risk). loop_counter->55, loop_in_lambda->49, four
negatives fire, tail-app byte-identical, cargo test --workspace
green. Spec fda9b78, plan 7381a42.
This commit is contained in:
2026-05-15 14:38:55 +02:00
parent 7381a4233b
commit 96db54d15d
38 changed files with 1891 additions and 32 deletions
+25
View File
@@ -2410,6 +2410,22 @@ are real surface forms.
{ "t": "assign",
"name": "<id>",
"value": Term }
// Iter it.1: named loop head. `binders` declares one or more
// lexically-scoped, typed, initialised binders (initialised in
// order); `body` is a single Term in scope of all binders. `recur`
// re-enters the lexically nearest enclosing loop, rebinding the
// binders positionally; it must be in tail position of that loop's
// body. The loop's static type is `body`'s static type.
{ "t": "loop",
"binders": [ { "name": "<id>", "type": Type, "init": Term }, ... ],
"body": Term }
// Iter it.1: backward jump to the lexically nearest enclosing loop;
// tail-position-only; binds that loop's binders. The `args` array is
// always present in canonical JSON (even when empty).
{ "t": "recur",
"args": [ Term, ... ] }
```
In the MVP, `do` is only a direct call to a built-in effect op (no
@@ -2425,6 +2441,15 @@ mut-var into a lambda body is rejected at typecheck via
`CheckError::MutVarCapturedByLambda` (iter mut.4-tidy). See
`docs/specs/2026-05-15-mut-local.md`.
`loop`/`recur` are additive as of iter it.1 (2026-05-15): they
parse, print, prose-project, round-trip, typecheck (binder typing,
recur arity/type unification, recur tail-position) and codegen
(loop-header block with one phi per binder, `recur` as a back-edge
`br`) without removing or modifying the existing `tail-app`/`tail-do`
paths. The structural-recursion restriction and the `Diverge`
effect land in it.2; `tail-app`/`tail-do` are retired in it.3. See
`docs/specs/2026-05-15-iteration-discipline.md`.
**`Literal`**:
```jsonc
+251
View File
@@ -0,0 +1,251 @@
# iter it.1 — loop/recur additive (parse/print/prose/check/codegen)
**Date:** 2026-05-15
**Started from:** 7381a4233b0ea34b23871c59e3a3e802441751ef
**Status:** DONE
**Tasks completed:** 8 of 8
## Summary
First of three iterations in the iteration-discipline milestone.
Adds `Term::Loop` / `Term::Recur` / `LoopBinder` as first-class
additive AST nodes end-to-end — parse, print, prose projection,
canonical-JSON serde + round-trip, schema/spec-drift lockstep,
typecheck (binder typing, recur arity/type unification via a
`loop_stack: &mut Vec<Vec<Type>>` threaded exactly as mut.2 threaded
`mut_scope_stack`, recur tail-position via a new private
`verify_loop_body` helper with `verify_tail_positions`'s public
signature unchanged), and codegen (loop-header block with one phi
per binder, `recur` as a back-edge `br` with a NEW parallel
`block_terminated = true` setter). The change is **strictly
additive**: zero deletions touch the existing `tail-app`/`tail-do`
paths, `verify_tail_positions`' tail-app role, or any of the seven
existing codegen `block_terminated` sites — the 32 within-iter
deletion-lines are exclusively the Task-1 `Internal(...)` stubs and
the Task-1 `verify_tail_positions` structural-passthrough being
replaced by their real Task-5/Task-7 implementations (DD-3
stub-then-fill). This additive discipline is what makes the
destructive it.3 safe later. Full `cargo test --workspace` green
(63 ok result groups, 0 failed); `loop_counter.ail` builds and
prints `55`; the four `Recur*` diagnostics fire on their negatives;
`tail-app` fixtures (`bench_compute_intsum`, `list_map_poly`) run
byte-identically; zero IR/prose snapshot drift.
## Per-task notes
- iter it.1.1: AST variants + every walker arm + serde/schema
lockstep. `Term::Loop { binders: Vec<LoopBinder>, body: Box<Term> }`,
`Term::Recur { #[serde(default)] args: Vec<Term> }`, and
`LoopBinder { name, #[serde(rename="type")] ty, init: Box<Term> }`
(DD-2 derives = `Debug, Clone, Serialize, Deserialize`, no
PartialEq; note `init` is `Box<Term>` per the plan's literal code
block, whereas `MutVar.init` is bare `Term` — a small divergence
from "mirrors MutVar exactly", recorded under Concerns). ~40
walker arms across desugar (incl. 3 in-test `any_*` helpers not
enumerated by the plan), workspace, all of ailang-check
(lib/lift/linearity/mono/pre_desugar_validation/reuse_shape/
uniqueness), codegen (escape×3/lambda/lib), prose, surface
parse/print, ail main + the test-side
`codegen_import_map_fallback_pin.rs` walker the plan flagged
(mut.1 missed its analogue; did not miss it here). Typecheck +
codegen *dispatch* stubbed `Internal(...)` (tuple variant —
the plan pseudo-code's `Internal { msg }` struct form does not
exist; copied mut.1's verbatim tuple shape). `synth_with_extras`
got real arms (loop → body's type, recur → unit) per plan.
schema_coverage left RED on "variant never observed" until
Task 3 (plan-expected); every other test green.
- iter it.1.2: Form-A parse. `parse_loop` / `parse_recur` modelled
on `parse_mut` / `parse_assign`; the binder list is an explicitly
parenthesised group `(loop ((var ...)*) BODY+)` (form_a.md
production added in Task 1.10 lockstep). Plan pseudo-code named
non-existent helpers (`parse_term_str`, `parse_body_seq`,
`parse_loop_binders`); substituted the real harness (module-level
`parse_term`) and inlined the per-`(var …)` decode exactly as
`parse_mut` does — the plan explicitly forbade introducing a new
body-folding helper. EBNF prologue + unknown-head error list
extended. RED→GREEN confirmed.
- iter it.1.3: Form-A print + positive fixtures. Print arms landed
early in Task 1 as a build-unblock necessity (documented mut.1
precedent — print arms are exhaustive-match neighbours of the
synth/lower_term dispatchers). `loop_smoke.ail` /
`loop_nested_in_lambda.ail` written in real Form-A matching
`mut_counter.ail`'s verbatim surface spellings (`(con Int)`,
`(app == …)`, `(app + …)`, `(lam (params (typed …)) (ret …)
(body …))`) — the plan's fixture pseudo-code (`(fn count_to :
Int -> Int …)`) is not valid Form-A and the plan's prose
explicitly directed matching mut_counter.ail verbatim.
parse→print→parse byte-idempotent; schema_coverage now GREEN
(Task 1.11 deferred RED closed).
- iter it.1.4: Prose projection lockstep. Step 4.1's literal
instruction ("render to prose and back, asserting AST equality")
is **not expressible** — AILang has no Form-B→AST parser by
design (CLAUDE.md / `crates/ail/src/main.rs:207` "no parser
exists for the prose surface"). Recorded as a plan defect (see
Concerns). The substantive work (render/free-var/subst arms,
landed in Task 1) is correct and verified by the full prose
suite; added `loop_and_recur_project_to_prose`, a render-assertion
pin using the same `render_term` harness the nearest existing
prose tests (`not_with_tail_flag_keeps_prefix_form`) use — the
feasible equivalent of the plan's intent. This is the documented
mut.1-class plan-pseudo-vs-real-API substitution, not a silent
swap. Status DONE_WITH_CONCERNS.
- iter it.1.5: Real typecheck (DD-1). Four `CheckError` variants
(`RecurOutsideLoop` / `RecurArityMismatch` / `RecurTypeMismatch`
/ `RecurNotInTailPosition`) — **no `span` field** because no
`Span` type is in scope in this crate and no existing CheckError
variant carries one (the plan pseudo-code's `span: Option<Span>`
references a non-existent-here type; mirrored the real
`MutAssignOutOfScope` field-shape exactly as the plan's prose
instructed). `loop_stack: &mut Vec<Vec<Type>>` threaded through
`synth`'s signature + every call site (signature + 22 single-line
recursive + 3 multi-line recursive + 2 production entry points +
lift.rs + 2 mono.rs + builtins.rs test helpers + 8 in-source
`#[cfg(test)]` calls), exactly the mut.2 pattern. Real Loop arm
binds binder names into `self`-… `locals` (save/restore like
`Term::Let` — the correct precedent for *immutable* bindings,
not the mut-var mechanism) and pushes the binder type-vector onto
`loop_stack`. **`Term::Recur` synth returns a fresh metavar
(`Subst::fresh(counter)`), not the plan's flagged-risky
`Type::unit()`** — see Concerns; this resolves the plan's own
self-review "Open risk" correctly (a fresh metavar is the
codebase-idiomatic bottom that unifies anywhere, which is what
makes `(if c then (recur …))` typecheck — `Type::unit()` would
have broken `loop_smoke`'s if-branch unification). `verify_loop_body`
added as a new private helper; `verify_tail_positions`' public
signature unchanged (it.3 reworks it). The diagnostic-doc list in
`diagnostic.rs` was NOT extended — the mut codes were never added
there (false plan premise; followed the actual mut.2 precedent).
RED→GREEN: loop_smoke clean, four negatives each return their
exact code. `loop_stack` threading caused zero regressions.
- iter it.1.6: Carve-out inventory. EXPECTED extended with the four
`test_recur_*.ail.json` negatives, 13→17, in an it.1 group
(the const is milestone-grouped not alphabetical; the test uses
a BTreeSet so order is functionally irrelevant). GREEN.
- iter it.1.7: Real codegen (loop-header + phi + recur back-edge +
lambda boundary). `loop_frames: Vec<LoopFrame>` field added (mut.3
analogue to `mut_var_allocas` — init/clear/lambda-save-reset-
restore symmetric). Loop lowers to `loop.header.<id>` with one
`phi` per binder; binder phi SSA pushed into `self.locals` so
`Term::Var` resolves to it via the existing lookup. Each phi's
back-edge incoming list is emitted as a unique `/*RECUR_EDGES…*/`
LLVM-comment placeholder, then `replacen(…, 1)`-patched once the
body and all its recur sites are lowered (the plan's
`patch_loop_phis` mechanism; the placeholder is a comment so an
unpatched one would be inert IR, not a syntax error). `Term::Recur`
records `(pred_block, [arg_ssa])`, emits the back-edge `br`, sets
`block_terminated = true` — a NEW parallel setter; the seven
existing tail-driven setters are untouched (verified: zero
deletions touch them). Reused `start_block`/`fresh_ssa`/`fresh_id`/
`self.current_block` instead of adding the plan-pseudo's sprawl of
single-use helper methods (plan explicitly: "REUSE … grep before
adding"). `loop_counter.ail` builds + prints `55`.
- iter it.1.8: tail-app coexistence + IR snapshots + acceptance.
`bench_compute_intsum.ail``3500003500000`,
`list_map_poly.ail``2\n3\n…` (byte-identical to pre-it.1; no
existing codegen path modified). Zero IR/prose snapshot drift (no
blanket regen). All it.1 acceptance bullets verified — the spec's
"five Recur diagnostics" is four in it.1 (the fifth,
`NonStructuralRecursion`, is it.2; resolved per plan Step 8.3).
- Phase 3 (E2E): added `loop_in_lambda_e2e.ail` + e2e
`loop_in_lambda_runs_and_prints_49` — a `loop` inside a `lam`
body invoked via a returned closure, proving the lambda-boundary
`loop_frames` save/restore is codegen-sound (the no-`main`
`loop_nested_in_lambda.ail` parse/print fixture cannot reach
codegen via the CLI; this runnable fixture closes that gap).
## Concerns
- `LoopBinder.init` is `Box<Term>` whereas the analogous
`MutVar.init` is bare `Term`. Per the plan's literal Step 1.1
code block (which specified `init: Box<Term>`), not implementer
drift. DD-2 says "mirrors MutVar exactly" — the divergence is in
the plan's own pseudo-code vs. its own DD-2 prose. Functional
impact: none (consistent `(*b.init).clone()` / `Box::new(...)` /
`&mut b.init` handling everywhere). A future iter that wants
strict parity could unbox; no it.1 test depends on the boxing.
- `Term::Recur` synth result: chose `Subst::fresh(counter)` (a
fresh metavar) over the plan's named fallback `Type::unit()`.
The plan's self-review flagged this exact point as an "Open
risk" with `Type::unit()` as the fallback "because recur is
always in tail position so its value is never consumed". But
`recur` frequently appears as an `if` branch (`loop_smoke`:
`(if (== i n) i (recur …))`), where the branch types MUST
unify — `unify(Int, unit)` fails, so `Type::unit()` would have
broken `loop_smoke`. A fresh metavar unifies with any type
(codegen-idiomatic bottom; the same mechanism `Subst::fresh`
serves everywhere). This is the correct resolution of the plan's
own flagged risk, not a deviation from its intent.
- Step 4.1 plan defect: it asks for a prose round-trip asserting
AST equality; AILang has no Form-B parser by design. Substituted
a feasible render-assertion pin (mut.1-class plan-pseudo-vs-real
substitution). The plan's *substance* (prose lockstep for the new
variants) is fully met. Boss may want to tighten the planner
template so it does not script prose round-trips.
- Step 5.2 plan premise defect: "Add the four code strings to the
diagnostic-doc list … same list the mut codes were added to" —
the mut codes were never added to `diagnostic.rs`'s doc list.
Followed the actual mut.2 precedent (not extended); the
authoritative registry is `CheckError::code()`, which has all
four.
- The plan's recon line numbers (e.g. `desugar.rs:349/559/…`,
`lib.rs:2590/2613/3593/3614`, `parse.rs:1212`) had drifted
vs. the live tree; drove every site off `cargo build`'s
E0004 enumeration as Step 1.2 instructs. Site *count* also
differed (6 desugar E0004 sites vs. plan's 9; resolved via the
build, which is authoritative). No behavioural impact —
same class as mut.1's documented recon misindexing.
## Known debt
- Prose-side `(loop …)` / `(recur …)` rendering is a
minimal-correctness shape (`loop { var n = init; …; body }` /
`recur(a, b)`), explicitly mirroring the deliberately-placeholder
mut-block prose shape. The prose surface for loops is not yet
designed; a follow-on prose iter refines it once the LLM-author
signal arrives. Not drift — recorded for visibility.
- `loop_nested_in_lambda.ail` has no `main`, so it is a
parse/print/typecheck fixture only (CLI codegen needs a `main`).
Codegen-of-loop-in-lambda is instead proven by the runnable
Phase-3 `loop_in_lambda_e2e.ail`. No gap remains; recorded so a
future reader does not mistake the no-main fixture for dead
coverage.
## Files touched
AST/core: `crates/ailang-core/src/ast.rs`,
`crates/ailang-core/src/desugar.rs`,
`crates/ailang-core/src/workspace.rs`,
`crates/ailang-core/specs/form_a.md`.
Drift/coverage tests: `crates/ailang-core/tests/{design_schema_drift,
schema_coverage,spec_drift,carve_out_inventory}.rs`.
Check: `crates/ailang-check/src/{lib,lift,linearity,mono,
pre_desugar_validation,reuse_shape,uniqueness,builtins}.rs`,
`crates/ailang-check/tests/loop_recur_pin.rs` (new).
Codegen: `crates/ailang-codegen/src/{lib,escape,lambda}.rs`.
Surface/prose: `crates/ailang-surface/src/{parse,print}.rs`,
`crates/ailang-prose/src/lib.rs`.
CLI: `crates/ail/src/main.rs`,
`crates/ail/tests/{e2e,codegen_import_map_fallback_pin}.rs`.
Spec: `docs/DESIGN.md`.
Fixtures (new): `examples/loop_smoke.ail`,
`examples/loop_nested_in_lambda.ail`, `examples/loop_counter.ail`,
`examples/loop_in_lambda_e2e.ail`,
`examples/test_recur_{outside_loop,arity_mismatch,type_mismatch,
not_in_tail_position}.ail.json`.
## Stats
bench/orchestrator-stats/2026-05-15-iter-it.1.json
+1
View File
@@ -75,3 +75,4 @@
- 2026-05-15 — iter mut.3: codegen + e2e for `Term::Mut` + `Term::Assign` closing the mut-local milestone end-to-end; mut-var allocas hoisted to fn entry block via a per-`Emitter` side buffer `pending_entry_allocas: String` + a captured `entry_block_end_marker: Option<usize>` byte position spliced via `String::insert_str` at the end of `emit_fn`; `Term::Mut` arm in `lower_term` emits one alloca per var into the side buffer, lowers each init at the current position, emits a `store` to bind, push/pops a per-block save stack for proper shadowing of outer mut-vars; `Term::Assign` arm emits `store <ty> <value_ssa>, ptr <alloca>` and yields the canonical Unit SSA; `Term::Var` arm prepends mut-var lookup with a `load` emission. Two beyond-plan adjustments: (1) `synth_with_extras`'s parallel `Term::Var` arm needed the same mut-var lookup precedence as `lower_term`, (2) `lambda.rs` thunk emission needed save/restore of all three new `Emitter` fields across the lambda-body boundary plus an in-thunk splice at the lambda's own entry marker so mut-blocks inside a closure body hoist to the closure's entry not the outer fn's. Two e2e fixtures `examples/mut_counter.ail` (Int) + `examples/mut_sum_floats.ail` (Float); both run end-to-end and print `55`. DESIGN.md "What **is** supported" subsection gains a "Local mutable state" bullet. mut-local milestone end-to-end closed. Tests 592 → 594 → 2026-05-15-iter-mut.3.md
- 2026-05-15 — iter mut.4-tidy: close mut-local audit drift. Architect's two `[high]` items addressed — new `CheckError::MutVarCapturedByLambda` rejects any lambda whose body's free vars hit the enclosing `mut_scope_stack` (via the existing `desugar::free_vars_in_term` helper, gated on non-empty stack), and `codegen/lambda.rs`'s blame-typechecker `Internal` path becomes `unreachable!` once typecheck is the gate. Two `[medium]` stale-history comments cleaned in DESIGN.md §"Term (expression)" and `ailang-codegen/src/lib.rs`. New negative fixture `examples/test_mut_var_captured_by_lambda.ail.json` + driver test extension; `carve_out_inventory.rs` EXPECTED 12→13. Spec §"Out of scope" amended with the lambda-capture rejection bullet. Bench: `compile_check.py` `check_ms` showed a uniform ~30-50% regression across the suite traced to a fixed-cost startup tax (mut-* added ~1400 lines of typecheck/codegen surface; the short-circuit on empty `mut_scope_stack` walk in Term::Var did not move the needle, falsifying the hot-path hypothesis); ratified as a feature tax with paired baseline update on `bench/baseline_compile.json`. `check.py` tail-noise envelope continues the audit-pd carve-out (no ratify needed). `cross_lang.py` clean. Tests 594 → 598. mut-local milestone audit closed → 2026-05-15-iter-mut.4-tidy.md
- 2026-05-15 — bugfix mut-diag-double-code: fieldtest F2 — the four mut-local `CheckError` variants embedded `[<code>]` in their `#[error]` Display body while the non-JSON CLI formatter also prepends `[code]`, doubling it in human stderr; dropped the embedded prefix from the four strings, bringing them in line with all non-mut variants; RED-first via `debug` (`ct1_check_cli.rs::check_human_mode_renders_mut_diagnostic_code_exactly_once`), GREEN applied inline as a trivial mechanical edit; tests 598 → 599 → 2026-05-15-bugfix-mut-diag-double-code.md
- 2026-05-15 — iter it.1: iteration-discipline milestone (1 of 3) — `Term::Loop` / `Term::Recur` / `LoopBinder` added as strictly-additive first-class AST nodes end-to-end: Form-A `parse_loop`/`parse_recur` + print + prose render/free-var/subst lockstep + canonical-JSON serde/round-trip + schema/spec-drift/schema-coverage/carve-out lockstep (13→17) + typecheck (binder typing + recur arity/type unification via `loop_stack: &mut Vec<Vec<Type>>` threaded exactly as mut.2's `mut_scope_stack`; recur-tail-position via a new private `verify_loop_body`, `verify_tail_positions` public signature unchanged) + codegen (loop-header block, one phi per binder, recur back-edge `br` with a NEW parallel `block_terminated` setter, lambda-boundary `loop_frames` save/restore mirroring mut.3). Four new `CheckError` variants `Recur{OutsideLoop,ArityMismatch,TypeMismatch,NotInTailPosition}` (bracket-`[code]`-free Display per the F2 convention). Strictly additive verified — zero deletions touch `tail-app`/`tail-do`, `verify_tail_positions`' tail-app role, or the seven existing codegen `block_terminated` sites (the 32 within-iter deletions are exclusively DD-3 stub-then-fill replacements). `Term::Recur` synth returns a fresh metavar (resolves the plan's flagged `Type::unit()` open risk: recur appears in `if` branches that must unify with the sibling type). `loop_counter.ail``55`, `loop_in_lambda_e2e.ail``49`, four negatives fire exact codes, `tail-app` fixtures byte-identical, zero IR/prose snapshot drift; `cargo test --workspace` green. Two mut.1-class plan-pseudo-vs-reality substitutions recorded (prose round-trip asserting AST-equality is impossible — no Form-B parser by design; the diagnostic.rs doc-list premise was false) → 2026-05-15-iter-it.1.md