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).
10 KiB
iter loop-recur.2 — Typecheck Semantics (Component 4)
Date: 2026-05-17
Started from: 5ac57fe8de
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*CheckErrorvariants (RecurOutsideLoop,RecurArityMismatch{expected,got},RecurTypeMismatch{position,expected,got},RecurNotInTailPosition) + 4code()arms + 2ctx()arms (Arity/Type only; OutsideLoop/NotInTailPosition use the{}catch-all). RED-first viarecur_checkerror_codes_are_exact(observed 4× E0599); GREEN exact. Bracket-[code]-free Display. - iter loop-recur.2.2:
synthsignature gainsloop_stack: &mut Vec<Vec<Type>>aftermut_scope_stack; threaded at every recursive call site (17 single-line canonical + 3 multi-lineTerm::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
Internalstub replaced with the two realTerm::Loop/Term::Recurarms. Binder-name save/restore mirrorsTerm::Let(lib.rs:3205-3218) verbatim; per-arg type check mirrorsTerm::Assign(lib.rs:3716-3730) verbatim per Boss call 1;recur's own type =Subst::fresh(counter). REDloop_sum_to_typechecks_cleanobserved["internal"]; GREEN zero diagnostics. - iter loop-recur.2.4: new private
verify_loop_body(t, in_loop_tail)immediately afterverify_tail_positions's closing brace — exhaustive no-_match over all 16Termvariants (verified equal toverify_tail_positions' variant set). Invoked fromcheck_fnafterverify_tail_positions(&f.body, true)?;. REDrecur_not_in_tail_positionobservedleft: []; 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_inventory13→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 siblingcheck_human_mode_renders_recur_diagnostic_code_exactly_once.
- 3 pins (all 5 green) +
- iter loop-recur.2.6:
examples/loop_forever.ail(loop whose only path isrecur, no exit) typechecks clean — asserts the spec "no termination claim is made or enforced". Form-A grammar verified verbatim against the shippedloop_sum_to.ail. Full suite 616/0; tail-app non-regression green (9tail-named tests ok incl. the iter-1 verify_tail_positions guards).
Boss design calls (mirrored from the plan header at iter close)
RecurTypeMismatchis an Assign-style structural pre-check, NOT aunify-propagate.Term::Recurper-arg checking mirrors theTerm::Assignarm (lib.rs:3716-3730) verbatim in mechanism: synth the arg,subst.applyboth the arg type and the binder type, structural!=, on mismatchErr(RecurTypeMismatch{position,expected,got}). Aunify-propagate would surface the generictype-mismatchcode and fail the spec acceptance ("dedicatedrecur-type-mismatchfires point-exactly"). Rationale semantic (one in-repo mechanism for "declared-vs-actual at a binding site with its own point-exact code", consistent withAssignTypeMismatch), not effort. Implemented verbatim; thetest_recur_type_mismatchfixture fires exactly["recur-type-mismatch"].loop_stackelement type isVec<Type>(ordered binder types), NOTIndexMap<String,Type>.recurrebinds binders positionally, so the frame models the ordered binder types for arity (.len()) + per-position type checks. Binder names enter the ordinarylocals: &mut IndexMap<String,Type>exactly asTerm::Letbinds its name (save/restore in reverse on loop exit). The spec phrase "threaded exactly as mut.2'smut_scope_stack" governs the threading discipline (a&mut Vec<…>pushed/popped around the body, passed through every recursivesynth), not the element type. Semantic, not effort. Implemented verbatim.- Diagnostic-code precedence is by pass ordering, no explicit
logic.
RecurOutsideLoop/RecurArityMismatch/RecurTypeMismatchare raised insynth(runs at thecheck_fnsynth call);RecurNotInTailPositionis raised inverify_loop_body, invoked afterverify_tail_positions(&f.body, true)?;. Arecuroutside any loop therefore firesRecurOutsideLoop(synth, first), notRecurNotInTailPosition. No precedence logic added; the pass sequence IS the mechanism.verify_loop_bodyis entered only on synth success, so everyrecurreached there is already inside a loop with matching arity/types — the pass adds only the tail rule.verify_tail_positionsis byte-frozen (0 deletions);verify_loop_bodyis a new sibling, never a repurpose.
Concerns
- DONE_WITH_CONCERNS (iter loop-recur.2.2): plan ordering defect —
check_fnthreading 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 iscargo build -p ailang-checkreachingFinishedwith 0 errors. That gate is unsatisfiable whilecheck_fnremains an unthreadedsynthcaller (harderror[E0061]). Resolution:check_fn'sloop_stackdeclaration + 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 theverify_loop_bodyinvocation (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
synthcallers —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 emptyloop_stackalongside the existing freshmut_scope_stack, pass&mut loop_stackimmediately 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; thelower_termCodegenError::Internalstub stays by design (real loop lowering- the positive sum_to-runs-to-a-value / deep-
nE2E is explicitly iter-3 scope). Anail runthis 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.
- the positive sum_to-runs-to-a-value / deep-
Files touched
- Check core:
crates/ailang-check/src/lib.rs(4 variants + code/ctx,synthsignature + threading, real Loop/Recur arms, newverify_loop_body,check_fn+check_constwiring),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 positiveail checkevidence)
Stats
bench/orchestrator-stats/2026-05-17-iter-loop-recur.2.json