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 {
27            vars: vars.into(),
28            rhs,
29        }
30    }
31}
32
33/// The outcome of solving an XOR system.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub enum XorOutcome {
36    /// Satisfiable, with an assignment over `0..num_vars` (re-checkable via [`satisfies`]).
37    Sat(Vec<bool>),
38    /// Unsatisfiable, witnessed by a subset of equation indices whose XOR is `0 = 1`
39    /// (re-checkable via [`is_refutation`]).
40    Unsat(Vec<usize>),
41}
42
43#[inline]
44fn get(bits: &[u64], i: usize) -> bool {
45    (bits[i / 64] >> (i % 64)) & 1 == 1
46}
47
48#[inline]
49fn flip(bits: &mut [u64], i: usize) {
50    bits[i / 64] ^= 1u64 << (i % 64);
51}
52
53#[inline]
54fn xor_assign(dst: &mut [u64], src: &[u64]) {
55    for (d, s) in dst.iter_mut().zip(src) {
56        *d ^= *s;
57    }
58}
59
60#[inline]
61fn is_zero(bits: &[u64]) -> bool {
62    bits.iter().all(|&w| w == 0)
63}
64
65fn set_indices(bits: &[u64]) -> Vec<usize> {
66    let mut out = Vec::new();
67    for (w, &word) in bits.iter().enumerate() {
68        let mut b = word;
69        while b != 0 {
70            let t = b.trailing_zeros() as usize;
71            out.push(w * 64 + t);
72            b &= b - 1;
73        }
74    }
75    out
76}
77
78#[derive(Clone)]
79struct Row {
80    lhs: Vec<u64>, // variable bitset
81    rhs: bool,
82    prov: Vec<u64>, // which original equations XOR to this row (the certificate's provenance)
83}
84
85/// Solve a parity system over `0..num_vars` by Gauss–Jordan elimination over GF(2). Returns a
86/// satisfying assignment or a certified `0 = 1` refutation. `O(eq · vars · (eq+vars)/64)`.
87pub fn solve(equations: &[XorEquation], num_vars: usize) -> XorOutcome {
88    let nb = num_vars.div_ceil(64).max(1);
89    let pb = equations.len().div_ceil(64).max(1);
90    let mut rows: Vec<Row> = equations
91        .iter()
92        .enumerate()
93        .map(|(i, eq)| {
94            let mut lhs = vec![0u64; nb];
95            for &v in &eq.vars {
96                if v < num_vars {
97                    flip(&mut lhs, v); // XOR ⇒ duplicate variables cancel
98                }
99            }
100            let mut prov = vec![0u64; pb];
101            flip(&mut prov, i);
102            Row {
103                lhs,
104                rhs: eq.rhs,
105                prov,
106            }
107        })
108        .collect();
109
110    let mut pivot_for_col = vec![usize::MAX; num_vars];
111    let mut rank = 0;
112    for c in 0..num_vars {
113        let Some(p) = (rank..rows.len()).find(|&i| get(&rows[i].lhs, c)) else {
114            continue;
115        };
116        rows.swap(rank, p);
117        let pivot = rows[rank].clone();
118        for (i, row) in rows.iter_mut().enumerate() {
119            if i != rank && get(&row.lhs, c) {
120                xor_assign(&mut row.lhs, &pivot.lhs);
121                row.rhs ^= pivot.rhs;
122                xor_assign(&mut row.prov, &pivot.prov);
123            }
124        }
125        pivot_for_col[c] = rank;
126        rank += 1;
127    }
128
129    // A reduced row with empty LHS but rhs = true is `0 = 1` — its provenance is the refutation.
130    for row in &rows {
131        if is_zero(&row.lhs) && row.rhs {
132            return XorOutcome::Unsat(set_indices(&row.prov));
133        }
134    }
135
136    // Consistent: free variables take 0; each pivot variable then equals its row's rhs (the row, in
137    // reduced form, holds only its pivot column plus free columns, all assigned 0).
138    let mut assignment = vec![false; num_vars];
139    for c in 0..num_vars {
140        let pr = pivot_for_col[c];
141        if pr != usize::MAX {
142            assignment[c] = rows[pr].rhs;
143        }
144    }
145    XorOutcome::Sat(assignment)
146}
147
148/// Re-check a satisfying assignment: every equation's variable-XOR equals its rhs.
149pub fn satisfies(equations: &[XorEquation], assignment: &[bool]) -> bool {
150    equations.iter().all(|eq| {
151        let ones = eq
152            .vars
153            .iter()
154            .filter(|&&v| v < assignment.len() && assignment[v])
155            .count();
156        (ones % 2 == 1) == eq.rhs
157    })
158}
159
160/// Re-check a refutation: the XOR of the chosen equations is `0 = 1` — their variables all cancel
161/// while their right-hand sides sum to 1. A solver-free certificate of unsatisfiability.
162pub fn is_refutation(equations: &[XorEquation], num_vars: usize, refutation: &[usize]) -> bool {
163    if refutation.is_empty() {
164        return false;
165    }
166    let nb = num_vars.div_ceil(64).max(1);
167    let mut lhs = vec![0u64; nb];
168    let mut rhs = false;
169    for &idx in refutation {
170        let Some(eq) = equations.get(idx) else {
171            return false;
172        };
173        for &v in &eq.vars {
174            if v < num_vars {
175                flip(&mut lhs, v);
176            }
177        }
178        rhs ^= eq.rhs;
179    }
180    is_zero(&lhs) && rhs
181}
182
183/// Recognize the XOR (parity) gadgets inside a CNF `ProofExpr` and refute via Gaussian elimination —
184/// the GF(2) shadow, as a fast-path for [`crate::sat::prove_unsat`]. A parity constraint
185/// `x_{a} ⊕ … ⊕ x_{z} = r` over `k` variables is encoded in CNF as exactly the `2^{k-1}` clauses that
186/// forbid the wrong-parity assignments; we group clauses by their variable set, and whenever a group
187/// is precisely such a full wrong-parity bundle we recover its [`XorEquation`].
188///
189/// **Soundness (never a false `true`):** a fully-present gadget's clauses *imply* its XOR equation
190/// (they forbid exactly the assignments the equation forbids), so the recovered equations are all
191/// logical consequences of `e`. If that recognized linear subsystem is inconsistent over GF(2), then
192/// `e` is unsatisfiable. Partial or malformed gadgets are simply not recognized — we fall through,
193/// never guess. The parity refutation is itself re-checkable via [`is_refutation`].
194pub fn refute_via_parity(e: &crate::ProofExpr) -> bool {
195    use std::collections::HashMap;
196    let mut idx: HashMap<String, usize> = HashMap::new();
197    let mut clauses: Vec<Vec<(usize, bool)>> = Vec::new();
198    if !collect_clauses(e, &mut clauses, &mut idx) {
199        return false;
200    }
201    // Group clauses by their (sorted, deduplicated) variable set.
202    let mut groups: HashMap<Vec<usize>, Vec<Vec<(usize, bool)>>> = HashMap::new();
203    for c in clauses {
204        let mut vars: Vec<usize> = c.iter().map(|&(v, _)| v).collect();
205        vars.sort_unstable();
206        vars.dedup();
207        if vars.len() != c.len() {
208            continue; // a repeated variable in one clause — not a clean parity literal set
209        }
210        groups.entry(vars).or_default().push(c);
211    }
212    let num_vars = idx.len();
213    let mut eqs = Vec::new();
214    for (vars, group) in groups {
215        let k = vars.len();
216        if k == 0 || k > 31 || group.len() != (1usize << (k - 1)) {
217            continue; // a gadget over k vars is exactly 2^{k-1} clauses; anything else isn't one
218        }
219        let pos: HashMap<usize, usize> = vars.iter().enumerate().map(|(i, &v)| (v, i)).collect();
220        let mut forbidden = std::collections::HashSet::new();
221        let mut parity: Option<u32> = None;
222        let mut clean = true;
223        for c in &group {
224            // The forbidden assignment this clause rules out: bit i is set iff literal i is negative.
225            let mut a = 0u32;
226            for &(v, positive) in c {
227                if !positive {
228                    a |= 1 << pos[&v];
229                }
230            }
231            if !forbidden.insert(a) {
232                clean = false; // a duplicated forbidden assignment ⟹ not a faithful gadget
233                break;
234            }
235            let p = a.count_ones() % 2;
236            match parity {
237                None => parity = Some(p),
238                Some(q) if q != p => {
239                    clean = false; // mixed parities ⟹ not one XOR equation
240                    break;
241                }
242                _ => {}
243            }
244        }
245        if !clean {
246            continue;
247        }
248        // All 2^{k-1} forbidden assignments share parity `p`; the equation is `⊕ vars = ¬p`.
249        let p = parity.unwrap_or(0);
250        eqs.push(XorEquation::new(vars, p == 0));
251    }
252    if eqs.is_empty() {
253        return false;
254    }
255    matches!(solve(&eqs, num_vars), XorOutcome::Unsat(_))
256}
257
258/// Flatten a CNF `ProofExpr` into clauses of `(var, positive)` literals over a shared atom index.
259/// Returns `false` if `e` is not a conjunction of disjunctions of literals (so parity recognition
260/// declines rather than misreads).
261fn collect_clauses(
262    e: &crate::ProofExpr,
263    out: &mut Vec<Vec<(usize, bool)>>,
264    idx: &mut std::collections::HashMap<String, usize>,
265) -> bool {
266    use crate::ProofExpr;
267    match e {
268        ProofExpr::And(l, r) => collect_clauses(l, out, idx) && collect_clauses(r, out, idx),
269        other => {
270            let mut lits = Vec::new();
271            if collect_literals(other, true, &mut lits, idx) {
272                out.push(lits);
273                true
274            } else {
275                false
276            }
277        }
278    }
279}
280
281fn collect_literals(
282    e: &crate::ProofExpr,
283    positive: bool,
284    out: &mut Vec<(usize, bool)>,
285    idx: &mut std::collections::HashMap<String, usize>,
286) -> bool {
287    use crate::ProofExpr;
288    match e {
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        ProofExpr::Not(inner) => match inner.as_ref() {
296            ProofExpr::Atom(name) => {
297                let n = idx.len();
298                let v = *idx.entry(name.clone()).or_insert(n);
299                out.push((v, !positive));
300                true
301            }
302            _ => false,
303        },
304        ProofExpr::Or(l, r) if positive => {
305            collect_literals(l, positive, out, idx) && collect_literals(r, positive, out, idx)
306        }
307        _ => false,
308    }
309}
310
311#[cfg(test)]
312#[path = "../../logicaffeine_tests/unit/logicaffeine_proof/xorsat/tests.rs"]
313mod tests;