Skip to main content

logicaffeine_proof/
sos.rs

1//! **Sum-of-Squares / Positivstellensatz refutation over ℚ — kept EXACT** (no SDP, no floating point)
2//! by the LP slice of the cone. True SoS is a semidefinite program over the reals; that would forfeit
3//! our certified guarantee. Instead we take the *diagonal* (fixed-square-basis) Positivstellensatz, which
4//! is an exact rational linear program: a CNF is lifted to degree-2 pseudo-Boolean inequalities and the
5//! lift is refuted by exact Fourier–Motzkin / Farkas elimination.
6//!
7//! The lift, over variables `xᵢ` and the products `zᵢⱼ = xᵢ·xⱼ` (i < j):
8//! - **box**: `0 ≤ xᵢ ≤ 1`;
9//! - **McCormick envelope** of each product: `zᵢⱼ ≥ 0`, `zᵢⱼ ≤ xᵢ`, `zᵢⱼ ≤ xⱼ`, `zᵢⱼ ≥ xᵢ+xⱼ−1`
10//!   (the exact integral hull of `zᵢⱼ = xᵢ ∧ xⱼ`);
11//! - **clauses**: `Σ val(lit) ≥ 1`, `val(x)=x`, `val(¬x)=1−x`;
12//! - **Sherali–Adams products**: each clause times `xⱼ` and times `1−xⱼ` (the degree-2 lift);
13//! - **squares**: `(xᵢ−xⱼ)² ≥ 0` and `(1−xᵢ−xⱼ)² ≥ 0` — the genuine sum-of-squares terms, the
14//!   ordered-field content that Nullstellensatz/Polynomial-Calculus (equality-only over GF(2)) cannot
15//!   express.
16//!
17//! **Soundness**: every constraint holds at every Boolean assignment, so a Boolean model of the CNF is a
18//! feasible point of the lift; therefore an *infeasible* lift (a Farkas refutation) certifies the CNF
19//! UNSAT. The elimination is exact `i128` and **fail-closed under load**: it declines (reports no
20//! refutation) the moment a coefficient would exceed [`MAG_CAP`] or the row count would exceed
21//! [`ROW_CAP`], so it never returns an overflow-corrupted verdict. Incomplete at degree 2 (that is the
22//! whole point of the degree dial), and the square basis is symmetry-reducible — quotient it by the
23//! formula's automorphisms to shrink the LP, the same "symmetry break to uncover more" that collapses
24//! the field cuts and the monomial basis.
25
26use crate::cdcl::Lit;
27use std::collections::BTreeMap;
28
29/// Degree-2 lifts have ≈ n²/2 product variables; Fourier–Motzkin is doubly-exponential in the variable
30/// count, so only small cores are lifted — larger instances decline to the other routes. (A rational
31/// simplex, or the symmetry-reduced LP, is the scaling path.)
32const MAX_VARS: usize = 6;
33/// Decline once the working set grows past this — bounds the elimination's time/space. Genuine
34/// degree-2 refutations of small cores are found in far fewer rows; past this we are in blow-up territory
35/// and decline (soundly) rather than grind.
36const ROW_CAP: usize = 20_000;
37/// Decline once any coefficient would exceed this — keeps every `i128` operation exact (no overflow).
38const MAG_CAP: i128 = 1 << 50;
39
40/// A degree-≤2 multilinear polynomial over the Booleans: constant + Σ aᵢ·xᵢ + Σ bᵢⱼ·xᵢxⱼ (i < j).
41#[derive(Clone, Default)]
42struct Poly {
43    c: i64,
44    lin: BTreeMap<usize, i64>,
45    quad: BTreeMap<(usize, usize), i64>,
46}
47
48impl Poly {
49    fn constant(c: i64) -> Self {
50        Poly { c, ..Default::default() }
51    }
52    fn x(i: usize) -> Self {
53        Poly { lin: BTreeMap::from([(i, 1)]), ..Default::default() }
54    }
55    fn add(&self, o: &Self) -> Self {
56        let mut r = self.clone();
57        r.c += o.c;
58        for (&k, &v) in &o.lin {
59            *r.lin.entry(k).or_insert(0) += v;
60        }
61        for (&k, &v) in &o.quad {
62            *r.quad.entry(k).or_insert(0) += v;
63        }
64        r.prune()
65    }
66    fn neg(&self) -> Self {
67        Poly {
68            c: -self.c,
69            lin: self.lin.iter().map(|(&k, &v)| (k, -v)).collect(),
70            quad: self.quad.iter().map(|(&k, &v)| (k, -v)).collect(),
71        }
72    }
73    fn sub(&self, o: &Self) -> Self {
74        self.add(&o.neg())
75    }
76    fn prune(mut self) -> Self {
77        self.lin.retain(|_, v| *v != 0);
78        self.quad.retain(|_, v| *v != 0);
79        self
80    }
81    /// Multiply two **linear** polynomials (no quadratic part), folding `xᵢ² = xᵢ` (multilinear).
82    fn mul_linear(a: &Self, b: &Self) -> Self {
83        debug_assert!(a.quad.is_empty() && b.quad.is_empty(), "mul_linear needs degree-1 inputs");
84        let mut r = Poly::constant(a.c * b.c);
85        for (&i, &ai) in &a.lin {
86            *r.lin.entry(i).or_insert(0) += ai * b.c;
87        }
88        for (&j, &bj) in &b.lin {
89            *r.lin.entry(j).or_insert(0) += a.c * bj;
90        }
91        for (&i, &ai) in &a.lin {
92            for (&j, &bj) in &b.lin {
93                if i == j {
94                    *r.lin.entry(i).or_insert(0) += ai * bj; // xᵢ·xᵢ = xᵢ
95                } else {
96                    *r.quad.entry((i.min(j), i.max(j))).or_insert(0) += ai * bj;
97                }
98            }
99        }
100        r.prune()
101    }
102}
103
104/// The value polynomial of a literal: `x` for a positive literal, `1 − x` for a negative one.
105fn lit_value(l: &Lit) -> Poly {
106    let x = Poly::x(l.var() as usize);
107    if l.is_positive() {
108        x
109    } else {
110        Poly::constant(1).sub(&x)
111    }
112}
113
114/// A linear inequality `Σ coeffs·var + constant ≤ 0` over the lifted variables (`xᵢ` and `zᵢⱼ`), carrying
115/// the non-negative combination `prov` of original constraints that produced it.
116#[derive(Clone)]
117struct Row {
118    coeffs: BTreeMap<usize, i128>,
119    constant: i128,
120    prov: BTreeMap<usize, i128>,
121}
122
123impl Row {
124    /// `combined = self·ka + other·kb` (ka, kb ≥ 0), or `None` if any magnitude would exceed [`MAG_CAP`].
125    fn combine(&self, ka: i128, other: &Row, kb: i128) -> Option<Row> {
126        let mut coeffs = BTreeMap::new();
127        let mut acc = |dst: &mut BTreeMap<usize, i128>, src: &BTreeMap<usize, i128>, k: i128| -> Option<()> {
128            for (&v, &c) in src {
129                let e = dst.entry(v).or_insert(0);
130                *e = e.checked_add(c.checked_mul(k)?)?;
131                if e.abs() > MAG_CAP {
132                    return None;
133                }
134            }
135            Some(())
136        };
137        acc(&mut coeffs, &self.coeffs, ka)?;
138        acc(&mut coeffs, &other.coeffs, kb)?;
139        coeffs.retain(|_, c| *c != 0);
140        let constant = self.constant.checked_mul(ka)?.checked_add(other.constant.checked_mul(kb)?)?;
141        if constant.abs() > MAG_CAP {
142            return None;
143        }
144        let mut prov = BTreeMap::new();
145        acc(&mut prov, &self.prov, ka)?;
146        acc(&mut prov, &other.prov, kb)?;
147        prov.retain(|_, c| *c != 0);
148        Some(Row { coeffs, constant, prov })
149    }
150}
151
152/// Convert a `Poly` (meaning `poly ≤ 0`) into a lift `Row`, tagged with original-constraint index `idx`.
153/// `zᵢⱼ` gets a stable index above the `xᵢ` block.
154fn poly_to_row(p: &Poly, num_vars: usize, idx: usize) -> Row {
155    let mut coeffs = BTreeMap::new();
156    for (&i, &v) in &p.lin {
157        coeffs.insert(i, v as i128);
158    }
159    for (&(i, j), &v) in &p.quad {
160        coeffs.insert(num_vars + i * num_vars + j, v as i128); // z index, unique for i<j<num_vars
161    }
162    Row { coeffs, constant: p.c as i128, prov: BTreeMap::from([(idx, 1)]) }
163}
164
165/// Push `p ≥ 0` into the lift as the constraint `−p ≤ 0`.
166fn ge0(out: &mut Vec<Poly>, p: Poly) {
167    out.push(p.neg());
168}
169
170/// The polynomials of the degree-2 lift, each meaning `poly ≤ 0`.
171fn lift_polys(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Poly> {
172    let mut out: Vec<Poly> = Vec::new();
173    let one = Poly::constant(1);
174    for i in 0..num_vars {
175        ge0(&mut out, Poly::x(i));
176        ge0(&mut out, one.sub(&Poly::x(i)));
177    }
178    for i in 0..num_vars {
179        for j in (i + 1)..num_vars {
180            let z = Poly { quad: BTreeMap::from([((i, j), 1)]), ..Default::default() };
181            let (xi, xj) = (Poly::x(i), Poly::x(j));
182            ge0(&mut out, z.clone());
183            ge0(&mut out, xi.sub(&z));
184            ge0(&mut out, xj.sub(&z));
185            ge0(&mut out, z.sub(&xi).sub(&xj).add(&one));
186            ge0(&mut out, xi.add(&xj).sub(&z).sub(&z)); // (xᵢ−xⱼ)²
187            ge0(&mut out, one.sub(&xi).sub(&xj).add(&z).add(&z)); // (1−xᵢ−xⱼ)²
188        }
189    }
190    for c in clauses {
191        if c.is_empty() {
192            out.push(Poly::constant(1)); // 1 ≤ 0
193            continue;
194        }
195        let mut cl = Poly::constant(-1);
196        for l in c {
197            cl = cl.add(&lit_value(l));
198        }
199        ge0(&mut out, cl.clone());
200        for j in 0..num_vars {
201            ge0(&mut out, Poly::mul_linear(&cl, &Poly::x(j)));
202            ge0(&mut out, Poly::mul_linear(&cl, &one.sub(&Poly::x(j))));
203        }
204    }
205    out
206}
207
208/// Exact Fourier–Motzkin: is `{ rowᵢ ≤ 0 }` infeasible over ℚ? Returns the Farkas multipliers (a
209/// non-negative combination of the original rows summing to a positive constant `≤ 0`) on infeasibility,
210/// or `None` if feasible **or** if the elimination exceeded [`ROW_CAP`]/[`MAG_CAP`] (fail-closed: a
211/// decline is reported as "no refutation found", never a false one).
212fn farkas_refute(rows: Vec<Row>, var_count: usize) -> Option<BTreeMap<usize, i128>> {
213    let contradiction = |rows: &[Row]| -> Option<BTreeMap<usize, i128>> {
214        rows.iter().find(|r| r.coeffs.is_empty() && r.constant > 0).map(|r| r.prov.clone())
215    };
216    let mut rows = rows;
217    if let Some(p) = contradiction(&rows) {
218        return Some(p);
219    }
220    for v in 0..var_count {
221        let (mut pos, mut neg, mut next): (Vec<&Row>, Vec<&Row>, Vec<Row>) =
222            (Vec::new(), Vec::new(), Vec::new());
223        for r in &rows {
224            match r.coeffs.get(&v).copied().unwrap_or(0) {
225                c if c > 0 => pos.push(r),
226                c if c < 0 => neg.push(r),
227                _ => next.push(r.clone()),
228            }
229        }
230        for p in &pos {
231            for n in &neg {
232                let pc = p.coeffs[&v];
233                let nc = -n.coeffs[&v];
234                let combined = p.combine(nc, n, pc)?; // None ⟹ magnitude overflow ⟹ decline
235                next.push(combined);
236                if next.len() > ROW_CAP {
237                    return None; // decline rather than blow up
238                }
239            }
240        }
241        rows = next;
242        if let Some(p) = contradiction(&rows) {
243            return Some(p);
244        }
245    }
246    contradiction(&rows)
247}
248
249/// Does a degree-2 Positivstellensatz (diagonal SoS) refutation of the CNF exist over ℚ? Sound: a `true`
250/// result is a certified UNSAT. Declines (false) when no degree-2 refutation exists or the instance
251/// exceeds [`MAX_VARS`] / the elimination caps.
252pub fn sos_refutes(num_vars: usize, clauses: &[Vec<Lit>]) -> bool {
253    sos_certificate(num_vars, clauses).is_some()
254}
255
256/// The Farkas certificate of a degree-2 SoS refutation: the non-negative multipliers over the lift
257/// constraints whose combination is a positive constant `≤ 0`. `None` if no degree-2 refutation is found.
258/// Re-checkable: combining the lift polynomials with these multipliers yields a bare positive constant.
259pub fn sos_certificate(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<BTreeMap<usize, i128>> {
260    if num_vars > MAX_VARS {
261        return None;
262    }
263    let polys = lift_polys(num_vars, clauses);
264    let rows: Vec<Row> =
265        polys.iter().enumerate().map(|(i, p)| poly_to_row(p, num_vars, i)).collect();
266    let var_count = num_vars + num_vars * num_vars; // xᵢ block + zᵢⱼ block (sparse upper indices)
267    farkas_refute(rows, var_count)
268}
269
270/// Independently re-check an SoS Farkas certificate from [`sos_certificate`]: recompute the degree-2
271/// lift from the clauses, combine its polynomials with the certificate's multipliers, and confirm the
272/// result cancels every variable and leaves a strictly positive constant — the self-contained
273/// contradiction `0 < d ≤ 0`. It shares no arithmetic with the Fourier–Motzkin elimination that
274/// produced the certificate, so it is a genuine independent witness of the refutation (the SoS analog
275/// of [`crate::xorsat::is_refutation`]). Fails closed on a negative multiplier, an out-of-range row,
276/// an empty certificate, or any residual variable.
277pub fn check_sos_certificate(num_vars: usize, clauses: &[Vec<Lit>], cert: &BTreeMap<usize, i128>) -> bool {
278    if cert.is_empty() {
279        return false;
280    }
281    let polys = lift_polys(num_vars, clauses);
282    let (mut lin, mut quad) = (BTreeMap::<usize, i128>::new(), BTreeMap::<(usize, usize), i128>::new());
283    let mut constant: i128 = 0;
284    for (&i, &mult) in cert {
285        if mult < 0 || i >= polys.len() {
286            return false; // Farkas multipliers are non-negative and index real lift rows.
287        }
288        let p = &polys[i];
289        constant += mult * p.c as i128;
290        for (&v, &c) in &p.lin {
291            *lin.entry(v).or_insert(0) += mult * c as i128;
292        }
293        for (&k, &c) in &p.quad {
294            *quad.entry(k).or_insert(0) += mult * c as i128;
295        }
296    }
297    lin.values().all(|&c| c == 0) && quad.values().all(|&c| c == 0) && constant > 0
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    fn sat(num_vars: usize, clauses: &[Vec<Lit>]) -> bool {
305        (0u64..(1u64 << num_vars)).any(|x| {
306            clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 != 0) == l.is_positive()))
307        })
308    }
309
310    fn splitmix(s: &mut u64) -> u64 {
311        *s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
312        let mut z = *s;
313        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
314        z ^ (z >> 31)
315    }
316
317    /// Re-check a returned certificate exactly: combining the lift polynomials with the (non-negative)
318    /// multipliers must cancel every variable and leave a strictly positive constant — the contradiction
319    /// `0 < d ≤ 0`. Independent of the elimination's internal arithmetic.
320    fn certificate_is_valid(num_vars: usize, clauses: &[Vec<Lit>], cert: &BTreeMap<usize, i128>) -> bool {
321        check_sos_certificate(num_vars, clauses, cert)
322    }
323
324    /// **Soundness to the point of absurdity**: across a fuzz of small CNFs, every SoS refutation is a
325    /// genuine UNSAT (checked against brute force) and its certificate re-checks to a positive constant —
326    /// a self-contained contradiction. A refutation is *never* issued for a satisfiable formula.
327    #[test]
328    fn sos_refutation_is_sound_against_brute_force() {
329        let mut state = 0x5050_0001u64;
330        for _ in 0..400 {
331            let nv = 2 + (splitmix(&mut state) % 3) as usize; // 2..4 vars (FM stays exact and fast)
332            let m = 1 + (splitmix(&mut state) % 8) as usize;
333            let mut cl: Vec<Vec<Lit>> = Vec::new();
334            for _ in 0..m {
335                let mut c = Vec::new();
336                for v in 0..nv {
337                    if splitmix(&mut state) % 3 == 0 {
338                        c.push(Lit::new(v as u32, splitmix(&mut state) % 2 == 0));
339                    }
340                }
341                if !c.is_empty() {
342                    cl.push(c);
343                }
344            }
345            if cl.is_empty() {
346                continue;
347            }
348            if let Some(cert) = sos_certificate(nv, &cl) {
349                assert!(!sat(nv, &cl), "SoS refuted a SATISFIABLE formula: {cl:?}");
350                assert!(certificate_is_valid(nv, &cl, &cert), "the Farkas certificate must re-check: {cl:?}");
351            }
352        }
353    }
354
355    /// A satisfiable formula is refuted at no point — isolated soundness.
356    #[test]
357    fn satisfiable_formulas_are_never_refuted() {
358        let cl = vec![
359            vec![Lit::new(0, true), Lit::new(1, true)],
360            vec![Lit::new(0, false), Lit::new(2, true)],
361        ];
362        assert!(sat(3, &cl));
363        assert!(!sos_refutes(3, &cl), "a SAT formula must not be refuted");
364    }
365
366    /// **The ordered-field power: an integrality gap degree 2 closes.** `x = y` (`x∨¬y`, `¬x∨y`) together
367    /// with `x ≠ y` (`x∨y`, `¬x∨¬y`) is UNSAT, but its degree-1 LP relaxation is feasible at `x=y=½`.
368    /// The degree-2 lift — the product `z=xy` with its McCormick envelope, the squares, and the
369    /// Sherali–Adams clause products — refutes it: exactly the cut Nullstellensatz over GF(2) cannot make,
370    /// because it lives in the order, not the field.
371    #[test]
372    fn sos_closes_an_integrality_gap_that_is_linear_feasible() {
373        let cl = vec![
374            vec![Lit::new(0, true), Lit::new(1, false)],
375            vec![Lit::new(0, false), Lit::new(1, true)],
376            vec![Lit::new(0, true), Lit::new(1, true)],
377            vec![Lit::new(0, false), Lit::new(1, false)],
378        ];
379        assert!(!sat(2, &cl), "x=y ∧ x≠y is UNSAT");
380        let cert = sos_certificate(2, &cl).expect("degree-2 SoS closes the x=y=½ integrality gap");
381        assert!(certificate_is_valid(2, &cl, &cert), "and its certificate re-checks");
382    }
383
384    /// **Strictly beyond the purely-linear cut.** The degree-1 relaxation (box + clauses only, no
385    /// products or squares) is feasible on the gap instance — no linear Farkas refutation exists — so the
386    /// degree-2 lift genuinely adds power, not just repackaging the linear engine.
387    #[test]
388    fn the_degree_2_lift_refutes_where_the_linear_relaxation_cannot() {
389        let cl = vec![
390            vec![Lit::new(0, true), Lit::new(1, false)],
391            vec![Lit::new(0, false), Lit::new(1, true)],
392            vec![Lit::new(0, true), Lit::new(1, true)],
393            vec![Lit::new(0, false), Lit::new(1, false)],
394        ];
395        // Degree-1 only: box + clause inequalities, no products/squares.
396        let one = Poly::constant(1);
397        let mut polys: Vec<Poly> = Vec::new();
398        for i in 0..2 {
399            polys.push(Poly::x(i).neg());
400            polys.push(one.sub(&Poly::x(i)).neg());
401        }
402        for c in &cl {
403            let mut p = Poly::constant(-1);
404            for l in c {
405                p = p.add(&lit_value(l));
406            }
407            polys.push(p.neg());
408        }
409        let rows: Vec<Row> = polys.iter().enumerate().map(|(i, p)| poly_to_row(p, 2, i)).collect();
410        assert!(farkas_refute(rows, 2 + 4).is_none(), "the degree-1 relaxation is feasible (x=y=½)");
411        assert!(sos_refutes(2, &cl), "but the degree-2 lift refutes it");
412    }
413}