iter loop-recur.2: typecheck semantics — binder typing, recur checks, verify_loop_body
Second of three iterations of the standalone loop/recur milestone
(plan 5ac57fe). Replaces the iter-1 synth CheckError::Internal stub
for Term::Loop/Term::Recur with real binder typing + positional
recur arity/type checking via a new loop_stack: &mut Vec<Vec<Type>>
frame threaded as mut.2's mut_scope_stack, a new private
verify_loop_body tail-position pass (sibling of the byte-frozen
verify_tail_positions — 0 deletions there), and the four Recur*
diagnostics firing point-exactly on four negative fixtures. The
iter-1 loop_sum_to.ail fixture now also typechecks clean; an
infinite loop typechecks (no termination claim). NO codegen (the
iter-1 lower_term stub stays — iter 3); NO Diverge/guardedness
(spec boundary).
Three Boss design calls implemented verbatim and journalled:
RecurTypeMismatch is an Assign-style structural pre-check (not a
unify-propagate); loop_stack is positional Vec<Type> (binder names
via ordinary locals); diagnostic-code precedence is by pass
ordering (no explicit logic).
Two DONE_WITH_CONCERNS, both journalled: a plan-ordering defect
(Task 2's compile gate forced the check_fn threading the plan
deferred to Task 4 — resolved byte-identically, only resequenced)
and the recurring mut.2-class recon-undercount of cross-module
synth callers (resolved via the plan's compile-sweep oracle).
Boss systemic fix folded in: planner SKILL.md Step-5 gains item 7
(compile-gate vs. deferred-caller ordering) so the plan-ordering
defect class is scrubbed at plan time. cargo test --workspace
608 -> 616 / 0 red (Boss-reran independently).
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
# iter loop-recur.2 — Typecheck Semantics (Component 4)
|
||||
|
||||
**Date:** 2026-05-17
|
||||
**Started from:** 5ac57fe8dec2d83ba135dd5ff6dd9caa1165c700
|
||||
**Status:** DONE
|
||||
**Tasks completed:** 6 of 6
|
||||
|
||||
## Summary
|
||||
|
||||
The iter-1 `synth` `CheckError::Internal` stub for `Term::Loop` /
|
||||
`Term::Recur` is replaced with real typecheck semantics: binder
|
||||
typing (each init synthed in outer scope + already-declared binder
|
||||
names of THIS loop; loop's static type = body type), positional
|
||||
`recur` arity + per-arg type checking via a new `loop_stack: &mut
|
||||
Vec<Vec<Type>>` frame threaded exactly as mut.2's `mut_scope_stack`,
|
||||
the four `Recur*` `CheckError` variants (bracket-`[code]`-free
|
||||
Display per F2) + `code()` + two `ctx()` arms, and a NEW private
|
||||
`verify_loop_body` tail-position pass (a *sibling* of the
|
||||
spec-frozen `verify_tail_positions`, run after `synth` in `check_fn`
|
||||
so synth-raised codes take precedence by pass ordering). Four
|
||||
negative `.ail.json` fixtures fire their codes point-exactly
|
||||
(`assert_eq!`, non-vacuous); the iter-1 `loop_sum_to.ail`
|
||||
round-trip fixture now ALSO typechecks clean; an infinite `loop`
|
||||
typechecks (no termination claim). NO codegen this iter (the iter-1
|
||||
`lower_term` `CodegenError::Internal` stub stays — iter 3); NO
|
||||
`Diverge`/`verify_structural_recursion`/guardedness (spec deliberate
|
||||
boundary). `verify_tail_positions` is byte-frozen (0 deletions).
|
||||
`cargo test --workspace` 616 green / 0 red (iter-1 baseline 608).
|
||||
|
||||
## Per-task notes
|
||||
|
||||
- iter loop-recur.2.1: the four `Recur*` `CheckError` variants
|
||||
(`RecurOutsideLoop`, `RecurArityMismatch{expected,got}`,
|
||||
`RecurTypeMismatch{position,expected,got}`,
|
||||
`RecurNotInTailPosition`) + 4 `code()` arms + 2 `ctx()` arms
|
||||
(Arity/Type only; OutsideLoop/NotInTailPosition use the `{}`
|
||||
catch-all). RED-first via `recur_checkerror_codes_are_exact`
|
||||
(observed 4× E0599); GREEN exact. Bracket-`[code]`-free Display.
|
||||
- iter loop-recur.2.2: `synth` signature gains
|
||||
`loop_stack: &mut Vec<Vec<Type>>` after `mut_scope_stack`;
|
||||
threaded at every recursive call site (17 single-line canonical +
|
||||
3 multi-line `Term::Mut`-init/body & `Term::Assign`-value + 2
|
||||
letrec-capture `&mut body_effects`-shape) + `check_const` (fresh
|
||||
empty stack) + cross-module callers + test-module callers. The
|
||||
compile-driven sweep (`cargo build`/`cargo test --no-run`) was the
|
||||
exhaustive caller oracle as the plan specifies; iterated to 0
|
||||
errors. Stub intact, lib suite green.
|
||||
- iter loop-recur.2.3: iter-1 `Internal` stub replaced with the two
|
||||
real `Term::Loop`/`Term::Recur` arms. Binder-name save/restore
|
||||
mirrors `Term::Let` (`lib.rs:3205-3218`) verbatim; per-arg type
|
||||
check mirrors `Term::Assign` (`lib.rs:3716-3730`) verbatim per
|
||||
Boss call 1; `recur`'s own type = `Subst::fresh(counter)`.
|
||||
RED `loop_sum_to_typechecks_clean` observed `["internal"]`;
|
||||
GREEN zero diagnostics.
|
||||
- iter loop-recur.2.4: new private `verify_loop_body(t, in_loop_tail)`
|
||||
immediately after `verify_tail_positions`'s closing brace —
|
||||
exhaustive no-`_` match over all 16 `Term` variants (verified
|
||||
equal to `verify_tail_positions`' variant set). Invoked from
|
||||
`check_fn` after `verify_tail_positions(&f.body, true)?;`. RED
|
||||
`recur_not_in_tail_position` observed `left: []`; GREEN
|
||||
`["recur-not-in-tail-position"]`, positive still clean.
|
||||
- iter loop-recur.2.5: 3 remaining negative fixtures
|
||||
(`test_recur_outside_loop` / `_arity_mismatch` / `_type_mismatch`)
|
||||
+ 3 pins (all 5 green) + `carve_out_inventory` 13→17 with header
|
||||
refresh (`Twelve…mut.2` → `Seventeen…loop-recur.2`, pre-existing
|
||||
Twelve-vs-13 mut.4-tidy drift corrected in passing) + ct1 F2
|
||||
sibling `check_human_mode_renders_recur_diagnostic_code_exactly_once`.
|
||||
- iter loop-recur.2.6: `examples/loop_forever.ail` (loop whose only
|
||||
path is `recur`, no exit) typechecks clean — asserts the spec
|
||||
"no termination claim is made or enforced". Form-A grammar
|
||||
verified verbatim against the shipped `loop_sum_to.ail`. Full
|
||||
suite 616/0; tail-app non-regression green (9 `tail`-named tests
|
||||
ok incl. the iter-1 verify_tail_positions guards).
|
||||
|
||||
## Boss design calls (mirrored from the plan header at iter close)
|
||||
|
||||
1. **`RecurTypeMismatch` is an Assign-style structural pre-check,
|
||||
NOT a `unify`-propagate.** `Term::Recur` per-arg checking mirrors
|
||||
the `Term::Assign` arm (`lib.rs:3716-3730`) verbatim in
|
||||
mechanism: synth the arg, `subst.apply` both the arg type and the
|
||||
binder type, structural `!=`, on mismatch
|
||||
`Err(RecurTypeMismatch{position,expected,got})`. A
|
||||
`unify`-propagate would surface the generic `type-mismatch` code
|
||||
and fail the spec acceptance ("dedicated `recur-type-mismatch`
|
||||
fires point-exactly"). Rationale semantic (one in-repo mechanism
|
||||
for "declared-vs-actual at a binding site with its own
|
||||
point-exact code", consistent with `AssignTypeMismatch`), not
|
||||
effort. Implemented verbatim; the `test_recur_type_mismatch`
|
||||
fixture fires exactly `["recur-type-mismatch"]`.
|
||||
2. **`loop_stack` element type is `Vec<Type>` (ordered binder
|
||||
types), NOT `IndexMap<String,Type>`.** `recur` rebinds binders
|
||||
*positionally*, so the frame models the ordered binder types for
|
||||
arity (`.len()`) + per-position type checks. Binder *names* enter
|
||||
the ordinary `locals: &mut IndexMap<String,Type>` exactly as
|
||||
`Term::Let` binds its name (save/restore in reverse on loop
|
||||
exit). The spec phrase "threaded exactly as mut.2's
|
||||
`mut_scope_stack`" governs the *threading discipline*
|
||||
(a `&mut Vec<…>` pushed/popped around the body, passed through
|
||||
every recursive `synth`), not the element type. Semantic, not
|
||||
effort. Implemented verbatim.
|
||||
3. **Diagnostic-code precedence is by pass ordering, no explicit
|
||||
logic.** `RecurOutsideLoop`/`RecurArityMismatch`/`RecurTypeMismatch`
|
||||
are raised in `synth` (runs at the `check_fn` synth call);
|
||||
`RecurNotInTailPosition` is raised in `verify_loop_body`, invoked
|
||||
*after* `verify_tail_positions(&f.body, true)?;`. A `recur`
|
||||
outside any loop therefore fires `RecurOutsideLoop` (synth,
|
||||
first), not `RecurNotInTailPosition`. No precedence logic added;
|
||||
the pass sequence IS the mechanism. `verify_loop_body` is entered
|
||||
only on synth success, so every `recur` reached there is already
|
||||
inside a loop with matching arity/types — the pass adds only the
|
||||
tail rule. `verify_tail_positions` is byte-frozen (0 deletions);
|
||||
`verify_loop_body` is a new sibling, never a repurpose.
|
||||
|
||||
## Concerns
|
||||
|
||||
- DONE_WITH_CONCERNS (iter loop-recur.2.2): **plan ordering defect —
|
||||
`check_fn` threading must be in Task 2, not Task 4.** The plan's
|
||||
Task 2 Step 3 bullet says "check_fn :1970: handled in Task 4 Step
|
||||
4 (it declares its own fresh loop_stack)", but Task 2 Step 4's
|
||||
gate is `cargo build -p ailang-check` reaching `Finished` with 0
|
||||
errors. That gate is *unsatisfiable* while `check_fn` remains an
|
||||
unthreaded `synth` caller (hard `error[E0061]`). Resolution:
|
||||
`check_fn`'s `loop_stack` declaration + synth-call threading
|
||||
(verbatim the plan's Task 4 Step 3 block + Step 4 first half) were
|
||||
pulled into Task 2 where the compile gate forces them; only the
|
||||
`verify_loop_body` *invocation* (Task 4 Step 4 second half)
|
||||
remained for Task 4. This is the unique resolution satisfying both
|
||||
Task 2 Step 4 AND preserving all plan code verbatim — the "fix to
|
||||
compile while preserving evident intent + Boss calls" repair the
|
||||
carrier explicitly authorises. Correctness: the declaration code
|
||||
is byte-identical to the plan's Task 4 Step 3 block; no semantic
|
||||
change, only sequencing. Recurring planner-gap class (a
|
||||
compile-gate task whose gate depends on a caller the plan defers
|
||||
past the gate) — candidate for a planner Step-5 self-review check.
|
||||
- DONE_WITH_CONCERNS (iter loop-recur.2.2): **recon under-counted
|
||||
cross-module `synth` callers** — `builtins.rs` ×2 (test helpers),
|
||||
`lift.rs:746` (letrec-capture re-entry), `mono.rs:720` &
|
||||
`mono.rs:1361` (mono re-synth) were not in the plan's site
|
||||
inventory (`:3161…:3717` + the 8 test sites). Exactly the
|
||||
mut.2-class recon-undercount the iter-1 journal flagged
|
||||
(Boss-call-2-class "recon under-scoped it"). Resolved by mirroring
|
||||
the established mut.2 fresh-stack pattern at each (declare a fresh
|
||||
empty `loop_stack` alongside the existing fresh `mut_scope_stack`,
|
||||
pass `&mut loop_stack` immediately after `&mut mut_scope_stack`).
|
||||
Observation, not a correctness risk — the compile-driven sweep IS
|
||||
the plan's designated exhaustive oracle and surfaced every site;
|
||||
the plan's literal site list is advisory, the compiler is
|
||||
authoritative (as the plan itself states).
|
||||
|
||||
## Known debt
|
||||
|
||||
- No additional `ail run`/binary-stdout E2E fixture written (Phase
|
||||
3). iter-2 ships typecheck semantics only; the `lower_term`
|
||||
`CodegenError::Internal` stub stays by design (real loop lowering
|
||||
+ the positive sum_to-runs-to-a-value / deep-`n` E2E is explicitly
|
||||
iter-3 scope). An `ail run` this iter would only exercise the
|
||||
deliberately-transient codegen stub (a negative-value test of a
|
||||
known-temporary error) — same justified Phase-3 reasoning as
|
||||
iter-1. The iter-2 invariants worth protecting (four codes
|
||||
point-exact, positive + infinite typecheck clean,
|
||||
carve-out/tail-app non-regression) are fully pinned by the tests
|
||||
added in Tasks 1/3/4/5/6 + the ct1 F2 sibling.
|
||||
|
||||
## Files touched
|
||||
|
||||
- Check core: `crates/ailang-check/src/lib.rs` (4 variants +
|
||||
code/ctx, `synth` signature + threading, real Loop/Recur arms,
|
||||
new `verify_loop_body`, `check_fn` + `check_const` wiring),
|
||||
`crates/ailang-check/src/lift.rs`, `crates/ailang-check/src/mono.rs`,
|
||||
`crates/ailang-check/src/builtins.rs` (cross-module synth-caller
|
||||
threading)
|
||||
- Tests/fixtures: `crates/ailang-check/tests/loop_recur_typecheck_pin.rs`
|
||||
(new — 7 tests), `crates/ail/tests/ct1_check_cli.rs` (F2 sibling),
|
||||
`crates/ailang-core/tests/carve_out_inventory.rs` (13→17 + header)
|
||||
- New fixtures: `examples/test_recur_outside_loop.ail.json`,
|
||||
`examples/test_recur_arity_mismatch.ail.json`,
|
||||
`examples/test_recur_type_mismatch.ail.json`,
|
||||
`examples/test_recur_not_in_tail_position.ail.json`,
|
||||
`examples/loop_forever.ail`
|
||||
- Reused (no new file): `examples/loop_sum_to.ail` (iter-1
|
||||
round-trip fixture, now also the positive `ail check` evidence)
|
||||
|
||||
## Stats
|
||||
|
||||
bench/orchestrator-stats/2026-05-17-iter-loop-recur.2.json
|
||||
@@ -82,3 +82,4 @@
|
||||
- 2026-05-16 — audit iteration-discipline-revert (milestone close, clean): architect confirmed the revert byte-pristine (18 src + 5 test files byte-identical to `1ff7e81`, zero residual it.1/it.2 production surface, DESIGN.md coherent, roadmap/spec hygiene correct); one `[medium]` drift — a dangling `loop`/`recur` cross-reference into the reverted milestone inside the *live* Stateful-islands roadmap scope bullet — fixed inline (Boss doc-hygiene; reworded to keep the real "no `while`; repetition stays recursion" scope guard, drop the reverted-milestone reference). Bench: `compile_check.py`/`cross_lang.py` exit 0; `check.py` exit 1 on the single metric `bench_list_sum.bump_s` +12.71%. Bencher localisation: HEAD and `1ff7e81` bump binaries `cmp`-identical, interleaved 3×60-run measurement shows head−oracle delta ~0 (±1.5% stdev) with *both* ~+11% over the 2026-05-09 baseline → environmental/hardware drift, NOT a revert regression (third independent proof codegen == `1ff7e81`). Resolution: carry-on, baseline NOT ratified (Iron Law — nothing intentionally moved the metric; byte-identical codegen must not move the baseline; the spec's PRISTINE mandate is satisfied because pristine = "no regression introduced", proven). Pre-existing `*.bump_s` baseline-vs-hardware staleness (the `1ff7e81` oracle trips the same +11%) filed forward as a separate P2 `[todo]` (bench-harness recalibration, no language change), not mis-attributed to the revert. Fieldtest skipped (revert restores the already-field-tested pre-`9973546` surface; Task-11 164-fixture byte-equivalence is the stronger proof). Milestone closed clean → 2026-05-16-audit-iteration-discipline-revert.md
|
||||
- 2026-05-16 — iter effect-doc-honesty: standalone documentation-honesty tidy (split out from the retired effect-op-arg-modes bundle — user rejected bundling a real DESIGN.md fix with speculative build-ahead infra). Corrected three false effect-system claims the recon surfaced: DESIGN.md Decision 3 "row-polymorphic (`![IO | r]`)" (no `EffectRow`/row var exists — effect sets are flat closed sets unified by set-equality) + "`IO` and `Diverge` … are wired up" (`Diverge` is 100% vapour: zero code in any crate) → reconciled to IO-only/Diverge-reserved-unimplemented (modelled on Decision 4's reserved-refinements precedent); DESIGN.md §"What is not supported" "IO and Diverge ops" bullet; `ast.rs` `Term::Do` doc-comment "resolved against the effect-handler table at link time" → the real mechanism (typecheck `Env::effect_ops` lookup + `lower_effect_op` literal codegen match, no handler table). Satellite lockstep: `form_a.md:226` + rule-3, `main.rs:289` merge-prose CONTRACT example. Guarded by a new 4-test doc-presence pin `crates/ailang-core/tests/effect_doc_honesty_pin.rs` (fiction-absent + corrected-anchor-present, single-line substrings — wrap-robust). No language/checker/codegen change; `ail check`/`run` byte-unchanged by construction. `cargo test --workspace` 600 → 604; drift/coverage trio (`design_schema_drift`/`spec_drift`/`schema_coverage`) green, empirically confirming none scans the effect-prose region. One concern: the plan's Task-2 replacement body soft-wrapped a phrase the single-line pin asserts; orchestrator resolved correctly (exact wording + exact pin preserved, wrap column moved) — recurring-class planner gap, fixed forward by a Step-5 self-review tightening → 2026-05-16-iter-effect-doc-honesty.md
|
||||
- 2026-05-17 — iter loop-recur.1: standalone-`loop`/`recur` milestone (1 of 3) — the strictly-additive AST-node foundation. `Term::Loop { binders: Vec<LoopBinder>, body }` / `Term::Recur { args }` / `struct LoopBinder` (mirrors `MutVar`'s `(name,type,init)` triple; `binders` no `skip_serializing_if`) added end-to-end across all six crates' no-`_`-wildcard exhaustive `Term` matches (~30 sites incl. one recon-undercount integration-test pin): Form-A `parse_loop`/`parse_recur` (binder/body boundary disambiguated by an `is_term_head_kw` guard — the plan's flagged sole parser judgement) + print + `form_a.md` grammar/notes + prose render/free-var/subst lockstep + canonical-JSON serde/round-trip + DESIGN.md §Data-model `"t":"loop"`/`"t":"recur"` blocks + design_schema_drift/spec_drift/schema_coverage anchors + a new `loop_recur` hash pin (additivity proven: iter13a + new pin both green, pre-existing canonical-JSON bytes byte-stable) + `examples/loop_sum_to.ail` round-trip fixture. NO typecheck semantics + NO real codegen by design: the two semantic dispatch points stubbed `synth`→`CheckError::Internal` / `lower_term`→`CodegenError::Internal` exactly as mut.1; pure analysis walkers (escape/lambda/`synth_with_extras`) get real structural pass-through. Two binding Boss design calls (mirrored into the journal): (1) `verify_tail_positions` "byte-unchanged" = tail-app *role* unchanged not source-frozen — it is an exhaustive no-wildcard match so two additive descent-only arms are mandatory; acceptance evidence is the tail-app non-regression test (all `tail`-filtered tests byte-identical), mut.1 set the precedent; (2) codegen IS iter-1 scope as stubs/pass-throughs (no real loop-header/phi/back-edge — that is iter 3). Three plan-pseudo-vs-reality substitutions, all intent-preserving, recurring `feedback_specs_need_concrete_code`/`feedback_plan_pseudo_vs_reality` class: serde `Type::Con` literal `{"t":"con",…,"args":[]}`→real `{"k":"con","name":"Int"}`; bare `Int`→`(con Int)` in binder triples (bare `Int` parses as `Type::Var`); `ailang_core::ast::LoopBinder`→unqualified inside ailang-core. Boss forward-fix folded in: Concern 2 surfaced that the committed specs themselves (`2026-05-17-loop-recur.md` headline + `bad_recur`, `2026-05-17-llm-surface-discipline.md` §5) wrote bare `Int` — corrected all three snippets to `(con Int)` (doc-honesty, same class as the mono.rs header; no design/schema/assumption change, no re-brainstorm). `cargo test --workspace` 600→608 / 0 red (Boss-reran independently). Component 4 (typecheck) = iter 2; Component 5 (codegen) + positive/deep-`n` E2E = iter 3 → 2026-05-17-iter-loop-recur.1.md
|
||||
- 2026-05-17 — iter loop-recur.2: standalone-`loop`/`recur` milestone (2 of 3) — Component 4 typecheck semantics. The iter-1 `synth` `CheckError::Internal` stub for `Term::Loop`/`Term::Recur` is replaced with real binder typing (each init synthed in outer scope + already-declared binder names of THIS loop via ordinary `locals` save/restore mirroring `Term::Let` verbatim; loop's static type = body type) + positional `recur` arity/per-arg-type checking via a new `loop_stack: &mut Vec<Vec<Type>>` frame threaded exactly as mut.2's `mut_scope_stack` (every recursive + cross-module + test synth caller, compile-swept). New private `verify_loop_body(t,in_loop_tail)` pass — a SIBLING of the byte-frozen `verify_tail_positions` (0 deletions, tail-app role untouched), invoked in `check_fn` after it so synth-raised codes take precedence by pass ordering. Four `Recur*` `CheckError` variants (`RecurOutsideLoop`, `RecurArityMismatch{expected,got}`, `RecurTypeMismatch{position,expected,got}`, `RecurNotInTailPosition`) + `code()` + 2 `ctx()` arms, bracket-`[code]`-free Display (F2); four negative `.ail.json` fixtures fire their codes point-exactly (`assert_eq!`, non-vacuous) via a new `loop_recur_typecheck_pin.rs` (7 tests) + a ct1 F2 human-mode sibling; `carve_out_inventory` 13→17 with the pre-existing mut.4-tidy "Twelve"-vs-13 stale-header drift corrected in passing. iter-1's `loop_sum_to.ail` round-trip fixture now ALSO typechecks clean (one fixture, two properties — no near-duplicate); `loop_forever.ail` (loop whose only path is `recur`) typechecks clean, asserting the spec "no termination claim is made or enforced". Three binding Boss design calls (mirrored into the journal): (1) `RecurTypeMismatch` is an Assign-style `subst.apply`-both-then-structural-`!=` pre-check, NOT a `unify`-propagate (a propagate would surface generic `type-mismatch` and fail the point-exact-code acceptance); (2) `loop_stack` element type is positional `Vec<Type>` NOT name-keyed `IndexMap` (recur rebinds by position; binder names live in `locals`); (3) diagnostic-code precedence is by pass ordering (synth before verify_loop_body), no explicit logic. NO codegen (iter-1 `lower_term` `CodegenError::Internal` stub stays — iter 3); NO `Diverge`/`verify_structural_recursion`/guardedness (spec deliberate boundary). Two DONE_WITH_CONCERNS, both Task 2, both journalled: (a) a plan-ordering defect — Task 2's `cargo build` 0-errors gate is unsatisfiable while `check_fn` (deferred by the plan to Task 4) is an unthreaded caller; orchestrator pulled the byte-identical declaration+threading forward to Task 2, left only the `verify_loop_body` invocation in Task 4 (no semantic change, only sequencing); (b) recon-undercount of cross-module synth callers (`builtins.rs`×2, `lift.rs:746`, `mono.rs:720`/`:1361`), the recurring mut.2-class, resolved via the plan's own designated compile-sweep oracle. Boss systemic fix folded into this commit: planner SKILL.md Step-5 self-review gains item 7 (compile-gate vs. deferred-caller ordering) so the Concern-(a) class is scrubbed at plan time, not discovered at execution. `cargo test --workspace` 608→616 / 0 red (Boss-reran independently); `verify_tail_positions`/tail-app byte-frozen confirmed by diff-hunk inspection. Component 5 (codegen loop-header/phi/back-edge) + the positive RUN-to-value / deep-`n` E2E = iteration 3 → 2026-05-17-iter-loop-recur.2.md
|
||||
|
||||
Reference in New Issue
Block a user