# iter it.2 — structural-guardedness checker + first real Diverge effect **Date:** 2026-05-15 **Started from:** bc9f5120034f2552ad7e78454bb198472404d4b1 **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.1 `Recur*` variants (bracket-`[code]`-free Display per F2), `code()` → `"non-structural-recursion"`, `ctx()` → `{callee, arg}`. New pin file `structural_recursion_pin.rs` (loop_recur_pin harness verbatim). `diagnostic.rs` untouched (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` (single `smaller`-threaded walk folding collection + guardedness, `tail==false` grandfather in the App arm, stops at `Term::Lam`) / `call_guarded_at` / `rec_call_arg_display`. `check_fn`/`check_def` gained a `module_fns: &[&FnDef]` param (plan-anticipated in Step 3.2 for mutual grouping; threaded from the per-module loop; synthetic instance-method fns pass `&[]`). Wired `verify_structural_recursion(f, &env, module_fns)?;` immediately after `verify_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`, inline `BTreeMap`-backed, path-halving, no dependency) + `mutual_structural_group` returning a `MutualGroup { members, family_valid }` (a plan-pseudo-vs-real refinement — the plan's `Vec<&FnDef>` could not express the cross-family-negative; an empty group silently drops the cross-call instead of rejecting it). `collect_direct_app_var_callees` for call-graph adjacency (same lam boundary). `verify_structural_recursion` reworked to a single `guarded` closure: self-calls vs callee-cand, cross-calls guarded only if `family_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 in `check_fn` before the declared-vs-raised reconcile. Lam-arrow coherence wired at BOTH the `Term::Lam` synth sub-effect site AND the `Term::LetRec` synth sub-effect site (the plan named only the Lam site; recon line drifted and there are now two structurally-identical reconcile sites — a `Term::LetRec` clause 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 (`Diverge` no longer nominal; carried by loop-bearing / Diverge-calling fns; structural recursion pure+total; `IO` description 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_drift` green. - iter it.2.6: acceptance gate. Full workspace green; all 7 named spec-acceptance pins green; `mut_counter.ail` still runs `55` (grandfathered, unmigrated); all 20 tail-app corpus fixtures grandfathered-clean (zero `non-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`, no `tail-app`) that runs to 15, proving the "total" verdict is behaviourally sound, not just a typecheck assertion. Plus `loop_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_calls` only collects `tail==false` calls") is insufficient: corpus fixtures like `bench_latency_explicit.ail`'s `build_tree(depth: Int)` recurse **non-tail** (inside a `term-ctor`) on a **primitive** arg — the `tail==false` grandfather 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 handled `calls.is_empty()`, not `cand.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.ail` and `rc_let_alias_implicit_param.ail` (NOT in the 20-fixture tail-app set) contain `loop(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 fires `NonStructuralRecursion` and 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 it `tail-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 in `pin`/`pin_aliased`'s match arm-close, unaffected by the `loop` recursion'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 named `loop_counter.ail` + `loop_in_lambda_e2e.ail` explicitly; the same in-scope action applies to all it.1 loop fixtures. `loop_counter.ail` (loop in `main` body) → `(effects IO Diverge)`. `loop_smoke.ail` `count_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: `main` in `loop_in_lambda_e2e` does NOT carry Diverge from its own body (the loop is behind the lam edge) but DOES via callee-effect propagation through `apply` (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 — `MutualGroup` is a plan-pseudo-vs-real-API refinement.** The plan's `mutual_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, 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_display` has no real term-pretty-printer (only `type_to_string` exists in `ailang_core::pretty`); non-`Var` offending args render as terse tags (`Ctor(…)`, `fn(…)`, ``). The canonical non-structural case is always a bare `Var` (the param passed unchanged), so this is cosmetic on the diagnostic only; a future term-pretty-printer would improve the `arg` field. 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