iter it.2: structural-guardedness checker + first real Diverge effect

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. Spec fda9b78, plan bc9f512.
This commit is contained in:
2026-05-15 15:29:43 +02:00
parent bc9f512003
commit a4be1e58a3
22 changed files with 1402 additions and 22 deletions
+25
View File
@@ -2853,6 +2853,31 @@ fn loop_in_lambda_runs_and_prints_49() {
assert_eq!(stdout.trim(), "49", "loop_in_lambda must print 49, got {stdout:?}");
}
/// Iter it.2 Phase-3: an it.2-clean structural recursion that runs.
/// `struct_rec_sum_e2e` sums [1,2,3,4,5] via plain non-tail
/// recursion on the Cons tail — the it.2 guardedness check
/// classifies it pure + total (no `!Diverge`, no `tail-app`). This
/// gate proves the structural-recursion "total" verdict is
/// behaviourally sound, not merely a typecheck assertion: a plain
/// (musttail-free) structurally-decreasing recursive call lowers
/// and runs to the correct value.
#[test]
fn struct_rec_sum_runs_and_prints_15() {
let stdout = build_and_run("struct_rec_sum_e2e.ail");
assert_eq!(stdout.trim(), "15", "struct_rec_sum must print 15, got {stdout:?}");
}
/// Iter it.2 Phase-3: the `Diverge`-path twin. `loop_needs_diverge`
/// declares `!Diverge` (loop-bearing per DD-4) and runs the it.1
/// accumulator loop to 55 — proving the Diverge-effect injection is
/// purely a typecheck-layer obligation with no codegen impact (the
/// loop lowers and runs identically to its pre-it.2 it.1 form).
#[test]
fn loop_needs_diverge_runs_and_prints_55() {
let stdout = build_and_run("loop_needs_diverge.ail");
assert_eq!(stdout.trim(), "55", "loop_needs_diverge must print 55, got {stdout:?}");
}
/// Iter mut.3: Float twin of `mut_counter_prints_55`. The mut-var
/// is `Float`, init is `0.0`, the recursive helper returns the
/// sum 1.0+...+10.0 = 55.0. The polymorphic `print` routes through
+767 -5
View File
@@ -35,7 +35,7 @@
use ailang_core::ast::*;
use ailang_core::Workspace;
use indexmap::IndexMap;
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
mod linearity;
mod pre_desugar_validation;
@@ -726,6 +726,16 @@ pub enum CheckError {
#[error("recur must be in tail position of its enclosing loop")]
RecurNotInTailPosition,
/// Iter it.2: a recursive call (self or mutual-group) passes a
/// non-structurally-smaller argument at every candidate structural
/// position. The author must express this iteration as an explicit
/// `(loop …)` / `recur` instead. Spec
/// `docs/specs/2026-05-15-iteration-discipline.md` (D1/D2). Display
/// body is bracket-`[code]`-free per the F2 convention — the CLI
/// formatter prepends the `[code]`.
#[error("recursive call to `{callee}` is not on a structurally-smaller argument (`{arg}`); express this iteration as `(loop …)` / `recur`")]
NonStructuralRecursion { callee: String, arg: String },
/// Iter 22b.3: an internal invariant in the typechecker / mono pass
/// was violated — surfaced as an error so callers can propagate
/// rather than abort, but in well-formed inputs (typecheck has
@@ -782,6 +792,7 @@ impl CheckError {
CheckError::RecurArityMismatch { .. } => "recur-arity-mismatch",
CheckError::RecurTypeMismatch { .. } => "recur-type-mismatch",
CheckError::RecurNotInTailPosition => "recur-not-in-tail-position",
CheckError::NonStructuralRecursion { .. } => "non-structural-recursion",
CheckError::Internal(_) => "internal",
}
}
@@ -862,6 +873,9 @@ impl CheckError {
CheckError::RecurTypeMismatch { pos, name, got, want } => {
serde_json::json!({"pos": pos, "name": name, "expected": want, "actual": got})
}
CheckError::NonStructuralRecursion { callee, arg } => {
serde_json::json!({ "callee": callee, "arg": arg })
}
_ => serde_json::Value::Object(serde_json::Map::new()),
}
}
@@ -1704,8 +1718,20 @@ fn check_in_workspace(
env.imports = import_map;
env.current_module = m.name.clone();
// Iter it.2 (DD-3): the module's `Def::Fn`s, for the
// mutual-structural-group analysis. A single owned Vec built once
// per module; `verify_structural_recursion` reads it to decide
// whether a cross-call is to a same-group member.
let module_fns: Vec<&FnDef> = m
.defs
.iter()
.filter_map(|d| match d {
Def::Fn(f) => Some(f),
_ => None,
})
.collect();
for def in &m.defs {
match check_def(def, &env, out_warnings) {
match check_def(def, &env, &module_fns, out_warnings) {
Ok(()) => {}
Err(e) => {
errors.push(CheckError::Def(def.name().to_string(), Box::new(e)));
@@ -1723,10 +1749,11 @@ fn check_in_workspace(
fn check_def(
def: &Def,
env: &Env,
module_fns: &[&FnDef],
out_warnings: &mut Vec<Diagnostic>,
) -> Result<()> {
match def {
Def::Fn(f) => check_fn(f, env, out_warnings),
Def::Fn(f) => check_fn(f, env, module_fns, out_warnings),
Def::Const(c) => check_const(c, env, out_warnings),
Def::Type(td) => check_type_def(td, env),
// bugfix-instance-body-unbound-var (2026-05-13): each instance
@@ -1825,7 +1852,10 @@ fn check_instance(
doc: None,
suppress: Vec::new(),
};
check_fn(&synthetic, env, out_warnings)?;
// Iter it.2: a synthetic instance-method fn is not a
// module-level def and does not participate in the
// mutual-structural-group analysis; pass no siblings.
check_fn(&synthetic, env, &[], out_warnings)?;
}
Ok(())
}
@@ -1918,7 +1948,12 @@ fn check_type_well_formed(t: &Type, env: &Env) -> Result<()> {
}
}
fn check_fn(f: &FnDef, env: &Env, out_warnings: &mut Vec<Diagnostic>) -> Result<()> {
fn check_fn(
f: &FnDef,
env: &Env,
module_fns: &[&FnDef],
out_warnings: &mut Vec<Diagnostic>,
) -> Result<()> {
// Peel an outer Forall (Iter 12a). The vars become rigid in the
// inner env so they pass `check_type_well_formed` and unify only
// with themselves. An empty `vars` list (vacuously polymorphic)
@@ -2020,6 +2055,29 @@ fn check_fn(f: &FnDef, env: &Env, out_warnings: &mut Vec<Diagnostic>) -> Result<
// call doesn't drown out the underlying type error.
verify_tail_positions(&f.body, true)?;
// Iter it.2 (DD-1): structural-recursion guardedness. Sibling of
// `verify_tail_positions`; runs on the whole post-synth body so
// it can build the `smaller`-set provenance before judging any
// recursive call. Out of scope for synthetic Forall-peeled types
// — it reads `f.ty` directly via `adt_param_positions`.
verify_structural_recursion(f, &env, module_fns)?;
// Iter it.2 (DD-4 / D2): the first real `Diverge` effect. A fn
// whose body syntactically contains a `Term::Loop` raises
// `Diverge`, exactly as a `do print` raises `IO`. The "calls a
// `Diverge`-declaring callee" half needs no code here — a
// callee's `Type::Fn.effects` already flows into `effects`
// during synth (identically to `IO`). The existing
// declared-vs-raised reconciliation below turns an undeclared
// `Diverge` into the existing `UndeclaredEffect` — no new
// diagnostic variant. Structural recursion injects nothing (it
// contains no `Term::Loop`). The lam boundary is honoured by
// `term_contains_loop` (a loop under a `Term::Lam` is that lam's
// arrow effect, reconciled at the lam sub-effect site).
if term_contains_loop(&f.body) {
effects.insert("Diverge".to_string());
}
let declared: BTreeSet<String> = declared_effs.into_iter().collect();
for e in &effects {
if !declared.contains(e) {
@@ -2810,6 +2868,693 @@ fn verify_loop_body(t: &Term) -> Result<()> {
}
}
/// Iter it.2 (DD-4): true iff `t` syntactically contains a
/// `Term::Loop`, **not** descending into `Term::Lam` bodies. A
/// lambda is a value with its own arrow effect row — a loop inside
/// it executes on closure call, not here, so it carries `Diverge`
/// on the lam's arrow type (handled at the lam sub-effect reconcile
/// site), not on the enclosing fn. This mirrors exactly how `!IO`
/// inside a lam does not leak to the enclosing fn.
fn term_contains_loop(t: &Term) -> bool {
match t {
Term::Loop { .. } => true,
Term::Lam { .. } => false,
Term::Lit { .. } | Term::Var { .. } => false,
Term::Recur { args } => args.iter().any(term_contains_loop),
Term::App { callee, args, .. } => {
term_contains_loop(callee) || args.iter().any(term_contains_loop)
}
Term::Do { args, .. } => args.iter().any(term_contains_loop),
Term::Let { value, body, .. } => {
term_contains_loop(value) || term_contains_loop(body)
}
Term::LetRec { body, in_term, .. } => {
term_contains_loop(body) || term_contains_loop(in_term)
}
Term::If { cond, then, else_ } => {
term_contains_loop(cond)
|| term_contains_loop(then)
|| term_contains_loop(else_)
}
Term::Seq { lhs, rhs } => {
term_contains_loop(lhs) || term_contains_loop(rhs)
}
Term::Match { scrutinee, arms } => {
term_contains_loop(scrutinee)
|| arms.iter().any(|a| term_contains_loop(&a.body))
}
Term::Ctor { args, .. } => args.iter().any(term_contains_loop),
Term::Clone { value } => term_contains_loop(value),
Term::ReuseAs { source, body } => {
term_contains_loop(source) || term_contains_loop(body)
}
Term::Mut { vars, body } => {
vars.iter().any(|v| term_contains_loop(&v.init))
|| term_contains_loop(body)
}
Term::Assign { value, .. } => term_contains_loop(value),
}
}
// ── Iter it.2: structural-recursion guardedness (DD-1/DD-2/DD-3) ──
//
// A recursive call (self, or to a same-ADT-family mutual-group
// member) must pass a structurally-smaller argument at some
// inferable parameter position. Accumulator positions are
// unconstrained (spec D1). it.2-only grandfather: a `tail:true`
// recursive call is not collected at all, so it never causes
// rejection (the 21 `tail-app` corpus fixtures stay clean through
// it.2; it.3 removes the `tail` field and this exemption together).
/// One collected recursive call: the callee name, its argument
/// terms, and the `smaller` set live at the call site. `tail:true`
/// recursive calls are never collected (the grandfather).
struct RecCall {
callee: String,
args: Vec<Term>,
smaller: HashSet<String>,
}
/// Resolve a declared type to its ADT `type`-decl name, if it is a
/// non-primitive `Type::Con` that names a `type` decl reachable from
/// `env` (bare or one-dot-qualified). `Type::Fn`, `Type::Var`,
/// `Type::Forall`, and primitive cons return `None`.
fn adt_type_head(t: &Type, env: &Env) -> Option<String> {
let Type::Con { name, .. } = t else {
return None;
};
if ailang_core::primitives::is_primitive_name(name) {
return None;
}
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.').expect("checked");
let target = env.imports.get(prefix)?;
if env
.module_types
.get(target)
.and_then(|tys| tys.get(suffix))
.is_some()
{
return Some(name.clone());
}
None
} else if env.types.contains_key(name) {
Some(name.clone())
} else {
None
}
}
/// Candidate structural parameter positions of `f`: indices whose
/// declared type is a non-primitive ADT `Type::Con` (DD-2).
fn adt_param_positions(f: &FnDef, env: &Env) -> Vec<usize> {
let inner = match &f.ty {
Type::Forall { body, .. } => (**body).clone(),
other => other.clone(),
};
let Type::Fn { params, .. } = inner else {
return Vec::new();
};
params
.iter()
.enumerate()
.filter_map(|(i, p)| adt_type_head(p, env).map(|_| i))
.collect()
}
/// The constructor-bound field names of a flat post-desugar
/// pattern. `Pattern::Ctor` fields are `Var`/`Wild` (nested ctor
/// patterns were rejected upstream by `NestedCtorPatternNotAllowed`).
fn ctor_bound_names(p: &Pattern) -> Vec<String> {
match p {
Pattern::Ctor { fields, .. } => fields
.iter()
.filter_map(|f| match f {
Pattern::Var { name } => Some(name.clone()),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
/// True iff `name` is the recursion name `f` calls itself by, or a
/// same-group mutual member (filled in Task 3 via `group`).
fn is_rec_callee(name: &str, rec_name: &str, group: &HashSet<String>) -> bool {
name == rec_name || group.contains(name)
}
/// Single `smaller`-threaded walk. Collects every non-`tail`
/// recursive call together with the `smaller` set in effect at that
/// syntactic position. `param_smaller_seed` is the set of parameter
/// names that are *themselves* candidate structural roots — a
/// `match` on one of them (or on an already-smaller var) extends
/// `smaller` with that arm's constructor-bound fields. `recur` /
/// `loop` bodies are walked (a `recur` is not a recursive *call*);
/// `Term::Lam` is NOT descended into (DD-3/DD-4 lam boundary).
#[allow(clippy::too_many_arguments)]
fn collect_rec_calls_walk(
t: &Term,
rec_name: &str,
group: &HashSet<String>,
param_roots: &HashSet<String>,
smaller: &HashSet<String>,
out: &mut Vec<RecCall>,
) {
match t {
Term::Lit { .. } | Term::Var { .. } => {}
Term::App { callee, args, tail } => {
if !*tail {
if let Term::Var { name } = &**callee {
if is_rec_callee(name, rec_name, group) {
out.push(RecCall {
callee: name.clone(),
args: args.clone(),
smaller: smaller.clone(),
});
}
}
}
collect_rec_calls_walk(callee, rec_name, group, param_roots, smaller, out);
for a in args {
collect_rec_calls_walk(a, rec_name, group, param_roots, smaller, out);
}
}
Term::Do { args, .. } => {
for a in args {
collect_rec_calls_walk(a, rec_name, group, param_roots, smaller, out);
}
}
Term::Let { name, value, body } => {
collect_rec_calls_walk(value, rec_name, group, param_roots, smaller, out);
// Alias propagation: `let v = <var>` where the bound term
// is a structural root (or already strictly smaller)
// makes `v` carry the same status in `body`. This is what
// makes the desugar-introduced `let $mp_N = <scrutinee>`
// (Iter 16a nested-pattern flattening) transparent to the
// guardedness walk — without it every nested-ctor-pattern
// recursion would be a false `NonStructuralRecursion`.
if let Term::Var { name: src } = &**value {
if smaller.contains(src) {
let mut s = smaller.clone();
s.insert(name.clone());
collect_rec_calls_walk(body, rec_name, group, param_roots, &s, out);
return;
}
if param_roots.contains(src) {
let mut r = param_roots.clone();
r.insert(name.clone());
collect_rec_calls_walk(body, rec_name, group, &r, smaller, out);
return;
}
}
collect_rec_calls_walk(body, rec_name, group, param_roots, smaller, out);
}
Term::LetRec { body, in_term, .. } => {
collect_rec_calls_walk(body, rec_name, group, param_roots, smaller, out);
collect_rec_calls_walk(in_term, rec_name, group, param_roots, smaller, out);
}
Term::If { cond, then, else_ } => {
collect_rec_calls_walk(cond, rec_name, group, param_roots, smaller, out);
collect_rec_calls_walk(then, rec_name, group, param_roots, smaller, out);
collect_rec_calls_walk(else_, rec_name, group, param_roots, smaller, out);
}
Term::Seq { lhs, rhs } => {
collect_rec_calls_walk(lhs, rec_name, group, param_roots, smaller, out);
collect_rec_calls_walk(rhs, rec_name, group, param_roots, smaller, out);
}
Term::Match { scrutinee, arms } => {
collect_rec_calls_walk(scrutinee, rec_name, group, param_roots, smaller, out);
// A match on a structural root (or an already-smaller var)
// makes that arm's constructor-bound fields strictly
// smaller. Other scrutinees do not extend `smaller`.
let extends = match &**scrutinee {
Term::Var { name } => param_roots.contains(name) || smaller.contains(name),
_ => false,
};
for arm in arms {
if extends {
let mut s = smaller.clone();
for n in ctor_bound_names(&arm.pat) {
s.insert(n);
}
collect_rec_calls_walk(&arm.body, rec_name, group, param_roots, &s, out);
} else {
collect_rec_calls_walk(&arm.body, rec_name, group, param_roots, smaller, out);
}
}
}
Term::Ctor { args, .. } => {
for a in args {
collect_rec_calls_walk(a, rec_name, group, param_roots, smaller, out);
}
}
// DD-3/DD-4: a lambda body is a separate def's territory; do
// not descend (consistent with the Diverge lam boundary).
Term::Lam { .. } => {}
Term::Clone { value } => {
collect_rec_calls_walk(value, rec_name, group, param_roots, smaller, out);
}
Term::ReuseAs { source, body } => {
collect_rec_calls_walk(source, rec_name, group, param_roots, smaller, out);
collect_rec_calls_walk(body, rec_name, group, param_roots, smaller, out);
}
Term::Mut { vars, body } => {
for v in vars {
collect_rec_calls_walk(&v.init, rec_name, group, param_roots, smaller, out);
}
collect_rec_calls_walk(body, rec_name, group, param_roots, smaller, out);
}
Term::Assign { value, .. } => {
collect_rec_calls_walk(value, rec_name, group, param_roots, smaller, out);
}
Term::Loop { binders, body } => {
for b in binders {
collect_rec_calls_walk(&b.init, rec_name, group, param_roots, smaller, out);
}
collect_rec_calls_walk(body, rec_name, group, param_roots, smaller, out);
}
Term::Recur { args } => {
for a in args {
collect_rec_calls_walk(a, rec_name, group, param_roots, smaller, out);
}
}
}
}
/// Whether the recursive call `c` is structurally guarded at the
/// candidate position `i`: its `i`-th argument is a bare
/// `Term::Var` whose name is in the `smaller` set at the call site.
fn call_guarded_at(c: &RecCall, i: usize) -> bool {
match c.args.get(i) {
Some(Term::Var { name }) => c.smaller.contains(name),
_ => false,
}
}
/// Display form of a recursive call's offending argument (the arg
/// at the first candidate position, else the first arg), for the
/// diagnostic. Mirrors the short pretty form the it.1 `Recur*`
/// diagnostics use.
fn rec_call_arg_display(c: &RecCall, cand: &[usize]) -> String {
let idx = cand.first().copied().unwrap_or(0);
match c.args.get(idx).or_else(|| c.args.first()) {
Some(Term::Var { name }) => name.clone(),
Some(Term::Ctor { ctor, .. }) => format!("{ctor}(…)"),
Some(Term::App { callee, .. }) => format!("{}(…)", callee_name(callee)),
Some(Term::Lit { .. }) => "<literal>".to_string(),
Some(_) => "<expr>".to_string(),
None => "<no argument>".to_string(),
}
}
/// DD-1/DD-2/DD-3: structural-recursion guardedness for a single
/// `FnDef`. A recursive call (self, or — Task 3 — a same-family
/// mutual-group member) must be structurally guarded at some
/// parameter position that is structural at *every* such call.
fn verify_structural_recursion(
f: &FnDef,
env: &Env,
module_fns: &[&FnDef],
) -> Result<()> {
let cand = adt_param_positions(f, env);
let group = mutual_structural_group(f, module_fns, env);
// it.2 transitional boundary: a def with no ADT candidate
// position and no mutual cycle has no *structural* parameter to
// verify. Its recursion is integer-counter / other-shaped
// (`f(n) = … f(n - 1) …`) — genuinely non-structural, but its
// 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 case: the canonical
// `f(xs) = … f(xs) …` negative still fires because it HAS an ADT
// candidate). This keeps the corpus clean through it.2 without
// weakening the ADT structural check — the `tail==false`
// grandfather alone is insufficient because corpus fixtures like
// `build_tree(depth: Int)` recurse non-tail on a primitive arg.
// Recorded as a resolved spec/it.2-boundary clarification in the
// iter journal.
if cand.is_empty() && group.members.is_empty() {
return Ok(());
}
let param_roots: HashSet<String> = cand
.iter()
.filter_map(|&i| f.params.get(i).cloned())
.collect();
let mut calls: Vec<RecCall> = Vec::new();
collect_rec_calls_walk(
&f.body,
&f.name,
&group.members,
&param_roots,
&HashSet::new(),
&mut calls,
);
if calls.is_empty() {
return Ok(());
}
// Per-call guardedness verdict:
// - self-call (`callee == f.name`): guarded iff some candidate
// position of `f` receives a structurally-smaller arg.
// - cross-call into a mutual-cycle member: only meaningful if the
// cycle is a valid same-family group (`family_valid`); then
// guarded iff some candidate position of the *callee* receives
// a var in the caller's `smaller` set (DD-3). A cross-call into
// a family-INVALID cycle is unconditionally unguarded — the
// cross-family negative.
let guarded = |c: &RecCall| -> bool {
if c.callee == f.name {
cand.iter().any(|&i| call_guarded_at(c, i))
} else if !group.family_valid {
false
} else {
module_fns
.iter()
.find(|g| g.name == c.callee)
.map(|g| adt_param_positions(g, env))
.map(|gc| gc.iter().any(|&i| call_guarded_at(c, i)))
.unwrap_or(false)
}
};
// Structural iff there is a single candidate position of `f`
// structural at *every* self-call (the implicit-inference rule,
// DD-2) AND every cross-call into the mutual cycle is guarded.
let self_calls: Vec<&RecCall> = calls.iter().filter(|c| c.callee == f.name).collect();
let self_clear = self_calls.is_empty()
|| cand
.iter()
.any(|&i| self_calls.iter().all(|c| call_guarded_at(c, i)));
let cross_clear = calls
.iter()
.filter(|c| c.callee != f.name)
.all(guarded);
if self_clear && cross_clear {
return Ok(());
}
// Not structural — emit on the first unguarded recursive call
// (all collected calls are already non-`tail`; the `tail:true`
// grandfather filtered them out at collection time).
let offending = calls.iter().find(|c| !guarded(c)).unwrap_or(&calls[0]);
Err(CheckError::NonStructuralRecursion {
callee: offending.callee.clone(),
arg: rec_call_arg_display(offending, &cand),
})
}
/// Tiny `BTreeMap`-backed union-find over type names (DD-3). No
/// external dependency; path-halving find, union-by-insertion.
struct TypeUnionFind {
parent: BTreeMap<String, String>,
}
impl TypeUnionFind {
fn new() -> Self {
TypeUnionFind { parent: BTreeMap::new() }
}
fn ensure(&mut self, x: &str) {
if !self.parent.contains_key(x) {
self.parent.insert(x.to_string(), x.to_string());
}
}
fn find(&mut self, x: &str) -> String {
self.ensure(x);
let mut cur = x.to_string();
while self.parent[&cur] != cur {
let grand = self.parent[&self.parent[&cur]].clone();
self.parent.insert(cur.clone(), grand.clone());
cur = self.parent[&cur].clone();
}
cur
}
fn union(&mut self, a: &str, b: &str) {
let ra = self.find(a);
let rb = self.find(b);
if ra != rb {
self.parent.insert(ra, rb);
}
}
fn same(&mut self, a: &str, b: &str) -> bool {
self.find(a) == self.find(b)
}
}
/// Collect the `Type::Con` head names referenced anywhere inside a
/// type (recursing through `Fn`/`Forall`/`Con` args), skipping
/// primitives and type vars.
fn referenced_con_names(t: &Type, out: &mut Vec<String>) {
match t {
Type::Con { name, args } => {
if !ailang_core::primitives::is_primitive_name(name) {
out.push(name.clone());
}
for a in args {
referenced_con_names(a, out);
}
}
Type::Fn { params, ret, .. } => {
for p in params {
referenced_con_names(p, out);
}
referenced_con_names(ret, out);
}
Type::Forall { body, .. } => referenced_con_names(body, out),
Type::Var { .. } => {}
}
}
/// DD-3: connected components of the ADT type-reference graph.
/// Nodes are every visible `type` decl name; an undirected edge
/// joins `T` and any non-primitive `Con` head appearing in one of
/// `T`'s constructor field types. The result answers
/// "are these two ADT heads in one family?".
fn adt_families(env: &Env) -> TypeUnionFind {
let mut uf = TypeUnionFind::new();
// All visible TypeDefs: the current module's plus every module's
// (workspace-flat). Bare names index the current module; that is
// the resolution `adt_type_head` already uses for fixtures.
let mut all: Vec<(&String, &TypeDef)> = env.types.iter().collect();
for tys in env.module_types.values() {
all.extend(tys.iter());
}
for (name, td) in &all {
uf.ensure(name);
for ctor in &td.ctors {
for field in &ctor.fields {
let mut refs = Vec::new();
referenced_con_names(field, &mut refs);
for r in refs {
// Edge only between ADT decls (a referenced name
// that is not a known type decl, e.g. a builtin
// wrapper, contributes no family edge).
uf.union(name, &r);
}
}
}
}
uf
}
/// DD-3: the mutual-recursion cycle `f` belongs to — the set of
/// *other* module fn names that `f` reaches and that reach `f`
/// through direct `Term::App` → `Term::Var` recursion (the
/// back-reaching connected component of the call graph restricted
/// to `module_fns`), plus whether that cycle is a valid same-ADT-
/// family structural group. Empty `members` ⇒
/// `verify_structural_recursion` treats `f` as self-recursive only.
/// Non-empty `members` with `family_valid = false` ⇒ the cycle
/// spans unrelated ADT families: each cross-call is an unguarded
/// recursive call (the cross-family negative).
fn mutual_structural_group(
f: &FnDef,
module_fns: &[&FnDef],
env: &Env,
) -> MutualGroup {
// Direct-call adjacency among module fns (callee names that are
// module fns; ignore builtins / cross-module).
let names: HashSet<&str> = module_fns.iter().map(|g| g.name.as_str()).collect();
let direct_callees = |g: &FnDef| -> HashSet<String> {
let mut cs = Vec::new();
collect_direct_app_var_callees(&g.body, &mut cs);
cs.into_iter().filter(|c| names.contains(c.as_str())).collect()
};
// Connected component of `f` in the (undirected) call graph.
let by_name: BTreeMap<&str, &FnDef> =
module_fns.iter().map(|g| (g.name.as_str(), *g)).collect();
let mut adj: BTreeMap<String, HashSet<String>> = BTreeMap::new();
for g in module_fns {
let cs = direct_callees(g);
for c in &cs {
adj.entry(g.name.clone()).or_default().insert(c.clone());
adj.entry(c.clone()).or_default().insert(g.name.clone());
}
}
let mut comp: HashSet<String> = HashSet::new();
let mut stack = vec![f.name.clone()];
while let Some(n) = stack.pop() {
if !comp.insert(n.clone()) {
continue;
}
if let Some(neigh) = adj.get(&n) {
for m in neigh {
if !comp.contains(m) {
stack.push(m.clone());
}
}
}
}
comp.remove(&f.name);
// Members that genuinely participate in a recursive cycle with
// `f`: keep only those that (transitively) call back to `f`.
// For the corpus shapes (tree/forest, even/odd) the component is
// already the mutual cycle; a one-way helper call would not put
// the helper into a back-reaching cycle, so it drops out here.
let reaches = |start: &str, target: &str| -> bool {
let mut seen: HashSet<String> = HashSet::new();
let mut st = vec![start.to_string()];
while let Some(n) = st.pop() {
if n == target && n != start {
return true;
}
if !seen.insert(n.clone()) {
continue;
}
if let Some(g) = by_name.get(n.as_str()) {
for c in direct_callees(g) {
if c == target {
return true;
}
st.push(c);
}
}
}
false
};
let members: HashSet<String> = comp
.into_iter()
.filter(|m| reaches(&f.name, m) && reaches(m, &f.name))
.collect();
if members.is_empty() {
return MutualGroup { members, family_valid: true };
}
// DD-3 family test: `f` and every cycle member must have a
// structural ADT parameter, and all those parameter type heads
// must be in ONE family component. If not, the cycle is not a
// valid mutual structural group — `family_valid = false` makes
// every cross-call into it an unguarded recursive call (the
// cross-family negative).
let mut uf = adt_families(env);
let head_of = |g: &FnDef| -> Option<String> {
let inner = match &g.ty {
Type::Forall { body, .. } => (**body).clone(),
other => other.clone(),
};
let Type::Fn { params, .. } = inner else { return None };
params.iter().find_map(|p| adt_type_head(p, env))
};
let family_valid = match head_of(f) {
None => false,
Some(f_head) => members.iter().all(|m| {
by_name
.get(m.as_str())
.and_then(|mg| head_of(mg))
.map(|h| uf.same(&f_head, &h))
.unwrap_or(false)
}),
};
MutualGroup { members, family_valid }
}
/// DD-3: the mutual-recursion cycle `f` participates in, and
/// whether that cycle is a valid same-ADT-family structural group.
/// `members` is family-agnostic (used to collect cross-calls); a
/// cross-call into a `members` fn is judged structurally only when
/// `family_valid` holds, otherwise it is an unguarded recursive
/// call.
struct MutualGroup {
members: HashSet<String>,
family_valid: bool,
}
/// Direct `Term::App` whose callee is a `Term::Var` — the callee
/// names reachable by direct application (no descent into `Lam`
/// bodies, consistent with the guardedness walk's lam boundary).
fn collect_direct_app_var_callees(t: &Term, out: &mut Vec<String>) {
match t {
Term::Lit { .. } | Term::Var { .. } | Term::Recur { .. } => {}
Term::App { callee, args, .. } => {
if let Term::Var { name } = &**callee {
out.push(name.clone());
}
collect_direct_app_var_callees(callee, out);
for a in args {
collect_direct_app_var_callees(a, out);
}
}
Term::Do { args, .. } => {
for a in args {
collect_direct_app_var_callees(a, out);
}
}
Term::Let { value, body, .. } => {
collect_direct_app_var_callees(value, out);
collect_direct_app_var_callees(body, out);
}
Term::LetRec { body, in_term, .. } => {
collect_direct_app_var_callees(body, out);
collect_direct_app_var_callees(in_term, out);
}
Term::If { cond, then, else_ } => {
collect_direct_app_var_callees(cond, out);
collect_direct_app_var_callees(then, out);
collect_direct_app_var_callees(else_, out);
}
Term::Seq { lhs, rhs } => {
collect_direct_app_var_callees(lhs, out);
collect_direct_app_var_callees(rhs, out);
}
Term::Match { scrutinee, arms } => {
collect_direct_app_var_callees(scrutinee, out);
for arm in arms {
collect_direct_app_var_callees(&arm.body, out);
}
}
Term::Ctor { args, .. } => {
for a in args {
collect_direct_app_var_callees(a, out);
}
}
// Lam boundary (DD-3/DD-4): a closure body is a separate
// def's territory; do not descend.
Term::Lam { .. } => {}
Term::Clone { value } => collect_direct_app_var_callees(value, out),
Term::ReuseAs { source, body } => {
collect_direct_app_var_callees(source, out);
collect_direct_app_var_callees(body, out);
}
Term::Mut { vars, body } => {
for v in vars {
collect_direct_app_var_callees(&v.init, out);
}
collect_direct_app_var_callees(body, out);
}
Term::Assign { value, .. } => collect_direct_app_var_callees(value, out),
Term::Loop { binders, body } => {
for b in binders {
collect_direct_app_var_callees(&b.init, out);
}
collect_direct_app_var_callees(body, out);
}
}
}
fn check_const(c: &ConstDef, env: &Env, out_warnings: &mut Vec<Diagnostic>) -> Result<()> {
// Const types are never polymorphic — a Forall here is rejected
// outright. Any other type passes through to `synth` as before.
@@ -3579,6 +4324,16 @@ pub(crate) fn synth(
}
let body_ty = body_ty?;
unify(ret_ty, &body_ty, subst)?;
// Iter it.2 (DD-4): a `(loop …)` directly in this lam's
// body makes the lam's arrow carry `Diverge` — coherent
// with how `!IO` scopes across the lam edge. A loop under
// a *further-nested* lam is that inner lam's concern;
// `term_contains_loop`'s own `Term::Lam => false` stops
// there. Reconciled against the lam's declared arrow
// effects exactly like every other raised effect.
if term_contains_loop(body) {
body_effects.insert("Diverge".to_string());
}
let declared: BTreeSet<String> = lam_effects.iter().cloned().collect();
for e in &body_effects {
if !declared.contains(e) {
@@ -3670,6 +4425,13 @@ pub(crate) fn synth(
let body_ty = body_ty?;
unify(&ret_ty, &body_ty, subst)?;
// Iter it.2 (DD-4): a `Term::LetRec` clause is a
// fn-equivalent (same as `check_fn` / `Term::Lam`); a
// `(loop …)` in its body makes its arrow carry `Diverge`.
// Same lam-boundary rule via `term_contains_loop`.
if term_contains_loop(body) {
body_effects.insert("Diverge".to_string());
}
let declared: BTreeSet<String> = declared_effs.into_iter().collect();
for e in &body_effects {
if !declared.contains(e) {
@@ -0,0 +1,129 @@
//! Iter it.2: pin tests for the structural-recursion guardedness
//! pass + the it.2-only `tail:true` grandfather + the first real
//! `Diverge` effect injection.
//!
//! Spec: `docs/specs/2026-05-15-iteration-discipline.md`. Mirrors the
//! it.1 `loop_recur_pin.rs` harness verbatim (same inline
//! `check_fixture` helper, no `mod common`).
use ailang_check::check_workspace;
use ailang_surface::load_workspace;
use std::path::PathBuf;
fn examples_dir() -> PathBuf {
let manifest = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest)
.parent().expect("CARGO_MANIFEST_DIR has a parent (crates/ailang-check)")
.parent().expect("CARGO_MANIFEST_DIR has a grandparent (crates/)")
.join("examples")
}
/// Load the named fixture under `examples/`, run check_workspace, and
/// return the diagnostic code list (one entry per Diagnostic).
fn check_fixture(fixture_name: &str) -> Vec<String> {
let path = examples_dir().join(fixture_name);
let ws = load_workspace(&path)
.unwrap_or_else(|e| panic!("workspace `{fixture_name}` must load: {e:?}"));
let diags = check_workspace(&ws);
diags.iter().map(|d| d.code.clone()).collect()
}
#[test]
fn structural_list_len_is_clean() {
// Plain non-tail recursion on a Cons sub-component typechecks
// clean: structurally guarded ⇒ pure + total, no diagnostics.
assert!(
check_fixture("struct_rec_list_len.ail").is_empty(),
"expected zero diagnostics for structural list length"
);
}
#[test]
fn foldl_accumulator_is_structural_and_clean() {
// A foldl-shape accumulator walk (structural on the tail,
// unconstrained accumulator) classifies as structural recursion
// and stays clean + Diverge-free (spec D1).
assert!(
check_fixture("struct_rec_foldl_sum.ail").is_empty(),
"expected zero diagnostics for foldl-shape accumulator walk"
);
}
#[test]
fn non_structural_self_call_is_rejected() {
// A self-call passing the un-decreased parameter at every
// candidate position, not `tail`-marked, fires
// `non-structural-recursion`.
assert!(
check_fixture("test_non_structural_recursion.ail.json")
.contains(&"non-structural-recursion".to_string()),
"expected non-structural-recursion diagnostic"
);
}
#[test]
fn tree_forest_mutual_is_clean() {
// Mutual tree/forest recursion over one ADT family (the
// cross-reference edge unions {Tree, Forest}) classifies as a
// mutual structural group: clean, Diverge-free.
assert!(
check_fixture("struct_rec_tree_forest.ail").is_empty(),
"expected zero diagnostics for same-family mutual recursion"
);
}
#[test]
fn mutual_cross_family_is_rejected() {
// Mutual recursion whose members' structural params lie in two
// unrelated ADT families is not a valid mutual structural group
// (DD-3): the cross-call is an unguarded recursive call.
assert!(
check_fixture("test_mutual_cross_family.ail.json")
.contains(&"non-structural-recursion".to_string()),
"expected non-structural-recursion for cross-family mutual recursion"
);
}
#[test]
fn loop_fn_declaring_diverge_is_clean() {
// A loop-bearing fn that declares `!Diverge` in its effect row
// reconciles clean (DD-4 / D2).
assert!(
check_fixture("loop_needs_diverge.ail").is_empty(),
"expected zero diagnostics for loop fn declaring !Diverge"
);
}
#[test]
fn loop_fn_missing_diverge_is_rejected() {
// A loop-bearing fn missing `!Diverge` raises the existing
// `undeclared-effect` (no new diagnostic variant; DD-4).
assert!(
check_fixture("test_loop_missing_diverge.ail.json")
.contains(&"undeclared-effect".to_string()),
"expected undeclared-effect for loop fn missing !Diverge"
);
}
#[test]
fn structural_recursion_is_diverge_free() {
// Structural recursion injects no effect: a structural list walk
// with no `loop` and no declared `!Diverge` stays clean.
assert!(
check_fixture("struct_rec_list_len.ail").is_empty(),
"structural recursion must be Diverge-free"
);
}
#[test]
fn non_structural_recursion_code_is_registered() {
// A CheckError::NonStructuralRecursion must map to the kebab code.
// This step only pins the code() arm exists and returns the exact
// string; fixture-level behaviour is pinned in Task 2+.
use ailang_check::CheckError;
let e = CheckError::NonStructuralRecursion {
callee: "f".into(),
arg: "n".into(),
};
assert_eq!(e.code(), "non-structural-recursion");
}
@@ -3,7 +3,7 @@
//! under `examples/*.ail.json` after milestone close. The list is
//! hardcoded; any change is a deliberate, brainstorm-level decision.
//!
//! Twelve carve-outs post iter mut.2 (2026-05-15):
//! Twenty carve-outs post iter it.2 (2026-05-15):
//! - §C4 (a) subject-matter: 7 fixtures from form-a.1 milestone
//! close (canonical-form rejection / unbound / class-def rejection).
//! - Iter mut.2: 5 negative typecheck fixtures for the new mut /
@@ -13,6 +13,14 @@
//! fixture convention. The positive nested-shadow case is a
//! .ail.json carve-out for symmetry with its negative siblings
//! (the driver tests all five from one helper).
//! - Iter mut.4-tidy: 1 lambda-capture-of-mut-var negative fixture.
//! - Iter it.1: 4 recur negative typecheck fixtures.
//! - Iter it.2: 3 negative fixtures — self non-structural recursion,
//! mutual cross-family recursion (both fire `non-structural-
//! recursion`), and a loop-bearing fn missing `!Diverge` (fires
//! the existing `undeclared-effect`, no new variant per DD-4). The
//! diagnostic code is the load-bearing assertion, not the surface
//! form.
//! - §C4 (b) compile-time-embed: retired 2026-05-14 by milestone
//! prelude-decouple; the prelude is now embedded as `prelude.ail`
//! in `ailang-surface` and parsed at compile time via
@@ -42,6 +50,10 @@ const EXPECTED: &[&str] = &[
"test_recur_arity_mismatch.ail.json",
"test_recur_type_mismatch.ail.json",
"test_recur_not_in_tail_position.ail.json",
// Iter it.2 — non-structural-recursion negative fixtures
"test_non_structural_recursion.ail.json",
"test_mutual_cross_family.ail.json",
"test_loop_missing_diverge.ail.json",
];
fn examples_dir() -> std::path::PathBuf {