Skip to main content

logicaffeine_kernel/
termination.rs

1//! Termination checking for fixpoints.
2//!
3//! This module implements the syntactic guard condition that ensures
4//! all recursive functions terminate. Without this check, the type system
5//! would be unsound - we could "prove" False by writing `fix f. f`.
6//!
7//! The algorithm (following Coq):
8//! 1. Identify the "structural parameter" - the first inductive-typed argument
9//! 2. Track variables that are "structurally smaller" than the structural parameter
10//! 3. Verify all recursive calls use a smaller argument
11//!
12//! A variable `k` is smaller than `n` if it was bound by matching on `n`:
13//! `match n with Succ k => ...` means k < n structurally.
14
15use std::collections::{HashMap, HashSet};
16
17use crate::context::Context;
18use crate::error::{KernelError, KernelResult};
19use crate::term::Term;
20
21/// Context for termination checking.
22struct GuardContext {
23    /// Each fixpoint name in the (possibly mutual) block → the ARGUMENT POSITION of its
24    /// structural (decreasing) argument in a call. For a plain recursor the position is
25    /// `0` (the scrutinee is the first argument); for an INDEXED family the scrutinee
26    /// follows its index binders, so a call `rec i… smaller` must decrease the argument
27    /// at that position. A single fixpoint is the ONE-ENTRY case; a mutual block lists
28    /// every member, since a call to ANY member must decrease.
29    fix_positions: HashMap<String, usize>,
30    /// The structural parameter of the CURRENT body — every recursive call, to SELF or a
31    /// sibling, must pass an argument structurally smaller than THIS one.
32    struct_param: String,
33    /// The type of the structural parameter (inductive name)
34    struct_type: String,
35    /// Variables known to be structurally smaller than struct_param
36    smaller_than: HashSet<String>,
37    /// False once an inner binder has shadowed `struct_param`. A `match` on that
38    /// name then refers to the shadowing binding, not the structural argument, so
39    /// it must NOT be treated as the guarding match.
40    struct_param_live: bool,
41    /// Fixpoint names shadowed by an inner binder — a call to such a name refers to the
42    /// shadowing binding, not our block, so it is not a recursive call.
43    shadowed_fix: HashSet<String>,
44}
45
46/// Check that a Fix term satisfies the syntactic guard condition.
47///
48/// This is the main entry point for termination checking.
49pub fn check_termination(ctx: &Context, fix_name: &str, body: &Term) -> KernelResult<()> {
50    // Extract the structural parameter (the scrutinee the body matches on) and its position.
51    let (struct_param, struct_type, struct_pos, inner) = extract_structural_param(ctx, body)?;
52
53    // A single fixpoint is a mutual block of one.
54    let mut fix_positions = HashMap::new();
55    fix_positions.insert(fix_name.to_string(), struct_pos);
56    let guard_ctx = GuardContext {
57        fix_positions,
58        struct_param,
59        struct_type,
60        smaller_than: HashSet::new(),
61        struct_param_live: true,
62        shadowed_fix: HashSet::new(),
63    };
64
65    // Check all recursive calls are guarded
66    check_guarded(ctx, &guard_ctx, inner)
67}
68
69/// Check that a MUTUAL block of fixpoints terminates — the mutual Giménez guard.
70///
71/// Every member's structural (decreasing) argument position is computed first; then
72/// each body is checked so that a call to ANY member — itself OR a sibling — passes, at
73/// that member's structural position, an argument structurally SMALLER than the CURRENT
74/// body's structural parameter. Any infinite call chain would then demand an infinite
75/// strictly-descending sequence of subterms, which cannot exist — so the whole block
76/// terminates. `Even.rec`'s fixpoint calling `Odd.rec` on the sub-proof, and vice
77/// versa, is exactly what this guards. It is the single-fix guard with the recursive
78/// name generalized from one to a set of positions.
79pub fn check_termination_mutual(ctx: &Context, defs: &[(String, Term)]) -> KernelResult<()> {
80    if defs.is_empty() {
81        return Err(KernelError::TerminationViolation {
82            fix_name: String::new(),
83            reason: "empty mutual fixpoint block".to_string(),
84        });
85    }
86    // 1. Structural parameter (name + type) and position of every member.
87    let mut fix_positions = HashMap::new();
88    let mut params: Vec<(String, String)> = Vec::with_capacity(defs.len());
89    for (name, body) in defs {
90        let (struct_param, struct_type, struct_pos, _) = extract_structural_param(ctx, body)?;
91        fix_positions.insert(name.clone(), struct_pos);
92        params.push((struct_param, struct_type));
93    }
94    // 2. Check every body against the whole block.
95    for (i, (_, body)) in defs.iter().enumerate() {
96        let (_, _, _, inner) = extract_structural_param(ctx, body)?;
97        let (struct_param, struct_type) = params[i].clone();
98        let guard_ctx = GuardContext {
99            fix_positions: fix_positions.clone(),
100            struct_param,
101            struct_type,
102            smaller_than: HashSet::new(),
103            struct_param_live: true,
104            shadowed_fix: HashSet::new(),
105        };
106        check_guarded(ctx, &guard_ctx, inner)?;
107    }
108    Ok(())
109}
110
111/// Identify the structural parameter — the argument recursion decreases — as the binder the
112/// fixpoint body's `match` actually discriminates on (the scrutinee), returning its name,
113/// inductive type, argument POSITION in the λ-telescope, and the body just past it.
114///
115/// This is what lets an INDEXED family recurse: `le.rec`'s fixpoint is
116/// `fix rec. λm:Nat. λh:le n m. match h …`, and the structural argument is the proof `h`
117/// (position 1) — NOT the `Nat` index `m` (position 0), even though `Nat` is inductive too.
118/// When the body does not match on a bound variable (a non-recursor fixpoint), we fall back
119/// to the first inductive-typed binder, preserving the original behavior.
120fn extract_structural_param<'a>(
121    ctx: &Context,
122    body: &'a Term,
123) -> KernelResult<(String, String, usize, &'a Term)> {
124    // Peel the λ-telescope, recording each binder and its type.
125    let mut chain: Vec<(&'a str, &'a Term)> = Vec::new();
126    let mut cur = body;
127    while let Term::Lambda { param, param_type, body: inner } = cur {
128        chain.push((param.as_str(), param_type.as_ref()));
129        cur = inner;
130    }
131
132    // The scrutinee: the binder the innermost `match` discriminates on, if any.
133    let scrutinee = match cur {
134        Term::Match { discriminant, .. } => match discriminant.as_ref() {
135            Term::Var(d) => chain.iter().position(|(n, _)| n == d),
136            _ => None,
137        },
138        _ => None,
139    };
140
141    // Choose the scrutinee when it is inductive-typed; otherwise the first inductive-typed
142    // binder (the legacy shape for non-indexed recursors and plain fixpoints).
143    let pos = scrutinee
144        .filter(|&p| extract_inductive_name(ctx, chain[p].1).is_some())
145        .or_else(|| chain.iter().position(|(_, t)| extract_inductive_name(ctx, t).is_some()))
146        .ok_or_else(|| KernelError::TerminationViolation {
147            fix_name: String::new(),
148            reason: "No inductive parameter found for structural recursion".to_string(),
149        })?;
150
151    let name = chain[pos].0.to_string();
152    let ind = extract_inductive_name(ctx, chain[pos].1).unwrap();
153
154    // The body just past the chosen structural λ, so `check_guarded` neither re-descends
155    // into that binder nor marks it shadowed.
156    let mut inner = body;
157    for _ in 0..=pos {
158        if let Term::Lambda { body: b, .. } = inner {
159            inner = b;
160        }
161    }
162    Ok((name, ind, pos, inner))
163}
164
165/// Extract the inductive type name from a type.
166///
167/// Handles both simple inductives like `Nat` and polymorphic ones like `List A`.
168fn extract_inductive_name(ctx: &Context, ty: &Term) -> Option<String> {
169    match ty {
170        Term::Global(name) if ctx.is_inductive(name) => Some(name.clone()),
171        Term::App(func, _) => extract_inductive_name(ctx, func),
172        _ => None,
173    }
174}
175
176/// Build the guard context for the scope under a binder that introduces `param`.
177///
178/// The binder shadows any prior meaning of `param`: within its body `param` no
179/// longer refers to the structural parameter, a structurally-smaller variable,
180/// or the fixpoint itself. Failing to honor this lets an inner `match` on a
181/// shadowed name be mistaken for the guarding match (admitting a non-decreasing
182/// recursion), or an inner binding of a smaller-marked name keep its "smaller"
183/// status after being rebound to an arbitrary value.
184fn enter_binder(guard_ctx: &GuardContext, param: &str) -> GuardContext {
185    let mut child = GuardContext {
186        fix_positions: guard_ctx.fix_positions.clone(),
187        struct_param: guard_ctx.struct_param.clone(),
188        struct_type: guard_ctx.struct_type.clone(),
189        smaller_than: guard_ctx.smaller_than.clone(),
190        struct_param_live: guard_ctx.struct_param_live,
191        shadowed_fix: guard_ctx.shadowed_fix.clone(),
192    };
193    child.smaller_than.remove(param);
194    if param == guard_ctx.struct_param {
195        child.struct_param_live = false;
196    }
197    if guard_ctx.fix_positions.contains_key(param) {
198        child.shadowed_fix.insert(param.to_string());
199    }
200    child
201}
202
203/// Check that all recursive calls in `term` are guarded (use smaller arguments).
204fn check_guarded(ctx: &Context, guard_ctx: &GuardContext, term: &Term) -> KernelResult<()> {
205    match term {
206        // Application. Peel the spine into (head, args-in-order). The recursive name is allowed to
207        // occur ONLY here, as the head of a call whose structural argument is smaller; in that position
208        // the head is *consumed* by the application and must NOT be re-descended as a bare value. Every
209        // argument is still checked, so a recursive name smuggled into an argument (`g f`) is caught by
210        // the `Var` leaf below.
211        Term::App(func, arg) => {
212            let mut args: Vec<&Term> = vec![arg.as_ref()];
213            let mut head = func.as_ref();
214            while let Term::App(inner_func, inner_arg) = head {
215                args.push(inner_arg.as_ref());
216                head = inner_func.as_ref();
217            }
218            args.reverse();
219
220            if let Term::Var(name) = head {
221                if !guard_ctx.shadowed_fix.contains(name) {
222                    if let Some(&pos) = guard_ctx.fix_positions.get(name) {
223                        // A recursive call — to this member or a sibling. The argument at
224                        // THAT member's structural position must be structurally smaller
225                        // than the CURRENT body's structural parameter.
226                        match args.get(pos) {
227                            Some(sarg) => verify_structural_arg_smaller(guard_ctx, sarg)?,
228                            None => {
229                                return Err(KernelError::TerminationViolation {
230                                    fix_name: name.clone(),
231                                    reason: format!(
232                                        "recursive call to '{}' is missing its structural argument (position {})",
233                                        name, pos
234                                    ),
235                                })
236                            }
237                        }
238                        for a in &args {
239                            check_guarded(ctx, guard_ctx, a)?;
240                        }
241                        return Ok(());
242                    }
243                }
244            }
245
246            // Not a recursive call at the head: check the head and every argument normally.
247            check_guarded(ctx, guard_ctx, head)?;
248            for a in &args {
249                check_guarded(ctx, guard_ctx, a)?;
250            }
251            Ok(())
252        }
253
254        // Match on structural parameter introduces smaller variables
255        Term::Match {
256            discriminant,
257            motive,
258            cases,
259        } => {
260            // The return motive is an ordinary subterm (in the current scope) and MUST be
261            // guarded too — a recursive occurrence hidden in the return predicate would
262            // otherwise evade the check, diverging from the standard CIC guard.
263            check_guarded(ctx, guard_ctx, motive)?;
264            // Check if we're matching on the structural parameter (and that it
265            // has not been shadowed by an inner binder).
266            if let Term::Var(disc_name) = discriminant.as_ref() {
267                if guard_ctx.struct_param_live && disc_name == &guard_ctx.struct_param {
268                    // This match guards recursive calls - check cases with
269                    // constructor-bound variables marked as smaller
270                    return check_match_cases_guarded(ctx, guard_ctx, cases);
271                }
272            }
273
274            // Not matching on structural param - just recurse normally
275            check_guarded(ctx, guard_ctx, discriminant)?;
276            for case in cases {
277                check_guarded(ctx, guard_ctx, case)?;
278            }
279            Ok(())
280        }
281
282        // Lambda: guard the DOMAIN annotation (current scope) as well as the body — a
283        // recursive occurrence in a binder's type must not evade the check. The binder may
284        // shadow the structural parameter, a smaller variable, or the fixpoint name.
285        Term::Lambda { param, param_type, body } => {
286            check_guarded(ctx, guard_ctx, param_type)?;
287            let child = enter_binder(guard_ctx, param);
288            check_guarded(ctx, &child, body)
289        }
290
291        // Pi: same domain + binder-shadowing handling as Lambda.
292        Term::Pi { param, param_type, body_type } => {
293            check_guarded(ctx, guard_ctx, param_type)?;
294            let child = enter_binder(guard_ctx, param);
295            check_guarded(ctx, &child, body_type)
296        }
297
298        // Nested fixpoint: its own name shadows ours; its body gets its own
299        // termination check when type-checked.
300        Term::Fix { name, body } => {
301            let child = enter_binder(guard_ctx, name);
302            check_guarded(ctx, &child, body)
303        }
304
305        // Nested MUTUAL fixpoint: all of its names shadow ours; each body gets its own
306        // mutual termination check when type-checked.
307        Term::MutualFix { defs, .. } => {
308            let mut child = enter_binder(guard_ctx, &defs[0].0);
309            for (n, _) in &defs[1..] {
310                child = enter_binder(&child, n);
311            }
312            for (_, b) in defs {
313                check_guarded(ctx, &child, b)?;
314            }
315            Ok(())
316        }
317
318        // Let: the type and value are in the outer scope; the body is under the
319        // let-binder, which may shadow the tracked names. The value is NOT
320        // registered as smaller (the conservative v1 guard — a `fix` whose
321        // recursive call passes a let-alias of a smaller variable is rejected).
322        Term::Let { name, ty, value, body, .. } => {
323            check_guarded(ctx, guard_ctx, ty)?;
324            check_guarded(ctx, guard_ctx, value)?;
325            let child = enter_binder(guard_ctx, name);
326            check_guarded(ctx, &child, body)
327        }
328
329        // A bare occurrence of a block member's name is the higher-order escape: `f` used
330        // as a first-class value (returned, or passed as an argument) rather than applied
331        // to a structurally-smaller argument. Only fully-applied decreasing calls are
332        // guarded (Giménez 1995; the Coq guard), so reject it. Valid recursive calls never
333        // reach here — their head is consumed by the `App` arm above.
334        Term::Var(name)
335            if guard_ctx.fix_positions.contains_key(name)
336                && !guard_ctx.shadowed_fix.contains(name) =>
337        {
338            Err(KernelError::TerminationViolation {
339                fix_name: name.clone(),
340                reason: format!(
341                    "recursive name '{}' occurs as a first-class value, not applied to a structurally-smaller argument",
342                    name
343                ),
344            })
345        }
346
347        // Other leaves: no recursive calls possible.
348        Term::Sort(_) | Term::Var(_) | Term::Global(_) | Term::Lit(_) | Term::Hole
349        | Term::Const { .. } => Ok(()),
350    }
351}
352
353/// Verify the structural (first) argument of a recursive call is structurally smaller
354/// than the decreasing parameter. Two admissible shapes:
355///
356/// - a bare variable bound by matching on the structural parameter (`x` in
357///   `match n with Succ x => … rec x …`); or
358/// - an APPLICATION `h a₁ … aₙ` whose HEAD `h` is such a smaller variable
359///   (Giménez's rule). This is what makes recursion over an accessibility proof
360///   terminate: `Acc_intro`'s field `h : Π(y). R y x → Acc A R y` is bound by the
361///   match and marked smaller, and `h y hr` is a sub-`Acc`-proof it contains.
362///
363/// The applied form is SOUND precisely because strict positivity (`positivity.rs`)
364/// guarantees every functional constructor field places the inductive only in a
365/// codomain-result position — so applying such a field can only yield a proper
366/// SUBTERM, never a larger one. A field placing the inductive in a domain (the
367/// `(Bad → …) → Bad` paradox) is rejected at inductive-registration, so it never
368/// reaches this guard.
369impl GuardContext {
370    /// A stable name for the fixpoint block, for error messages.
371    fn block_name(&self) -> String {
372        let mut names: Vec<&String> = self.fix_positions.keys().collect();
373        names.sort();
374        names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join("/")
375    }
376}
377
378fn verify_structural_arg_smaller(guard_ctx: &GuardContext, first_arg: &Term) -> KernelResult<()> {
379    // Peel the application spine to its head.
380    let mut head = first_arg;
381    while let Term::App(f, _) = head {
382        head = f;
383    }
384    match head {
385        Term::Var(arg_name) if guard_ctx.smaller_than.contains(arg_name) => Ok(()),
386        Term::Var(arg_name) => Err(KernelError::TerminationViolation {
387            fix_name: guard_ctx.block_name(),
388            reason: format!(
389                "Recursive call with '{}' which is not structurally smaller than '{}'",
390                arg_name, guard_ctx.struct_param
391            ),
392        }),
393        _ => Err(KernelError::TerminationViolation {
394            fix_name: guard_ctx.block_name(),
395            reason: "Recursive call whose structural argument is not headed by a \
396                     structurally-smaller variable"
397                .to_string(),
398        }),
399    }
400}
401
402/// Check match cases with structural variables marked as smaller.
403fn check_match_cases_guarded(
404    ctx: &Context,
405    guard_ctx: &GuardContext,
406    cases: &[Term],
407) -> KernelResult<()> {
408    // Get constructors for the inductive type
409    let constructors = ctx.get_constructors(&guard_ctx.struct_type);
410
411    for (case, (ctor_name, ctor_type)) in cases.iter().zip(constructors.iter()) {
412        // Count constructor parameters
413        let param_count = count_pi_params(ctor_type);
414
415        // Extract the smaller variables from this case
416        // The case is typically: λx1. λx2. ... λxn. body
417        // where x1..xn are the constructor parameters
418        let (smaller_vars, case_body) = extract_lambda_params(case, param_count);
419
420        // Create extended guard context with these variables as smaller
421        let mut extended_ctx = GuardContext {
422            fix_positions: guard_ctx.fix_positions.clone(),
423            struct_param: guard_ctx.struct_param.clone(),
424            struct_type: guard_ctx.struct_type.clone(),
425            smaller_than: guard_ctx.smaller_than.clone(),
426            struct_param_live: guard_ctx.struct_param_live,
427            shadowed_fix: guard_ctx.shadowed_fix.clone(),
428        };
429
430        // Mark every constructor parameter as structurally smaller.
431        //
432        // Soundness: we only reach here when the discriminant is the structural
433        // parameter itself (see `check_guarded`'s `Match` arm), so the matched
434        // value is `ctor x1 … xn` and each `xi` is a *direct argument* of that
435        // constructor — hence a genuine structural subterm of the parameter.
436        // Recursing on any `xi` therefore decreases. Parameters of a foreign
437        // type are still marked, but a recursive call on one cannot type-check
438        // (the fixpoint expects the structural type), and complex (non-variable)
439        // recursive arguments are rejected outright — so the over-approximation
440        // never admits a non-terminating fixpoint. `ctor_name`/`ctor_type` are
441        // bound for the constructor-arity computation above.
442        let _ = ctor_name;
443        for var in &smaller_vars {
444            extended_ctx.smaller_than.insert(var.clone());
445            // A constructor binder may reuse the name of the structural parameter
446            // or the fixpoint; inside this case that name now refers to the
447            // (smaller) bound variable, so the original meaning is shadowed.
448            if var == &guard_ctx.struct_param {
449                extended_ctx.struct_param_live = false;
450            }
451            if guard_ctx.fix_positions.contains_key(var) {
452                extended_ctx.shadowed_fix.insert(var.clone());
453            }
454        }
455
456        // Check the case body with the extended context
457        check_guarded(ctx, &extended_ctx, case_body)?;
458    }
459
460    Ok(())
461}
462
463/// Count the number of Pi parameters in a type.
464fn count_pi_params(ty: &Term) -> usize {
465    match ty {
466        Term::Pi { body_type, .. } => 1 + count_pi_params(body_type),
467        _ => 0,
468    }
469}
470
471/// Extract lambda parameters and return (param_names, body).
472fn extract_lambda_params(term: &Term, count: usize) -> (Vec<String>, &Term) {
473    if count == 0 {
474        return (Vec::new(), term);
475    }
476
477    match term {
478        Term::Lambda { param, body, .. } => {
479            let (mut params, final_body) = extract_lambda_params(body, count - 1);
480            params.insert(0, param.clone());
481            (params, final_body)
482        }
483        _ => (Vec::new(), term),
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::term::Universe;
491
492    /// A context with `Nat` (with `Zero`/`Succ`) and `False : Prop` — enough to exercise the guard.
493    fn nat_context() -> Context {
494        let mut ctx = Context::new();
495        let nat = Term::Global("Nat".to_string());
496        ctx.add_inductive("Nat", Term::Sort(Universe::Type(0)));
497        ctx.add_constructor("Zero", "Nat", nat.clone());
498        ctx.add_constructor(
499            "Succ",
500            "Nat",
501            Term::Pi { param: "_".to_string(), param_type: Box::new(nat.clone()), body_type: Box::new(nat) },
502        );
503        ctx.add_inductive("False", Term::Sort(Universe::Prop));
504        ctx
505    }
506
507    fn nat() -> Term {
508        Term::Global("Nat".to_string())
509    }
510
511    fn app(f: Term, x: Term) -> Term {
512        Term::App(Box::new(f), Box::new(x))
513    }
514
515    fn var(n: &str) -> Term {
516        Term::Var(n.to_string())
517    }
518
519    /// THE SOUNDNESS RED TEST (CRITIQUE finding #1). The structural-recursion guard must reject a
520    /// fixpoint that smuggles its own recursive name `f` as a FIRST-CLASS ARGUMENT — `(λg:Nat→False. g
521    /// Zero) f` — instead of applying it to a structurally-smaller value. With the higher-order escape,
522    /// `f` is visited only as an inert `Var` leaf, the guard passes, and `boom Zero : False` inhabits
523    /// `False` with zero axioms. The guard MUST reject this body.
524    #[test]
525    fn recursive_name_smuggled_as_a_first_class_argument_is_rejected() {
526        let ctx = nat_context();
527        let nat_to_false = Term::Pi {
528            param: "_".to_string(),
529            param_type: Box::new(nat()),
530            body_type: Box::new(Term::Global("False".to_string())),
531        };
532
533        // Zero case: (λg:Nat→False. g Zero) f  — `f` (the fixpoint) passed as an argument.
534        let zero_case = app(
535            Term::Lambda {
536                param: "g".to_string(),
537                param_type: Box::new(nat_to_false),
538                body: Box::new(app(var("g"), Term::Global("Zero".to_string()))),
539            },
540            var("f"),
541        );
542        // Succ case: λk:Nat. f k  — a genuinely-guarded recursive call (only the Zero case escapes).
543        let succ_case = Term::Lambda {
544            param: "k".to_string(),
545            param_type: Box::new(nat()),
546            body: Box::new(app(var("f"), var("k"))),
547        };
548        let body = Term::Lambda {
549            param: "n".to_string(),
550            param_type: Box::new(nat()),
551            body: Box::new(Term::Match {
552                discriminant: Box::new(var("n")),
553                motive: Box::new(Term::Lambda {
554                    param: "_".to_string(),
555                    param_type: Box::new(nat()),
556                    body: Box::new(Term::Global("False".to_string())),
557                }),
558                cases: vec![zero_case, succ_case],
559            }),
560        };
561
562        let result = check_termination(&ctx, "f", &body);
563        assert!(
564            result.is_err(),
565            "kernel soundness: a fixpoint that passes its recursive name as a first-class value inhabits \
566             False and MUST be rejected by the termination guard, but it was accepted"
567        );
568    }
569
570    /// Regression guard: genuine structural recursion `fix f. λn. match n with Zero => Zero | Succ k => f k`
571    /// must still pass. The fix for the escape must not reject honest fully-applied decreasing calls.
572    #[test]
573    fn genuine_structural_recursion_still_passes() {
574        let ctx = nat_context();
575        let succ_case = Term::Lambda {
576            param: "k".to_string(),
577            param_type: Box::new(nat()),
578            body: Box::new(app(var("f"), var("k"))), // f k — k is structurally smaller
579        };
580        let body = Term::Lambda {
581            param: "n".to_string(),
582            param_type: Box::new(nat()),
583            body: Box::new(Term::Match {
584                discriminant: Box::new(var("n")),
585                motive: Box::new(Term::Lambda {
586                    param: "_".to_string(),
587                    param_type: Box::new(nat()),
588                    body: Box::new(nat()),
589                }),
590                cases: vec![Term::Global("Zero".to_string()), succ_case],
591            }),
592        };
593        assert!(check_termination(&ctx, "f", &body).is_ok(), "honest structural recursion must still pass");
594    }
595
596    /// The recursive name returned bare from a branch (`match n with Zero => f | …`) is the same escape
597    /// in a different costume — `f` as a value, not a guarded call. Must be rejected.
598    #[test]
599    fn recursive_name_returned_bare_from_a_branch_is_rejected() {
600        let ctx = nat_context();
601        let succ_case = Term::Lambda {
602            param: "k".to_string(),
603            param_type: Box::new(nat()),
604            body: Box::new(app(var("f"), var("k"))),
605        };
606        let body = Term::Lambda {
607            param: "n".to_string(),
608            param_type: Box::new(nat()),
609            body: Box::new(Term::Match {
610                discriminant: Box::new(var("n")),
611                motive: Box::new(Term::Lambda {
612                    param: "_".to_string(),
613                    param_type: Box::new(nat()),
614                    body: Box::new(nat()),
615                }),
616                cases: vec![var("f"), succ_case], // Zero => f  (bare recursive name)
617            }),
618        };
619        assert!(check_termination(&ctx, "f", &body).is_err(), "a branch returning the bare fixpoint must be rejected");
620    }
621
622    /// APPLIED-SMALLER FENCE #1 (the `Acc` extension's over-admission risk). The
623    /// applied-smaller rule admits `rec (h a…)` ONLY when the HEAD `h` is a
624    /// structurally-smaller variable. A CONSTRUCTOR head is not smaller: `f (Succ k)`
625    /// passes `Succ k`, which is strictly LARGER than the matched `k`. Accepting it
626    /// would make `fix f. λn. match n with Zero => … | Succ k => f (Succ k)` loop
627    /// forever (it recurses on a value bigger than the one it destructed). The guard
628    /// MUST reject the constructor-headed structural argument.
629    #[test]
630    fn recursive_call_on_a_constructor_applied_to_a_smaller_var_is_rejected() {
631        let ctx = nat_context();
632        let succ_case = Term::Lambda {
633            param: "k".to_string(),
634            param_type: Box::new(nat()),
635            // f (Succ k) — structural argument headed by the constructor `Succ`, not a smaller var.
636            body: Box::new(app(var("f"), app(Term::Global("Succ".to_string()), var("k")))),
637        };
638        let body = Term::Lambda {
639            param: "n".to_string(),
640            param_type: Box::new(nat()),
641            body: Box::new(Term::Match {
642                discriminant: Box::new(var("n")),
643                motive: Box::new(Term::Lambda {
644                    param: "_".to_string(),
645                    param_type: Box::new(nat()),
646                    body: Box::new(nat()),
647                }),
648                cases: vec![Term::Global("Zero".to_string()), succ_case],
649            }),
650        };
651        assert!(
652            check_termination(&ctx, "f", &body).is_err(),
653            "kernel soundness: a recursive call on `Succ k` (larger than the matched `k`) must be rejected"
654        );
655    }
656
657    /// APPLIED-SMALLER FENCE #2. The applied head must be a SMALLER variable, not
658    /// merely *some* variable. Here `h` is an ordinary function parameter — never
659    /// bound by the guarding match, so not marked smaller — and `f h (h k)` recurses
660    /// on `h k`. Since `h` could be `Succ`, `h k` is not a subterm of `n`; the guard
661    /// must reject applying a non-smaller variable in the structural position.
662    #[test]
663    fn recursive_call_on_a_non_smaller_variable_applied_is_rejected() {
664        let ctx = nat_context();
665        let nat_to_nat = Term::Pi {
666            param: "_".to_string(),
667            param_type: Box::new(nat()),
668            body_type: Box::new(nat()),
669        };
670        let succ_case = Term::Lambda {
671            param: "k".to_string(),
672            param_type: Box::new(nat()),
673            // f h (h k) — structural argument (position 1) is `h k`, headed by the non-smaller `h`.
674            body: Box::new(app(app(var("f"), var("h")), app(var("h"), var("k")))),
675        };
676        let body = Term::Lambda {
677            param: "h".to_string(),
678            param_type: Box::new(nat_to_nat),
679            body: Box::new(Term::Lambda {
680                param: "n".to_string(),
681                param_type: Box::new(nat()),
682                body: Box::new(Term::Match {
683                    discriminant: Box::new(var("n")),
684                    motive: Box::new(Term::Lambda {
685                        param: "_".to_string(),
686                        param_type: Box::new(nat()),
687                        body: Box::new(nat()),
688                    }),
689                    cases: vec![Term::Global("Zero".to_string()), succ_case],
690                }),
691            }),
692        };
693        assert!(
694            check_termination(&ctx, "f", &body).is_err(),
695            "kernel soundness: recursing on `h k` where `h` is not structurally smaller must be rejected"
696        );
697    }
698
699    /// AUDIT FIX: a non-decreasing recursive call hidden in the match RETURN MOTIVE must be
700    /// caught. The guard traverses the motive (as the standard CIC guard does), not only the
701    /// discriminant and cases — otherwise `f n` here would evade the check.
702    #[test]
703    fn recursive_call_hidden_in_the_match_motive_is_rejected() {
704        let ctx = nat_context();
705        let succ_case = Term::Lambda {
706            param: "k".to_string(),
707            param_type: Box::new(nat()),
708            body: Box::new(app(var("f"), var("k"))),
709        };
710        let body = Term::Lambda {
711            param: "n".to_string(),
712            param_type: Box::new(nat()),
713            body: Box::new(Term::Match {
714                discriminant: Box::new(var("n")),
715                // motive λ_:Nat. (f n) — a non-decreasing recursive occurrence.
716                motive: Box::new(Term::Lambda {
717                    param: "_".to_string(),
718                    param_type: Box::new(nat()),
719                    body: Box::new(app(var("f"), var("n"))),
720                }),
721                cases: vec![Term::Global("Zero".to_string()), succ_case],
722            }),
723        };
724        assert!(
725            check_termination(&ctx, "f", &body).is_err(),
726            "a recursive call in the match motive must be rejected"
727        );
728    }
729
730    /// AUDIT FIX: a non-decreasing recursive call hidden in a binder's DOMAIN annotation
731    /// must be caught — the guard traverses `λ`/`Π` parameter types too.
732    #[test]
733    fn recursive_call_hidden_in_a_binder_domain_is_rejected() {
734        let ctx = nat_context();
735        // Succ case: λk. λ(_ : f n). f k  — `f n` sits in the inner λ's domain.
736        let succ_case = Term::Lambda {
737            param: "k".to_string(),
738            param_type: Box::new(nat()),
739            body: Box::new(Term::Lambda {
740                param: "_".to_string(),
741                param_type: Box::new(app(var("f"), var("n"))),
742                body: Box::new(app(var("f"), var("k"))),
743            }),
744        };
745        let body = Term::Lambda {
746            param: "n".to_string(),
747            param_type: Box::new(nat()),
748            body: Box::new(Term::Match {
749                discriminant: Box::new(var("n")),
750                motive: Box::new(Term::Lambda {
751                    param: "_".to_string(),
752                    param_type: Box::new(nat()),
753                    body: Box::new(nat()),
754                }),
755                cases: vec![Term::Global("Zero".to_string()), succ_case],
756            }),
757        };
758        assert!(
759            check_termination(&ctx, "f", &body).is_err(),
760            "a recursive call in a binder domain must be rejected"
761        );
762    }
763
764    /// The classic non-terminating shape `fix f. λn. f n` — a recursive call on the structural
765    /// parameter itself, which does not decrease. Must be rejected.
766    #[test]
767    fn non_decreasing_recursive_call_is_rejected() {
768        let ctx = nat_context();
769        let body = Term::Lambda {
770            param: "n".to_string(),
771            param_type: Box::new(nat()),
772            body: Box::new(app(var("f"), var("n"))), // f n — n is the parameter, not smaller
773        };
774        assert!(check_termination(&ctx, "f", &body).is_err(), "a non-decreasing self-call must be rejected");
775    }
776}
777