From c9355d7d58b485cb4c3e0d60a69337bac27051ef Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 18 May 2026 01:04:19 +0200 Subject: [PATCH] iter prose-loop-binders.1: Form-B loop binders as parenthesised init-list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Term::Loop arm of write_term rendered binders as bare `name = init;` statements inside the `loop { … }` body block, which reads as C/Rust re-init-every-iteration semantics — a projection lying about a body the prose surface exists to let the reader trust. Rewrite to a parenthesised init-list on the keyword line, `loop(acc = 0, i = 1) { … }`, positionally isomorphic to the unchanged Term::Recur arm. Projection-only: loop/recur AST, Form-A, JSON-AST, typecheck, codegen, and the Form-A↔JSON round-trip invariant untouched. RED-first via two new committed examples/*.prose.txt byte-equality snapshots + two snapshot_loop_* tests; full ailang-prose suite 10/0; both ail-prose CLI byte-matches green. Single-iteration milestone, brainstorm→plan→implement. Spec: docs/specs/2026-05-18-prose-loop-binders.md Plan: docs/plans/prose-loop-binders.1.md --- .../2026-05-18-iter-prose-loop-binders.1.json | 12 ++++ crates/ailang-prose/src/lib.rs | 15 ++-- crates/ailang-prose/tests/snapshot.rs | 10 +++ .../2026-05-18-iter-prose-loop-binders.1.md | 72 +++++++++++++++++++ docs/journals/INDEX.md | 1 + examples/loop_forever_build.prose.txt | 14 ++++ examples/loop_sum_to_run.prose.txt | 16 +++++ 7 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 bench/orchestrator-stats/2026-05-18-iter-prose-loop-binders.1.json create mode 100644 docs/journals/2026-05-18-iter-prose-loop-binders.1.md create mode 100644 examples/loop_forever_build.prose.txt create mode 100644 examples/loop_sum_to_run.prose.txt diff --git a/bench/orchestrator-stats/2026-05-18-iter-prose-loop-binders.1.json b/bench/orchestrator-stats/2026-05-18-iter-prose-loop-binders.1.json new file mode 100644 index 0000000..15cf859 --- /dev/null +++ b/bench/orchestrator-stats/2026-05-18-iter-prose-loop-binders.1.json @@ -0,0 +1,12 @@ +{ + "iter_id": "prose-loop-binders.1", + "date": "2026-05-18", + "mode": "standard", + "outcome": "DONE", + "tasks_total": 2, + "tasks_completed": 2, + "reloops_per_task": { "1": 0, "2": 0 }, + "review_loops_spec": 0, + "review_loops_quality": 0, + "blocked_reason": null +} diff --git a/crates/ailang-prose/src/lib.rs b/crates/ailang-prose/src/lib.rs index 1f200c4..39b8594 100644 --- a/crates/ailang-prose/src/lib.rs +++ b/crates/ailang-prose/src/lib.rs @@ -936,17 +936,16 @@ fn write_term_prec(out: &mut String, t: &Term, level: usize, parent_prec: u8, ow write_term(out, value, level, owning_module); } 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("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 + 1, owning_module); - out.push_str(";\n"); + 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'); diff --git a/crates/ailang-prose/tests/snapshot.rs b/crates/ailang-prose/tests/snapshot.rs index 9c9f896..9b8c1a2 100644 --- a/crates/ailang-prose/tests/snapshot.rs +++ b/crates/ailang-prose/tests/snapshot.rs @@ -100,3 +100,13 @@ fn snapshot_test_22b2_constraint_declared_via_superclass() { fn snapshot_ordering_match() { check_snapshot("ordering_match"); } + +#[test] +fn snapshot_loop_sum_to_run() { + check_snapshot("loop_sum_to_run"); +} + +#[test] +fn snapshot_loop_forever_build() { + check_snapshot("loop_forever_build"); +} diff --git a/docs/journals/2026-05-18-iter-prose-loop-binders.1.md b/docs/journals/2026-05-18-iter-prose-loop-binders.1.md new file mode 100644 index 0000000..4c49a2a --- /dev/null +++ b/docs/journals/2026-05-18-iter-prose-loop-binders.1.md @@ -0,0 +1,72 @@ +# iter prose-loop-binders.1 — Form-B `loop` renders binders as a parenthesised init-list on the keyword + +**Date:** 2026-05-18 +**Started from:** 6533134dab888605c24fb2e50a65fcbf99c1897e +**Status:** DONE +**Tasks completed:** 2 of 2 + +## Summary + +Projection-only single-iteration milestone in `ailang-prose`. The +`Term::Loop` arm of `write_term` (`crates/ailang-prose/src/lib.rs`) +now renders loop binders as a parenthesised init-list on the keyword +line — `loop(acc = 0, i = 1) { … }` — instead of bare +`name = init;` statements inside the body block. The old shape +implied C/Rust re-init-every-iteration semantics; the new shape +mirrors the adjacent (untouched) `Term::Recur` arm's +`enumerate()` + `", "`-separator idiom and renders init expressions +at `level` (they sit on the keyword line, not the indented block). +RED-first: two committed `examples/*.prose.txt` snapshot fixtures +plus two `check_snapshot`-calling `#[test]` fns were added and +verified failing against the old renderer before the arm rewrite +made them pass. Loop/recur AST, Form-A, JSON-AST, typecheck, +codegen and the Form-A↔JSON round-trip invariant are untouched by +construction (single render-arm body rewrite, no signature change, +no caller touched). + +## Per-task notes + +- iter prose-loop-binders.1.1: RED — created + `examples/loop_sum_to_run.prose.txt` (multi-binder + non-recur + exit) and `examples/loop_forever_build.prose.txt` (single-binder + recur-only) with the exact Option-A bytes from the plan; appended + `snapshot_loop_sum_to_run` + `snapshot_loop_forever_build` after + `snapshot_ordering_match` in `tests/snapshot.rs`. `cargo test -p + ailang-prose snapshot_loop` → `0 passed; 2 failed; 8 filtered + out` (filter resolved to exactly the two new tests; both panic + with the expected `prose snapshot mismatch` message). RED + confirmed. +- iter prose-loop-binders.1.2: GREEN — rewrote the `Term::Loop` + arm at `lib.rs:938-955` verbatim per the plan (parenthesised + init-list, init at `level`, stale "not yet designed" comment + removed). `cargo test -p ailang-prose snapshot_loop` → `2 + passed; 0 failed`; full `ailang-prose` suite → `10 passed; 0 + failed` (no pre-existing snapshot regressed); both `ail prose | + diff` CLI byte-matches → `MATCH`; post-cleanup `git status + --porcelain` is EXACTLY the four expected paths. + +## Concerns + +(none) + +## Known debt + +(none — projection-only single-arm rewrite, fully gated by +whole-file byte-equality snapshots + live-CLI diff) + +## Files touched + +- `crates/ailang-prose/src/lib.rs` — `Term::Loop` arm of + `write_term` rewritten (single hunk, lines 938-953) +- `crates/ailang-prose/tests/snapshot.rs` — two `#[test]` fns + appended after `snapshot_ordering_match` +- `examples/loop_sum_to_run.prose.txt` — new committed snapshot +- `examples/loop_forever_build.prose.txt` — new committed snapshot + +## Blocked detail + +(n/a — Status DONE) + +## Stats + +bench/orchestrator-stats/2026-05-18-iter-prose-loop-binders.1.json diff --git a/docs/journals/INDEX.md b/docs/journals/INDEX.md index 1c77fd6..2c6c1e6 100644 --- a/docs/journals/INDEX.md +++ b/docs/journals/INDEX.md @@ -86,3 +86,4 @@ - 2026-05-17 — iter loop-recur.3: standalone-`loop`/`recur` milestone (3 of 3, TERMINAL) — Component 5 codegen + run-to-value E2E. The iter-1 `lower_term` `CodegenError::Internal` stub for `Term::Loop`/`Term::Recur` is replaced with real LLVM-IR lowering: loop binders are loop-carried values lowered as entry-block allocas (the mut.3 `pending_entry_allocas` mechanism) registered in the EXISTING `mut_var_allocas` map so the EXISTING `Term::Var` load path resolves them with zero new Var code (representation-sharing only — Var-lowering byte-unchanged); a fresh `loop.header.` block reached by an unconditional `br` from the pre-header; `recur` lowers ALL args to SSA BEFORE any store (simultaneous positional rebind), stores into the binder allocas, back-edges `br` to the header, and sets the SINGLE existing `block_terminated` field at its OWN new emit site (a parallel SET call-site) so the loop's exit value is the emergent product of the existing `if`/`match` join — no separate loop-result phi/exit-block. A new `loop_frames` codegen stack lets `recur` find its target header+slots; saved/reset/restored at the single lambda-lowering boundary exactly as mut.3's `mut_var_allocas` triple. `clang -O2` mem2reg promotes the binder allocas to the phi nodes the spec's secondary implementation-shape describes. Four binding Boss design calls (mirrored into the journal), all on architectural-consistency / spec-pinned-invariant grounds (NOT effort): (1) alloca+mem2reg NOT hand-emitted phi (the linear-emit architecture has no phi-with-body-discovered-back-edge-predecessor precedent; the `if` arm sidesteps by opening its join last — a loop header cannot be opened last; mem2reg yields identical optimized output; spec's "phi" is the explicitly-secondary impl-shape the spec subordinates to the planner's exact-bytes authority); (2) reuse `mut_var_allocas` + the existing `Term::Var` load path (byte-unchanged; representation-sharing, the iter-2 AST/typecheck binder distinction is post-typecheck-irrelevant to codegen); (3) emergent loop-exit via the existing if/match join once recur terminates its block, no separate result phi; (4) reuse the single `block_terminated` field, recur sets it at its own emit site, ZERO edits to any existing SET/READ site (a second field would force editing every READ site — the opposite of the spec-pinned "tail-app's use byte-unchanged"). Diff confirmed surgical by hunk-header inspection: codegen/lib.rs = exactly 3 hunks (field + init + the stub→2-arms replacement), codegen/lambda.rs = exactly 2 hunks (save + restore); NO hunk at any existing `block_terminated` SET/READ site, at tail-app/tail-do lowering, or at `verify_tail_positions`. Three new `.ail` fixtures: `loop_sum_to_run.ail`→`55` (run-to-value), `loop_sum_to_deep.ail`→`500000500000` (1e6 iterations via the back-edge, no stack growth — the spec clause-2 correctness claim made executable), `loop_forever_build.ail` (infinite loop, no non-recur exit, `ail build` exit 0 — "typechecks AND compiles, no termination claim"; never executed). Codegen-ONLY iteration: no schema/AST/serde/typecheck change; hash pins (`loop_recur` + iter13a) + drift trio + `carve_out_inventory` confirmed green UNTOUCHED (not modified); the 3 new `.ail` fixtures are round-trip-auto-covered, NOT carve-outs. One DONE_WITH_CONCERNS (T3): the plan's literal `cargo test --workspace tail` filter resolves to ZERO tests (real guards are `*_tail_position_*`/`*musttail*`); orchestrator ran the gate via real names + the authoritative full-suite 619/0 which subsumes it — recurring `feedback_plan_pseudo_vs_reality` class, no behaviour change. Boss systemic fix folded into this commit: planner SKILL.md Step-5 gains item 8 (verification-command filter strings must resolve to ≥1 named test, or use the unfiltered suite + a count assertion) — the SECOND planner-meta-gap this milestone surfaced (iter-2 added item 7). `cargo test --workspace` 616→619 / 0 red (Boss-reran independently); the 3 loop/recur e2e explicitly green (55 / 500000500000 / infinite-compiles). **All three components shipped — the loop/recur milestone is structurally complete; milestone-close (mandatory `audit`, then `fieldtest` since loop/recur is user-visible Form-A surface) is the Boss's next pipeline step.** → 2026-05-17-iter-loop-recur.3.md - 2026-05-18 — iter loop-recur.tidy: milestone-close audit resolution (RED `39380d3` + this GREEN). Architect drift review found a `[high]` correctness defect: a `(lam ...)` body capturing an enclosing `Term::Loop` binder passed `ail check` (exit 0) then panicked `unreachable!()` at `crates/ailang-codegen/src/lambda.rs:102` on type-correct input — a DESIGN.md "Robustness against hallucinations" violation. Root cause (debugger-confirmed RED-first): the `Term::Lam` escape guard at `crates/ailang-check/src/lib.rs:3608` iterated only `mut_scope_stack`; loop binders live in the ordinary `locals` (iter-2 design) and never entered any escape-checked scope, so the mut.4-tidy `MutVarCapturedByLambda`-class rejection had no loop-binder counterpart. Fix (GREEN, implement mini-mode): new `CheckError::LoopBinderCapturedByLambda { name }` (bracket-`[code]`-free F2 Display) + `code()` `loop-binder-captured-by-lambda` + `ctx() {name}`, symmetric to `MutVarCapturedByLambda`; the `Term::Lam` escape guard gained a parallel pass over `loop_stack` (gate widened to `!mut_scope_stack.is_empty() || !loop_stack.is_empty()`). Minimal-mechanism design call: `loop_stack`'s frame element extended `Vec` → `Vec<(String, Type)>` so the already-threaded per-loop scope frame also carries binder names (`Term::Recur` still reads `.1` BY POSITION — Boss-call-2's positional-recur invariant preserved verbatim, the name is a second field consumed only by the escape guard, a consumer iter-2 did not anticipate; the parallel-stack alternative would touch all 35 `synth` call sites, strictly larger). Codegen byte-unchanged (typecheck-only fix; the lambda.rs:102 `unreachable!()` is now genuinely unreachable, kept as a defensive assertion). Two mut.4-tidy-mirrored in-source unit tests; lockstep `carve_out_inventory` 17→18 + `ct1_check_cli` mut-F2 sibling `cases`; DESIGN.md one-line symmetric-rule note. Boss folded into this GREEN commit: the two `[medium]` doc-honesty edits the audit also surfaced (`mut_var_allocas` rustdoc now states its mut-var+loop-binder dual use truthfully; `Term::Loop` doc-comment now describes the real shipped iter-2/3 typecheck+alloca+mem2reg state, not iter-1's stale "per-binder phi" stub forward-look) + a `[low]` P2 roadmap `[todo]` (the recon-undercount 3×-pattern → an `ailang-plan-recon` agent-definition countermeasure; pairs with planner Step-5 items 7+8). Bench gate (audit Step 2): `check.py`/`compile_check.py`/`cross_lang.py` all exit 0, 25 metrics 0 regressed — **pristine** (strictly-additive surface, no hot-path; the pre-existing P2 `*.bump_s` hardware-staleness drift did not trip) → carry-on, no baseline/ratify. `cargo test --workspace` 619→622 / 0 red (Boss-reran independently incl. hash_pin 11/0 — the ast.rs/codegen doc-comment edits moved no canonical-JSON hash). **The loop/recur milestone is audit-clean** — architect drift cleared, bench pristine; the only remaining pipeline step before full close is `fieldtest` (loop/recur is user-visible Form-A surface) → 2026-05-17-iter-loop-recur.tidy.md - 2026-05-18 — fieldtest loop-recur (milestone CLOSE, clean on all milestone axes): post-audit downstream-LLM-author field test of the shipped loop/recur surface. The fieldtester (DESIGN.md + public examples only, never compiler source) wrote 3 real iterative programs — integer Newton sqrt (multi-binder, recur buried in if/let/if, loop result into let/seq), Collatz length (recur in match-arm tails), Euclidean gcd (loop as a value sub-expression in argument position) — plus 5 plausible-mistake negatives and 2 no-termination probes, all run through the public `ail` CLI. **0 bugs; 4 working findings, all on the milestone's own axes**: the five rejection diagnostics (recur-outside-loop / -arity / -type / -not-in-tail / loop-binder-captured-by-lambda) are point-exact AND self-fixing (name the fn, describe the fix, no false positives, no crash — the clause-2 silent-failure-class-becomes-compile-error claim made real); recur tail-position threads correctly through match-arm/let/outer-if (the spec only demonstrated `if` — generalises exactly as an author assumes); loop composes as a value sub-expression everywhere + every loop/recur fixture is `ail parse|render|parse` byte-identical (Roundtrip Invariant holds in practice); the no-termination boundary is exactly as specified (infinite loop check+build clean, zero spurious diagnostics — the precise difference from the reverted Iteration-discipline milestone). This empirically substantiates the milestone's "an LLM author can now write iterative programs with loop/recur" claim — the gate fieldtest exists to provide. Two ORTHOGONAL non-blocking findings surfaced incidentally while probing the no-termination/negative axes, neither in loop/recur scope, both routed to P2 todos (not a loop/recur tidy — refusing the scope creep): (spec_gap) niladic `(app f)` is unrepresentable in Form A and DESIGN.md's `Term::App args:[Term...]` states no minimum — INDEPENDENTLY re-confirms the existing mut-local-F3 roadmap todo (two fieldtests now hit it; the accept-`(app f)`-vs-ratify-DESIGN.md decision is a genuine design fork deliberately NOT auto-ratified under autonomous /boss — parked, priority-strengthened); (friction) the module-level `(doc …)` rejection lists valid def heads but omits that doc attaches inside fn/data — a one-line diagnostic-hint tidy. Independent Boss verification: `loop_recur_4_gcd_value_pos.ail` re-run → stdout `27`; `loop_recur_3a` re-run → `ail check` exit 1 `[recur-outside-loop] countdown: …`. **The standalone loop/recur milestone is fully ratified and CLOSED**: 3 iterations + a tidy shipped, audit clean (architect drift resolved, bench pristine carry-on), fieldtest clean on every milestone axis. Fixtures `examples/fieldtest/loop_recur_*.ail` + spec `docs/specs/2026-05-18-fieldtest-loop-recur.md` committed; roadmap P0 entry marked closed; the two orthogonal findings tracked as P2 todos → 2026-05-18-fieldtest-loop-recur.md +- 2026-05-18 — iter prose-loop-binders.1 (single-iteration milestone, DONE): Form-B prose `loop` now renders binders as a parenthesised init-list on the keyword line — `loop(acc = 0, i = 1) { … }` — instead of bare `name = init;` statements inside the body block, which had implied C/Rust re-init-every-iteration semantics. Projection-only single-arm rewrite of the `Term::Loop` case of `write_term` (`crates/ailang-prose/src/lib.rs`); init exprs render at `level` (keyword line, not the indented block), mirroring the untouched adjacent `Term::Recur` `enumerate()`+`", "` idiom — making the binder list positionally isomorphic to `recur(...)`, which teaches the correct "recur re-binds these positionally" model instead of obscuring it. RED-first via the existing stem-explicit `check_snapshot` harness: two new committed `examples/{loop_sum_to_run,loop_forever_build}.prose.txt` whole-file byte-equality fixtures + two `snapshot_loop_*` `#[test]` fns, verified `0 passed; 2 failed` against the old renderer before the arm rewrite turned them green. Loop/recur AST, Form-A, JSON-AST, typecheck, codegen and the Form-A↔JSON round-trip invariant untouched by construction (no signature change, no caller touched, prose is a one-way lossy projection off the round-trip path — spec §Architecture states this explicitly so milestone-close audit does not chase a phantom). Full `ailang-prose` suite 10/0; both `ail prose | diff` live-CLI byte-matches `MATCH`; post-cleanup porcelain exactly the expected paths. Surfaced from the 2026-05-18 user prose review of the just-closed loop/recur milestone; brainstorm spec `docs/specs/2026-05-18-prose-loop-binders.md` (Step-7.5 grounding-check PASS), plan `docs/plans/prose-loop-binders.1.md`. Both implement review phases first-pass, 0 re-loops. Milestone-close `audit` is the Boss's next pipeline step (projection-only, no Form-A surface change → no fieldtest). → 2026-05-18-iter-prose-loop-binders.1.md diff --git a/examples/loop_forever_build.prose.txt b/examples/loop_forever_build.prose.txt new file mode 100644 index 0000000..b89fd0d --- /dev/null +++ b/examples/loop_forever_build.prose.txt @@ -0,0 +1,14 @@ +// module loop_forever_build + +/// loop-recur iter 3 — an infinite loop (no non-recur exit) must COMPILE +/// (typechecks AND compiles; no termination claim). Build-only; by design never +/// returns, so the binary is never executed. +fn main() -> Unit with IO { + print(spin(0)) +} + +fn spin(n: Int) -> Int { + loop(i = 0) { + recur(i + 1) + } +} diff --git a/examples/loop_sum_to_run.prose.txt b/examples/loop_sum_to_run.prose.txt new file mode 100644 index 0000000..f93dafc --- /dev/null +++ b/examples/loop_sum_to_run.prose.txt @@ -0,0 +1,16 @@ +// 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) + } + } +}