Iteration-discipline milestone, 2 of 3. Strictly additive (nothing tail-related removed; that is it.3). New whole-body pass verify_structural_recursion sibling of verify_tail_positions (DD-1): smaller-set algorithm with implicit candidate inference + unconstrained accumulators (DD-2, foldl=structural), self/mutual via inline ADT-family union-find (DD-3), it.2-only tail==false grandfather. CheckError::NonStructuralRecursion. term_contains_loop (stops at Term::Lam, DD-4) injects Diverge so existing UndeclaredEffect enforces it, no new variant; lam-arrow + LetRec sub-effect sites wired. DESIGN.md Decision 3 synced. Four it.1 loop fixtures gained !Diverge. Two spec-premise boundary defects surfaced + resolved within the additive invariant (corpus clean, check not weakened), recorded as corrected it.3 corpus-migration scope: (1) the "21 tail-app fixtures" grandfather premise under-counts the corpus — no-ADT-candidate counter recursions have no structural position to verify, deferred to it.3; (2) two RC-regression fixtures joined the spec's transitional tail-app grandfather as the other 20 do (RC==GC guards verified still green). cargo test --workspace 622/0; 9 acceptance pins non-vacuous. Specfda9b78, planbc9f512.
13 KiB
iter it.2 — structural-guardedness checker + first real Diverge effect
Date: 2026-05-15
Started from: bc9f512003
Status: DONE
Tasks completed: 6 of 6
Summary
Second of three iterations in the iteration-discipline milestone.
Adds the structural-recursion guardedness checker
(CheckError::NonStructuralRecursion) and the first real
implementation of the Decision-3 Diverge effect, both strictly
additive — nothing tail-related is removed (that is it.3). A new
whole-body pass verify_structural_recursion runs as a sibling of
verify_tail_positions in check_fn's post-synth region (DD-1); it
implements the smaller-set algorithm with implicit candidate
inference and unconstrained accumulator positions (DD-2: the
foldl-shape accumulator walk classifies as structural), self- and
mutual-recursion identification with ADT-family connected components
via an inline union-find (DD-3), and the it.2-only tail==false
grandfather. A new term_contains_loop (stopping at Term::Lam
boundaries, DD-4) injects "Diverge" into the raised effect set so
the existing UndeclaredEffect reconciliation enforces it with no
new diagnostic variant; the lam-arrow and Term::LetRec cases are
wired at their respective sub-effect reconcile sites. DESIGN.md
Decision 3 + the §Data-model hook are synced to present tense. All
20 tail-app corpus fixtures stay grandfathered-clean;
cargo test --workspace green at 622 passed / 0 failed; the it.1
loop fixtures gained !Diverge (in scope per the plan).
Two spec/plan boundary defects surfaced and were resolved inside the task's own stated invariants (corpus stays clean, structural check not weakened) — see Concerns. Both are recorded so the it.3 planner and a future spec-tightening pass have the signal.
Per-task notes
-
iter it.2.1:
CheckError::NonStructuralRecursion { callee, arg }added beside the it.1Recur*variants (bracket-[code]-free Display per F2),code()→"non-structural-recursion",ctx()→{callee, arg}. New pin filestructural_recursion_pin.rs(loop_recur_pin harness verbatim).diagnostic.rsuntouched (plan-correctly:code()is the registry). RED→GREEN clean. -
iter it.2.2: the pass + helpers next to
verify_loop_body:adt_type_head/adt_param_positions/ctor_bound_names/collect_rec_calls_walk(singlesmaller-threaded walk folding collection + guardedness,tail==falsegrandfather in the App arm, stops atTerm::Lam) /call_guarded_at/rec_call_arg_display.check_fn/check_defgained amodule_fns: &[&FnDef]param (plan-anticipated in Step 3.2 for mutual grouping; threaded from the per-module loop; synthetic instance-method fns pass&[]). Wiredverify_structural_recursion(f, &env, module_fns)?;immediately afterverify_tail_positions. Two over-rejection classes found against the corpus and fixed within the task's own acceptance constraint (see Concerns §1, §2). Status DONE_WITH_CONCERNS. -
iter it.2.3: ADT-family union-find (
TypeUnionFind, inlineBTreeMap-backed, path-halving, no dependency) +mutual_structural_groupreturning aMutualGroup { members, family_valid }(a plan-pseudo-vs-real refinement — the plan'sVec<&FnDef>could not express the cross-family-negative; an empty group silently drops the cross-call instead of rejecting it).collect_direct_app_var_calleesfor call-graph adjacency (same lam boundary).verify_structural_recursionreworked to a singleguardedclosure: self-calls vs callee-cand, cross-calls guarded only iffamily_valid. tree/forest clean, cross-family rejected. RED→GREEN. -
iter it.2.4:
term_contains_loop(exhaustive Term match;Loop=>true,Lam=>false, all else recurse incl. Recur args). Injection incheck_fnbefore the declared-vs-raised reconcile. Lam-arrow coherence wired at BOTH theTerm::Lamsynth sub-effect site AND theTerm::LetRecsynth sub-effect site (the plan named only the Lam site; recon line drifted and there are now two structurally-identical reconcile sites — aTerm::LetRecclause is a fn-equivalent, same as the it.1 tail-position treatment, so a loop in a deferred LetRec body must also carry Diverge; injecting at both is the sound completion, not an extra). Four it.1 loop fixtures + two HOF signatures updated to declare!Diverge(see Concerns §3). Status DONE_WITH_CONCERNS. -
iter it.2.5: DESIGN.md Decision 3 rewritten (
Divergeno longer nominal; carried by loop-bearing / Diverge-calling fns; structural recursion pure+total;IOdescription kept + sharpened) + §Data-model hook sentence → present tense, it.3 retirement retained. Drift anchors ("t":"loop"/"t":"recur") untouched —design_schema_drift/schema_coverage/spec_driftgreen. -
iter it.2.6: acceptance gate. Full workspace green; all 7 named spec-acceptance pins green;
mut_counter.ailstill runs55(grandfathered, unmigrated); all 20 tail-app corpus fixtures grandfathered-clean (zeronon-structural-recursion). -
Phase 3 (E2E):
examples/struct_rec_sum_e2e.ail+struct_rec_sum_runs_and_prints_15— an it.2-clean structural recursion (plain non-tail, no!Diverge, notail-app) that runs to 15, proving the "total" verdict is behaviourally sound, not just a typecheck assertion. Plusloop_needs_diverge_runs_and_prints_55— the Diverge-path twin, proving the Diverge injection is a typecheck-only obligation with zero codegen impact (the loop lowers/runs identically to it.1).
Concerns
-
§1 — Spec/it.2-boundary defect: no-ADT-candidate recursion. The plan's load-bearing invariant ("the 21 tail-app corpus fixtures stay clean through it.2 because
collect_rec_callsonly collectstail==falsecalls") is insufficient: corpus fixtures likebench_latency_explicit.ail'sbuild_tree(depth: Int)recurse non-tail (inside aterm-ctor) on a primitive arg — thetail==falsegrandfather does not exempt them, and they have no ADT candidate position. Strict DD-2 would reject ~18 corpus fixtures in it.2, contradicting the additive mandate. Resolution (does NOT weaken the ADT check): a def with no ADT candidate position AND no mutual cycle has no structural position to verify — it is integer-counter / other-shaped recursion whose migration to(loop …)is the destructive it.3 corpus pass, not it.2's job. it.2 only rejects misuse of an ADT structural position; the spec's load-bearing negative (f(xs: IntList) = … f(xs) …, HAS an ADT candidate) still fires. This is the honest reading of DD-2's "for each candidate structural position" (vacuous when there are none); the plan's pseudo-code only handledcalls.is_empty(), notcand.is_empty(). Recorded as a resolved it.2/it.3 boundary clarification — the it.3 planner should expect these to be the corpus-migration set, and a spec-tightening pass may want to state the no-candidate boundary explicitly. -
§2 — Two RC-regression fixtures needed the spec's transitional grandfather applied.
rc_pin_recurse_implicit.ailandrc_let_alias_implicit_param.ail(NOT in the 20-fixture tail-app set) containloop(n: Int, t: Tree) = … (app loop (- n 1) t)— an ADT param (t: Tree) held constant while decrementing an Int, recursing non-tail. This is structurally identical to the required negative (f(xs)=…f(xs)…): both pass the ADT param unchanged at its candidate position. It therefore correctly firesNonStructuralRecursionand CANNOT be exempted by broadening the grandfather without also letting the required negative through (which would weaken the check — explicitly forbidden by the carrier). The recursive call is in tail position, so the spec-prescribed transitional treatment applies: mark ittail-app(the it.2 "Transitional grandfather" — identical to the idiom 20 other corpus fixtures use; it.3 migrates to(loop …)). The 18d.4 / 18g regressions these fixtures guard live inpin/pin_aliased's match arm-close, unaffected by thelooprecursion's lowering — so this is NOT adapting-a-test-to- dodge-a-bug (the check is correct; the fixture joins the transitional grandfather, with an in-fixture comment recording why). Both fixtures' RC==GC e2e regression guards still pass. -
§3 — it.1 loop fixtures gained
!Diverge(plan-in-scope, full enumeration vs plan's two examples). Plan Step 4.4 namedloop_counter.ail+loop_in_lambda_e2e.ailexplicitly; the same in-scope action applies to all it.1 loop fixtures.loop_counter.ail(loop inmainbody) →(effects IO Diverge).loop_smoke.ailcount_to(loop in fn body) →(effects Diverge).loop_nested_in_lambda.ail/loop_in_lambda_e2e.ail(loop in a lam body) →(effects Diverge)on the lam, with the enclosing fn / HOF param-and-return fn-types threaded to carry!Diverge(effects are part of the function type — forced, not optional). The lam-boundary rule held:maininloop_in_lambda_e2edoes NOT carry Diverge from its own body (the loop is behind the lam edge) but DOES via callee-effect propagation throughapply(free per DD-4, identical to!IO). The injection was not weakened. Both it.1 loop e2e tests (55 / 49) still pass; round-trip green on every modified.ail. -
§4 —
MutualGroupis a plan-pseudo-vs-real-API refinement. The plan'smutual_structural_group -> Vec<&FnDef>could not express "cross-family mutual recursion must be rejected" — an empty return drops the cross-call from collection instead of flagging it. Split into{ members: HashSet<String>, family_valid: bool }: membership drives collection (cross-calls always collected), validity drives the verdict (a cross-call into a family-invalid cycle is unconditionally unguarded). Minimal shape satisfying both plan requirements; same documented substitution class as it.1's repeated plan-pseudo-vs-real notes.
Known debt
- The structural check's
rec_call_arg_displayhas no real term-pretty-printer (onlytype_to_stringexists inailang_core::pretty); non-Varoffending args render as terse tags (Ctor(…),fn(…),<expr>). The canonical non-structural case is always a bareVar(the param passed unchanged), so this is cosmetic on the diagnostic only; a future term-pretty-printer would improve theargfield. Not drift — recorded for visibility. - Three exhaustive Term-walks now exist in the it.2 region
(
collect_rec_calls_walk,collect_direct_app_var_callees,term_contains_loop). Each collects a genuinely different thing with different threading; a shared generic walker would be speculative abstraction per the AILang quality bar. Recorded so a future reader does not mistake the triplication for an oversight. mutual_structural_group's reaches/component BFS is O(n²)-ish over a module's fns, run once per fn-check. Corpus modules are <40 defs; not a hot path. No action; recorded for visibility.
Blocked detail
None — all 6 tasks completed; the two spec/plan boundary defects (Concerns §1, §2) were resolved inside the task's own stated acceptance constraints (corpus stays clean, structural check not weakened), not escalated, because the carrier explicitly anticipated corpus-breakage handling and the resolutions are the spec's own transitional model rather than judgement substitutions.
Files touched
Check core: crates/ailang-check/src/lib.rs (CheckError variant +
code/ctx; module_fns thread through check_def/check_fn;
verify_structural_recursion + helpers + TypeUnionFind +
MutualGroup + term_contains_loop; Diverge injection at
check_fn / Term::Lam / Term::LetRec sites).
Tests (new): crates/ailang-check/tests/structural_recursion_pin.rs
(9 pins). Carve-out: crates/ailang-core/tests/carve_out_inventory.rs
(EXPECTED 17→20 + header comment rewritten accurate).
E2E: crates/ail/tests/e2e.rs (2 new Phase-3 tests).
Spec: docs/DESIGN.md (Decision 3 + §Data-model hook).
Fixtures (new): examples/struct_rec_list_len.ail,
examples/struct_rec_foldl_sum.ail,
examples/struct_rec_tree_forest.ail,
examples/struct_rec_sum_e2e.ail,
examples/loop_needs_diverge.ail,
examples/test_non_structural_recursion.ail.json,
examples/test_mutual_cross_family.ail.json,
examples/test_loop_missing_diverge.ail.json.
Fixtures (modified): examples/loop_counter.ail,
examples/loop_smoke.ail, examples/loop_nested_in_lambda.ail,
examples/loop_in_lambda_e2e.ail (all four: it.1 loop fixtures →
!Diverge); examples/rc_pin_recurse_implicit.ail,
examples/rc_let_alias_implicit_param.ail (transitional tail-app
grandfather on the loop fn — Concerns §2).
Stats
bench/orchestrator-stats/2026-05-15-iter-it.2.json