diff --git a/docs/roadmap.md b/docs/roadmap.md index 2553d5d..be99145 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -35,6 +35,20 @@ work progresses. ## P0 — In flight +- [~] **\[milestone\]** Prose `loop` binders — projection redesign. + Form-B prose currently renders loop binders as `name = init;` + statements *inside* the `loop { … }` body, which misreads as + C/Rust re-init-every-iteration semantics. Move them to a + parenthesised init-list on the keyword — `loop(acc = 0, i = 1) + { … }` — positionally isomorphic to `recur(...)`. Projection-only: + one `ailang-prose` `write_term` `Term::Loop` arm + 2 snapshot + fixtures/tests; loop/recur AST, Form A, JSON-AST, typecheck, + codegen, and the Form-A↔JSON round-trip invariant all unchanged. + Spec'd + approved 2026-05-18; Step-7.5 grounding-check PASS. Plan + next. + - context: `docs/specs/2026-05-18-prose-loop-binders.md`; + surfaced from the loop/recur prose review (2026-05-18 chat). + - [x] **\[milestone\]** Standalone `loop` / `recur` — CLOSED 2026-05-18. Strictly-additive strict-iteration surface, `recur` tail-position-only, no totality claim. Shipped: iter 1 `a179ec3` (additive AST nodes), diff --git a/docs/specs/2026-05-18-prose-loop-binders.md b/docs/specs/2026-05-18-prose-loop-binders.md new file mode 100644 index 0000000..b123991 --- /dev/null +++ b/docs/specs/2026-05-18-prose-loop-binders.md @@ -0,0 +1,341 @@ +# Prose `loop` binders — projection redesign — Design Spec + +**Date:** 2026-05-18 +**Status:** Draft — awaiting user spec review +**Authors:** Brummel (orchestrator) + Claude + +## Goal + +The Form-B prose projection of `Term::Loop` currently renders the +binder-with-initial-value list as bare `name = init;` statements +*inside* the `loop { … }` body block: + +```rust +loop { + acc = 0; + i = 1; + if i > n { acc } else { recur(acc + i, i + 1) } +} +``` + +A reader (LLM or human) decodes `loop { x = 0; … }` with the +C/Rust mental model: *re-assign `x = 0` on every iteration, then run +the rest*. That reading is wrong. The binders are **loop entry +state**, bound once on entry and thereafter re-bound **only** by +`recur`; they are not body statements and do not re-run per iteration. + +Prose exists as the reading/diffing surface. AILang's load-bearing +doctrine is "the signature tells the truth without reading the body"; +the dual obligation is that *when the body is read, the projection +must not lie about it*. The current projection lies. This milestone +replaces it. + +The fix is a single change to one render arm in `ailang-prose`. +The `loop`-recur AST, Form-A surface, canonical JSON-AST, typechecker +and codegen are **unchanged**. This is a projection-only milestone, +one iteration. + +## Architecture + +`ailang-prose` is a one-way, deliberately lossy projection +(`docs/PROSE_ROUNDTRIP.md`). There is **no Form-B parser**: edited +prose is re-integrated by an LLM mediator (`ail merge-prose`), and +that mediator is fed `ailang_surface::print` (Form-A) as the original +module — *not* the prose. Consequently the `loop` prose rendering +feeds nothing back: it is read by humans/LLMs in step 2 of the +prose cycle and nowhere else. + +The byte-isomorphic round-trip invariant in this project is +**Form-A ↔ JSON-AST** (surface crate, gated by the cross-form-identity +preflight). Prose is not on that path. This milestone therefore has +**zero** interaction with the round-trip invariant — stated explicitly +here so milestone-close audit does not chase a phantom risk. + +The only contract the change must preserve is *projection +determinism* (same module → same prose bytes), which any pure printer +change preserves trivially, plus the snapshot-test discipline in +`crates/ailang-prose/tests/snapshot.rs` (a renderer change requires a +deliberate, diff-visible snapshot update — exactly the RED→GREEN of +this milestone). + +The chosen rendering is a parenthesised init-list on the `loop` +keyword, positionally isomorphic to `recur`'s argument list: + +```rust +loop(acc = 0, i = 1) { + if i > n { acc } else { recur(acc + i, i + 1) } +} +``` + +Rationale (semantic, not effort): +- The body block `{ … }` now contains exactly the loop body — the + binders are physically outside it, killing the "re-init every + iteration" misreading at the syntax level. +- `loop(acc = 0, i = 1)` and `recur(acc + i, i + 1)` are the same + positional shape. The binder list *is* the parameter list that + `recur` re-supplies; making them visually isomorphic teaches the + correct model ("`recur` re-binds these, positionally") instead of + obscuring it. +- Types are stripped (`acc = 0`, not `acc: Int = 0`), consistent + with the standing prose rule that `(con T)` is dropped everywhere + — `let`, `mut`/`var`, and fn parameters all render type-free in + prose because the type is re-derivable. + +Rejected alternatives: +- **`loop with acc = 0, i = 1 { … }`** — `with` already denotes the + effect set in prose (`fn main() -> Unit with IO`). Overloading one + token with two unrelated meanings reduces readability; rejected on + the token-collision ground, not on effort. +- **`loop { initially acc = 0, i = 1;
}`** — stays inside the + braces (still visually conflated) and invents an `initially` + keyword; weaker than the chosen form on the exact axis at issue. + +## Concrete code shapes + +### The AILang program (primary — clause-1 evidence) + +This milestone changes **no** authoring surface, so there is no new +construct an LLM author reaches for. The clause-1 artefact is the +existing, unchanged Form-A program and its projection. The program +(`examples/loop_sum_to_run.ail`, verbatim, unchanged by this +milestone): + +```lisp +(module loop_sum_to_run + (fn main + (doc "loop-recur iter 3 — sum 1..10 via loop/recur. Expected stdout: 55.") + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body (app print (app sum_to 10)))) + (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))))))) +``` + +Its prose projection, **before** (current, misleading): + +```rust +// module loop_sum_to_run + +/// loop-recur iter 3 — sum 1..10 via loop/recur. Expected stdout: 55. +fn main() -> Unit with IO { + print(sum_to(10)) +} + +fn sum_to(n: Int) -> Int { + loop { + acc = 0; + i = 1; + if i > n { + acc + } else { + recur(acc + i, i + 1) + } + } +} +``` + +**After** (this milestone): + +```rust +// module loop_sum_to_run + +/// loop-recur iter 3 — sum 1..10 via loop/recur. Expected stdout: 55. +fn main() -> Unit with IO { + print(sum_to(10)) +} + +fn sum_to(n: Int) -> Int { + loop(acc = 0, i = 1) { + if i > n { + acc + } else { + recur(acc + i, i + 1) + } + } +} +``` + +The empty-binder loop (`examples/loop_forever_build.ail`'s +`(loop (i (con Int) 0) (recur (app + i 1)))` has one binder; a +genuinely binder-less `(loop )` is also legal) renders +`loop() { … }` — parentheses are always emitted, for regularity +and symmetry with `recur()` (which already renders the no-arg form +with empty parens). Single-binder example +(`examples/loop_forever_build.ail`): + +```rust +fn spin(n: Int) -> Int { + loop(i = 0) { + recur(i + 1) + } +} +``` + +### Implementation shape (secondary — supporting, not the point) + +One arm in `crates/ailang-prose/src/lib.rs`, the `Term::Loop` case +of `write_term` (currently lib.rs:938–954). + +Before: + +```rust +Term::Loop { binders, body } => { + // loop-recur iter 1: minimal-correctness Form-B render. + // Prose surface for loop is not yet designed; render the + // shape so a reader sees it without overcommitting. + out.push_str("loop {\n"); + for b in binders { + indent(out, level + 1); + out.push_str(&b.name); + out.push_str(" = "); + write_term(out, &b.init, level + 1, owning_module); + out.push_str(";\n"); + } + indent(out, level + 1); + write_term(out, body, level + 1, owning_module); + out.push('\n'); + indent(out, level); + out.push('}'); +} +``` + +After: + +```rust +Term::Loop { binders, body } => { + out.push_str("loop("); + for (i, b) in binders.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(&b.name); + out.push_str(" = "); + write_term(out, &b.init, level, owning_module); + } + out.push_str(") {\n"); + indent(out, level + 1); + write_term(out, body, level + 1, owning_module); + out.push('\n'); + indent(out, level); + out.push('}'); +} +``` + +The init expressions render at `level` (they are on the keyword +line, not inside the indented block) — mirrors how `recur`'s args +already render at the call-site level (lib.rs:956–965). The doc +comment that says "Prose surface for loop is not yet designed" is +removed (it is now designed). `Term::Recur` is **unchanged**: it +already renders `recur(a, b)` correctly and is now visibly +isomorphic to the loop head. + +The two non-render `Term::Loop` sites — `count_free_var` +(lib.rs:~1167) and `subst_var_with_term` (lib.rs:~1341) — are +**not touched**; they implement shadowing/substitution semantics, +not rendering. + +## Components + +- `crates/ailang-prose/src/lib.rs` — rewrite the `Term::Loop` arm + of `write_term`. ~12 LOC in one arm. No signature change, no new + function, no new dependency. +- `examples/loop_sum_to_run.prose.txt` (new) and + `examples/loop_forever_build.prose.txt` (new) — committed snapshot + expectations carrying the Option-A bytes. +- `crates/ailang-prose/tests/snapshot.rs` — two `#[test]` functions + (`snapshot_loop_sum_to_run`, `snapshot_loop_forever_build`) + invoking the existing `check_snapshot` harness. These cover the + two structurally distinct cases: multi-binder + non-recur exit, + and single-binder + recur-only (no exit). + +## Data flow + +`examples/