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 ¤t {
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 ¤t {
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 ° {
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;