Skip to main content

logicaffeine_proof/
xorsat.rs

1//! XOR-SAT via Gaussian elimination over GF(2) — the parity analog of the pigeonhole/matching
2//! supercrush.
3//!
4//! A system of parity (XOR) constraints — `x₁ ⊕ x₃ ⊕ x₇ = 1`, etc. — is the canonical
5//! *resolution-hard* problem: Tseitin formulas over expander graphs need exponentially long
6//! resolution refutations, so CDCL solvers (ours and Z3 alike) blow up on the CNF encoding. But the
7//! underlying question is just a linear system over GF(2), decided in **polynomial time** by
8//! Gaussian elimination — and certified: an inconsistent system yields a subset of equations whose
9//! XOR is `0 = 1` (a re-checkable linear-dependency refutation), and a consistent one yields a
10//! satisfying assignment. Parity systems are everywhere — cryptanalysis, error-correcting codes,
11//! checksum logic — so this is a broad class we decide instantly where SAT/Z3 cannot.
12
13/// A parity equation: the XOR of the variables in `vars` equals `rhs`. (Repeated variables cancel,
14/// per GF(2).)
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct XorEquation {
17    /// Variable indices (`0..num_vars`) whose XOR is constrained.
18    pub vars: Vec<usize>,
19    /// The right-hand side of the equation.
20    pub rhs: bool,
21}
22
23impl XorEquation {
24    /// Convenience constructor.
25    pub fn new(vars: impl Into<Vec<usize>>, rhs: bool) -> Self {
26        XorEquation { vars: vars.into(), rhs }
27    }
28}
29
30/// The outcome of solving an XOR system.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum XorOutcome {
33    /// Satisfiable, with an assignment over `0..num_vars` (re-checkable via [`satisfies`]).
34    Sat(Vec<bool>),
35    /// Unsatisfiable, witnessed by a subset of equation indices whose XOR is `0 = 1`
36    /// (re-checkable via [`is_refutation`]).
37    Unsat(Vec<usize>),
38}
39
40#[inline]
41fn get(bits: &[u64], i: usize) -> bool {
42    (bits[i / 64] >> (i % 64)) & 1 == 1
43}
44
45#[inline]
46fn flip(bits: &mut [u64], i: usize) {
47    bits[i / 64] ^= 1u64 << (i % 64);
48}
49
50#[inline]
51fn xor_assign(dst: &mut [u64], src: &[u64]) {
52    for (d, s) in dst.iter_mut().zip(src) {
53        *d ^= *s;
54    }
55}
56
57#[inline]
58fn is_zero(bits: &[u64]) -> bool {
59    bits.iter().all(|&w| w == 0)
60}
61
62fn set_indices(bits: &[u64]) -> Vec<usize> {
63    let mut out = Vec::new();
64    for (w, &word) in bits.iter().enumerate() {
65        let mut b = word;
66        while b != 0 {
67            let t = b.trailing_zeros() as usize;
68            out.push(w * 64 + t);
69            b &= b - 1;
70        }
71    }
72    out
73}
74
75#[derive(Clone)]
76struct Row {
77    lhs: Vec<u64>,  // variable bitset
78    rhs: bool,
79    prov: Vec<u64>, // which original equations XOR to this row (the certificate's provenance)
80}
81
82/// Solve a parity system over `0..num_vars` by Gauss–Jordan elimination over GF(2). Returns a
83/// satisfying assignment or a certified `0 = 1` refutation. `O(eq · vars · (eq+vars)/64)`.
84pub fn solve(equations: &[XorEquation], num_vars: usize) -> XorOutcome {
85    let nb = num_vars.div_ceil(64).max(1);
86    let pb = equations.len().div_ceil(64).max(1);
87    let mut rows: Vec<Row> = equations
88        .iter()
89        .enumerate()
90        .map(|(i, eq)| {
91            let mut lhs = vec![0u64; nb];
92            for &v in &eq.vars {
93                if v < num_vars {
94                    flip(&mut lhs, v); // XOR ⇒ duplicate variables cancel
95                }
96            }
97            let mut prov = vec![0u64; pb];
98            flip(&mut prov, i);
99            Row { lhs, rhs: eq.rhs, prov }
100        })
101        .collect();
102
103    let mut pivot_for_col = vec![usize::MAX; num_vars];
104    let mut rank = 0;
105    for c in 0..num_vars {
106        let Some(p) = (rank..rows.len()).find(|&i| get(&rows[i].lhs, c)) else {
107            continue;
108        };
109        rows.swap(rank, p);
110        let pivot = rows[rank].clone();
111        for (i, row) in rows.iter_mut().enumerate() {
112            if i != rank && get(&row.lhs, c) {
113                xor_assign(&mut row.lhs, &pivot.lhs);
114                row.rhs ^= pivot.rhs;
115                xor_assign(&mut row.prov, &pivot.prov);
116            }
117        }
118        pivot_for_col[c] = rank;
119        rank += 1;
120    }
121
122    // A reduced row with empty LHS but rhs = true is `0 = 1` — its provenance is the refutation.
123    for row in &rows {
124        if is_zero(&row.lhs) && row.rhs {
125            return XorOutcome::Unsat(set_indices(&row.prov));
126        }
127    }
128
129    // Consistent: free variables take 0; each pivot variable then equals its row's rhs (the row, in
130    // reduced form, holds only its pivot column plus free columns, all assigned 0).
131    let mut assignment = vec![false; num_vars];
132    for c in 0..num_vars {
133        let pr = pivot_for_col[c];
134        if pr != usize::MAX {
135            assignment[c] = rows[pr].rhs;
136        }
137    }
138    XorOutcome::Sat(assignment)
139}
140
141/// Re-check a satisfying assignment: every equation's variable-XOR equals its rhs.
142pub fn satisfies(equations: &[XorEquation], assignment: &[bool]) -> bool {
143    equations.iter().all(|eq| {
144        let ones = eq
145            .vars
146            .iter()
147            .filter(|&&v| v < assignment.len() && assignment[v])
148            .count();
149        (ones % 2 == 1) == eq.rhs
150    })
151}
152
153/// Re-check a refutation: the XOR of the chosen equations is `0 = 1` — their variables all cancel
154/// while their right-hand sides sum to 1. A solver-free certificate of unsatisfiability.
155pub fn is_refutation(equations: &[XorEquation], num_vars: usize, refutation: &[usize]) -> bool {
156    if refutation.is_empty() {
157        return false;
158    }
159    let nb = num_vars.div_ceil(64).max(1);
160    let mut lhs = vec![0u64; nb];
161    let mut rhs = false;
162    for &idx in refutation {
163        let Some(eq) = equations.get(idx) else {
164            return false;
165        };
166        for &v in &eq.vars {
167            if v < num_vars {
168                flip(&mut lhs, v);
169            }
170        }
171        rhs ^= eq.rhs;
172    }
173    is_zero(&lhs) && rhs
174}
175
176/// Recognize the XOR (parity) gadgets inside a CNF `ProofExpr` and refute via Gaussian elimination —
177/// the GF(2) shadow, as a fast-path for [`crate::sat::prove_unsat`]. A parity constraint
178/// `x_{a} ⊕ … ⊕ x_{z} = r` over `k` variables is encoded in CNF as exactly the `2^{k-1}` clauses that
179/// forbid the wrong-parity assignments; we group clauses by their variable set, and whenever a group
180/// is precisely such a full wrong-parity bundle we recover its [`XorEquation`].
181///
182/// **Soundness (never a false `true`):** a fully-present gadget's clauses *imply* its XOR equation
183/// (they forbid exactly the assignments the equation forbids), so the recovered equations are all
184/// logical consequences of `e`. If that recognized linear subsystem is inconsistent over GF(2), then
185/// `e` is unsatisfiable. Partial or malformed gadgets are simply not recognized — we fall through,
186/// never guess. The parity refutation is itself re-checkable via [`is_refutation`].
187pub fn refute_via_parity(e: &crate::ProofExpr) -> bool {
188    use std::collections::HashMap;
189    let mut idx: HashMap<String, usize> = HashMap::new();
190    let mut clauses: Vec<Vec<(usize, bool)>> = Vec::new();
191    if !collect_clauses(e, &mut clauses, &mut idx) {
192        return false;
193    }
194    // Group clauses by their (sorted, deduplicated) variable set.
195    let mut groups: HashMap<Vec<usize>, Vec<Vec<(usize, bool)>>> = HashMap::new();
196    for c in clauses {
197        let mut vars: Vec<usize> = c.iter().map(|&(v, _)| v).collect();
198        vars.sort_unstable();
199        vars.dedup();
200        if vars.len() != c.len() {
201            continue; // a repeated variable in one clause — not a clean parity literal set
202        }
203        groups.entry(vars).or_default().push(c);
204    }
205    let num_vars = idx.len();
206    let mut eqs = Vec::new();
207    for (vars, group) in groups {
208        let k = vars.len();
209        if k == 0 || k > 31 || group.len() != (1usize << (k - 1)) {
210            continue; // a gadget over k vars is exactly 2^{k-1} clauses; anything else isn't one
211        }
212        let pos: HashMap<usize, usize> = vars.iter().enumerate().map(|(i, &v)| (v, i)).collect();
213        let mut forbidden = std::collections::HashSet::new();
214        let mut parity: Option<u32> = None;
215        let mut clean = true;
216        for c in &group {
217            // The forbidden assignment this clause rules out: bit i is set iff literal i is negative.
218            let mut a = 0u32;
219            for &(v, positive) in c {
220                if !positive {
221                    a |= 1 << pos[&v];
222                }
223            }
224            if !forbidden.insert(a) {
225                clean = false; // a duplicated forbidden assignment ⟹ not a faithful gadget
226                break;
227            }
228            let p = a.count_ones() % 2;
229            match parity {
230                None => parity = Some(p),
231                Some(q) if q != p => {
232                    clean = false; // mixed parities ⟹ not one XOR equation
233                    break;
234                }
235                _ => {}
236            }
237        }
238        if !clean {
239            continue;
240        }
241        // All 2^{k-1} forbidden assignments share parity `p`; the equation is `⊕ vars = ¬p`.
242        let p = parity.unwrap_or(0);
243        eqs.push(XorEquation::new(vars, p == 0));
244    }
245    if eqs.is_empty() {
246        return false;
247    }
248    matches!(solve(&eqs, num_vars), XorOutcome::Unsat(_))
249}
250
251/// Flatten a CNF `ProofExpr` into clauses of `(var, positive)` literals over a shared atom index.
252/// Returns `false` if `e` is not a conjunction of disjunctions of literals (so parity recognition
253/// declines rather than misreads).
254fn collect_clauses(
255    e: &crate::ProofExpr,
256    out: &mut Vec<Vec<(usize, bool)>>,
257    idx: &mut std::collections::HashMap<String, usize>,
258) -> bool {
259    use crate::ProofExpr;
260    match e {
261        ProofExpr::And(l, r) => collect_clauses(l, out, idx) && collect_clauses(r, out, idx),
262        other => {
263            let mut lits = Vec::new();
264            if collect_literals(other, true, &mut lits, idx) {
265                out.push(lits);
266                true
267            } else {
268                false
269            }
270        }
271    }
272}
273
274fn collect_literals(
275    e: &crate::ProofExpr,
276    positive: bool,
277    out: &mut Vec<(usize, bool)>,
278    idx: &mut std::collections::HashMap<String, usize>,
279) -> bool {
280    use crate::ProofExpr;
281    match e {
282        ProofExpr::Atom(name) => {
283            let n = idx.len();
284            let v = *idx.entry(name.clone()).or_insert(n);
285            out.push((v, positive));
286            true
287        }
288        ProofExpr::Not(inner) => match inner.as_ref() {
289            ProofExpr::Atom(name) => {
290                let n = idx.len();
291                let v = *idx.entry(name.clone()).or_insert(n);
292                out.push((v, !positive));
293                true
294            }
295            _ => false,
296        },
297        ProofExpr::Or(l, r) if positive => {
298            collect_literals(l, positive, out, idx) && collect_literals(r, positive, out, idx)
299        }
300        _ => false,
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    fn eq(vars: &[usize], rhs: bool) -> XorEquation {
309        XorEquation::new(vars.to_vec(), rhs)
310    }
311
312    /// CNF-encode a list of XOR equations into a `ProofExpr` over atoms `x{v}`: each equation becomes
313    /// its `2^{k-1}` wrong-parity clauses (the inverse of [`refute_via_parity`]'s recognition).
314    fn xor_system_to_expr(system: &[(Vec<usize>, bool)]) -> crate::ProofExpr {
315        use crate::ProofExpr;
316        let lit = |v: usize, positive: bool| {
317            let a = ProofExpr::Atom(format!("x{v}"));
318            if positive { a } else { ProofExpr::Not(Box::new(a)) }
319        };
320        let mut clauses = Vec::new();
321        for (vars, rhs) in system {
322            let k = vars.len();
323            for mask in 0u32..(1 << k) {
324                let parity = mask.count_ones() % 2 == 1;
325                if parity != *rhs {
326                    let mut it = vars.iter().enumerate();
327                    let (i0, &v0) = it.next().unwrap();
328                    let first = lit(v0, mask & (1 << i0) == 0);
329                    let clause = it.fold(first, |acc, (i, &v)| {
330                        ProofExpr::Or(Box::new(acc), Box::new(lit(v, mask & (1 << i) == 0)))
331                    });
332                    clauses.push(clause);
333                }
334            }
335        }
336        let mut it = clauses.into_iter();
337        let first = it.next().expect("non-empty system");
338        it.fold(first, |acc, c| crate::ProofExpr::And(Box::new(acc), Box::new(c)))
339    }
340
341    /// The GF(2) shadow, stacked into the default prover: an inconsistent parity cover that resolution
342    /// refutes only exponentially is now decided in polynomial time by Gaussian elimination — recovered
343    /// straight from the CNF, with no CDCL search, and `prove_unsat` returns a certified `Refuted`.
344    #[test]
345    fn parity_shadow_refutes_inconsistent_xor_through_prove_unsat() {
346        // x0⊕x1=1, x1⊕x2=1, x0⊕x2=1 — the three sum to 0 = 1, an odd cycle: UNSAT.
347        let system = vec![
348            (vec![0, 1], true),
349            (vec![1, 2], true),
350            (vec![0, 2], true),
351        ];
352        let e = xor_system_to_expr(&system);
353        assert!(refute_via_parity(&e), "the parity shadow must recognize and refute the odd cycle");
354        assert_eq!(
355            crate::sat::prove_unsat(&e),
356            crate::sat::UnsatOutcome::Refuted,
357            "prove_unsat now routes the parity cover to Gaussian elimination"
358        );
359    }
360
361    /// Soundness: a *satisfiable* parity cover is never falsely refuted — the shadow declines and the
362    /// prover finds a model.
363    #[test]
364    fn parity_shadow_is_sound_on_satisfiable_xor() {
365        // x0⊕x1=1, x1⊕x2=1 — consistent (x0=0,x1=1,x2=0).
366        let system = vec![(vec![0, 1], true), (vec![1, 2], true)];
367        let e = xor_system_to_expr(&system);
368        assert!(!refute_via_parity(&e), "a satisfiable parity system must not be refuted");
369        assert!(
370            matches!(crate::sat::prove_unsat(&e), crate::sat::UnsatOutcome::Sat(_)),
371            "prove_unsat must find a model for the satisfiable parity cover"
372        );
373    }
374
375    /// The shadows do not interfere: pigeonhole is a *counting* obstruction, not a parity one, so the
376    /// GF(2) recognizer declines it (leaving the counting shadow to decide it). No gadget, no guess.
377    #[test]
378    fn parity_shadow_declines_non_parity_pigeonhole() {
379        use crate::cdcl::Lit;
380        use crate::ProofExpr;
381        let (cnf, _) = crate::families::php(4);
382        let clause_expr = |c: &[Lit]| {
383            let mut it = c.iter().map(|l| {
384                let a = ProofExpr::Atom(format!("x{}", l.var()));
385                if l.is_positive() { a } else { ProofExpr::Not(Box::new(a)) }
386            });
387            let first = it.next().expect("non-empty clause");
388            it.fold(first, |acc, l| ProofExpr::Or(Box::new(acc), Box::new(l)))
389        };
390        let mut it = cnf.clauses.iter().map(|c| clause_expr(c));
391        let first = it.next().unwrap();
392        let e = it.fold(first, |acc, c| ProofExpr::And(Box::new(acc), Box::new(c)));
393        assert!(!refute_via_parity(&e), "pigeonhole is not a parity cover — the GF(2) shadow declines");
394    }
395
396    #[test]
397    fn simple_consistent_system_is_solved() {
398        // x0 ⊕ x1 = 1, x1 = 1  ⇒  x1 = 1, x0 = 0.
399        let sys = vec![eq(&[0, 1], true), eq(&[1], true)];
400        match solve(&sys, 2) {
401            XorOutcome::Sat(a) => {
402                assert!(satisfies(&sys, &a), "assignment must satisfy: {a:?}");
403                assert_eq!(a, vec![false, true]);
404            }
405            o => panic!("expected Sat, got {o:?}"),
406        }
407    }
408
409    #[test]
410    fn direct_contradiction_is_refuted() {
411        // x0 ⊕ x1 = 0 and x0 ⊕ x1 = 1 — summing them gives 0 = 1.
412        let sys = vec![eq(&[0, 1], false), eq(&[0, 1], true)];
413        match solve(&sys, 2) {
414            XorOutcome::Unsat(r) => {
415                assert!(is_refutation(&sys, 2, &r), "refutation must re-check: {r:?}");
416                assert_eq!(r.len(), 2, "both equations are needed");
417            }
418            o => panic!("expected Unsat, got {o:?}"),
419        }
420    }
421
422    #[test]
423    fn parity_chain_summing_to_one_is_refuted() {
424        // x_i ⊕ x_{i+1} = 0 for a chain, plus x0 ⊕ x_{n-1} = 1 — all equal yet endpoints differ.
425        let n = 8;
426        let mut sys: Vec<XorEquation> = (0..n - 1).map(|i| eq(&[i, i + 1], false)).collect();
427        sys.push(eq(&[0, n - 1], true));
428        match solve(&sys, n) {
429            XorOutcome::Unsat(r) => assert!(is_refutation(&sys, n, &r), "refutation invalid: {r:?}"),
430            o => panic!("inconsistent chain must be Unsat, got {o:?}"),
431        }
432    }
433
434    #[test]
435    fn duplicate_variables_cancel() {
436        // x0 ⊕ x0 ⊕ x1 = 1  ≡  x1 = 1.
437        let sys = vec![eq(&[0, 0, 1], true)];
438        match solve(&sys, 2) {
439            XorOutcome::Sat(a) => assert!(a[1], "x1 must be true: {a:?}"),
440            o => panic!("expected Sat, got {o:?}"),
441        }
442    }
443
444    #[test]
445    fn empty_system_is_trivially_sat() {
446        assert!(matches!(solve(&[], 3), XorOutcome::Sat(_)));
447    }
448
449    #[test]
450    fn matches_brute_force_on_random_systems() {
451        // Independent oracle: enumerate all 2^num_vars assignments; the system is SAT iff some
452        // assignment satisfies every equation. Cross-check verdict + re-check every witness.
453        let mut s: u64 = 0xD1B54A32D192ED03;
454        let mut next = || {
455            s ^= s << 13;
456            s ^= s >> 7;
457            s ^= s << 17;
458            s
459        };
460        for _ in 0..400 {
461            let num_vars = (next() % 6) as usize + 1; // 1..=6
462            let m = (next() % 8) as usize + 1; // 1..=8 equations
463            let sys: Vec<XorEquation> = (0..m)
464                .map(|_| {
465                    let vars: Vec<usize> =
466                        (0..num_vars).filter(|_| next() % 2 == 0).collect();
467                    eq(&vars, next() % 2 == 0)
468                })
469                .collect();
470            let brute_sat = (0..(1u32 << num_vars)).any(|mask| {
471                let a: Vec<bool> = (0..num_vars).map(|i| (mask >> i) & 1 == 1).collect();
472                satisfies(&sys, &a)
473            });
474            match solve(&sys, num_vars) {
475                XorOutcome::Sat(a) => {
476                    assert!(brute_sat, "we said SAT but brute force says UNSAT: {sys:?}");
477                    assert!(satisfies(&sys, &a), "returned assignment is wrong: {a:?}");
478                }
479                XorOutcome::Unsat(r) => {
480                    assert!(!brute_sat, "we said UNSAT but brute force found a model: {sys:?}");
481                    assert!(is_refutation(&sys, num_vars, &r), "bogus refutation {r:?}");
482                }
483            }
484        }
485    }
486
487    #[test]
488    fn a_bad_refutation_is_rejected() {
489        let sys = vec![eq(&[0, 1], false), eq(&[0, 1], true)];
490        assert!(!is_refutation(&sys, 2, &[]), "empty is not a refutation");
491        assert!(!is_refutation(&sys, 2, &[0]), "one consistent equation is not 0=1");
492        assert!(is_refutation(&sys, 2, &[0, 1]), "the pair sums to 0=1");
493    }
494}