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