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
104 .terms
105 .iter()
106 .map(|(&v, &(coeff, s))| (v, (coeff * c, s)))
107 .collect(),
108 degree: self.degree * c,
109 }
110 }
111
112 /// **Division with rounding-up** (the Gomory–Chvátal cut) — `Σ ⌈aᵢ/d⌉·ℓᵢ ≥ ⌈degree/d⌉`. Sound
113 /// ONLY because variables are integral: `⌈aᵢ/d⌉ ≥ aᵢ/d` so the LHS only grows (≥ degree/d), and
114 /// being an integer it is `≥ ⌈degree/d⌉`. This rounding of the fractional slack is exactly what
115 /// makes cutting planes stronger than resolution.
116 pub fn divide_round(&self, d: i64) -> PbConstraint {
117 assert!(d > 0, "divide by a positive integer");
118 let ceil = |x: i64| x.div_euclid(d) + i64::from(x.rem_euclid(d) != 0);
119 PbConstraint {
120 terms: self
121 .terms
122 .iter()
123 .map(|(&v, &(c, s))| (v, (ceil(c), s)))
124 .collect(),
125 degree: ceil(self.degree),
126 }
127 }
128
129 /// **Saturation** — clamp every coefficient to the degree (`min(aᵢ, degree)`). A single 0/1
130 /// variable can contribute at most `degree` toward the bound, so the excess is redundant. Sound,
131 /// and it keeps coefficients from blowing up across many additions.
132 pub fn saturate(&self) -> PbConstraint {
133 let d = self.degree;
134 PbConstraint {
135 terms: self
136 .terms
137 .iter()
138 .map(|(&v, &(c, s))| (v, (c.min(d), s)))
139 .collect(),
140 degree: d,
141 }
142 }
143
144 /// A constraint NO 0/1 assignment can satisfy: the maximum possible LHS (every coefficient
145 /// counted) is still below the degree. The terminal of a refutation — e.g. `0 ≥ 1`.
146 pub fn is_contradiction(&self) -> bool {
147 let max_lhs: i64 = self.terms.values().map(|&(c, _)| c).sum();
148 self.degree > max_lhs
149 }
150
151 /// Evaluate under `assign` (a variable → bool map; unmentioned variables are false): does
152 /// `Σ coeff·[literal true] ≥ degree` hold? The oracle the rule soundness tests check against.
153 pub fn is_satisfied(&self, assign: &dyn Fn(usize) -> bool) -> bool {
154 let lhs: i64 = self
155 .terms
156 .iter()
157 .map(|(&v, &(c, s))| if assign(v) == s { c } else { 0 })
158 .sum();
159 lhs >= self.degree
160 }
161
162 /// `Σ coeffᵢ·litᵢ ≥ degree` directly from weighted terms `(var, coeff, positive)` — for constraints
163 /// whose coefficients are not all 1.
164 pub fn new_weighted(terms: &[(usize, i64, bool)], degree: i64) -> PbConstraint {
165 PbConstraint {
166 terms: terms.iter().map(|&(v, c, s)| (v, (c, s))).collect(),
167 degree,
168 }
169 }
170
171 /// The `(coeff, positive)` term on variable `v`, or `None` if `v` does not occur.
172 pub fn term(&self, v: usize) -> Option<(i64, bool)> {
173 self.terms.get(&v).copied()
174 }
175
176 /// Iterate the constraint's terms as `(variable, coeff, positive)` — the public view of the
177 /// otherwise-private normalized map, so a live theory can read a constraint without owning it.
178 pub fn terms(&self) -> impl Iterator<Item = (usize, i64, bool)> + '_ {
179 self.terms.iter().map(|(&v, &(c, s))| (v, c, s))
180 }
181}
182
183/// Does the variable permutation `perm` preserve every constraint? (`term(perm[v]) == term(v)` for all
184/// `v` — a relabelling that fixes each constraint's coefficient/sign structure, hence its solution set.)
185pub fn is_pb_symmetry(constraints: &[PbConstraint], perm: &[usize]) -> bool {
186 constraints
187 .iter()
188 .all(|c| (0..perm.len()).all(|v| c.term(perm[v]) == c.term(v)))
189}
190
191/// The **coefficient-symmetry** generators of a pseudo-Boolean system: adjacent transpositions of variables
192/// that share a *coefficient profile* — the same `(coeff, sign)` (or absence) in every constraint. Such
193/// variables are interchangeable because each constraint is a weighted sum, blind to which equal-weight
194/// variable carries which value. Each generator is a genuine symmetry ([`is_pb_symmetry`]); together they
195/// generate the full product of symmetric groups on the profile classes. Variables in no constraint are
196/// excluded (free, not a constraint symmetry).
197pub fn coeff_symmetry_generators(num_vars: usize, constraints: &[PbConstraint]) -> Vec<Vec<usize>> {
198 let profile =
199 |v: usize| -> Vec<Option<(i64, bool)>> { constraints.iter().map(|c| c.term(v)).collect() };
200 let mut classes: BTreeMap<Vec<Option<(i64, bool)>>, Vec<usize>> = BTreeMap::new();
201 for v in 0..num_vars {
202 let p = profile(v);
203 if p.iter().all(|t| t.is_none()) {
204 continue; // appears in no constraint
205 }
206 classes.entry(p).or_default().push(v);
207 }
208 let mut gens = Vec::new();
209 for vars in classes.values() {
210 for w in vars.windows(2) {
211 let mut p: Vec<usize> = (0..num_vars).collect();
212 p.swap(w[0], w[1]);
213 gens.push(p);
214 }
215 }
216 gens
217}
218
219/// Refute the pigeonhole principle PHP(`n`, `n−1`) with the classic Cook–Coullard–Turán cutting-
220/// plane proof: sum all `n` "each pigeon in ≥1 hole" constraints with all `n−1` "each hole holds
221/// ≤1 pigeon" constraints. Every `x` meets its `¬x` and cancels, collapsing the whole system to the
222/// trivially-false `0 ≥ 1` in just `2n−1` additions — **linear** size, where resolution needs
223/// exponentially many steps. Returns the final derived constraint (a contradiction).
224pub fn php_refutation(n: usize) -> PbConstraint {
225 let holes = n - 1;
226 let var = |i: usize, h: usize| i * holes + h;
227 let mut acc: Option<PbConstraint> = None;
228 let mut absorb = |c: PbConstraint| {
229 acc = Some(match acc.take() {
230 None => c,
231 Some(a) => a.add(&c),
232 });
233 };
234 // Each pigeon occupies at least one hole: Σ_h x_{i,h} ≥ 1.
235 for i in 0..n {
236 let lits: Vec<(usize, bool)> = (0..holes).map(|h| (var(i, h), true)).collect();
237 absorb(PbConstraint::clause(&lits));
238 }
239 // Each hole holds at most one pigeon: Σ_i x_{i,h} ≤ 1.
240 for h in 0..holes {
241 let lits: Vec<(usize, bool)> = (0..n).map(|i| (var(i, h), true)).collect();
242 absorb(PbConstraint::at_most(&lits, 1));
243 }
244 acc.expect("PHP has at least one constraint")
245}
246
247// ─────────────────────────────────────────────────────────────────────────────
248// Wiring into the general solver: refute a cardinality/pigeonhole CNF by cutting planes.
249// ─────────────────────────────────────────────────────────────────────────────
250
251use crate::ProofExpr;
252use std::collections::HashMap;
253
254/// Refute `e` by CUTTING PLANES when it is a cardinality/pigeonhole-shaped CNF. The pairwise
255/// encoding loses the cardinality structure, so we RECOVER it: the binary exclusions `¬(a∧b)` form
256/// conflict cliques, each a genuine "at most one" group; summing those `at_most(group,1)` cardinality
257/// constraints with the positive "at least one" clauses cancels every literal against its negation
258/// and collapses a pigeonhole-shaped system to `0 ≥ 1` — POLYNOMIAL, where resolution/CDCL explode.
259///
260/// Returns `true` ONLY on a genuine cutting-plane contradiction (a sound UNSAT proof); `false` (the
261/// caller falls through) for any formula that does not collapse. Never a false `true`: each step is
262/// a sound cutting-plane inference, and the at-most-one constraints are *implied* by the verified
263/// exclusion cliques.
264pub fn refute_clausal(e: &ProofExpr) -> bool {
265 let Some((rows, exclusions, nvars)) = extract_clausal(e) else {
266 return false;
267 };
268 if rows.is_empty() {
269 return false;
270 }
271 // Recover at-most-one groups: connected components of the exclusion graph that are full cliques.
272 let mut uf = UnionFind::new(nvars);
273 for &(a, b) in &exclusions {
274 uf.union(a, b);
275 }
276 let excl_set: std::collections::HashSet<(usize, usize)> = exclusions
277 .iter()
278 .map(|&(a, b)| (a.min(b), a.max(b)))
279 .collect();
280 let mut comps: HashMap<usize, Vec<usize>> = HashMap::new();
281 for v in 0..nvars {
282 comps.entry(uf.find(v)).or_default().push(v);
283 }
284 let mut sum: Option<PbConstraint> = None;
285 let mut absorb = |c: PbConstraint| {
286 sum = Some(match sum.take() {
287 None => c,
288 Some(s) => s.add(&c),
289 });
290 };
291 for row in &rows {
292 let lits: Vec<(usize, bool)> = row.iter().map(|&v| (v, true)).collect();
293 absorb(PbConstraint::at_least(&lits, 1));
294 }
295 for members in comps.values() {
296 if members.len() < 2 {
297 continue;
298 }
299 // Only a FULL clique is a sound at-most-one (else two members could share a slot).
300 let is_clique = members.iter().enumerate().all(|(i, &a)| {
301 members[i + 1..]
302 .iter()
303 .all(|&b| excl_set.contains(&(a.min(b), a.max(b))))
304 });
305 if !is_clique {
306 return false; // incomplete at-most-one → can't soundly refute this way
307 }
308 let lits: Vec<(usize, bool)> = members.iter().map(|&v| (v, true)).collect();
309 absorb(PbConstraint::at_most(&lits, 1));
310 }
311 match sum {
312 Some(c) => c.is_contradiction() || c.saturate().is_contradiction(),
313 None => false,
314 }
315}
316
317/// Flatten `e` into `(positive-clause variable lists, binary exclusion pairs, var count)` over dense
318/// variable indices, or `None` if any top-level conjunct is neither an all-positive disjunction nor
319/// a binary mutual-exclusion. (A standalone reader so the PB path is independent of the others.)
320fn extract_clausal(e: &ProofExpr) -> Option<(Vec<Vec<usize>>, Vec<(usize, usize)>, usize)> {
321 let mut conjuncts = Vec::new();
322 flatten_and(e, &mut conjuncts);
323 let mut idx: HashMap<String, usize> = HashMap::new();
324 let mut var = |name: &str, idx: &mut HashMap<String, usize>| -> usize {
325 let n = idx.len();
326 *idx.entry(name.to_string()).or_insert(n)
327 };
328 let mut rows = Vec::new();
329 let mut excl = Vec::new();
330 for c in conjuncts {
331 if let Some(atoms) = positive_disjunction(c) {
332 rows.push(atoms.iter().map(|a| var(a, &mut idx)).collect());
333 } else if let Some((a, b)) = exclusion_pair(c) {
334 excl.push((var(&a, &mut idx), var(&b, &mut idx)));
335 } else {
336 return None;
337 }
338 }
339 let nvars = idx.len();
340 Some((rows, excl, nvars))
341}
342
343fn flatten_and<'a>(e: &'a ProofExpr, out: &mut Vec<&'a ProofExpr>) {
344 // Iterative worklist rather than recursion: a flat CNF clausifies into a left-nested `And` spine
345 // whose depth is the clause count, so a recursive walk overflows the stack on a few-thousand-clause
346 // formula. The explicit stack handles any depth. Pushing `r` before `l` pops `l` first, preserving
347 // the left-to-right clause order the recursive version produced.
348 let mut stack = vec![e];
349 while let Some(node) = stack.pop() {
350 match node {
351 ProofExpr::And(l, r) => {
352 stack.push(r);
353 stack.push(l);
354 }
355 other => out.push(other),
356 }
357 }
358}
359
360fn positive_disjunction(e: &ProofExpr) -> Option<Vec<String>> {
361 fn walk(e: &ProofExpr, out: &mut Vec<String>) -> bool {
362 match e {
363 ProofExpr::Or(l, r) => walk(l, out) && walk(r, out),
364 ProofExpr::Atom(a) => {
365 out.push(a.clone());
366 true
367 }
368 _ => false,
369 }
370 }
371 let mut atoms = Vec::new();
372 walk(e, &mut atoms).then_some(atoms)
373}
374
375fn exclusion_pair(e: &ProofExpr) -> Option<(String, String)> {
376 match e {
377 ProofExpr::Not(inner) => match inner.as_ref() {
378 ProofExpr::And(a, b) => match (a.as_ref(), b.as_ref()) {
379 (ProofExpr::Atom(a), ProofExpr::Atom(b)) => Some((a.clone(), b.clone())),
380 _ => None,
381 },
382 _ => None,
383 },
384 ProofExpr::Or(l, r) => match (l.as_ref(), r.as_ref()) {
385 (ProofExpr::Not(a), ProofExpr::Not(b)) => match (a.as_ref(), b.as_ref()) {
386 (ProofExpr::Atom(a), ProofExpr::Atom(b)) => Some((a.clone(), b.clone())),
387 _ => None,
388 },
389 _ => None,
390 },
391 _ => None,
392 }
393}
394
395struct UnionFind {
396 parent: Vec<usize>,
397}
398impl UnionFind {
399 fn new(n: usize) -> Self {
400 UnionFind {
401 parent: (0..n).collect(),
402 }
403 }
404 fn find(&mut self, x: usize) -> usize {
405 if self.parent[x] != x {
406 let r = self.find(self.parent[x]);
407 self.parent[x] = r;
408 }
409 self.parent[x]
410 }
411 fn union(&mut self, a: usize, b: usize) {
412 let (ra, rb) = (self.find(a), self.find(b));
413 if ra != rb {
414 self.parent[ra] = rb;
415 }
416 }
417}
418
419use crate::cdcl::Lit;
420
421/// A **live cardinality theory** for the CDCL engine ([`crate::cdcl::Theory`]) — the trail-driven twin of
422/// the static cutting-plane refuter [`refute_clausal`]. It propagates and conflicts on unit-coefficient
423/// cardinality constraints (`Σ ℓ ≥ k`, covering clauses, at-least-`k`, and at-most-`k`) directly on the
424/// solver's trail (stateless: it rebuilds the partial assignment each call, so it can never desync), so it
425/// FUSES with a GF(2) parity theory ([`crate::xor_engine::XorEngine`]) under one
426/// [`crate::cdcl::Solver::solve_with`]: parity and counting reason *together* on the shared trail. That is
427/// exactly the structure that defeats either alone — e.g. minimal-disagreement parity, a parity core plus a
428/// residual cardinality core, where Gaussian elimination handles the parity and cardinality propagation
429/// handles the counting, but neither is a refutation by itself.
430///
431/// Pair it with the stateless [`crate::xor_engine::XorEngine`], not the incremental
432/// [`crate::xor_engine::IncXor`]: `IncXor`'s trail-sync matches by variable but not value, so a
433/// backtrack-then-flip leaves its matrix stale — correct whenever a clausal XOR encoding backs it (the
434/// dispatcher's case), but unsound in a pure-theory fusion with no Boolean clauses to mask it.
435///
436/// **Soundness** rests on the cardinality reason clause: for `Σ ℓ ≥ k` over `n` literals, ANY `n − k + 1`
437/// of them form a clause entailed by the constraint (at most `n − k` of the literals can be false, so any
438/// `n − k + 1` contain a true one). Hence when more than `n − k` are false, `n − k + 1` false literals are
439/// an all-false entailed clause — a **conflict**; when exactly `n − k` are false, every unassigned literal
440/// is forced true with reason `{the false literals} ∨ ℓ` (size `n − k + 1`, entailed, currently unit).
441/// Every returned clause is a logical consequence of the constraint, so `Solver::solve_with` may carry it
442/// into the learned database soundly.
443pub struct CardinalityTheory {
444 num_vars: usize,
445 /// Each constraint as `(literals, k)` in `Σ ℓ ≥ k` form. `at_most`/`clause` normalize into this.
446 constraints: Vec<(Vec<(usize, bool)>, i64)>,
447}
448
449impl CardinalityTheory {
450 /// Build from normalized [`PbConstraint`]s. Every input MUST be unit-coefficient (a cardinality
451 /// constraint); a weighted term is rejected (`panic`) rather than risk an unsound weighted reason —
452 /// general weighted PB belongs to the static cutting-plane engine ([`refute_clausal`]), not this live
453 /// clausal-reason theory.
454 pub fn new(num_vars: usize, constraints: &[PbConstraint]) -> Self {
455 let constraints = constraints
456 .iter()
457 .map(|pb| {
458 let lits: Vec<(usize, bool)> = pb
459 .terms()
460 .map(|(v, c, s)| {
461 assert_eq!(
462 c, 1,
463 "CardinalityTheory requires unit coefficients (got {c} on var {v})"
464 );
465 (v, s)
466 })
467 .collect();
468 (lits, pb.degree())
469 })
470 .collect();
471 CardinalityTheory {
472 num_vars,
473 constraints,
474 }
475 }
476}
477
478impl crate::cdcl::Theory for CardinalityTheory {
479 fn propagate(&mut self, trail: &[Lit]) -> Vec<Vec<Lit>> {
480 let mut a: Vec<Option<bool>> = vec![None; self.num_vars];
481 for &l in trail {
482 a[l.var() as usize] = Some(l.is_positive());
483 }
484 let mut out: Vec<Vec<Lit>> = Vec::new();
485 for (lits, k) in &self.constraints {
486 let k = *k;
487 if k <= 0 {
488 continue; // trivially satisfied
489 }
490 let n = lits.len() as i64;
491 if k > n {
492 out.push(Vec::new()); // Σ ℓ ≥ k > n is unsatisfiable — an unconditional contradiction
493 continue;
494 }
495 let mut false_lits: Vec<(usize, bool)> = Vec::new();
496 let mut unassigned: Vec<(usize, bool)> = Vec::new();
497 let mut true_count = 0i64;
498 for &(v, s) in lits {
499 match a[v] {
500 Some(val) if val == s => true_count += 1,
501 Some(_) => false_lits.push((v, s)),
502 None => unassigned.push((v, s)),
503 }
504 }
505 if true_count >= k {
506 continue; // already satisfied
507 }
508 let max_false = n - k; // the constraint tolerates at most this many false literals
509 let fc = false_lits.len() as i64;
510 if fc > max_false {
511 // CONFLICT: any (max_false + 1) of the false literals are an entailed, all-false clause.
512 let take = (max_false + 1) as usize;
513 out.push(
514 false_lits[..take]
515 .iter()
516 .map(|&(v, s)| Lit::new(v as u32, s))
517 .collect(),
518 );
519 } else if fc == max_false && !unassigned.is_empty() {
520 // Every unassigned literal is forced true: reason = {the false literals} ∨ ℓ.
521 let base: Vec<Lit> = false_lits
522 .iter()
523 .map(|&(v, s)| Lit::new(v as u32, s))
524 .collect();
525 for &(v, s) in &unassigned {
526 let mut c = base.clone();
527 c.push(Lit::new(v as u32, s));
528 out.push(c);
529 }
530 }
531 }
532 out
533 }
534}
535
536#[cfg(test)]
537#[path = "../../logicaffeine_tests/unit/logicaffeine_proof/pseudo_boolean/tests.rs"]
538mod tests;