Skip to main content

logicaffeine_kernel/
tactic_cert.rs

1//! Independent verification of reflected ring, linear-arithmetic, and congruence goals.
2//!
3//! The second kernel uses these checks for tactic computations and their derivation
4//! conclusions. Linear arithmetic and congruence emit certificates and replay them;
5//! ring uses exact Kronecker substitution rather than polynomial normalization.
6//! None calls the first kernel's arithmetic engines or reflected-syntax reader.
7//!
8//! A successful check corroborates the primitive's success constructor. Failure means
9//! the independent checker has no verdict; it never licenses an error reduction.
10//! The proof crate re-exports this module for certificate producers and clients.
11
12use std::collections::BTreeMap;
13
14use crate::{BigInt, Literal, Term};
15
16// =================================================================================================
17// THE REFLECTED-SYNTAX READER
18// =================================================================================================
19
20/// The object language's four shapes, as this module reads them.
21///
22/// The reflection grammar the tactic primitives are handed is `SLit n`, `SVar i`, `SName s` and
23/// `SApp f x` — four global constructors applied to literals and to each other. That is the
24/// whole of it. Reading it here rather than through the kernel's `reify` extractors is the
25/// point: a shared reader is a shared assumption, and an assumption both kernels make is one
26/// neither checks.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum Syn {
29    /// `SLit n` — an integer literal of the object language.
30    Lit(i64),
31    /// `SVar i` — a de Bruijn occurrence.
32    Var(i64),
33    /// `SName s` — a named global: a free variable to arithmetic, a function symbol to
34    /// congruence.
35    Name(String),
36    /// `SApp f x` — object-language application, curried.
37    App(Box<Syn>, Box<Syn>),
38}
39
40impl Syn {
41    /// `SApp (SApp (SName op) a) b`, destructured — the two-argument spine every relation and
42    /// every arithmetic operator arrives in.
43    fn binary(&self) -> Option<(&str, &Syn, &Syn)> {
44        let Syn::App(fx, b) = self else { return None };
45        let Syn::App(f, a) = fx.as_ref() else {
46            return None;
47        };
48        let Syn::Name(op) = f.as_ref() else {
49            return None;
50        };
51        Some((op.as_str(), a, b.as_ref()))
52    }
53}
54
55/// `SApp (SApp (SApp (SName "Eq") ty) a) b`, destructured.
56///
57/// A free function over the spine rather than a method, because the three-argument form is NOT
58/// the two-argument form with a layer added: its head is `SApp (SName "Eq") ty`, not
59/// `SName "Eq"`, so a matcher written as "binary, then peel" would read `Eq ty a` out of a
60/// four-argument application and hand `ring` the wrong sides.
61fn typed_equation(s: &Syn) -> Option<(&Syn, &Syn, &Syn)> {
62    let Syn::App(l3, b) = s else { return None };
63    let Syn::App(l2, a) = l3.as_ref() else {
64        return None;
65    };
66    let Syn::App(l1, ty) = l2.as_ref() else {
67        return None;
68    };
69    let Syn::Name(head) = l1.as_ref() else {
70        return None;
71    };
72    if head != "Eq" {
73        return None;
74    }
75    Some((ty, a, b))
76}
77
78/// Read a kernel `Term` as reflected object syntax, or `None` if it is not one.
79///
80/// The literal window is EXACTLY `Literal::Int`. An `SLit` carrying a `Literal::BigInt` is
81/// refused here because the kernel's `reify::extract_slit` refuses it too, and a re-checker
82/// that read a numeral the first kernel cannot would decide goals the first kernel leaves
83/// stuck. Widening that window is a change to BOTH kernels or to neither.
84pub fn syn_of_term(t: &Term) -> Option<Syn> {
85    let Term::App(ctor, arg) = t else { return None };
86    if let Term::Global(name) = ctor.as_ref() {
87        match name.as_str() {
88            "SLit" => {
89                return match arg.as_ref() {
90                    Term::Lit(Literal::Int(n)) => Some(Syn::Lit(*n)),
91                    _ => None,
92                }
93            }
94            "SVar" => {
95                return match arg.as_ref() {
96                    Term::Lit(Literal::Int(i)) => Some(Syn::Var(*i)),
97                    _ => None,
98                }
99            }
100            "SName" => {
101                return match arg.as_ref() {
102                    Term::Lit(Literal::Text(s)) => Some(Syn::Name(s.clone())),
103                    _ => None,
104                }
105            }
106            _ => {}
107        }
108    }
109    // `SApp f x` is `App(App(Global("SApp"), f), x)`.
110    if let Term::App(sapp, f) = ctor.as_ref() {
111        if matches!(sapp.as_ref(), Term::Global(c) if c == "SApp") {
112            let fs = syn_of_term(f)?;
113            let xs = syn_of_term(arg)?;
114            return Some(Syn::App(Box::new(fs), Box::new(xs)));
115        }
116    }
117    None
118}
119
120// =================================================================================================
121// THE VARIABLE KEY — no interner, so no interner collision
122// =================================================================================================
123
124/// What an arithmetic procedure treats as an unknown.
125///
126/// `SVar i` and `SName s` are different kinds of thing, and this key keeps them different
127/// STRUCTURALLY, where the kernel's `reify::VarInterner` keeps them apart by allocating named
128/// globals from `-1_000_000` downwards — a separation that holds for every de Bruijn index the
129/// quoter produces (all non-negative) and fails on a hand-built `SVar (-1000000)`, which the
130/// interner would identify with the first named global it meets. This module is therefore never
131/// more complete than the first kernel, and on that one malformed shape strictly less: it keeps
132/// two unknowns apart where the interner merges them.
133#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
134enum VarKey {
135    /// A de Bruijn occurrence `SVar i`.
136    Bound(i64),
137    /// A named global `SName s`.
138    Named(String),
139}
140
141// =================================================================================================
142// LINEAR FORMS — integer coefficients, exactly
143// =================================================================================================
144
145/// `constant + Σ coeff·var`, with coefficients in ℤ.
146///
147/// Integer, not rational, and that is a fact about the FRAGMENT rather than a restriction:
148/// `SLit` carries an integer and the only multiplication the reader admits is by a literal, so
149/// nothing a reified goal can say has a non-integer coefficient. Rationals enter the first
150/// kernel only INSIDE Fourier–Motzkin, where a bound is divided by its coefficient; the Farkas
151/// check below never divides, so this module stays in ℤ and never rounds.
152#[derive(Debug, Clone, PartialEq, Eq)]
153struct LinForm {
154    constant: BigInt,
155    coeffs: BTreeMap<VarKey, BigInt>,
156}
157
158impl LinForm {
159    fn constant(c: BigInt) -> Self {
160        LinForm {
161            constant: c,
162            coeffs: BTreeMap::new(),
163        }
164    }
165
166    fn var(k: VarKey) -> Self {
167        let mut coeffs = BTreeMap::new();
168        coeffs.insert(k, BigInt::from_i64(1));
169        LinForm {
170            constant: BigInt::zero(),
171            coeffs,
172        }
173    }
174
175    fn is_constant(&self) -> bool {
176        self.coeffs.is_empty()
177    }
178
179    fn add(&self, other: &LinForm) -> LinForm {
180        let mut out = self.clone();
181        out.constant = out.constant.add(&other.constant);
182        for (k, c) in &other.coeffs {
183            let slot = out.coeffs.entry(k.clone()).or_insert_with(BigInt::zero);
184            *slot = slot.add(c);
185            if slot.is_zero() {
186                out.coeffs.remove(k);
187            }
188        }
189        out
190    }
191
192    fn scale(&self, f: &BigInt) -> LinForm {
193        if f.is_zero() {
194            return LinForm::constant(BigInt::zero());
195        }
196        LinForm {
197            constant: self.constant.mul(f),
198            coeffs: self
199                .coeffs
200                .iter()
201                .map(|(k, c)| (k.clone(), c.mul(f)))
202                .collect(),
203        }
204    }
205
206    fn neg(&self) -> LinForm {
207        self.scale(&BigInt::from_i64(-1))
208    }
209
210    fn sub(&self, other: &LinForm) -> LinForm {
211        self.add(&other.neg())
212    }
213
214    fn coeff(&self, k: &VarKey) -> BigInt {
215        self.coeffs.get(k).cloned().unwrap_or_else(BigInt::zero)
216    }
217
218    /// The largest magnitude anywhere in the form, in bytes — the meter the elimination budget
219    /// reads. Fourier–Motzkin multiplies coefficients pairwise, so an unbudgeted run on an
220    /// adversarial system grows them geometrically, and a checker that dies of memory is
221    /// exactly as useless as one that answers wrongly.
222    fn magnitude_bytes(&self) -> usize {
223        let mut worst = self.constant.to_le_bytes().1.len();
224        for c in self.coeffs.values() {
225            worst = worst.max(c.to_le_bytes().1.len());
226        }
227        worst
228    }
229}
230
231/// `form ≤ 0`, or `form < 0` when `strict`.
232///
233/// The same normal form the kernel's `lia::Constraint` uses, reached independently: every
234/// comparison becomes an upper bound on a linear form, so that a non-negative combination of
235/// constraints is again a constraint of the same shape — which is the entire mechanism Farkas
236/// duality runs on.
237#[derive(Debug, Clone, PartialEq, Eq)]
238struct Ineq {
239    form: LinForm,
240    strict: bool,
241}
242
243impl Ineq {
244    /// Whether a CONSTANT inequality is false — `c ≤ 0` with `c > 0`, or `c < 0` with `c ≥ 0`.
245    fn is_constant_contradiction(&self) -> bool {
246        if !self.form.is_constant() {
247            return false;
248        }
249        if self.strict {
250            !self.form.constant.is_negative()
251        } else {
252            self.form.constant.is_positive()
253        }
254    }
255}
256
257// =================================================================================================
258// LIA — THE FARKAS CERTIFICATE
259// =================================================================================================
260
261/// A refutation of a constraint system, as the multipliers that refute it.
262///
263/// Farkas' lemma in the form the checker uses: if every `λᵢ ≥ 0` and `Σ λᵢ·formᵢ` is a CONSTANT
264/// `c` with `c > 0` — or with `c ≥ 0` when some `λᵢ > 0` weights a STRICT constraint — then no
265/// assignment satisfies every `formᵢ ≤ 0` at once, because that assignment would have to make a
266/// non-negative combination of non-positive numbers positive.
267///
268/// This is Lean's `Justification` flattened. Lean keeps the derivation as a TREE of
269/// `combo a j b k` nodes (`Lean/Elab/Tactic/Omega/Core.lean:49`) because it must assemble a
270/// proof TERM out of `combo_sat'` applications; a checker that only has to agree with a VERDICT
271/// can multiply the tree out into one multiplier per assumption and check a single dot product.
272/// Same certificate, one level flatter, and the flattening is what makes the checker fit on a
273/// page.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct LiaCert {
276    multipliers: Vec<BigInt>,
277}
278
279impl LiaCert {
280    /// The multipliers, in constraint order.
281    pub fn multipliers(&self) -> &[BigInt] {
282        &self.multipliers
283    }
284
285    /// Build a certificate from raw multipliers — the door a test uses to hand the checker a
286    /// LIE and require it to be refused. A certificate no mutation can spoil is a certificate
287    /// nothing is checking.
288    pub fn from_multipliers(multipliers: Vec<BigInt>) -> Self {
289        LiaCert { multipliers }
290    }
291}
292
293/// How large a coefficient the elimination will carry before giving up, in bytes.
294///
295/// The emitter is untrusted, so this bound costs nothing but completeness: an abandoned search
296/// produces no certificate, the checker verifies nothing, and the caller falls back to the
297/// honest `Unchecked`.
298const MAX_COEFF_BYTES: usize = 64;
299
300/// How many constraints the elimination will hold at once. Fourier–Motzkin is doubly
301/// exponential in the worst case; this is the door before that.
302const MAX_CONSTRAINTS: usize = 512;
303
304/// Check `cert` against `system`: are the multipliers a Farkas refutation?
305///
306/// Nothing here searches. It multiplies, adds, and looks at the sign of one constant — which is
307/// why it can be believed about a system refuted by a search it does not contain.
308fn check_farkas(system: &[Ineq], cert: &LiaCert) -> bool {
309    if system.is_empty() || cert.multipliers.len() != system.len() {
310        return false;
311    }
312    let mut combination = LinForm::constant(BigInt::zero());
313    let mut any_positive = false;
314    let mut strict = false;
315    for (c, lambda) in system.iter().zip(cert.multipliers.iter()) {
316        if lambda.is_negative() {
317            return false;
318        }
319        if lambda.is_zero() {
320            continue;
321        }
322        any_positive = true;
323        if c.strict {
324            strict = true;
325        }
326        combination = combination.add(&c.form.scale(lambda));
327    }
328    if !any_positive {
329        return false;
330    }
331    Ineq {
332        form: combination,
333        strict,
334    }
335    .is_constant_contradiction()
336}
337
338/// Search for a Farkas refutation of `system` — UNTRUSTED, and deliberately so.
339///
340/// Fourier–Motzkin elimination carrying provenance: every constraint holds the vector of
341/// multipliers over the ORIGINAL system that produced it, and eliminating `v` from a lower
342/// bound `L` (coefficient `a < 0` on `v`) against an upper bound `U` (coefficient `b > 0`)
343/// forms `(-a)·U + b·L`, whose `v` coefficient is `(-a)b + ba = 0` and whose provenance is the
344/// same combination of theirs. Both weights are positive, so provenance stays a NON-NEGATIVE
345/// combination at every step — exactly the invariant [`check_farkas`] re-establishes from
346/// scratch, without being told any of this.
347///
348/// Cross-multiplying instead of dividing through by the coefficient (which is what the kernel's
349/// `lia.rs` does, and why it needs rationals) is the reason this module stays in ℤ.
350fn find_farkas(system: &[Ineq]) -> Option<LiaCert> {
351    if system.is_empty() || system.len() > MAX_CONSTRAINTS {
352        return None;
353    }
354    let n = system.len();
355    let unit = |i: usize| -> Vec<BigInt> {
356        (0..n)
357            .map(|j| BigInt::from_i64(i64::from(j == i)))
358            .collect()
359    };
360    let mut current: Vec<(Ineq, Vec<BigInt>)> = system
361        .iter()
362        .enumerate()
363        .map(|(i, c)| (c.clone(), unit(i)))
364        .collect();
365
366    // A contradiction already present in the input needs no elimination at all — which is the
367    // only shape `try_lia` reaches today, since it hands Fourier–Motzkin a single constraint.
368    for (c, mult) in &current {
369        if c.is_constant_contradiction() {
370            return Some(LiaCert {
371                multipliers: mult.clone(),
372            });
373        }
374    }
375
376    let mut vars: Vec<VarKey> = current
377        .iter()
378        .flat_map(|(c, _)| c.form.coeffs.keys().cloned())
379        .collect();
380    vars.sort();
381    vars.dedup();
382
383    for v in vars {
384        let mut lower: Vec<(Ineq, Vec<BigInt>, BigInt)> = Vec::new();
385        let mut upper: Vec<(Ineq, Vec<BigInt>, BigInt)> = Vec::new();
386        let mut next: Vec<(Ineq, Vec<BigInt>)> = Vec::new();
387        for (c, mult) in &current {
388            let a = c.form.coeff(&v);
389            if a.is_zero() {
390                next.push((c.clone(), mult.clone()));
391            } else if a.is_positive() {
392                upper.push((c.clone(), mult.clone(), a));
393            } else {
394                lower.push((c.clone(), mult.clone(), a));
395            }
396        }
397        for (lo, lo_mult, a) in &lower {
398            for (up, up_mult, b) in &upper {
399                let wa = a.negated();
400                let form = up.form.scale(&wa).add(&lo.form.scale(b));
401                if form.magnitude_bytes() > MAX_COEFF_BYTES {
402                    return None;
403                }
404                let combined = Ineq {
405                    form,
406                    strict: lo.strict || up.strict,
407                };
408                let mult: Vec<BigInt> = (0..n)
409                    .map(|i| up_mult[i].mul(&wa).add(&lo_mult[i].mul(b)))
410                    .collect();
411                if combined.is_constant_contradiction() {
412                    return Some(LiaCert { multipliers: mult });
413                }
414                next.push((combined, mult));
415                if next.len() > MAX_CONSTRAINTS {
416                    return None;
417                }
418            }
419        }
420        current = next;
421    }
422    None
423}
424
425/// Reify a reflected arithmetic term to a linear form, or `None` outside the fragment.
426///
427/// The fragment is the kernel's `lia::reify_linear`, name for name: `SLit`, `SVar`, `SName`,
428/// `add`, `sub`, and `mul` ONLY when one side is constant. `div` and `mod` are not linear and
429/// are refused here as they are there — this module may not decide a goal the first kernel
430/// leaves stuck.
431fn lin_of_syn(s: &Syn) -> Option<LinForm> {
432    match s {
433        Syn::Lit(n) => Some(LinForm::constant(BigInt::from_i64(*n))),
434        Syn::Var(i) => Some(LinForm::var(VarKey::Bound(*i))),
435        Syn::Name(n) => Some(LinForm::var(VarKey::Named(n.clone()))),
436        Syn::App(..) => {
437            let (op, a, b) = s.binary()?;
438            let la = lin_of_syn(a)?;
439            let lb = lin_of_syn(b)?;
440            match op {
441                "add" => Some(la.add(&lb)),
442                "sub" => Some(la.sub(&lb)),
443                "mul" => {
444                    if la.is_constant() {
445                        Some(lb.scale(&la.constant))
446                    } else if lb.is_constant() {
447                        Some(la.scale(&lb.constant))
448                    } else {
449                        None
450                    }
451                }
452                _ => None,
453            }
454        }
455    }
456}
457
458/// The system whose unsatisfiability makes `goal` valid: the goal's NEGATION, as one
459/// constraint.
460///
461/// The kernel's `lia::goal_to_negated_constraint` table, re-derived rather than copied: `a ≤ b`
462/// is valid exactly when `b - a < 0` is unsatisfiable, `a < b` exactly when `b - a ≤ 0` is, and
463/// the `≥`/`>` rows are those two with the sides swapped.
464fn lia_negated_system(goal: &Syn) -> Option<Vec<Ineq>> {
465    let (rel, lhs, rhs) = goal.binary()?;
466    let a = lin_of_syn(lhs)?;
467    let b = lin_of_syn(rhs)?;
468    let c = match rel {
469        "Lt" | "lt" => Ineq {
470            form: b.sub(&a),
471            strict: false,
472        },
473        "Le" | "le" => Ineq {
474            form: b.sub(&a),
475            strict: true,
476        },
477        "Gt" | "gt" => Ineq {
478            form: a.sub(&b),
479            strict: false,
480        },
481        "Ge" | "ge" => Ineq {
482            form: a.sub(&b),
483            strict: true,
484        },
485        _ => return None,
486    };
487    Some(vec![c])
488}
489
490/// Emit a Farkas certificate for a `try_lia` goal, or `None` when the search finds none.
491///
492/// The producer is untrusted — this is the half a re-checker is allowed to get wrong, because
493/// [`check_lia`] re-establishes the claim from the certificate alone.
494pub fn emit_lia(goal: &Syn) -> Option<LiaCert> {
495    find_farkas(&lia_negated_system(goal)?)
496}
497
498/// Check a Farkas certificate against a `try_lia` goal.
499pub fn check_lia(goal: &Syn, cert: &LiaCert) -> bool {
500    match lia_negated_system(goal) {
501        Some(system) => check_farkas(&system, cert),
502        None => false,
503    }
504}
505
506/// `try_lia goal` reduces to `DLiaSolve goal`, verified from a certificate this module both
507/// produced and then checked without reference to how it was produced.
508///
509/// COMPLETENESS, stated so the one-sided contract can be audited: a verified certificate means
510/// the negated system is genuinely infeasible over ℚ, and Fourier–Motzkin is complete for
511/// exactly that, so the first kernel succeeds wherever this returns `true`. The converse is not
512/// claimed — a `false` is `Unchecked`, never a disagreement.
513pub fn verify_lia(goal: &Syn) -> bool {
514    match emit_lia(goal) {
515        Some(cert) => check_lia(goal, &cert),
516        None => false,
517    }
518}
519
520// =================================================================================================
521// RING — A SECOND DECISION PROCEDURE, BY KRONECKER SUBSTITUTION
522// =================================================================================================
523
524/// The per-variable degree bound and the sum of coefficient magnitudes of a reflected
525/// polynomial expression, computed structurally.
526///
527/// Both are UPPER bounds on the polynomial the expression denotes, and neither expands it:
528/// `deg` is pointwise `max` at a sum and pointwise `+` at a product, `weight` is `+` at a sum
529/// and `×` at a product. `weight` bounds the sum of the absolute values of the coefficients,
530/// hence bounds every individual coefficient — which is what the substitution below needs.
531struct PolyBounds {
532    deg: BTreeMap<VarKey, u32>,
533    weight: BigInt,
534}
535
536fn poly_bounds(s: &Syn) -> Option<PolyBounds> {
537    match s {
538        Syn::Lit(n) => Some(PolyBounds {
539            deg: BTreeMap::new(),
540            weight: BigInt::from_i64(*n).abs(),
541        }),
542        Syn::Var(i) => {
543            let mut deg = BTreeMap::new();
544            deg.insert(VarKey::Bound(*i), 1u32);
545            Some(PolyBounds {
546                deg,
547                weight: BigInt::from_i64(1),
548            })
549        }
550        Syn::Name(n) => {
551            let mut deg = BTreeMap::new();
552            deg.insert(VarKey::Named(n.clone()), 1u32);
553            Some(PolyBounds {
554                deg,
555                weight: BigInt::from_i64(1),
556            })
557        }
558        Syn::App(..) => {
559            let (op, a, b) = s.binary()?;
560            let pa = poly_bounds(a)?;
561            let pb = poly_bounds(b)?;
562            match op {
563                "add" | "sub" => {
564                    let mut deg = pa.deg;
565                    for (k, d) in pb.deg {
566                        let slot = deg.entry(k).or_insert(0);
567                        *slot = (*slot).max(d);
568                    }
569                    Some(PolyBounds {
570                        deg,
571                        weight: pa.weight.add(&pb.weight),
572                    })
573                }
574                "mul" => {
575                    let mut deg = pa.deg;
576                    for (k, d) in pb.deg {
577                        let slot = deg.entry(k).or_insert(0);
578                        *slot = slot.checked_add(d)?;
579                    }
580                    Some(PolyBounds {
581                        deg,
582                        weight: pa.weight.mul(&pb.weight),
583                    })
584                }
585                _ => None,
586            }
587        }
588    }
589}
590
591/// Evaluate a reflected polynomial expression at an integer point, exactly.
592fn eval_at(s: &Syn, point: &BTreeMap<VarKey, BigInt>) -> Option<BigInt> {
593    match s {
594        Syn::Lit(n) => Some(BigInt::from_i64(*n)),
595        Syn::Var(i) => point.get(&VarKey::Bound(*i)).cloned(),
596        Syn::Name(n) => point.get(&VarKey::Named(n.clone())).cloned(),
597        Syn::App(..) => {
598            let (op, a, b) = s.binary()?;
599            let va = eval_at(a, point)?;
600            let vb = eval_at(b, point)?;
601            match op {
602                "add" => Some(va.add(&vb)),
603                "sub" => Some(va.sub(&vb)),
604                "mul" => Some(va.mul(&vb)),
605                _ => None,
606            }
607        }
608    }
609}
610
611/// How many monomial slots the Kronecker point may have to keep apart — the product of
612/// `(degᵢ + 1)` over the variables.
613const MAX_KRONECKER_SLOTS: u32 = 1 << 14;
614
615/// How many bits the substitution point's largest power may occupy before the procedure gives
616/// up and lets the caller fall back to `Unchecked`.
617const MAX_KRONECKER_BITS: u64 = 1 << 21;
618
619/// Decide whether two reflected polynomial expressions denote the SAME polynomial over ℤ —
620/// without ever forming a polynomial.
621///
622/// **Kronecker substitution.** Order the variables `x₁ … xₙ` and let `dᵢ` bound the degree of
623/// either side in `xᵢ`. Substituting `xᵢ ↦ B^(Eᵢ)` with `E₁ = 1` and `Eᵢ₊₁ = Eᵢ·(dᵢ + 1)` sends
624/// distinct monomials `∏ xᵢ^(eᵢ)` with `eᵢ ≤ dᵢ` to distinct powers `B^(Σ eᵢEᵢ)`, because the
625/// exponent vector is precisely the mixed-radix digit string of that exponent. Taking
626/// `B = 2·C + 1` with `C` bounding every coefficient makes the value's balanced base-`B`
627/// expansion unique, so the two sides evaluate to the same integer if and only if they are the
628/// same polynomial.
629///
630/// This decides the relation `ring`'s canonical normal form decides, by an argument that shares
631/// no step with it: no monomial map, no like-term collection, no comparison of normal forms.
632/// Exact `BigInt` arithmetic throughout — an evaluation that wrapped would equate unequal
633/// polynomials, which is the failure the first kernel's own doc comment records as its reason
634/// for arbitrary precision.
635fn polynomials_identical(a: &Syn, b: &Syn) -> Option<bool> {
636    let ba = poly_bounds(a)?;
637    let bb = poly_bounds(b)?;
638    let mut deg = ba.deg;
639    for (k, d) in bb.deg {
640        let slot = deg.entry(k).or_insert(0);
641        *slot = (*slot).max(d);
642    }
643    let coeff_bound = ba.weight.add(&bb.weight);
644    let base = coeff_bound
645        .mul(&BigInt::from_i64(2))
646        .add(&BigInt::from_i64(1));
647
648    let mut slots: u32 = 1;
649    let mut point: BTreeMap<VarKey, BigInt> = BTreeMap::new();
650    for (k, d) in &deg {
651        let base_bits = (base.to_le_bytes().1.len() as u64).saturating_mul(8);
652        if base_bits.saturating_mul(u64::from(slots)) > MAX_KRONECKER_BITS {
653            return None;
654        }
655        point.insert(k.clone(), base.pow(slots));
656        slots = slots.checked_mul(d.checked_add(1)?)?;
657        if slots > MAX_KRONECKER_SLOTS {
658            return None;
659        }
660    }
661
662    Some(eval_at(a, &point)? == eval_at(b, &point)?)
663}
664
665/// `try_ring goal` reduces to `DRingSolve goal`, verified here.
666///
667/// The gate is the first kernel's: a three-argument `Eq ty lhs rhs` whose `ty` is `Int` or
668/// `Nat`, both sides inside the `add`/`sub`/`mul` fragment. `div` and `mod` fall out of
669/// `poly_bounds` exactly as `ring::reify` refuses them.
670pub fn verify_ring(goal: &Syn) -> bool {
671    let Some((ty, lhs, rhs)) = typed_equation(goal) else {
672        return false;
673    };
674    let Syn::Name(ty_name) = ty else { return false };
675    if ty_name != "Int" && ty_name != "Nat" {
676        return false;
677    }
678    polynomials_identical(lhs, rhs).unwrap_or(false)
679}
680
681// =================================================================================================
682// CC — THE CONGRUENCE PROOF CERTIFICATE
683// =================================================================================================
684
685/// One step of an equality proof.
686///
687/// Five rules, and between them they are the whole of equational logic on a first-order term
688/// language. A congruence-closure ENGINE is a fixpoint over an e-graph with a union-find and
689/// use lists; a PROOF that one particular equation is in that closure is this list, and
690/// checking it needs neither. That asymmetry is the entire reason a second kernel can afford to
691/// look at `try_cc` at all.
692#[derive(Debug, Clone, PartialEq, Eq)]
693pub enum CcStep {
694    /// The goal's `i`-th equality hypothesis, as it stands.
695    Hyp(usize),
696    /// `t = t`, for the term at table index `t`.
697    Refl(usize),
698    /// From step `i` proving `a = b`, conclude `b = a`.
699    Sym(usize),
700    /// From step `i` proving `a = b` and step `j` proving `b = c`, conclude `a = c`.
701    Trans(usize, usize),
702    /// From step `i` proving `f = g` and step `j` proving `x = y`, conclude `f x = g y`.
703    Cong(usize, usize),
704}
705
706/// A checkable proof that a goal's conclusion follows from its hypotheses by congruence.
707#[derive(Debug, Clone, PartialEq, Eq)]
708pub struct CcCert {
709    steps: Vec<CcStep>,
710}
711
712impl CcCert {
713    /// The steps, in dependency order.
714    pub fn steps(&self) -> &[CcStep] {
715        &self.steps
716    }
717
718    /// Build a certificate from raw steps — the door a test uses to hand the checker a forged
719    /// proof and require it to be refused.
720    pub fn from_steps(steps: Vec<CcStep>) -> Self {
721        CcCert { steps }
722    }
723}
724
725#[derive(Debug, Clone, PartialEq, Eq)]
726enum CcNode {
727    Lit(i64),
728    Var(i64),
729    Name(String),
730    App(usize, usize),
731}
732
733/// A hash-consed first-order term table. `App(f, x)` names earlier entries by index, so a term
734/// IS an index and equality of indices is syntactic identity.
735#[derive(Default)]
736struct TermTable {
737    nodes: Vec<CcNode>,
738}
739
740impl TermTable {
741    fn add(&mut self, n: CcNode) -> usize {
742        if let Some(i) = self.nodes.iter().position(|m| *m == n) {
743            return i;
744        }
745        self.nodes.push(n);
746        self.nodes.len() - 1
747    }
748
749    fn intern(&mut self, s: &Syn) -> Option<usize> {
750        let node = match s {
751            Syn::Lit(n) => CcNode::Lit(*n),
752            Syn::Var(i) => CcNode::Var(*i),
753            Syn::Name(n) => CcNode::Name(n.clone()),
754            Syn::App(f, x) => {
755                let fi = self.intern(f)?;
756                let xi = self.intern(x)?;
757                CcNode::App(fi, xi)
758            }
759        };
760        Some(self.add(node))
761    }
762}
763
764/// `SApp (SApp (SName "Eq") a) b` — the TWO-argument equation congruence closure reads.
765///
766/// Deliberately not [`typed_equation`]: the kernel's `cc::extract_equality` matches the untyped
767/// spine and accepts the lowercase spelling `eq` as well, and a re-checker that demanded the
768/// typed form would decline goals the first kernel decides — which costs completeness, not
769/// soundness, but costs it for no reason.
770fn untyped_equation(s: &Syn) -> Option<(&Syn, &Syn)> {
771    let (op, a, b) = s.binary()?;
772    if op == "Eq" || op == "eq" {
773        Some((a, b))
774    } else {
775        None
776    }
777}
778
779/// Peel `implies h₁ (implies h₂ … concl)`, keeping the hypotheses that are equations.
780///
781/// The kernel's `cc::decompose_goal` shape: a non-equational hypothesis is DROPPED rather than
782/// refused, because congruence closure has nothing to do with it and refusing would lose goals
783/// for nothing.
784fn cc_decompose(goal: &Syn) -> (Vec<(&Syn, &Syn)>, &Syn) {
785    let mut hyps = Vec::new();
786    let mut current = goal;
787    while let Some(("implies", h, rest)) = current.binary() {
788        if let Some(eq) = untyped_equation(h) {
789            hyps.push(eq);
790        }
791        current = rest;
792    }
793    (hyps, current)
794}
795
796/// How many steps a `try_cc` certificate may hold. This is the CHECKER's door, and it bounds
797/// the work a hostile certificate can ask for.
798const MAX_CC_STEPS: usize = 4096;
799
800/// How many pairs the emitter's saturation may derive, and how many rounds it may take, before
801/// giving up. Separate from — and tighter than — the checker's door, because the search is
802/// quadratic in the pairs it holds while the check is linear in the steps it is handed. A
803/// budget spent here costs completeness only: no certificate, no verification, `Unchecked`.
804const MAX_CC_SEARCH_STEPS: usize = 512;
805
806/// How many saturation rounds the emitter will run. Congruence closure over a goal's own
807/// subterms converges in a handful; this is the door before an adversarial goal turns the
808/// emitter into the hang a re-checker must never have.
809const MAX_CC_ROUNDS: usize = 16;
810
811/// Check a congruence proof against a goal.
812///
813/// A LINEAR REPLAY: each step's conclusion is computed from steps that come strictly BEFORE it,
814/// each premise is checked to say what its rule needs it to say, and the last step must be the
815/// goal's conclusion, orientation included. There is no union-find here and no e-graph; a
816/// checker that rebuilt one would be the copy this module exists to avoid.
817pub fn check_cc(goal: &Syn, cert: &CcCert) -> bool {
818    if cert.steps.is_empty() || cert.steps.len() > MAX_CC_STEPS {
819        return false;
820    }
821    let (hyps, concl) = cc_decompose(goal);
822    let Some((clhs, crhs)) = untyped_equation(concl) else {
823        return false;
824    };
825
826    let mut table = TermTable::default();
827    let (Some(goal_l), Some(goal_r)) = (table.intern(clhs), table.intern(crhs)) else {
828        return false;
829    };
830    let mut hyp_ids = Vec::with_capacity(hyps.len());
831    for (hl, hr) in &hyps {
832        let (Some(a), Some(b)) = (table.intern(hl), table.intern(hr)) else {
833            return false;
834        };
835        hyp_ids.push((a, b));
836    }
837
838    let mut proved: Vec<(usize, usize)> = Vec::with_capacity(cert.steps.len());
839    for (at, step) in cert.steps.iter().enumerate() {
840        let claim = match step {
841            CcStep::Hyp(i) => match hyp_ids.get(*i) {
842                Some(p) => *p,
843                None => return false,
844            },
845            CcStep::Refl(t) => {
846                if *t >= table.nodes.len() {
847                    return false;
848                }
849                (*t, *t)
850            }
851            CcStep::Sym(i) => {
852                if *i >= at {
853                    return false;
854                }
855                let (a, b) = proved[*i];
856                (b, a)
857            }
858            CcStep::Trans(i, j) => {
859                if *i >= at || *j >= at {
860                    return false;
861                }
862                let (a, b) = proved[*i];
863                let (b2, c) = proved[*j];
864                if b != b2 {
865                    return false;
866                }
867                (a, c)
868            }
869            CcStep::Cong(i, j) => {
870                if *i >= at || *j >= at {
871                    return false;
872                }
873                let (f, g) = proved[*i];
874                let (x, y) = proved[*j];
875                let left = table.add(CcNode::App(f, x));
876                let right = table.add(CcNode::App(g, y));
877                (left, right)
878            }
879        };
880        proved.push(claim);
881    }
882    proved.last().copied() == Some((goal_l, goal_r))
883}
884
885/// Search for a congruence proof of `goal` — UNTRUSTED.
886///
887/// Saturation over PAIRS rather than a union-find: seed with reflexivity on every subterm of
888/// the goal and with each hypothesis, then close under symmetry, transitivity and congruence,
889/// recording the rule that derived each new pair, until the conclusion appears. The trace is
890/// already in dependency order, because a pair is only ever derived from pairs already in the
891/// list — which is exactly what [`check_cc`] demands of the indices.
892///
893/// Congruence is applied only where the LEFT side already occurs in the goal's term table.
894/// Without that restriction the saturation is unbounded — `f x` can always be applied again —
895/// and an emitter that does not terminate is a re-checker that hangs.
896pub fn emit_cc(goal: &Syn) -> Option<CcCert> {
897    let (hyps, concl) = cc_decompose(goal);
898    let (clhs, crhs) = untyped_equation(concl)?;
899
900    let mut table = TermTable::default();
901    let goal_l = table.intern(clhs)?;
902    let goal_r = table.intern(crhs)?;
903    let mut hyp_ids = Vec::with_capacity(hyps.len());
904    for (hl, hr) in &hyps {
905        hyp_ids.push((table.intern(hl)?, table.intern(hr)?));
906    }
907
908    let mut steps: Vec<CcStep> = Vec::new();
909    let mut proved: Vec<(usize, usize)> = Vec::new();
910    let mut seen: BTreeMap<(usize, usize), usize> = BTreeMap::new();
911
912    fn push(
913        steps: &mut Vec<CcStep>,
914        proved: &mut Vec<(usize, usize)>,
915        seen: &mut BTreeMap<(usize, usize), usize>,
916        claim: (usize, usize),
917        step: CcStep,
918    ) -> Option<()> {
919        if seen.contains_key(&claim) {
920            return Some(());
921        }
922        if steps.len() >= MAX_CC_SEARCH_STEPS {
923            return None;
924        }
925        steps.push(step);
926        proved.push(claim);
927        seen.insert(claim, steps.len() - 1);
928        Some(())
929    }
930
931    for t in 0..table.nodes.len() {
932        push(&mut steps, &mut proved, &mut seen, (t, t), CcStep::Refl(t))?;
933    }
934    for (i, pair) in hyp_ids.iter().enumerate() {
935        push(&mut steps, &mut proved, &mut seen, *pair, CcStep::Hyp(i))?;
936    }
937
938    for _round in 0..MAX_CC_ROUNDS {
939        if let Some(i) = seen.get(&(goal_l, goal_r)).copied() {
940            return Some(finalize_cc(&steps, i));
941        }
942        let before = steps.len();
943
944        let snapshot = proved.clone();
945        for (i, (a, b)) in snapshot.iter().enumerate() {
946            push(&mut steps, &mut proved, &mut seen, (*b, *a), CcStep::Sym(i))?;
947        }
948
949        let snapshot = proved.clone();
950        for (i, (a, b)) in snapshot.iter().enumerate() {
951            for (j, (b2, c)) in snapshot.iter().enumerate() {
952                if b == b2 {
953                    push(
954                        &mut steps,
955                        &mut proved,
956                        &mut seen,
957                        (*a, *c),
958                        CcStep::Trans(i, j),
959                    )?;
960                }
961            }
962        }
963
964        let snapshot = proved.clone();
965        let live = table.nodes.clone();
966        for (i, (f, g)) in snapshot.iter().enumerate() {
967            for (j, (x, y)) in snapshot.iter().enumerate() {
968                if !live.iter().any(|n| *n == CcNode::App(*f, *x)) {
969                    continue;
970                }
971                let left = table.add(CcNode::App(*f, *x));
972                let right = table.add(CcNode::App(*g, *y));
973                push(
974                    &mut steps,
975                    &mut proved,
976                    &mut seen,
977                    (left, right),
978                    CcStep::Cong(i, j),
979                )?;
980            }
981        }
982
983        if steps.len() == before {
984            return None;
985        }
986    }
987    seen.get(&(goal_l, goal_r))
988        .copied()
989        .map(|i| finalize_cc(&steps, i))
990}
991
992/// Trim a saturation trace to the steps the conclusion actually rests on, and re-index.
993///
994/// A certificate carrying a thousand irrelevant pairs would still CHECK, so this is not about
995/// soundness — it is about a certificate a person can read, and about the checker's cost being
996/// the proof's size rather than the search's.
997fn finalize_cc(steps: &[CcStep], goal_step: usize) -> CcCert {
998    let mut needed = vec![false; steps.len()];
999    let mut stack = vec![goal_step];
1000    while let Some(i) = stack.pop() {
1001        if needed[i] {
1002            continue;
1003        }
1004        needed[i] = true;
1005        match &steps[i] {
1006            CcStep::Hyp(_) | CcStep::Refl(_) => {}
1007            CcStep::Sym(j) => stack.push(*j),
1008            CcStep::Trans(j, k) | CcStep::Cong(j, k) => {
1009                stack.push(*j);
1010                stack.push(*k);
1011            }
1012        }
1013    }
1014    let mut remap = vec![usize::MAX; steps.len()];
1015    let mut out = Vec::new();
1016    for (i, keep) in needed.iter().enumerate() {
1017        if !keep {
1018            continue;
1019        }
1020        let step = match &steps[i] {
1021            CcStep::Hyp(h) => CcStep::Hyp(*h),
1022            CcStep::Refl(t) => CcStep::Refl(*t),
1023            CcStep::Sym(j) => CcStep::Sym(remap[*j]),
1024            CcStep::Trans(j, k) => CcStep::Trans(remap[*j], remap[*k]),
1025            CcStep::Cong(j, k) => CcStep::Cong(remap[*j], remap[*k]),
1026        };
1027        remap[i] = out.len();
1028        out.push(step);
1029    }
1030    CcCert { steps: out }
1031}
1032
1033/// `try_cc goal` reduces to `DccSolve goal`, verified from a certificate this module both
1034/// produced and then checked without reference to how it was produced.
1035pub fn verify_cc(goal: &Syn) -> bool {
1036    match emit_cc(goal) {
1037        Some(cert) => check_cc(goal, &cert),
1038        None => false,
1039    }
1040}
1041
1042// =================================================================================================
1043// THE THREE ENTRY POINTS A RE-CHECKER CALLS
1044// =================================================================================================
1045
1046/// A tactic reduction primitive a second kernel can now corroborate.
1047#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1048pub enum CertifiablePrimitive {
1049    /// `try_ring goal ⟶ DRingSolve goal`.
1050    Ring,
1051    /// `try_lia goal ⟶ DLiaSolve goal`.
1052    Lia,
1053    /// `try_cc goal ⟶ DccSolve goal`.
1054    Cc,
1055}
1056
1057impl CertifiablePrimitive {
1058    /// The reduction-rule name this primitive fires on.
1059    pub fn rule_name(self) -> &'static str {
1060        match self {
1061            CertifiablePrimitive::Ring => "try_ring",
1062            CertifiablePrimitive::Lia => "try_lia",
1063            CertifiablePrimitive::Cc => "try_cc",
1064        }
1065    }
1066
1067    /// The derivation constructor the rule reduces to on success.
1068    pub fn success_constructor(self) -> &'static str {
1069        match self {
1070            CertifiablePrimitive::Ring => "DRingSolve",
1071            CertifiablePrimitive::Lia => "DLiaSolve",
1072            CertifiablePrimitive::Cc => "DccSolve",
1073        }
1074    }
1075
1076    /// Whether `goal` is one this primitive genuinely solves, decided independently.
1077    ///
1078    /// `true` licenses the caller to reduce `<rule> goal` to `<ctor> goal`. `false` licenses
1079    /// NOTHING — in particular it never licenses reducing to the error derivation, because this
1080    /// module is incomplete on purpose and its silence is not a verdict.
1081    pub fn verify(self, goal: &Syn) -> bool {
1082        match self {
1083            CertifiablePrimitive::Ring => verify_ring(goal),
1084            CertifiablePrimitive::Lia => verify_lia(goal),
1085            CertifiablePrimitive::Cc => verify_cc(goal),
1086        }
1087    }
1088
1089    /// The primitive a reduction-rule name denotes, if it is one of the three.
1090    pub fn of_rule(name: &str) -> Option<Self> {
1091        match name {
1092            "try_ring" => Some(CertifiablePrimitive::Ring),
1093            "try_lia" => Some(CertifiablePrimitive::Lia),
1094            "try_cc" => Some(CertifiablePrimitive::Cc),
1095            _ => None,
1096        }
1097    }
1098
1099    /// All three, so a census can iterate them without a hand-written list going stale.
1100    pub const ALL: [CertifiablePrimitive; 3] = [
1101        CertifiablePrimitive::Ring,
1102        CertifiablePrimitive::Lia,
1103        CertifiablePrimitive::Cc,
1104    ];
1105}
1106
1107// =================================================================================================
1108// BUILDING REFLECTED SYNTAX — so a caller can state a goal without spelling out four `SApp`s
1109// =================================================================================================
1110
1111/// `SApp (SApp (SName op) a) b`.
1112pub fn syn_binary(op: &str, a: Syn, b: Syn) -> Syn {
1113    Syn::App(
1114        Box::new(Syn::App(Box::new(Syn::Name(op.to_string())), Box::new(a))),
1115        Box::new(b),
1116    )
1117}
1118
1119/// `SApp f x`.
1120pub fn syn_apply(f: Syn, x: Syn) -> Syn {
1121    Syn::App(Box::new(f), Box::new(x))
1122}
1123
1124/// `SApp (SApp (SApp (SName "Eq") (SName ty)) a) b` — the typed equation `try_ring` reads.
1125pub fn syn_typed_eq(ty: &str, a: Syn, b: Syn) -> Syn {
1126    syn_apply(
1127        syn_apply(
1128            syn_apply(Syn::Name("Eq".to_string()), Syn::Name(ty.to_string())),
1129            a,
1130        ),
1131        b,
1132    )
1133}
1134
1135/// The same reflected syntax as a kernel `Term`, so a caller can hand the FIRST kernel exactly
1136/// the goal it handed this module — which is what makes a differential between the two a
1137/// differential and not two different questions.
1138pub fn term_of_syn(s: &Syn) -> Term {
1139    fn ctor(name: &str, arg: Term) -> Term {
1140        Term::App(Box::new(Term::Global(name.to_string())), Box::new(arg))
1141    }
1142    match s {
1143        Syn::Lit(n) => ctor("SLit", Term::Lit(Literal::Int(*n))),
1144        Syn::Var(i) => ctor("SVar", Term::Lit(Literal::Int(*i))),
1145        Syn::Name(n) => ctor("SName", Term::Lit(Literal::Text(n.clone()))),
1146        Syn::App(f, x) => Term::App(
1147            Box::new(Term::App(
1148                Box::new(Term::Global("SApp".to_string())),
1149                Box::new(term_of_syn(f)),
1150            )),
1151            Box::new(term_of_syn(x)),
1152        ),
1153    }
1154}
1155
1156#[cfg(test)]
1157#[path = "../../logicaffeine_tests/unit/logicaffeine_kernel/tactic_cert/tests.rs"]
1158mod tests;