Skip to main content

logicaffeine_proof/
pseudo_boolean.rs

1//! Pseudo-Boolean constraints + cutting planes — a proof system STRICTLY STRONGER than resolution.
2//!
3//! Resolution (and therefore CDCL — ours and Z3's) needs EXPONENTIALLY long refutations of the
4//! pigeonhole principle and of many counting/cardinality problems. **Cutting planes** refutes them
5//! in POLYNOMIAL size by reasoning about linear 0/1 inequalities directly: it p-simulates resolution
6//! and has short proofs where resolution provably cannot.
7//!
8//! This is the certified core: a normalized PB constraint `Σ aᵢ·ℓᵢ ≥ d` (coeffs > 0, each variable
9//! once, `ℓ` a literal `x` or `¬x`) and the four sound cutting-plane rules — **addition** (with
10//! literal cancellation via `x + ¬x = 1`), **multiplication**, **division-with-rounding** (the
11//! Gomory–Chvátal cut, sound only because variables are integral), and **saturation** — each of
12//! which is *implied by* its premises, so deriving the trivially-false `0 ≥ 1` refutes the inputs.
13//! Every rule is pinned against a brute-force oracle. The headline is [`php_refutation`]: the
14//! classic Cook–Coullard–Turán **linear-size** refutation of PHP — pure algebra where resolution
15//! explodes.
16
17use std::collections::BTreeMap;
18
19/// A normalized pseudo-Boolean constraint `Σ coeff·lit ≥ degree`: every `coeff > 0`, every variable
20/// appears at most once, and the literal sign rides the entry — `(coeff, positive)` with
21/// `positive == false` meaning the literal `¬x`. `degree` may be ≤ 0 (then the constraint is
22/// trivially true) or exceed the coefficient sum (then it is a contradiction).
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct PbConstraint {
25    terms: BTreeMap<usize, (i64, bool)>,
26    degree: i64,
27}
28
29impl PbConstraint {
30    /// `Σ ℓ ≥ k` over the given literals (each coefficient 1). Literals must be over distinct
31    /// variables (the caller's responsibility — true for clause/cardinality inputs).
32    pub fn at_least(lits: &[(usize, bool)], k: i64) -> PbConstraint {
33        let terms = lits.iter().map(|&(v, s)| (v, (1, s))).collect();
34        PbConstraint { terms, degree: k }
35    }
36
37    /// A CNF clause `ℓ₁ ∨ … ∨ ℓₖ` as the PB constraint `Σ ℓ ≥ 1` — the bridge that lets the cutting
38    /// plane engine consume ordinary SAT clauses (and p-simulate resolution on them).
39    pub fn clause(lits: &[(usize, bool)]) -> PbConstraint {
40        Self::at_least(lits, 1)
41    }
42
43    /// `Σ ℓ ≤ k`, normalized to `≥` form by flipping every literal: `Σ ¬ℓ ≥ n − k`.
44    pub fn at_most(lits: &[(usize, bool)], k: i64) -> PbConstraint {
45        let negated: Vec<(usize, bool)> = lits.iter().map(|&(v, s)| (v, !s)).collect();
46        Self::at_least(&negated, lits.len() as i64 - k)
47    }
48
49    /// The degree (right-hand side).
50    pub fn degree(&self) -> i64 {
51        self.degree
52    }
53
54    /// Number of (non-cancelled) terms.
55    pub fn len(&self) -> usize {
56        self.terms.len()
57    }
58
59    pub fn is_empty(&self) -> bool {
60        self.terms.is_empty()
61    }
62
63    /// **Addition** — `LHS₁ + LHS₂ ≥ d₁ + d₂`, then re-normalized. A variable that is `x` in one and
64    /// `¬x` in the other partially cancels (`a·x + b·¬x = (a−b)·x + b`), so the shared minimum
65    /// `min(a,b)` leaves the literal and is moved to the right, dropping the degree by `min(a,b)`.
66    /// Sound: the sum of two valid `≥` constraints is valid, and `x + ¬x = 1` is exact for 0/1.
67    pub fn add(&self, other: &PbConstraint) -> PbConstraint {
68        let mut terms = self.terms.clone();
69        let mut degree = self.degree + other.degree;
70        for (&v, &(c2, s2)) in &other.terms {
71            match terms.get(&v).copied() {
72                None => {
73                    terms.insert(v, (c2, s2));
74                }
75                Some((c1, s1)) if s1 == s2 => {
76                    terms.insert(v, (c1 + c2, s1));
77                }
78                Some((c1, _s1_opposite)) => {
79                    let cancel = c1.min(c2);
80                    degree -= cancel;
81                    match c1.cmp(&c2) {
82                        std::cmp::Ordering::Equal => {
83                            terms.remove(&v);
84                        }
85                        std::cmp::Ordering::Greater => {
86                            terms.insert(v, (c1 - c2, self.terms[&v].1));
87                        }
88                        std::cmp::Ordering::Less => {
89                            terms.insert(v, (c2 - c1, s2));
90                        }
91                    }
92                }
93            }
94        }
95        PbConstraint { terms, degree }
96    }
97
98    /// **Multiplication** by a positive integer — `c·LHS ≥ c·degree`. Trivially sound (scaling a
99    /// valid `≥` constraint by `c > 0`).
100    pub fn multiply(&self, c: i64) -> PbConstraint {
101        assert!(c > 0, "multiply by a positive integer");
102        PbConstraint {
103            terms: self.terms.iter().map(|(&v, &(coeff, s))| (v, (coeff * c, s))).collect(),
104            degree: self.degree * c,
105        }
106    }
107
108    /// **Division with rounding-up** (the Gomory–Chvátal cut) — `Σ ⌈aᵢ/d⌉·ℓᵢ ≥ ⌈degree/d⌉`. Sound
109    /// ONLY because variables are integral: `⌈aᵢ/d⌉ ≥ aᵢ/d` so the LHS only grows (≥ degree/d), and
110    /// being an integer it is `≥ ⌈degree/d⌉`. This rounding of the fractional slack is exactly what
111    /// makes cutting planes stronger than resolution.
112    pub fn divide_round(&self, d: i64) -> PbConstraint {
113        assert!(d > 0, "divide by a positive integer");
114        let ceil = |x: i64| x.div_euclid(d) + i64::from(x.rem_euclid(d) != 0);
115        PbConstraint {
116            terms: self.terms.iter().map(|(&v, &(c, s))| (v, (ceil(c), s))).collect(),
117            degree: ceil(self.degree),
118        }
119    }
120
121    /// **Saturation** — clamp every coefficient to the degree (`min(aᵢ, degree)`). A single 0/1
122    /// variable can contribute at most `degree` toward the bound, so the excess is redundant. Sound,
123    /// and it keeps coefficients from blowing up across many additions.
124    pub fn saturate(&self) -> PbConstraint {
125        let d = self.degree;
126        PbConstraint {
127            terms: self.terms.iter().map(|(&v, &(c, s))| (v, (c.min(d), s))).collect(),
128            degree: d,
129        }
130    }
131
132    /// A constraint NO 0/1 assignment can satisfy: the maximum possible LHS (every coefficient
133    /// counted) is still below the degree. The terminal of a refutation — e.g. `0 ≥ 1`.
134    pub fn is_contradiction(&self) -> bool {
135        let max_lhs: i64 = self.terms.values().map(|&(c, _)| c).sum();
136        self.degree > max_lhs
137    }
138
139    /// Evaluate under `assign` (a variable → bool map; unmentioned variables are false): does
140    /// `Σ coeff·[literal true] ≥ degree` hold? The oracle the rule soundness tests check against.
141    pub fn is_satisfied(&self, assign: &dyn Fn(usize) -> bool) -> bool {
142        let lhs: i64 = self
143            .terms
144            .iter()
145            .map(|(&v, &(c, s))| if assign(v) == s { c } else { 0 })
146            .sum();
147        lhs >= self.degree
148    }
149
150    /// `Σ coeffᵢ·litᵢ ≥ degree` directly from weighted terms `(var, coeff, positive)` — for constraints
151    /// whose coefficients are not all 1.
152    pub fn new_weighted(terms: &[(usize, i64, bool)], degree: i64) -> PbConstraint {
153        PbConstraint { terms: terms.iter().map(|&(v, c, s)| (v, (c, s))).collect(), degree }
154    }
155
156    /// The `(coeff, positive)` term on variable `v`, or `None` if `v` does not occur.
157    pub fn term(&self, v: usize) -> Option<(i64, bool)> {
158        self.terms.get(&v).copied()
159    }
160
161    /// Iterate the constraint's terms as `(variable, coeff, positive)` — the public view of the
162    /// otherwise-private normalized map, so a live theory can read a constraint without owning it.
163    pub fn terms(&self) -> impl Iterator<Item = (usize, i64, bool)> + '_ {
164        self.terms.iter().map(|(&v, &(c, s))| (v, c, s))
165    }
166}
167
168/// Does the variable permutation `perm` preserve every constraint? (`term(perm[v]) == term(v)` for all
169/// `v` — a relabelling that fixes each constraint's coefficient/sign structure, hence its solution set.)
170pub fn is_pb_symmetry(constraints: &[PbConstraint], perm: &[usize]) -> bool {
171    constraints
172        .iter()
173        .all(|c| (0..perm.len()).all(|v| c.term(perm[v]) == c.term(v)))
174}
175
176/// The **coefficient-symmetry** generators of a pseudo-Boolean system: adjacent transpositions of variables
177/// that share a *coefficient profile* — the same `(coeff, sign)` (or absence) in every constraint. Such
178/// variables are interchangeable because each constraint is a weighted sum, blind to which equal-weight
179/// variable carries which value. Each generator is a genuine symmetry ([`is_pb_symmetry`]); together they
180/// generate the full product of symmetric groups on the profile classes. Variables in no constraint are
181/// excluded (free, not a constraint symmetry).
182pub fn coeff_symmetry_generators(num_vars: usize, constraints: &[PbConstraint]) -> Vec<Vec<usize>> {
183    let profile = |v: usize| -> Vec<Option<(i64, bool)>> {
184        constraints.iter().map(|c| c.term(v)).collect()
185    };
186    let mut classes: BTreeMap<Vec<Option<(i64, bool)>>, Vec<usize>> = BTreeMap::new();
187    for v in 0..num_vars {
188        let p = profile(v);
189        if p.iter().all(|t| t.is_none()) {
190            continue; // appears in no constraint
191        }
192        classes.entry(p).or_default().push(v);
193    }
194    let mut gens = Vec::new();
195    for vars in classes.values() {
196        for w in vars.windows(2) {
197            let mut p: Vec<usize> = (0..num_vars).collect();
198            p.swap(w[0], w[1]);
199            gens.push(p);
200        }
201    }
202    gens
203}
204
205/// Refute the pigeonhole principle PHP(`n`, `n−1`) with the classic Cook–Coullard–Turán cutting-
206/// plane proof: sum all `n` "each pigeon in ≥1 hole" constraints with all `n−1` "each hole holds
207/// ≤1 pigeon" constraints. Every `x` meets its `¬x` and cancels, collapsing the whole system to the
208/// trivially-false `0 ≥ 1` in just `2n−1` additions — **linear** size, where resolution needs
209/// exponentially many steps. Returns the final derived constraint (a contradiction).
210pub fn php_refutation(n: usize) -> PbConstraint {
211    let holes = n - 1;
212    let var = |i: usize, h: usize| i * holes + h;
213    let mut acc: Option<PbConstraint> = None;
214    let mut absorb = |c: PbConstraint| {
215        acc = Some(match acc.take() {
216            None => c,
217            Some(a) => a.add(&c),
218        });
219    };
220    // Each pigeon occupies at least one hole: Σ_h x_{i,h} ≥ 1.
221    for i in 0..n {
222        let lits: Vec<(usize, bool)> = (0..holes).map(|h| (var(i, h), true)).collect();
223        absorb(PbConstraint::clause(&lits));
224    }
225    // Each hole holds at most one pigeon: Σ_i x_{i,h} ≤ 1.
226    for h in 0..holes {
227        let lits: Vec<(usize, bool)> = (0..n).map(|i| (var(i, h), true)).collect();
228        absorb(PbConstraint::at_most(&lits, 1));
229    }
230    acc.expect("PHP has at least one constraint")
231}
232
233// ─────────────────────────────────────────────────────────────────────────────
234// Wiring into the general solver: refute a cardinality/pigeonhole CNF by cutting planes.
235// ─────────────────────────────────────────────────────────────────────────────
236
237use crate::ProofExpr;
238use std::collections::HashMap;
239
240/// Refute `e` by CUTTING PLANES when it is a cardinality/pigeonhole-shaped CNF. The pairwise
241/// encoding loses the cardinality structure, so we RECOVER it: the binary exclusions `¬(a∧b)` form
242/// conflict cliques, each a genuine "at most one" group; summing those `at_most(group,1)` cardinality
243/// constraints with the positive "at least one" clauses cancels every literal against its negation
244/// and collapses a pigeonhole-shaped system to `0 ≥ 1` — POLYNOMIAL, where resolution/CDCL explode.
245///
246/// Returns `true` ONLY on a genuine cutting-plane contradiction (a sound UNSAT proof); `false` (the
247/// caller falls through) for any formula that does not collapse. Never a false `true`: each step is
248/// a sound cutting-plane inference, and the at-most-one constraints are *implied* by the verified
249/// exclusion cliques.
250pub fn refute_clausal(e: &ProofExpr) -> bool {
251    let Some((rows, exclusions, nvars)) = extract_clausal(e) else {
252        return false;
253    };
254    if rows.is_empty() {
255        return false;
256    }
257    // Recover at-most-one groups: connected components of the exclusion graph that are full cliques.
258    let mut uf = UnionFind::new(nvars);
259    for &(a, b) in &exclusions {
260        uf.union(a, b);
261    }
262    let excl_set: std::collections::HashSet<(usize, usize)> =
263        exclusions.iter().map(|&(a, b)| (a.min(b), a.max(b))).collect();
264    let mut comps: HashMap<usize, Vec<usize>> = HashMap::new();
265    for v in 0..nvars {
266        comps.entry(uf.find(v)).or_default().push(v);
267    }
268    let mut sum: Option<PbConstraint> = None;
269    let mut absorb = |c: PbConstraint| {
270        sum = Some(match sum.take() {
271            None => c,
272            Some(s) => s.add(&c),
273        });
274    };
275    for row in &rows {
276        let lits: Vec<(usize, bool)> = row.iter().map(|&v| (v, true)).collect();
277        absorb(PbConstraint::at_least(&lits, 1));
278    }
279    for members in comps.values() {
280        if members.len() < 2 {
281            continue;
282        }
283        // Only a FULL clique is a sound at-most-one (else two members could share a slot).
284        let is_clique = members.iter().enumerate().all(|(i, &a)| {
285            members[i + 1..].iter().all(|&b| excl_set.contains(&(a.min(b), a.max(b))))
286        });
287        if !is_clique {
288            return false; // incomplete at-most-one → can't soundly refute this way
289        }
290        let lits: Vec<(usize, bool)> = members.iter().map(|&v| (v, true)).collect();
291        absorb(PbConstraint::at_most(&lits, 1));
292    }
293    match sum {
294        Some(c) => c.is_contradiction() || c.saturate().is_contradiction(),
295        None => false,
296    }
297}
298
299/// Flatten `e` into `(positive-clause variable lists, binary exclusion pairs, var count)` over dense
300/// variable indices, or `None` if any top-level conjunct is neither an all-positive disjunction nor
301/// a binary mutual-exclusion. (A standalone reader so the PB path is independent of the others.)
302fn extract_clausal(e: &ProofExpr) -> Option<(Vec<Vec<usize>>, Vec<(usize, usize)>, usize)> {
303    let mut conjuncts = Vec::new();
304    flatten_and(e, &mut conjuncts);
305    let mut idx: HashMap<String, usize> = HashMap::new();
306    let mut var = |name: &str, idx: &mut HashMap<String, usize>| -> usize {
307        let n = idx.len();
308        *idx.entry(name.to_string()).or_insert(n)
309    };
310    let mut rows = Vec::new();
311    let mut excl = Vec::new();
312    for c in conjuncts {
313        if let Some(atoms) = positive_disjunction(c) {
314            rows.push(atoms.iter().map(|a| var(a, &mut idx)).collect());
315        } else if let Some((a, b)) = exclusion_pair(c) {
316            excl.push((var(&a, &mut idx), var(&b, &mut idx)));
317        } else {
318            return None;
319        }
320    }
321    let nvars = idx.len();
322    Some((rows, excl, nvars))
323}
324
325fn flatten_and<'a>(e: &'a ProofExpr, out: &mut Vec<&'a ProofExpr>) {
326    // Iterative worklist rather than recursion: a flat CNF clausifies into a left-nested `And` spine
327    // whose depth is the clause count, so a recursive walk overflows the stack on a few-thousand-clause
328    // formula. The explicit stack handles any depth. Pushing `r` before `l` pops `l` first, preserving
329    // the left-to-right clause order the recursive version produced.
330    let mut stack = vec![e];
331    while let Some(node) = stack.pop() {
332        match node {
333            ProofExpr::And(l, r) => {
334                stack.push(r);
335                stack.push(l);
336            }
337            other => out.push(other),
338        }
339    }
340}
341
342fn positive_disjunction(e: &ProofExpr) -> Option<Vec<String>> {
343    fn walk(e: &ProofExpr, out: &mut Vec<String>) -> bool {
344        match e {
345            ProofExpr::Or(l, r) => walk(l, out) && walk(r, out),
346            ProofExpr::Atom(a) => {
347                out.push(a.clone());
348                true
349            }
350            _ => false,
351        }
352    }
353    let mut atoms = Vec::new();
354    walk(e, &mut atoms).then_some(atoms)
355}
356
357fn exclusion_pair(e: &ProofExpr) -> Option<(String, String)> {
358    match e {
359        ProofExpr::Not(inner) => match inner.as_ref() {
360            ProofExpr::And(a, b) => match (a.as_ref(), b.as_ref()) {
361                (ProofExpr::Atom(a), ProofExpr::Atom(b)) => Some((a.clone(), b.clone())),
362                _ => None,
363            },
364            _ => None,
365        },
366        ProofExpr::Or(l, r) => match (l.as_ref(), r.as_ref()) {
367            (ProofExpr::Not(a), ProofExpr::Not(b)) => match (a.as_ref(), b.as_ref()) {
368                (ProofExpr::Atom(a), ProofExpr::Atom(b)) => Some((a.clone(), b.clone())),
369                _ => None,
370            },
371            _ => None,
372        },
373        _ => None,
374    }
375}
376
377struct UnionFind {
378    parent: Vec<usize>,
379}
380impl UnionFind {
381    fn new(n: usize) -> Self {
382        UnionFind { parent: (0..n).collect() }
383    }
384    fn find(&mut self, x: usize) -> usize {
385        if self.parent[x] != x {
386            let r = self.find(self.parent[x]);
387            self.parent[x] = r;
388        }
389        self.parent[x]
390    }
391    fn union(&mut self, a: usize, b: usize) {
392        let (ra, rb) = (self.find(a), self.find(b));
393        if ra != rb {
394            self.parent[ra] = rb;
395        }
396    }
397}
398
399use crate::cdcl::Lit;
400
401/// A **live cardinality theory** for the CDCL engine ([`crate::cdcl::Theory`]) — the trail-driven twin of
402/// the static cutting-plane refuter [`refute_clausal`]. It propagates and conflicts on unit-coefficient
403/// cardinality constraints (`Σ ℓ ≥ k`, covering clauses, at-least-`k`, and at-most-`k`) directly on the
404/// solver's trail (stateless: it rebuilds the partial assignment each call, so it can never desync), so it
405/// FUSES with a GF(2) parity theory ([`crate::xor_engine::XorEngine`]) under one
406/// [`crate::cdcl::Solver::solve_with`]: parity and counting reason *together* on the shared trail. That is
407/// exactly the structure that defeats either alone — e.g. minimal-disagreement parity, a parity core plus a
408/// residual cardinality core, where Gaussian elimination handles the parity and cardinality propagation
409/// handles the counting, but neither is a refutation by itself.
410///
411/// Pair it with the stateless [`crate::xor_engine::XorEngine`], not the incremental
412/// [`crate::xor_engine::IncXor`]: `IncXor`'s trail-sync matches by variable but not value, so a
413/// backtrack-then-flip leaves its matrix stale — correct whenever a clausal XOR encoding backs it (the
414/// dispatcher's case), but unsound in a pure-theory fusion with no Boolean clauses to mask it.
415///
416/// **Soundness** rests on the cardinality reason clause: for `Σ ℓ ≥ k` over `n` literals, ANY `n − k + 1`
417/// of them form a clause entailed by the constraint (at most `n − k` of the literals can be false, so any
418/// `n − k + 1` contain a true one). Hence when more than `n − k` are false, `n − k + 1` false literals are
419/// an all-false entailed clause — a **conflict**; when exactly `n − k` are false, every unassigned literal
420/// is forced true with reason `{the false literals} ∨ ℓ` (size `n − k + 1`, entailed, currently unit).
421/// Every returned clause is a logical consequence of the constraint, so `Solver::solve_with` may carry it
422/// into the learned database soundly.
423pub struct CardinalityTheory {
424    num_vars: usize,
425    /// Each constraint as `(literals, k)` in `Σ ℓ ≥ k` form. `at_most`/`clause` normalize into this.
426    constraints: Vec<(Vec<(usize, bool)>, i64)>,
427}
428
429impl CardinalityTheory {
430    /// Build from normalized [`PbConstraint`]s. Every input MUST be unit-coefficient (a cardinality
431    /// constraint); a weighted term is rejected (`panic`) rather than risk an unsound weighted reason —
432    /// general weighted PB belongs to the static cutting-plane engine ([`refute_clausal`]), not this live
433    /// clausal-reason theory.
434    pub fn new(num_vars: usize, constraints: &[PbConstraint]) -> Self {
435        let constraints = constraints
436            .iter()
437            .map(|pb| {
438                let lits: Vec<(usize, bool)> = pb
439                    .terms()
440                    .map(|(v, c, s)| {
441                        assert_eq!(c, 1, "CardinalityTheory requires unit coefficients (got {c} on var {v})");
442                        (v, s)
443                    })
444                    .collect();
445                (lits, pb.degree())
446            })
447            .collect();
448        CardinalityTheory { num_vars, constraints }
449    }
450}
451
452impl crate::cdcl::Theory for CardinalityTheory {
453    fn propagate(&mut self, trail: &[Lit]) -> Vec<Vec<Lit>> {
454        let mut a: Vec<Option<bool>> = vec![None; self.num_vars];
455        for &l in trail {
456            a[l.var() as usize] = Some(l.is_positive());
457        }
458        let mut out: Vec<Vec<Lit>> = Vec::new();
459        for (lits, k) in &self.constraints {
460            let k = *k;
461            if k <= 0 {
462                continue; // trivially satisfied
463            }
464            let n = lits.len() as i64;
465            if k > n {
466                out.push(Vec::new()); // Σ ℓ ≥ k > n is unsatisfiable — an unconditional contradiction
467                continue;
468            }
469            let mut false_lits: Vec<(usize, bool)> = Vec::new();
470            let mut unassigned: Vec<(usize, bool)> = Vec::new();
471            let mut true_count = 0i64;
472            for &(v, s) in lits {
473                match a[v] {
474                    Some(val) if val == s => true_count += 1,
475                    Some(_) => false_lits.push((v, s)),
476                    None => unassigned.push((v, s)),
477                }
478            }
479            if true_count >= k {
480                continue; // already satisfied
481            }
482            let max_false = n - k; // the constraint tolerates at most this many false literals
483            let fc = false_lits.len() as i64;
484            if fc > max_false {
485                // CONFLICT: any (max_false + 1) of the false literals are an entailed, all-false clause.
486                let take = (max_false + 1) as usize;
487                out.push(false_lits[..take].iter().map(|&(v, s)| Lit::new(v as u32, s)).collect());
488            } else if fc == max_false && !unassigned.is_empty() {
489                // Every unassigned literal is forced true: reason = {the false literals} ∨ ℓ.
490                let base: Vec<Lit> = false_lits.iter().map(|&(v, s)| Lit::new(v as u32, s)).collect();
491                for &(v, s) in &unassigned {
492                    let mut c = base.clone();
493                    c.push(Lit::new(v as u32, s));
494                    out.push(c);
495                }
496            }
497        }
498        out
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    fn for_all_assignments(nvars: usize, mut f: impl FnMut(&dyn Fn(usize) -> bool)) {
507        for mask in 0..(1u32 << nvars) {
508            let assign = move |v: usize| (mask >> v) & 1 == 1;
509            f(&assign);
510        }
511    }
512
513    /// A rule is sound iff its conclusion is IMPLIED by its premises: every assignment satisfying
514    /// all premises also satisfies the derived constraint.
515    fn assert_implied(premises: &[&PbConstraint], derived: &PbConstraint, nvars: usize) {
516        for_all_assignments(nvars, |a| {
517            if premises.iter().all(|c| c.is_satisfied(a)) {
518                assert!(
519                    derived.is_satisfied(a),
520                    "UNSOUND rule: a premise-satisfying assignment falsifies the conclusion"
521                );
522            }
523        });
524    }
525
526    #[test]
527    fn addition_is_sound() {
528        // Exhaustive over small constraints, including the x/¬x cancellation case.
529        let c1 = PbConstraint::at_least(&[(0, true), (1, true), (2, true)], 2);
530        let c2 = PbConstraint::at_least(&[(0, false), (1, true), (3, true)], 2);
531        assert_implied(&[&c1, &c2], &c1.add(&c2), 4);
532
533        // Full cancellation: (x0+x1≥1) + (¬x0+¬x1≥1) — degree drops by 2 → 0 ≥ 0 (trivially true).
534        let a = PbConstraint::clause(&[(0, true), (1, true)]);
535        let b = PbConstraint::at_least(&[(0, false), (1, false)], 1);
536        let sum = a.add(&b);
537        assert_implied(&[&a, &b], &sum, 2);
538        assert!(sum.is_empty() && sum.degree() == 0, "x+¬x cancels to a trivially-true 0 ≥ 0");
539    }
540
541    #[test]
542    fn multiply_divide_saturate_are_sound() {
543        let c = PbConstraint::at_least(&[(0, true), (1, true), (2, false)], 2);
544        assert_implied(&[&c], &c.multiply(3), 3);
545        assert_implied(&[&c], &c.saturate(), 3);
546        // Division rounds up: a constraint with mixed coefficients.
547        let d = PbConstraint { terms: [(0, (5, true)), (1, (3, true)), (2, (2, true))].into(), degree: 6 };
548        assert_implied(&[&d], &d.divide_round(2), 3);
549        assert_implied(&[&d], &d.divide_round(3), 3);
550    }
551
552    #[test]
553    fn contradiction_detection() {
554        let unsat = PbConstraint { terms: BTreeMap::new(), degree: 1 };
555        assert!(unsat.is_contradiction(), "0 ≥ 1 is a contradiction");
556        let tight = PbConstraint::at_least(&[(0, true), (1, true)], 2);
557        assert!(!tight.is_contradiction(), "x0+x1 ≥ 2 is satisfiable (both true)");
558        let over = PbConstraint::at_least(&[(0, true)], 2);
559        assert!(over.is_contradiction(), "x0 ≥ 2 is impossible for a 0/1 variable");
560    }
561
562    #[test]
563    fn pigeonhole_is_refuted_in_linear_size() {
564        // THE HEADLINE: cutting planes refutes PHP(n, n-1) with a LINEAR-size derivation (2n-1
565        // additions), collapsing to `0 ≥ 1` — where resolution/CDCL need exponentially many steps.
566        for n in 2..=30 {
567            let refutation = php_refutation(n);
568            assert!(
569                refutation.is_contradiction(),
570                "PHP({n}) must collapse to a contradiction under cutting planes"
571            );
572            assert!(refutation.is_empty(), "every x meets its ¬x and cancels — the LHS is empty");
573            assert_eq!(refutation.degree(), 1, "the terminal is exactly 0 ≥ 1");
574        }
575    }
576
577    fn php_expr(n: usize) -> ProofExpr {
578        let holes = n - 1;
579        let p = |i: usize, h: usize| ProofExpr::Atom(format!("p_{i}_{h}"));
580        let mut clauses = Vec::new();
581        for i in 0..n {
582            clauses.push(
583                (0..holes).map(|h| p(i, h)).reduce(|a, b| ProofExpr::Or(Box::new(a), Box::new(b))).unwrap(),
584            );
585        }
586        for h in 0..holes {
587            for i in 0..n {
588                for j in (i + 1)..n {
589                    clauses.push(ProofExpr::Not(Box::new(ProofExpr::And(Box::new(p(i, h)), Box::new(p(j, h))))));
590                }
591            }
592        }
593        clauses.into_iter().reduce(|a, b| ProofExpr::And(Box::new(a), Box::new(b))).unwrap()
594    }
595
596    fn feasible_expr(n: usize) -> ProofExpr {
597        // n items into n slots — SATISFIABLE; must NOT be refuted.
598        let q = |i: usize, h: usize| ProofExpr::Atom(format!("q_{i}_{h}"));
599        let mut clauses = Vec::new();
600        for i in 0..n {
601            clauses.push((0..n).map(|h| q(i, h)).reduce(|a, b| ProofExpr::Or(Box::new(a), Box::new(b))).unwrap());
602        }
603        for h in 0..n {
604            for i in 0..n {
605                for j in (i + 1)..n {
606                    clauses.push(ProofExpr::Not(Box::new(ProofExpr::And(Box::new(q(i, h)), Box::new(q(j, h))))));
607                }
608            }
609        }
610        clauses.into_iter().reduce(|a, b| ProofExpr::And(Box::new(a), Box::new(b))).unwrap()
611    }
612
613    #[test]
614    fn cutting_planes_refutes_pairwise_pigeonhole_from_cnf() {
615        // The wiring target: a pairwise-encoded PHP CNF (resolution-EXPONENTIAL) is refuted by
616        // recovering the at-most-one cardinality from the exclusion cliques and summing — polynomial.
617        for n in 2..=10 {
618            assert!(refute_clausal(&php_expr(n)), "cutting planes must refute pairwise PHP({n}) from CNF");
619        }
620    }
621
622    #[test]
623    fn cutting_planes_refutation_is_sound() {
624        // Soundness-critical: a SATISFIABLE formula is NEVER refuted, and a non-cardinality formula
625        // falls through (false) instead of claiming a contradiction.
626        for n in 1..=8 {
627            assert!(!refute_clausal(&feasible_expr(n)), "feasible({n}) must NOT be refuted");
628        }
629        let lone = ProofExpr::Or(
630            Box::new(ProofExpr::Atom("a".into())),
631            Box::new(ProofExpr::Atom("b".into())),
632        );
633        assert!(!refute_clausal(&lone), "a lone satisfiable clause is not a cutting-plane contradiction");
634    }
635
636    #[test]
637    fn pigeonhole_refutation_is_genuinely_unsat_small() {
638        // Sanity: the PHP(3,2) constraints really are jointly unsatisfiable (the refutation isn't
639        // refuting a satisfiable system). Brute-force over the 6 variables.
640        let n = 3usize;
641        let holes = n - 1;
642        let var = |i: usize, h: usize| i * holes + h;
643        let mut cs = Vec::new();
644        for i in 0..n {
645            cs.push(PbConstraint::clause(&(0..holes).map(|h| (var(i, h), true)).collect::<Vec<_>>()));
646        }
647        for h in 0..holes {
648            cs.push(PbConstraint::at_most(&(0..n).map(|i| (var(i, h), true)).collect::<Vec<_>>(), 1));
649        }
650        let mut any = false;
651        for_all_assignments(n * holes, |a| {
652            if cs.iter().all(|c| c.is_satisfied(a)) {
653                any = true;
654            }
655        });
656        assert!(!any, "PHP(3,2) is UNSAT — no assignment satisfies all constraints");
657    }
658
659    #[test]
660    fn coefficient_symmetry_is_detected_and_sound() {
661        // 2·x0 + 2·x1 + 3·x2 ≥ 4: x0 and x1 share the coefficient 2, so they are interchangeable; x2 (coeff
662        // 3) is alone. The symmetry is in the WEIGHTS, not the clause structure.
663        let weighted = PbConstraint::new_weighted(&[(0, 2, true), (1, 2, true), (2, 3, true)], 4);
664        let gens = coeff_symmetry_generators(3, &[weighted.clone()]);
665        assert_eq!(gens.len(), 1, "one generator: the x0 ↔ x1 swap");
666        assert_eq!(gens[0], vec![1, 0, 2], "x0 ↔ x1");
667        // Every generator is a genuine symmetry…
668        assert!(gens.iter().all(|g| is_pb_symmetry(&[weighted.clone()], g)));
669        // …and it really preserves the solution set (brute force: swapping x0,x1 maps models to models).
670        for_all_assignments(3, |a| {
671            let swapped = |v: usize| a(if v == 0 { 1 } else if v == 1 { 0 } else { v });
672            assert_eq!(
673                weighted.is_satisfied(a),
674                weighted.is_satisfied(&swapped),
675                "the x0↔x1 swap preserves satisfaction"
676            );
677        });
678
679        // Distinct coefficients ⇒ no coefficient symmetry.
680        let distinct = PbConstraint::new_weighted(&[(0, 1, true), (1, 2, true), (2, 3, true)], 3);
681        assert!(coeff_symmetry_generators(3, &[distinct]).is_empty(), "all-distinct coefficients: no symmetry");
682
683        // Across a SYSTEM: a variable's profile is its coefficient in EVERY constraint. Here x0,x1 share
684        // (2 in C₁, 1 in C₂) while x2 differs in C₂, so only x0,x1 remain interchangeable.
685        let c1 = PbConstraint::new_weighted(&[(0, 2, true), (1, 2, true), (2, 2, true)], 3);
686        let c2 = PbConstraint::new_weighted(&[(0, 1, true), (1, 1, true), (2, 5, true)], 2);
687        let sysg = coeff_symmetry_generators(3, &[c1.clone(), c2.clone()]);
688        assert_eq!(sysg, vec![vec![1, 0, 2]], "only x0 ↔ x1 survives both constraints");
689        assert!(sysg.iter().all(|g| is_pb_symmetry(&[c1.clone(), c2.clone()], g)));
690    }
691
692    /// The live cardinality theory forces the right literals and conflicts at the right moment, with
693    /// every returned clause currently unit (a forced literal) or all-false (a conflict).
694    #[test]
695    fn cardinality_theory_forces_then_conflicts_on_at_least_two() {
696        use crate::cdcl::{Lit, Theory};
697        let lits = [(0usize, true), (1, true), (2, true)];
698        let mut th = CardinalityTheory::new(3, &[PbConstraint::at_least(&lits, 2)]);
699        // x0 = false ⇒ with one of three false, the other two must be true to reach ≥ 2.
700        let forced = th.propagate(&[Lit::new(0, false)]);
701        assert_eq!(forced.len(), 2, "two literals forced; got {forced:?}");
702        for c in &forced {
703            let free: Vec<&Lit> = c.iter().filter(|l| l.var() != 0).collect();
704            assert_eq!(free.len(), 1, "each reason is unit under {{x0=false}}: {c:?}");
705            assert!(free[0].is_positive(), "the forced literal is x_i true: {c:?}");
706        }
707        // x0 = false ∧ x1 = false ⇒ at most one can be true < 2 ⇒ conflict over the two false literals.
708        let conf = th.propagate(&[Lit::new(0, false), Lit::new(1, false)]);
709        assert!(!conf.is_empty(), "must conflict; got {conf:?}");
710        assert!(
711            conf.iter().any(|c| !c.is_empty() && c.iter().all(|l| (l.var() == 0 || l.var() == 1) && l.is_positive())),
712            "a conflict clause of the two now-false literals; got {conf:?}"
713        );
714    }
715
716    /// DIAGNOSTIC: the cardinality theory ALONE, with no Boolean clauses, must refute an infeasible system
717    /// (`≥ 3` and `≤ 1` of three variables) — i.e. the engine's pure-theory `solve_with` drives a theory
718    /// conflict all the way to UNSAT.
719    #[test]
720    fn cardinality_theory_alone_refutes_an_infeasible_system() {
721        use crate::cdcl::{Solver, SolveResult, Theory};
722        let lits = [(0usize, true), (1, true), (2, true)];
723        let card = vec![PbConstraint::at_least(&lits, 3), PbConstraint::at_most(&lits, 1)];
724        let mut s = Solver::new(3);
725        let mut t: Vec<Box<dyn Theory>> = vec![Box::new(CardinalityTheory::new(3, &card))];
726        assert!(matches!(s.solve_with(&mut t), SolveResult::Unsat), "≥3 ∧ ≤1 of three is UNSAT");
727    }
728
729    /// DIAGNOSTIC: `IncXor` alone, no Boolean clauses, must refute an inconsistent linear system
730    /// (`x0 ⊕ x1 = 0 ∧ x0 ⊕ x1 = 1`) — isolating whether pure-theory refutation works for the parity engine.
731    #[test]
732    fn incxor_alone_refutes_an_inconsistent_system() {
733        use crate::cdcl::{Solver, SolveResult, Theory};
734        use crate::xor_engine::IncXor;
735        use crate::xorsat::XorEquation;
736        let xor = vec![XorEquation::new(vec![0, 1], false), XorEquation::new(vec![0, 1], true)];
737        let mut s = Solver::new(2);
738        let mut t: Vec<Box<dyn Theory>> = vec![Box::new(IncXor::new(2, &xor))];
739        assert!(matches!(s.solve_with(&mut t), SolveResult::Unsat), "x0⊕x1=0 ∧ x0⊕x1=1 is UNSAT");
740    }
741
742    /// **The headline fusion.** Parity (`x0 ⊕ x1 ⊕ x2 = 1`, odd) ∧ exactly-two-true is UNSAT (two is
743    /// even), yet NEITHER theory alone refutes it — parity alone is SAT (one true), cardinality alone is
744    /// SAT (any two). Only `IncXor` and `CardinalityTheory` reasoning *together* on the shared trail close
745    /// it, with no Boolean clauses at all.
746    #[test]
747    fn fused_parity_and_cardinality_is_unsat_though_neither_alone_is() {
748        use crate::cdcl::{Solver, SolveResult, Theory};
749        use crate::xor_engine::XorEngine;
750        use crate::xorsat::XorEquation;
751        // The parity theory is the stateless `XorEngine` (the GF(2) correctness oracle), not the
752        // incremental `IncXor`: `IncXor`'s trail-sync matches by variable but not value, so a
753        // backtrack-then-flip leaves its matrix stale — harmless when a clausal XOR encoding backs it,
754        // unsound in pure-theory fusion (see the docs on `CardinalityTheory`).
755        let xor = vec![XorEquation::new(vec![0, 1, 2], true)];
756        let lits = [(0usize, true), (1, true), (2, true)];
757        let card = vec![PbConstraint::at_least(&lits, 2), PbConstraint::at_most(&lits, 2)];
758
759        let mut s1 = Solver::new(3);
760        let mut t1: Vec<Box<dyn Theory>> = vec![Box::new(XorEngine::new(3, &xor))];
761        assert!(matches!(s1.solve_with(&mut t1), SolveResult::Sat(_)), "parity alone is SAT");
762
763        let mut s2 = Solver::new(3);
764        let mut t2: Vec<Box<dyn Theory>> = vec![Box::new(CardinalityTheory::new(3, &card))];
765        assert!(matches!(s2.solve_with(&mut t2), SolveResult::Sat(_)), "exactly-two alone is SAT");
766
767        let mut s = Solver::new(3);
768        let mut fused: Vec<Box<dyn Theory>> =
769            vec![Box::new(XorEngine::new(3, &xor)), Box::new(CardinalityTheory::new(3, &card))];
770        assert!(matches!(s.solve_with(&mut fused), SolveResult::Unsat), "odd-parity ∧ exactly-two is UNSAT");
771    }
772
773    /// The consistent twin: even parity ∧ exactly-two-true IS satisfiable, and the fused solver returns a
774    /// model that genuinely has even parity and exactly two true.
775    #[test]
776    fn fused_parity_and_cardinality_sat_model_is_valid() {
777        use crate::cdcl::{Solver, SolveResult, Theory};
778        use crate::xor_engine::XorEngine;
779        use crate::xorsat::XorEquation;
780        let xor = vec![XorEquation::new(vec![0, 1, 2], false)];
781        let lits = [(0usize, true), (1, true), (2, true)];
782        let card = vec![PbConstraint::at_least(&lits, 2), PbConstraint::at_most(&lits, 2)];
783        let mut s = Solver::new(3);
784        let mut fused: Vec<Box<dyn Theory>> =
785            vec![Box::new(XorEngine::new(3, &xor)), Box::new(CardinalityTheory::new(3, &card))];
786        match s.solve_with(&mut fused) {
787            SolveResult::Sat(m) => {
788                assert_eq!(m.iter().filter(|&&b| b).count(), 2, "exactly two true: {m:?}");
789                assert!(!(m[0] ^ m[1] ^ m[2]), "even parity: {m:?}");
790            }
791            SolveResult::Unsat => panic!("even-parity ∧ exactly-two is SAT"),
792        }
793    }
794
795    /// **Soundness to the point of absurdity.** Random instances combining Boolean clauses + XOR
796    /// equations (`IncXor`) + cardinality constraints (`CardinalityTheory`): the fused `solve_with`
797    /// verdict must match brute-force enumeration of all `2ⁿ` assignments exactly, and every reported SAT
798    /// model must satisfy every clause, every parity, and every cardinality constraint.
799    #[test]
800    fn fused_solve_matches_brute_force() {
801        use crate::cdcl::{Lit, Solver, SolveResult, Theory};
802        use crate::xor_engine::XorEngine;
803        use crate::xorsat::XorEquation;
804        let mut st = 0xCA11_AB1Eu64;
805        let mut rng = || {
806            st ^= st << 13;
807            st ^= st >> 7;
808            st ^= st << 17;
809            st
810        };
811        for _ in 0..300 {
812            let n = 3 + (rng() % 3) as usize; // 3..=5 vars
813            let mut xor_specs: Vec<(Vec<usize>, bool)> = Vec::new();
814            for _ in 0..(1 + rng() % 2) {
815                let vars: Vec<usize> = (0..n).filter(|_| rng() % 2 == 0).collect();
816                if vars.len() >= 2 {
817                    xor_specs.push((vars, rng() % 2 == 0));
818                }
819            }
820            let mut pbs: Vec<PbConstraint> = Vec::new();
821            for _ in 0..(1 + rng() % 2) {
822                let lits: Vec<(usize, bool)> =
823                    (0..n).filter_map(|v| (rng() % 2 == 0).then(|| (v, rng() % 2 == 0))).collect();
824                if lits.len() < 2 {
825                    continue;
826                }
827                let k = (rng() % (lits.len() as u64 + 1)) as i64;
828                pbs.push(if rng() % 2 == 0 { PbConstraint::at_least(&lits, k) } else { PbConstraint::at_most(&lits, k) });
829            }
830            let mut clauses: Vec<Vec<Lit>> = Vec::new();
831            for _ in 0..(rng() % 3) {
832                let c: Vec<Lit> =
833                    (0..n).filter_map(|v| (rng() % 2 == 0).then(|| Lit::new(v as u32, rng() % 2 == 0))).collect();
834                if !c.is_empty() {
835                    clauses.push(c);
836                }
837            }
838
839            let xor_ok = |x: u64| xor_specs.iter().all(|(vars, rhs)| (vars.iter().filter(|&&v| (x >> v) & 1 == 1).count() % 2 == 1) == *rhs);
840            let pb_ok = |x: u64| {
841                pbs.iter().all(|pb| {
842                    let sum: i64 = pb.terms().map(|(v, c, s)| if (((x >> v) & 1 == 1) == s) { c } else { 0 }).sum();
843                    sum >= pb.degree()
844                })
845            };
846            let cl_ok = |x: u64| clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive()));
847            let brute_sat = (0u64..(1u64 << n)).any(|x| xor_ok(x) && pb_ok(x) && cl_ok(x));
848
849            let xeqs: Vec<XorEquation> = xor_specs.iter().map(|(v, r)| XorEquation::new(v.clone(), *r)).collect();
850            let mut s = Solver::new(n);
851            for c in &clauses {
852                s.add_clause(c.clone());
853            }
854            let mut theories: Vec<Box<dyn Theory>> =
855                vec![Box::new(XorEngine::new(n, &xeqs)), Box::new(CardinalityTheory::new(n, &pbs))];
856            let got = s.solve_with(&mut theories);
857            assert_eq!(
858                matches!(got, SolveResult::Sat(_)),
859                brute_sat,
860                "fused verdict must match brute force (n={n}, xor={xor_specs:?}, pbs={pbs:?}, clauses={clauses:?})"
861            );
862            if let SolveResult::Sat(m) = got {
863                let x = (0..n).fold(0u64, |acc, v| acc | ((m[v] as u64) << v));
864                assert!(xor_ok(x) && pb_ok(x) && cl_ok(x), "the fused model must satisfy every constraint: {m:?}");
865            }
866        }
867    }
868}