Skip to main content

aver/codegen/lean/
lemma_calc.rs

1//! Lemma-Calc: mechanically CALCULATE the forced auxiliary lemma from a stuck
2//! `--explain` residual goal ([`UntranslatedGoal`]). A pure function of the goal
3//! and the induction hypothesis — no enumeration, no search, no solver. Every
4//! step is deterministic; anything that is not forced is an HONEST DECLINE
5//! ([`CalcVerdict::Decline`]) with a named reason. The result feeds the same VM
6//! sample-check + candidate renderer as the raw residual (`--explain` in
7//! `src/main/commands.rs`); on decline the caller falls back to the raw
8//! candidate, so the calculator only ever ADDS a stronger lemma, never removes
9//! information.
10//!
11//! Operations (in order), from the procedure trace in the G2b design:
12//!   1. `subst` — apply the induction hypothesis ONCE, left-to-right, with an
13//!      anti-loop guard. An IH `l = r` whose `l` is an exact subtree of the claim
14//!      rewrites every `l` to `r` and is consumed (the claim becomes IH-free).
15//!   2. `lift (a)` — anti-unify the claim against a surviving IH premise: lift
16//!      each maximal common subterm to a fresh variable, allowing the claim side
17//!      to carry ONE extra constructor (`len ys` vs `Succ (len ys)`). Every diff
18//!      point must be a common subterm or such a constructor-wrap, else decline
19//!      (a lemma that needs a term outside the goal and IH is not forced).
20//!   3. `lift (b)` — lift each maximal user-function-application subterm that
21//!      occurs on BOTH sides of the claim to a fresh variable (typed by the
22//!      function's declared return type). Correspondence-preserving: a subterm on
23//!      only one side is left literal (the calculator stops at the literal
24//!      remainder; further generalization is The Method's job).
25//!
26//! Identity note (identity_guardrails): a pure `ast::Expr` transform. It keys on
27//! the claim's SHAPE (constructor-wrap, function-application head), never on
28//! function or domain names; the constructor / function names it reads are DATA
29//! from the program, threaded through [`CalcEnv`]. Holds no `FnId`/`TypeId` table.
30
31use std::collections::{BTreeMap, HashSet};
32
33use crate::ast::{BinOp, Expr, Spanned, TopLevel};
34
35use super::untranslate::UntranslatedGoal;
36
37/// The calculator's verdict: a forced lemma (a rewritten goal ready for the
38/// candidate builder), or an honest decline naming why the lemma is not forced.
39#[derive(Debug, Clone)]
40pub enum CalcVerdict {
41    Lemma(Box<UntranslatedGoal>),
42    Decline(String),
43}
44
45/// Program facts the calculator reads as DATA: the constructor names (so a
46/// constructor-wrap is distinguished from a function application) and each
47/// function's declared return type (to type a lifted variable). Shape-keyed
48/// machinery, name-blind: these are looked up, never matched against.
49pub struct CalcEnv {
50    ctors: HashSet<String>,
51    fn_ret: BTreeMap<String, String>,
52}
53
54impl CalcEnv {
55    pub fn from_items(items: &[TopLevel]) -> Self {
56        let mut ctors = HashSet::new();
57        let mut fn_ret = BTreeMap::new();
58        for it in items {
59            match it {
60                TopLevel::TypeDef(crate::ast::TypeDef::Sum { name, variants, .. }) => {
61                    for v in variants {
62                        ctors.insert(v.name.clone());
63                        ctors.insert(format!("{name}.{}", v.name));
64                    }
65                }
66                TopLevel::FnDef(f) => {
67                    fn_ret.insert(f.name.clone(), f.return_type.clone());
68                }
69                _ => {}
70            }
71        }
72        CalcEnv { ctors, fn_ret }
73    }
74
75    fn is_ctor(&self, name: &str) -> bool {
76        self.ctors.contains(name)
77            || name
78                .rsplit_once('.')
79                .is_some_and(|(_, s)| self.ctors.contains(s))
80    }
81
82    /// The declared return type of `name`, if it is a program function.
83    fn fn_return(&self, name: &str) -> Option<&str> {
84        self.fn_ret.get(name).map(String::as_str)
85    }
86}
87
88/// Calculate the forced lemma for `goal`, or decline. Mutates a clone; the input
89/// is untouched (the caller keeps the raw residual for the decline fallback).
90/// `reserved` names are kept off the fresh-variable stream in addition to the
91/// goal's own binders: the candidate builder resolves givens by name against the
92/// PARENT law, so a lifted variable that collides with a parent given would clone
93/// the wrong sample domain (name capture). The caller passes the parent law's
94/// given names.
95pub fn calculate(
96    goal: &UntranslatedGoal,
97    env: &CalcEnv,
98    reserved: &HashSet<String>,
99) -> CalcVerdict {
100    // Aver `when` is a single Bool expression; a residual with several surviving
101    // premises is not a single forced lemma (the candidate builder declines it
102    // too, but naming it here keeps the verdict on the calculator's surface).
103    if goal.premises.len() > 1 {
104        return CalcVerdict::Decline(format!(
105            "{} surviving premises (a `when` is one Bool expression)",
106            goal.premises.len()
107        ));
108    }
109    let mut g = goal.clone();
110
111    // 1. subst: apply the IH once, L->R.
112    let substituted = subst_ih_once(&mut g);
113
114    // 2a. lift (a): anti-unify the claim against a surviving IH premise.
115    if let Some(prem) = g.premises.first().cloned() {
116        match lift_antiunify(&mut g, &prem, env, reserved) {
117            Ok(()) => {}
118            Err(reason) => return CalcVerdict::Decline(reason),
119        }
120    }
121
122    // 2b. lift (b): generalize maximal both-sides user-function subterms.
123    let lifted_b = lift_opaque_subterms(&mut g, env, reserved);
124
125    rebuild_givens(&mut g, goal);
126
127    // A residual that neither substituted, anti-unified, nor lifted is the goal
128    // itself — an executor gap in the engine, not a missing lemma.
129    if !substituted && !lifted_b && g.claim == goal.claim && g.premises == goal.premises {
130        return CalcVerdict::Decline(
131            "residual is the parent claim itself (no decomposition — an executor gap)".to_string(),
132        );
133    }
134    CalcVerdict::Lemma(Box::new(g))
135}
136
137// ---------------------------------------------------------------- subst -------
138
139/// Apply a single IH premise `l = r` left-to-right if `l` is an exact subtree of
140/// the claim and `r` does not contain `l` (anti-loop). Rewrites every `l` to `r`
141/// and consumes the premise. Returns whether it applied.
142fn subst_ih_once(g: &mut UntranslatedGoal) -> bool {
143    let Some(prem) = g.premises.first() else {
144        return false;
145    };
146    let Expr::BinOp(BinOp::Eq, l, r) = &prem.node else {
147        return false;
148    };
149    let (l, r) = (l.node.clone(), r.node.clone());
150    let in_claim = contains_subtree(&g.claim.0.node, &l) || contains_subtree(&g.claim.1.node, &l);
151    if !in_claim || contains_subtree(&r, &l) {
152        return false;
153    }
154    substitute(&mut g.claim.0.node, &l, &r);
155    substitute(&mut g.claim.1.node, &l, &r);
156    g.premises.clear();
157    true
158}
159
160// -------------------------------------------------------------- lift (a) ------
161
162/// Anti-unify the claim against a surviving IH premise (both `<expr> = <rhs>`
163/// shapes), lifting maximal common subterms to fresh variables and allowing the
164/// claim to carry one extra unary constructor. On success the generalization is
165/// applied to BOTH the claim and the premise; a diff point that is neither a
166/// common subterm nor a constructor-wrap declines.
167fn lift_antiunify(
168    g: &mut UntranslatedGoal,
169    prem: &Spanned<Expr>,
170    env: &CalcEnv,
171    reserved: &HashSet<String>,
172) -> Result<(), String> {
173    // The premise is `<pl> = <pr>` (an IH equality untranslated to `pl == pr`).
174    let Expr::BinOp(BinOp::Eq, pl, pr) = &prem.node else {
175        return Ok(()); // not an equality premise — nothing to anti-unify
176    };
177    let mut existing: HashSet<String> = g.givens.iter().map(|(n, _)| n.clone()).collect();
178    existing.extend(reserved.iter().cloned());
179    let mut fresh = FreshVars::new(existing);
180    let mut subst: Vec<(Expr, String)> = Vec::new();
181
182    // Anti-unify the two equality sides pairwise. Both must align structurally
183    // (same claim/premise shape); a mismatch that is not a constructor-wrap is a
184    // diff requiring an invented term — decline.
185    let new_lhs = antiunify(&g.claim.0.node, &pl.node, env, &mut subst, &mut fresh)?;
186    let new_rhs = antiunify(&g.claim.1.node, &pr.node, env, &mut subst, &mut fresh)?;
187    if subst.is_empty() {
188        return Ok(()); // no common structure lifted — leave as-is
189    }
190    g.claim.0.node = new_lhs;
191    g.claim.1.node = new_rhs;
192    // Generalize the premise consistently: replace each lifted concrete subterm.
193    let mut new_prem = prem.node.clone();
194    for (concrete, var) in &subst {
195        substitute(&mut new_prem, concrete, &Expr::Ident(var.clone()));
196    }
197    g.premises = vec![Spanned::new(new_prem, 0)];
198    // Record the fresh givens (type = the head function's return type).
199    for (concrete, var) in &subst {
200        let ty = lifted_type(concrete, env)
201            .ok_or_else(|| format!("cannot type the lifted variable `{var}`"))?;
202        g.givens.push((var.clone(), ty));
203    }
204    Ok(())
205}
206
207/// The anti-unifier of a claim node and a premise node. Equal subtrees lift to a
208/// shared fresh variable; a claim `Ctor(inner)` whose `inner` anti-unifies with
209/// the premise carries the constructor over the lifted result; same head + arity
210/// recurses; anything else is a diff that needs an invented term (`Err`).
211fn antiunify(
212    claim: &Expr,
213    prem: &Expr,
214    env: &CalcEnv,
215    subst: &mut Vec<(Expr, String)>,
216    fresh: &mut FreshVars,
217) -> Result<Expr, String> {
218    if claim == prem {
219        // Only lift a compound common subterm — a bare literal / variable shared
220        // by both sides is not a generalization target (it would rename a
221        // constant), so keep it literal.
222        if is_liftable_atom_or_call(claim, env) {
223            return Ok(Expr::Ident(var_for(subst, claim, fresh)));
224        }
225        return Ok(claim.clone());
226    }
227    // Constructor-wrap: the claim has one extra unary constructor the IH lacks
228    // (`Succ (len ys)` vs `len ys`). Peel it, anti-unify the inner, rewrap.
229    if let Some((ctor, inner)) = unary_ctor_app(claim, env) {
230        let g_inner = antiunify(inner, prem, env, subst, fresh)?;
231        return Ok(rewrap_ctor(ctor, g_inner));
232    }
233    // Same application head and arity → recurse pairwise.
234    if let (Some((h1, a1)), Some((h2, a2))) = (as_call(claim), as_call(prem))
235        && h1 == h2
236        && a1.len() == a2.len()
237    {
238        let mut args = Vec::with_capacity(a1.len());
239        for (c, p) in a1.iter().zip(a2.iter()) {
240            args.push(Spanned::new(
241                antiunify(&c.node, &p.node, env, subst, fresh)?,
242                0,
243            ));
244        }
245        return Ok(Expr::FnCall(
246            Box::new(Spanned::new(Expr::Ident(h1.to_string()), 0)),
247            args,
248        ));
249    }
250    // Same binary operator → recurse on both operands.
251    if let (Expr::BinOp(o1, l1, r1), Expr::BinOp(o2, l2, r2)) = (claim, prem)
252        && o1 == o2
253    {
254        let l = antiunify(&l1.node, &l2.node, env, subst, fresh)?;
255        let r = antiunify(&r1.node, &r2.node, env, subst, fresh)?;
256        return Ok(Expr::BinOp(
257            *o1,
258            Box::new(Spanned::new(l, 0)),
259            Box::new(Spanned::new(r, 0)),
260        ));
261    }
262    Err("anti-unification diff needs a term outside the goal and IH".to_string())
263}
264
265// -------------------------------------------------------------- lift (b) ------
266
267/// Lift each maximal user-function-application subterm that occurs on BOTH sides
268/// of the claim to a fresh variable (correspondence-preserving generalization).
269/// A subterm on only one side is left literal. Returns whether anything lifted.
270fn lift_opaque_subterms(
271    g: &mut UntranslatedGoal,
272    env: &CalcEnv,
273    reserved: &HashSet<String>,
274) -> bool {
275    // Candidate fn-app subterms on each side (excluding the whole side itself:
276    // lifting `f(x) = y` to `v = y` is not a decomposition).
277    let mut lhs: Vec<Expr> = Vec::new();
278    collect_fn_apps(&g.claim.0.node, env, true, &mut lhs);
279    let mut rhs: Vec<Expr> = Vec::new();
280    collect_fn_apps(&g.claim.1.node, env, true, &mut rhs);
281
282    // Subterms occurring on BOTH sides (deduped), then the MAXIMAL of those
283    // (drop any that is contained in another both-sides subterm).
284    let mut both: Vec<Expr> = Vec::new();
285    for e in &lhs {
286        if rhs.iter().any(|r| r == e) && !both.iter().any(|b| b == e) {
287            both.push(e.clone());
288        }
289    }
290    let maximal: Vec<Expr> = both
291        .iter()
292        .filter(|e| {
293            !both
294                .iter()
295                .any(|other| other != *e && contains_subtree(other, e))
296        })
297        .cloned()
298        .collect();
299
300    let mut existing: HashSet<String> = g.givens.iter().map(|(n, _)| n.clone()).collect();
301    existing.extend(reserved.iter().cloned());
302    let mut fresh = FreshVars::new(existing);
303    let mut lifted = false;
304    for sub in maximal {
305        let Some(ty) = lifted_type(&sub, env) else {
306            continue; // untypable head → leave literal
307        };
308        let var = fresh.next();
309        substitute(&mut g.claim.0.node, &sub, &Expr::Ident(var.clone()));
310        substitute(&mut g.claim.1.node, &sub, &Expr::Ident(var.clone()));
311        for p in &mut g.premises {
312            substitute(&mut p.node, &sub, &Expr::Ident(var.clone()));
313        }
314        g.givens.push((var, ty));
315        lifted = true;
316    }
317    lifted
318}
319
320/// Collect EVERY user-function-application subterm of `e` (nested included, so a
321/// deeper subterm shared across sides is found even when it sits inside a bigger
322/// fn-app on one side). When `top` is true the whole `e` is skipped (only proper
323/// subterms), so a claim side that is itself `f(...)` is not lifted wholesale.
324/// Maximality is decided later, over the both-sides intersection.
325fn collect_fn_apps(e: &Expr, env: &CalcEnv, top: bool, out: &mut Vec<Expr>) {
326    if !top && is_user_fn_call(e, env) {
327        out.push(e.clone());
328    }
329    for c in children(e) {
330        collect_fn_apps(&c.node, env, false, out);
331    }
332}
333
334// ------------------------------------------------------------- helpers --------
335
336/// A monotonic fresh-variable source (`a`, `b`, …, `a1`, `b1`, …) skipping any
337/// name already bound in the goal.
338struct FreshVars {
339    used: HashSet<String>,
340    idx: usize,
341}
342
343impl FreshVars {
344    fn new(used: HashSet<String>) -> Self {
345        FreshVars { used, idx: 0 }
346    }
347
348    fn next(&mut self) -> String {
349        loop {
350            let letter = (b'a' + (self.idx % 26) as u8) as char;
351            let round = self.idx / 26;
352            let name = if round == 0 {
353                letter.to_string()
354            } else {
355                format!("{letter}{round}")
356            };
357            self.idx += 1;
358            if !self.used.contains(&name) {
359                self.used.insert(name.clone());
360                return name;
361            }
362        }
363    }
364}
365
366/// The fresh variable already assigned to `subterm`, or a new one.
367fn var_for(subst: &mut Vec<(Expr, String)>, subterm: &Expr, fresh: &mut FreshVars) -> String {
368    if let Some((_, v)) = subst.iter().find(|(e, _)| e == subterm) {
369        return v.clone();
370    }
371    let v = fresh.next();
372    subst.push((subterm.clone(), v.clone()));
373    v
374}
375
376/// `FnCall(Ident(name), [arg])` where `name` is a UNARY constructor → `(name, arg)`.
377fn unary_ctor_app<'a>(e: &'a Expr, env: &CalcEnv) -> Option<(&'a str, &'a Expr)> {
378    if let Expr::FnCall(head, args) = e
379        && args.len() == 1
380        && let Expr::Ident(name) = &head.node
381        && env.is_ctor(name)
382    {
383        return Some((name.as_str(), &args[0].node));
384    }
385    None
386}
387
388fn rewrap_ctor(ctor: &str, inner: Expr) -> Expr {
389    Expr::FnCall(
390        Box::new(Spanned::new(Expr::Ident(ctor.to_string()), 0)),
391        vec![Spanned::new(inner, 0)],
392    )
393}
394
395/// `FnCall(Ident(name), args)` → `(name, args)` regardless of what `name` is.
396fn as_call(e: &Expr) -> Option<(&str, &[Spanned<Expr>])> {
397    if let Expr::FnCall(head, args) = e
398        && let Expr::Ident(name) = &head.node
399    {
400        return Some((name.as_str(), args.as_slice()));
401    }
402    None
403}
404
405/// A `FnCall` whose head is a PROGRAM function (not a constructor) — the lift(b)
406/// target. `List.concat` and other builtins are not program functions, so they
407/// stay structural.
408fn is_user_fn_call(e: &Expr, env: &CalcEnv) -> bool {
409    matches!(as_call(e), Some((name, _)) if !env.is_ctor(name) && lifted_head(name, env).is_some())
410}
411
412/// A compound term worth lifting as a common subterm in anti-unification: a
413/// call (fn or ctor) — not a bare literal or variable (renaming a constant is
414/// not a generalization).
415fn is_liftable_atom_or_call(e: &Expr, env: &CalcEnv) -> bool {
416    match e {
417        Expr::FnCall(..) => lifted_type(e, env).is_some(),
418        _ => false,
419    }
420}
421
422/// The Aver type of a lifted subterm: the return type of its head function.
423fn lifted_type(e: &Expr, env: &CalcEnv) -> Option<String> {
424    let (name, _) = as_call(e)?;
425    lifted_head(name, env).map(str::to_string)
426}
427
428fn lifted_head<'a>(name: &str, env: &'a CalcEnv) -> Option<&'a str> {
429    // A module-qualified name spells its final segment as the fn.
430    env.fn_return(name)
431        .or_else(|| name.rsplit_once('.').and_then(|(_, s)| env.fn_return(s)))
432}
433
434/// The direct sub-expressions of `e` (for structural recursion over the
435/// un-translator's Expr image).
436fn children(e: &Expr) -> Vec<&Spanned<Expr>> {
437    match e {
438        Expr::FnCall(head, args) => {
439            let mut v = vec![head.as_ref()];
440            v.extend(args.iter());
441            v
442        }
443        Expr::BinOp(_, a, b) => vec![a.as_ref(), b.as_ref()],
444        Expr::Neg(a) => vec![a.as_ref()],
445        Expr::List(xs) | Expr::Tuple(xs) => xs.iter().collect(),
446        Expr::Attr(b, _) => vec![b.as_ref()],
447        _ => vec![],
448    }
449}
450
451/// True iff `needle` is `hay` or an exact subtree of it (span-agnostic — the
452/// `Spanned` `PartialEq` compares nodes only).
453fn contains_subtree(hay: &Expr, needle: &Expr) -> bool {
454    hay == needle
455        || children(hay)
456            .iter()
457            .any(|c| contains_subtree(&c.node, needle))
458}
459
460/// Replace every subtree structurally equal to `from` with `to`.
461fn substitute(e: &mut Expr, from: &Expr, to: &Expr) {
462    if e == from {
463        *e = to.clone();
464        return;
465    }
466    match e {
467        Expr::FnCall(head, args) => {
468            substitute(&mut head.node, from, to);
469            for a in args {
470                substitute(&mut a.node, from, to);
471            }
472        }
473        Expr::BinOp(_, a, b) => {
474            substitute(&mut a.node, from, to);
475            substitute(&mut b.node, from, to);
476        }
477        Expr::Neg(a) => substitute(&mut a.node, from, to),
478        Expr::List(xs) | Expr::Tuple(xs) => {
479            for x in xs {
480                substitute(&mut x.node, from, to);
481            }
482        }
483        Expr::Attr(b, _) => substitute(&mut b.node, from, to),
484        _ => {}
485    }
486}
487
488/// Drop givens no longer referenced after lifting; keep survivors in their
489/// original order, then the fresh lifted variables (already appended in order).
490fn rebuild_givens(g: &mut UntranslatedGoal, original: &UntranslatedGoal) {
491    let mut used = HashSet::new();
492    collect_idents(&g.claim.0.node, &mut used);
493    collect_idents(&g.claim.1.node, &mut used);
494    for p in &g.premises {
495        collect_idents(&p.node, &mut used);
496    }
497    let orig_names: HashSet<&String> = original.givens.iter().map(|(n, _)| n).collect();
498    g.givens.retain(|(n, _)| used.contains(n));
499    // Preserve declaration order: originals first (as they were), then lifted.
500    g.givens.sort_by_key(|(n, _)| !orig_names.contains(n));
501}
502
503fn collect_idents(e: &Expr, out: &mut HashSet<String>) {
504    if let Expr::Ident(n) = e {
505        out.insert(n.clone());
506    }
507    for c in children(e) {
508        collect_idents(&c.node, out);
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use crate::ast::unparse;
516    use crate::codegen::lean::untranslate::{PeanoCtx, UntranslateCtx, untranslate_goal_ctx};
517
518    fn render(g: &UntranslatedGoal) -> String {
519        let mut out = String::new();
520        for (n, t) in &g.givens {
521            out.push_str(&format!("given {n}: {t}; "));
522        }
523        for p in &g.premises {
524            let mut buf = String::new();
525            unparse::write_expr_public(&mut buf, p, 0).unwrap();
526            out.push_str(&format!("when {buf}; "));
527        }
528        let mut l = String::new();
529        let mut r = String::new();
530        unparse::write_expr_public(&mut l, &g.claim.0, 0).unwrap();
531        unparse::write_expr_public(&mut r, &g.claim.1, 0).unwrap();
532        out.push_str(&format!("{l} => {r}"));
533        out
534    }
535
536    fn nat_env() -> CalcEnv {
537        // `type Nat { Z; S(Nat) }` + `fn len(...) -> Nat`, `fn le(...) -> Bool`,
538        // `fn filterZ(...) -> List<Nat>`, `fn rev(...) -> List<Nat>`.
539        let src = "type Nat\n    Z\n    S(Nat)\n\n\
540                   fn len(xs: List<Nat>) -> Nat\n    Nat.Z\n\n\
541                   fn le(x: Nat, y: Nat) -> Bool\n    true\n\n\
542                   fn filterZ(xs: List<Nat>) -> List<Nat>\n    xs\n\n\
543                   fn rev(xs: List<Nat>) -> List<Nat>\n    xs\n";
544        CalcEnv::from_items(&crate::source::parse_source(src).expect("parses"))
545    }
546
547    fn peano_nat() -> UntranslateCtx {
548        UntranslateCtx {
549            peano: Some(PeanoCtx {
550                type_name: "Nat".to_string(),
551                zero_ctor: "Z".to_string(),
552                succ_ctor: "S".to_string(),
553            }),
554        }
555    }
556
557    const DUMP_P66_1: &str = include_str!("testdata/lemma_calc_krok0/p66_1.json");
558
559    #[test]
560    fn antiunify_forces_the_prop_66_successor_lemma() {
561        // The distinctive G2b case: constructor-on-a-variable. p66_1 un-translates
562        // to `when le(len(filterZ tail), len tail); le(len(filterZ tail),
563        // S(len tail)) => true`; the IH does not subst (its `le` is not a subtree
564        // of the claim's `le`), so anti-unification lifts `len(filterZ tail) -> a`,
565        // `len tail -> b`, allowing the claim's extra `S`.
566        let goal = untranslate_goal_ctx(DUMP_P66_1, &peano_nat()).expect("in grammar");
567        let CalcVerdict::Lemma(l) = calculate(&goal, &nat_env(), &HashSet::new()) else {
568            panic!("expected a forced lemma");
569        };
570        assert_eq!(
571            render(&l),
572            "given a: Nat; given b: Nat; when (le(a, b) == true); le(a, Nat.S(b)) => true"
573        );
574    }
575
576    #[test]
577    fn fresh_vars_skip_parent_law_given_names() {
578        // NAME-CAPTURE guard: the fresh lifted variables must dedup against the
579        // PARENT law's given names, not only the residual's own binders. The
580        // candidate builder resolves givens by name against the parent law, so a
581        // lifted `a` colliding with a parent given `a` would clone the wrong
582        // sample domain. Reserving `a` forces the lifted variables to `b`, `c`.
583        let goal = untranslate_goal_ctx(DUMP_P66_1, &peano_nat()).expect("in grammar");
584        let reserved: HashSet<String> = ["a".to_string()].into_iter().collect();
585        let CalcVerdict::Lemma(l) = calculate(&goal, &nat_env(), &reserved) else {
586            panic!("expected a forced lemma");
587        };
588        assert_eq!(
589            render(&l),
590            "given b: Nat; given c: Nat; when (le(b, c) == true); le(b, Nat.S(c)) => true"
591        );
592    }
593
594    #[test]
595    fn subst_consumes_ih_and_lifts_snoc_general_lemma() {
596        // prop_73 cons branch (hand-built to the shape the real probe emits): IH
597        // `rev(filterZ tail) = filterZ(rev tail)` substs into the claim
598        // `rev(filterZ tail) ++ [Z] = filterZ(rev tail ++ [Z])`, then `rev tail`
599        // (both sides) lifts to a fresh `xs`, giving the general snoc lemma.
600        let concat = |a: &str, b: &str| {
601            format!(r#"{{"app":{{"fn":{{"const":"List.append"}},"args":[{a},{b}]}}}}"#)
602        };
603        let rev = |a: &str| format!(r#"{{"app":{{"fn":{{"const":"rev"}},"args":[{a}]}}}}"#);
604        let filterz = |a: &str| format!(r#"{{"app":{{"fn":{{"const":"filterZ"}},"args":[{a}]}}}}"#);
605        let tail = r#"{"var":"tail"}"#;
606        let zlist = r#"{"app":{"fn":{"const":"List.cons"},"args":[{"const":"Nat"},{"app":{"fn":{"const":"OfNat.ofNat"},"args":[{"const":"Nat"},{"nat":"0"},{"opaque":"i"}]}},{"app":{"fn":{"const":"List.nil"},"args":[{"const":"Nat"}]}}]}}"#;
607        let list_nat = r#"{"app":{"fn":{"const":"List"},"args":[{"const":"Nat"}]}}"#;
608        let ih = format!(
609            r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{list_nat},{},{}]}}}}"#,
610            rev(&filterz(tail)),
611            filterz(&rev(tail))
612        );
613        let claim = format!(
614            r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{list_nat},{},{}]}}}}"#,
615            concat(&rev(&filterz(tail)), zlist),
616            filterz(&concat(&rev(tail), zlist))
617        );
618        let json = format!(
619            r#"{{"forall":{{"name":"tail","ty":{list_nat},"body":{{"forall":{{"name":"ih","ty":{ih},"body":{claim}}}}}}}}}"#
620        );
621        let goal = untranslate_goal_ctx(&json, &peano_nat()).expect("in grammar");
622        let CalcVerdict::Lemma(l) = calculate(&goal, &nat_env(), &HashSet::new()) else {
623            panic!("expected a forced lemma");
624        };
625        // IH consumed (no `when`), `rev tail` generalized to `a`.
626        let r = render(&l);
627        assert!(!r.contains("when"), "IH should be substituted away: {r}");
628        assert!(r.contains("given a: List<Nat>"), "{r}");
629        assert_eq!(
630            r,
631            "given a: List<Nat>; List.concat(filterZ(a), List.concat([Nat.Z], [])) \
632             => filterZ(List.concat(a, List.concat([Nat.Z], [])))"
633        );
634    }
635
636    #[test]
637    fn declines_when_diff_is_not_common_or_ctor_wrap() {
638        // A claim/IH pair whose difference is a genuinely different term (not a
639        // shared subterm, not a constructor-wrap) is NOT forced — decline.
640        let f = |a: &str| format!(r#"{{"app":{{"fn":{{"const":"le"}},"args":[{a},{a}]}}}}"#);
641        let ih = format!(
642            r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{{"const":"Bool"}},{},{{"const":"Bool.true"}}]}}}}"#,
643            f(r#"{"var":"x"}"#)
644        );
645        let claim = format!(
646            r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{{"const":"Bool"}},{},{{"const":"Bool.true"}}]}}}}"#,
647            f(r#"{"var":"y"}"#)
648        );
649        let list_nat = r#"{"app":{"fn":{"const":"List"},"args":[{"const":"Nat"}]}}"#;
650        let json = format!(
651            r#"{{"forall":{{"name":"x","ty":{{"const":"Nat"}},"body":{{"forall":{{"name":"ih","ty":{ih},"body":{claim}}}}}}}}}"#
652        );
653        let _ = list_nat;
654        let goal = untranslate_goal_ctx(&json, &peano_nat()).expect("in grammar");
655        match calculate(&goal, &nat_env(), &HashSet::new()) {
656            CalcVerdict::Decline(reason) => {
657                assert!(
658                    reason.contains("outside") || reason.contains("term"),
659                    "{reason}"
660                );
661            }
662            CalcVerdict::Lemma(l) => panic!("should decline, got {}", render(&l)),
663        }
664    }
665}