Skip to main content

logicaffeine_proof/
xor_engine.rs

1//! DPLL(XOR) — the live GF(2) reasoning engine.
2//!
3//! A system of XOR (parity) constraints `x_{i1} ⊕ … ⊕ x_{ik} = b` is a linear system over GF(2).
4//! Plain CDCL refutes it only through resolution, which is exponential on parity (Tseitin/par
5//! families); Gaussian elimination decides it in polynomial time. This engine carries that linear
6//! reasoning *into* the search: given the solver's current partial assignment it derives every
7//! XOR-forced literal — and detects contradiction — by Gaussian elimination over the unassigned
8//! variables, so the solver never has to rediscover linear consequences by resolution.
9//!
10//! **Soundness is the whole game.** Every clause this engine hands back to CDCL is the gadget clause
11//! of a *derived* equation `E* = Σ_{i∈P} E_i` (a GF(2) sum of recovered equations, tracked by the
12//! provenance set `P`). Each `E_i` is a logical consequence of the formula (a full XOR gadget's
13//! clauses imply its equation), so `E*` is too, and so is the single gadget clause we emit — which is
14//! exactly unit (one unassigned literal) when `E*` forces a variable, or fully falsified when `E*`
15//! is violated. The engine therefore can never make the solver unsound; it can only make it faster.
16//!
17//! This module is the correctness-validated core (an exhaustive brute-force oracle checks that the
18//! derived forced-literals/conflicts are precisely the GF(2) consequences, and that every emitted
19//! clause is implied and correctly unit/falsified). The incremental watched-matrix that makes it
20//! cheap per call is layered on top of this oracle.
21
22use crate::cdcl::Lit;
23use crate::xorsat::XorEquation;
24
25/// One parity constraint over a variable set, carrying the provenance (which original equations it
26/// is a GF(2) sum of) so a derived implication can be explained by an implied clause.
27#[derive(Clone)]
28struct Row {
29    /// Bitset over variables: bit `v` set ⇔ variable `v` occurs in this equation.
30    vars: Vec<u64>,
31    /// Right-hand side parity.
32    rhs: bool,
33    /// Bitset over ORIGINAL equation indices: which equations XOR to this row.
34    prov: Vec<u64>,
35}
36
37/// The result of consulting the engine at a Boolean fixpoint.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub enum XorStep {
40    /// The linear system is violated under the current assignment; the clause is implied by the
41    /// formula and fully falsified (a conflict).
42    Conflict(Vec<Lit>),
43    /// `lit` is forced; the clause is implied and unit (its only unassigned literal is `lit`).
44    Imply { lit: Lit, reason: Vec<Lit> },
45    /// No XOR-forced literal and no contradiction at this assignment.
46    Sat,
47}
48
49/// A GF(2) constraint system with Gaussian reasoning under a partial assignment.
50pub struct XorEngine {
51    num_vars: usize,
52    var_words: usize,
53    eq_words: usize,
54    /// The original equations as variable bitsets (for building explanation clauses).
55    orig_vars: Vec<Vec<u64>>,
56    orig_rhs: Vec<bool>,
57}
58
59#[inline]
60fn words_for(n: usize) -> usize {
61    n.div_ceil(64).max(1)
62}
63#[inline]
64fn bit_set(bits: &mut [u64], i: usize) {
65    bits[i / 64] |= 1u64 << (i % 64);
66}
67#[inline]
68fn bit_get(bits: &[u64], i: usize) -> bool {
69    (bits[i / 64] >> (i % 64)) & 1 == 1
70}
71#[inline]
72fn xor_into(dst: &mut [u64], src: &[u64]) {
73    for (d, s) in dst.iter_mut().zip(src) {
74        *d ^= *s;
75    }
76}
77#[inline]
78fn bit_clear(bits: &mut [u64], i: usize) {
79    bits[i / 64] &= !(1u64 << (i % 64));
80}
81/// Remove `row` from an occurrence list (unordered set; rows are sparse so the scan is short).
82#[inline]
83fn occ_remove(list: &mut Vec<usize>, row: usize) {
84    if let Some(p) = list.iter().position(|&r| r == row) {
85        list.swap_remove(p);
86    }
87}
88#[inline]
89fn is_zero(bits: &[u64]) -> bool {
90    bits.iter().all(|&w| w == 0)
91}
92#[inline]
93fn popcount(bits: &[u64]) -> u32 {
94    bits.iter().map(|w| w.count_ones()).sum()
95}
96/// Index of the lowest set bit, or `None` if all zero.
97fn lowest_set(bits: &[u64]) -> Option<usize> {
98    for (wi, &w) in bits.iter().enumerate() {
99        if w != 0 {
100            return Some(wi * 64 + w.trailing_zeros() as usize);
101        }
102    }
103    None
104}
105fn set_bits(bits: &[u64]) -> Vec<usize> {
106    let mut out = Vec::new();
107    for (wi, &w) in bits.iter().enumerate() {
108        let mut w = w;
109        while w != 0 {
110            let b = w.trailing_zeros() as usize;
111            out.push(wi * 64 + b);
112            w &= w - 1;
113        }
114    }
115    out
116}
117
118impl XorEngine {
119    /// Build the engine from recovered XOR equations over `num_vars` variables. Equations with a
120    /// variable out of range are dropped (defensive; callers pass in-range systems).
121    pub fn new(num_vars: usize, eqs: &[XorEquation]) -> Self {
122        let var_words = words_for(num_vars);
123        let eq_words = words_for(eqs.len());
124        let mut orig_vars = Vec::with_capacity(eqs.len());
125        let mut orig_rhs = Vec::with_capacity(eqs.len());
126        for e in eqs {
127            let mut vars = vec![0u64; var_words];
128            let mut ok = true;
129            for &v in &e.vars {
130                if v >= num_vars {
131                    ok = false;
132                    break;
133                }
134                bit_set(&mut vars, v);
135            }
136            if !ok {
137                continue;
138            }
139            orig_vars.push(vars);
140            orig_rhs.push(e.rhs);
141        }
142        XorEngine {
143            num_vars,
144            var_words,
145            eq_words,
146            orig_vars,
147            orig_rhs,
148        }
149    }
150
151    /// Is this system non-trivial enough to be worth running (≥1 equation)?
152    pub fn is_active(&self) -> bool {
153        !self.orig_vars.is_empty()
154    }
155
156    /// The derived equation `E* = Σ_{i∈prov} E_i`: its full variable bitset and parity.
157    fn derived(&self, prov: &[u64]) -> (Vec<u64>, bool) {
158        let mut vars = vec![0u64; self.var_words];
159        let mut rhs = false;
160        for i in set_bits(prov) {
161            xor_into(&mut vars, &self.orig_vars[i]);
162            rhs ^= self.orig_rhs[i];
163        }
164        (vars, rhs)
165    }
166
167    /// Build the gadget clause of derived equation `E*` (variable bitset `dv`, parity `drhs`) that is
168    /// falsified by the current assignment together with `forced` (when `forced` is `Some((v, val))`,
169    /// `v` is the one unassigned variable and `val` its implied value). The clause's literal for each
170    /// variable `u` is the negation of `u`'s value in that falsifying assignment, so the clause is
171    /// fully false there — i.e. unit on `forced`'s literal, or an outright conflict when `forced` is
172    /// `None`.
173    fn gadget_clause(
174        &self,
175        dv: &[u64],
176        assign: &[Option<bool>],
177        forced: Option<(usize, bool)>,
178    ) -> Vec<Lit> {
179        let mut clause = Vec::new();
180        for u in set_bits(dv) {
181            match forced {
182                // The forced variable's literal is `Lit(v, val)`: it is the clause's single
183                // unassigned literal, so unit propagation drives `v` to `val`.
184                Some((fv, fval)) if fv == u => clause.push(Lit::new(u as u32, fval)),
185                // Every other variable is assigned; its literal is false under that value, so the
186                // clause is all-false on the forbidden (wrong-parity) row — unit on `forced`, or a
187                // full conflict when `forced` is `None`.
188                _ => {
189                    let val = assign[u].expect("gadget var must be assigned");
190                    clause.push(Lit::new(u as u32, !val));
191                }
192            }
193        }
194        clause
195    }
196
197    /// The core query: under partial assignment `assign` (`None` = unassigned), return the first
198    /// XOR-forced literal or a conflict, each with an implied, correctly-shaped clause. Complete —
199    /// it finds every linear consequence, not just per-equation ones — via Gaussian elimination over
200    /// the unassigned variables with provenance tracking.
201    pub fn analyze(&self, assign: &[Option<bool>]) -> XorStep {
202        match self.all_consequences(assign) {
203            Err(conflict) => XorStep::Conflict(conflict),
204            Ok(forced) => match forced.into_iter().next() {
205                Some((lit, reason)) => XorStep::Imply { lit, reason },
206                None => XorStep::Sat,
207            },
208        }
209    }
210
211    /// Every XOR-forced literal under `assign` (with implied unit reasons), or `Err(conflict clause)`
212    /// if the system is contradicted. This is the complete, oracle-checkable consequence set.
213    pub fn all_consequences(
214        &self,
215        assign: &[Option<bool>],
216    ) -> Result<Vec<(Lit, Vec<Lit>)>, Vec<Lit>> {
217        // Reduce each equation by the current assignment: move assigned variables to the rhs, keeping
218        // only the unassigned variables — and remember the provenance so we can rebuild E*.
219        let mut rows: Vec<Row> = Vec::with_capacity(self.orig_vars.len());
220        for (i, ov) in self.orig_vars.iter().enumerate() {
221            let mut vars = vec![0u64; self.var_words];
222            let mut rhs = self.orig_rhs[i];
223            for v in set_bits(ov) {
224                match assign[v] {
225                    Some(val) => rhs ^= val,       // assigned: fold into the parity
226                    None => bit_set(&mut vars, v), // unassigned: stays a live coefficient
227                }
228            }
229            let mut prov = vec![0u64; self.eq_words];
230            bit_set(&mut prov, i);
231            rows.push(Row { vars, rhs, prov });
232        }
233
234        // Gaussian elimination over the unassigned variables, carrying provenance through every XOR.
235        // pivot_for[v] = index in `basis` of the row whose pivot (lowest live var) is `v`.
236        let mut basis: Vec<Row> = Vec::new();
237        let mut pivot_at: Vec<Option<usize>> = vec![None; self.num_vars];
238        let mut forced: Vec<(Lit, Vec<Lit>)> = Vec::new();
239
240        for mut row in rows {
241            // Reduce `row` against EVERY pivot column it still contains (not merely its lowest bit —
242            // a higher pivot column left unreduced is what hides a forced variable).
243            loop {
244                let pivot_col = set_bits(&row.vars)
245                    .into_iter()
246                    .find(|&p| pivot_at[p].is_some());
247                match pivot_col {
248                    Some(p) => {
249                        let b = &basis[pivot_at[p].unwrap()];
250                        xor_into(&mut row.vars, &b.vars);
251                        row.rhs ^= b.rhs;
252                        xor_into(&mut row.prov, &b.prov);
253                    }
254                    None => break,
255                }
256            }
257            if is_zero(&row.vars) {
258                if row.rhs {
259                    // 0 = 1: contradiction. Explain with the derived equation's gadget under `assign`.
260                    let (dv, _) = self.derived(&row.prov);
261                    return Err(self.gadget_clause(&dv, assign, None));
262                }
263                continue; // 0 = 0: redundant.
264            }
265            let p = lowest_set(&row.vars).unwrap();
266            // Gauss-JORDAN: eliminate the new pivot `p` from every existing basis row, keeping the
267            // basis in REDUCED row echelon form. Without this back-substitution a variable forced
268            // only after a later pivot is found stays hidden inside an earlier row (incompleteness).
269            for b in basis.iter_mut() {
270                if bit_get(&b.vars, p) {
271                    xor_into(&mut b.vars, &row.vars);
272                    b.rhs ^= row.rhs;
273                    xor_into(&mut b.prov, &row.prov);
274                }
275            }
276            pivot_at[p] = Some(basis.len());
277            basis.push(row);
278        }
279
280        // A basis row with a single live variable forces it; explain with the derived gadget.
281        for row in &basis {
282            if popcount(&row.vars) == 1 {
283                let v = lowest_set(&row.vars).unwrap();
284                let val = row.rhs;
285                let (dv, _) = self.derived(&row.prov);
286                let reason = self.gadget_clause(&dv, assign, Some((v, val)));
287                forced.push((Lit::new(v as u32, val), reason));
288            }
289        }
290        Ok(forced)
291    }
292}
293
294/// Incremental GF(2) engine — the *fast* DPLL(XOR) core.
295///
296/// The matrix is held in reduced row-echelon form and updated in O(affected-rows) work per variable
297/// assignment (each assignment is substituted out, with at most one re-pivot), not by re-running
298/// Gaussian over the whole system every call. A first-touch undo trail makes backtrack O(touched).
299/// Provenance is carried through every row XOR, so the forced/conflict explanation clauses are the
300/// same implied gadget clauses the recompute [`XorEngine`] produces. It is differentially tested
301/// against that recompute oracle under random assign/unassign sequences — the fast engine is proven
302/// equivalent to the proven-correct one before it ever drives the solver.
303pub struct IncXor {
304    num_vars: usize,
305    var_words: usize,
306    orig_vars: Vec<Vec<u64>>,
307    orig_rhs: Vec<bool>,
308    rows: Vec<IncRow>,
309    /// `pivot_row[v] = Some(r)` ⇔ free variable `v` is the pivot of row `r`.
310    pivot_row: Vec<Option<usize>>,
311    /// Occurrence index: `occ[v]` lists the rows whose bitset currently contains variable `v`. The
312    /// reduced matrix is SPARSE (avg ~17 of 3176 vars/row on par32), so this lets each assignment
313    /// touch only the handful of rows that mention the variable instead of scanning all rows — the
314    /// watch-layer of the engine.
315    occ: Vec<Vec<usize>>,
316    /// `weight[r]` = popcount of row `r`'s bitset, maintained incrementally. A weight of 1 is a
317    /// forced unit, 0 (with `rhs`) a conflict — so propagation reads the cheap counter instead of
318    /// re-popcounting every row.
319    weight: Vec<u32>,
320    /// Candidate queue: a superset of the rows currently at weight ≤ 1. Pushed whenever a row drops
321    /// to that range; [`IncXor::state`] drains it, reports the genuine units/conflicts, and keeps only
322    /// the still-low rows — so propagation is O(touched), not O(rows). `in_low` dedups it.
323    low: Vec<usize>,
324    in_low: Vec<bool>,
325    value: Vec<Option<bool>>,
326    applied: Vec<usize>,
327    undo: Vec<Frame>,
328}
329
330#[derive(Clone)]
331struct IncRow {
332    bits: Vec<u64>,
333    rhs: bool,
334    prov: Vec<u64>,
335}
336
337/// First-touch snapshots for undoing one assignment.
338struct Frame {
339    var: usize,
340    rows: Vec<(usize, IncRow)>,
341    pivots: Vec<(usize, Option<usize>)>,
342    touched_rows: std::collections::HashSet<usize>,
343    touched_pivots: std::collections::HashSet<usize>,
344}
345
346impl IncXor {
347    /// Build from recovered equations, reducing the matrix to RREF once.
348    pub fn new(num_vars: usize, eqs: &[XorEquation]) -> Self {
349        let var_words = words_for(num_vars);
350        let eq_words = words_for(eqs.len());
351        let mut orig_vars = Vec::new();
352        let mut orig_rhs = Vec::new();
353        let mut staged: Vec<IncRow> = Vec::new();
354        for e in eqs {
355            let mut bits = vec![0u64; var_words];
356            let mut ok = true;
357            for &v in &e.vars {
358                if v >= num_vars {
359                    ok = false;
360                    break;
361                }
362                bit_set(&mut bits, v);
363            }
364            if !ok {
365                continue;
366            }
367            let idx = orig_vars.len();
368            let mut prov = vec![0u64; eq_words];
369            bit_set(&mut prov, idx);
370            orig_vars.push(bits.clone());
371            orig_rhs.push(e.rhs);
372            staged.push(IncRow {
373                bits,
374                rhs: e.rhs,
375                prov,
376            });
377        }
378
379        // Gauss-Jordan to RREF.
380        let mut rows: Vec<IncRow> = Vec::new();
381        let mut pivot_row: Vec<Option<usize>> = vec![None; num_vars];
382        for mut row in staged {
383            loop {
384                let pc = set_bits(&row.bits)
385                    .into_iter()
386                    .find(|&p| pivot_row[p].is_some());
387                match pc {
388                    Some(p) => {
389                        let b = rows[pivot_row[p].unwrap()].clone();
390                        xor_into(&mut row.bits, &b.bits);
391                        row.rhs ^= b.rhs;
392                        xor_into(&mut row.prov, &b.prov);
393                    }
394                    None => break,
395                }
396            }
397            if is_zero(&row.bits) {
398                if row.rhs {
399                    rows.push(row); // a permanent 0 = 1 conflict row.
400                }
401                continue;
402            }
403            let p = lowest_set(&row.bits).unwrap();
404            let idx = rows.len();
405            for b in rows.iter_mut() {
406                if bit_get(&b.bits, p) {
407                    xor_into(&mut b.bits, &row.bits);
408                    b.rhs ^= row.rhs;
409                    xor_into(&mut b.prov, &row.prov);
410                }
411            }
412            pivot_row[p] = Some(idx);
413            rows.push(row);
414        }
415
416        let mut occ = vec![Vec::new(); num_vars];
417        let mut weight = vec![0u32; rows.len()];
418        let mut low = Vec::new();
419        let mut in_low = vec![false; rows.len()];
420        for (ri, row) in rows.iter().enumerate() {
421            for v in set_bits(&row.bits) {
422                occ[v].push(ri);
423            }
424            weight[ri] = popcount(&row.bits);
425            if weight[ri] <= 1 {
426                low.push(ri);
427                in_low[ri] = true;
428            }
429        }
430
431        IncXor {
432            num_vars,
433            var_words,
434            orig_vars,
435            orig_rhs,
436            rows,
437            pivot_row,
438            occ,
439            weight,
440            low,
441            in_low,
442            value: vec![None; num_vars],
443            applied: Vec::new(),
444            undo: Vec::new(),
445        }
446    }
447
448    /// Record that row `r`'s weight changed; queue it if it is now a unit/conflict candidate.
449    #[inline]
450    fn note_weight(&mut self, r: usize) {
451        if self.weight[r] <= 1 && !self.in_low[r] {
452            self.in_low[r] = true;
453            self.low.push(r);
454        }
455    }
456
457    pub fn is_active(&self) -> bool {
458        !self.orig_vars.is_empty()
459    }
460
461    pub fn assigned_len(&self) -> usize {
462        self.applied.len()
463    }
464
465    fn snap_row(frame: &mut Frame, rows: &[IncRow], i: usize) {
466        if frame.touched_rows.insert(i) {
467            frame.rows.push((i, rows[i].clone()));
468        }
469    }
470    fn snap_pivot(frame: &mut Frame, pivot_row: &[Option<usize>], v: usize) {
471        if frame.touched_pivots.insert(v) {
472            frame.pivots.push((v, pivot_row[v]));
473        }
474    }
475
476    /// Assign `var = val` (it must currently be free), updating the matrix incrementally.
477    pub fn assign(&mut self, var: usize, val: bool) {
478        let mut frame = Frame {
479            var,
480            rows: Vec::new(),
481            pivots: Vec::new(),
482            touched_rows: std::collections::HashSet::new(),
483            touched_pivots: std::collections::HashSet::new(),
484        };
485        if let Some(r0) = self.pivot_row[var] {
486            // `var` is a pivot: in RREF it occurs only in row r0. Substitute it out, then re-pivot.
487            Self::snap_pivot(&mut frame, &self.pivot_row, var);
488            Self::snap_row(&mut frame, &self.rows, r0);
489            self.rows[r0].rhs ^= val;
490            bit_clear(&mut self.rows[r0].bits, var);
491            self.weight[r0] -= 1;
492            self.note_weight(r0);
493            occ_remove(&mut self.occ[var], r0);
494            self.pivot_row[var] = None;
495            self.re_pivot(r0, &mut frame);
496        } else {
497            // free non-pivot: clear it from every row that mentions it (pivots are untouched). The
498            // occurrence index gives exactly those rows — `var` vanishes from the matrix, so its
499            // list empties out.
500            let rows_with_var = std::mem::take(&mut self.occ[var]);
501            for i in rows_with_var {
502                if bit_get(&self.rows[i].bits, var) {
503                    Self::snap_row(&mut frame, &self.rows, i);
504                    self.rows[i].rhs ^= val;
505                    bit_clear(&mut self.rows[i].bits, var);
506                    self.weight[i] -= 1;
507                    self.note_weight(i);
508                }
509            }
510        }
511        self.value[var] = Some(val);
512        self.applied.push(var);
513        self.undo.push(frame);
514    }
515
516    /// XOR `pivot` into row `j`, keeping the occurrence index and row weight in lock-step: every
517    /// variable the XOR toggles is added to / removed from its row list, and the weight tracks the
518    /// net change. Returns the new weight of row `j`.
519    fn xor_row(
520        rows: &mut [IncRow],
521        occ: &mut [Vec<usize>],
522        weight: &mut [u32],
523        j: usize,
524        pivot: &IncRow,
525    ) {
526        let mut delta: i64 = 0;
527        for b in set_bits(&pivot.bits) {
528            if bit_get(&rows[j].bits, b) {
529                occ_remove(&mut occ[b], j);
530                delta -= 1;
531            } else {
532                occ[b].push(j);
533                delta += 1;
534            }
535        }
536        xor_into(&mut rows[j].bits, &pivot.bits);
537        rows[j].rhs ^= pivot.rhs;
538        xor_into(&mut rows[j].prov, &pivot.prov);
539        weight[j] = (weight[j] as i64 + delta) as u32;
540    }
541
542    /// Re-establish a pivot for row `r0` (whose pivot was just substituted out) and keep RREF. Only
543    /// the rows that actually contain the new pivot column `q` are reduced — the occurrence index
544    /// hands them over directly instead of a full-matrix scan.
545    fn re_pivot(&mut self, r0: usize, frame: &mut Frame) {
546        let Some(q) = lowest_set(&self.rows[r0].bits) else {
547            return; // empty row: 0=0 (redundant) or 0=1 (conflict, detected in `state`).
548        };
549        let pivot = self.rows[r0].clone();
550        let targets = self.occ[q].clone();
551        for j in targets {
552            if j != r0 && bit_get(&self.rows[j].bits, q) {
553                Self::snap_row(frame, &self.rows, j);
554                Self::xor_row(&mut self.rows, &mut self.occ, &mut self.weight, j, &pivot);
555                self.note_weight(j);
556            }
557        }
558        Self::snap_pivot(frame, &self.pivot_row, q);
559        self.pivot_row[q] = Some(r0);
560    }
561
562    /// Undo the most recent `assign`. Each touched row is rolled back to its snapshot, and the
563    /// occurrence index is restored by diffing the row's current bits against that snapshot — every
564    /// variable whose membership flipped is re-added or removed for that row.
565    pub fn unassign(&mut self) {
566        let Some(frame) = self.undo.pop() else { return };
567        for (i, old) in frame.rows.into_iter().rev() {
568            for w in 0..self.var_words {
569                let mut diff = self.rows[i].bits[w] ^ old.bits[w];
570                while diff != 0 {
571                    let b = w * 64 + diff.trailing_zeros() as usize;
572                    diff &= diff - 1;
573                    if bit_get(&old.bits, b) {
574                        self.occ[b].push(i); // present in the snapshot, absent now: re-add.
575                    } else {
576                        occ_remove(&mut self.occ[b], i); // absent in the snapshot, present now: drop.
577                    }
578                }
579            }
580            self.weight[i] = popcount(&old.bits);
581            self.note_weight(i);
582            self.rows[i] = old;
583        }
584        for (v, old) in frame.pivots.into_iter().rev() {
585            self.pivot_row[v] = old;
586        }
587        self.value[frame.var] = None;
588        self.applied.pop();
589    }
590
591    fn derived(&self, prov: &[u64]) -> Vec<u64> {
592        let mut vars = vec![0u64; self.var_words];
593        for i in set_bits(prov) {
594            xor_into(&mut vars, &self.orig_vars[i]);
595        }
596        vars
597    }
598
599    fn gadget(&self, dv: &[u64], forced: Option<(usize, bool)>) -> Vec<Lit> {
600        let mut clause = Vec::new();
601        for u in set_bits(dv) {
602            match forced {
603                Some((fv, fval)) if fv == u => clause.push(Lit::new(u as u32, fval)),
604                _ => clause.push(Lit::new(
605                    u as u32,
606                    !self.value[u].expect("gadget var must be assigned"),
607                )),
608            }
609        }
610        clause
611    }
612
613    /// Matrix density after the initial reduction: (rows, average row weight, max row weight). A
614    /// dense reduced matrix (high average weight) is why eager Gauss-Jordan is slow and why the
615    /// watch-based engine must avoid full densification.
616    pub fn density(&self) -> (usize, f64, usize) {
617        let weights: Vec<usize> = self
618            .rows
619            .iter()
620            .map(|r| popcount(&r.bits) as usize)
621            .collect();
622        let n = weights.len().max(1);
623        let sum: usize = weights.iter().sum();
624        (
625            weights.len(),
626            sum as f64 / n as f64,
627            weights.iter().copied().max().unwrap_or(0),
628        )
629    }
630
631    /// The next variable to branch ("break") on: an unassigned **non-pivot** — a free/kernel degree
632    /// of freedom the linear system does not determine. Deciding it lets Gaussian propagation force a
633    /// fresh batch of pivots, collapsing the residual. Prefers a free variable that actually occurs in
634    /// the matrix (a true kernel direction) over one the linear system never mentions.
635    pub fn next_branch(&self) -> Option<usize> {
636        let mut fallback = None;
637        for v in 0..self.num_vars {
638            if self.value[v].is_some() || self.pivot_row[v].is_some() {
639                continue;
640            }
641            if self.rows.iter().any(|r| bit_get(&r.bits, v)) {
642                return Some(v);
643            }
644            if fallback.is_none() {
645                fallback = Some(v);
646            }
647        }
648        fallback
649    }
650
651    /// The variables CDCL should branch on under DPLL(XOR): every NON-pivot variable. The pivots are
652    /// determined by Gaussian propagation (the theory forces them), so the search only ranges over the
653    /// kernel free variables (plus any variables the linear system never mentions).
654    pub fn decision_vars(&self) -> Vec<usize> {
655        (0..self.num_vars)
656            .filter(|&v| self.pivot_row[v].is_none())
657            .collect()
658    }
659
660    /// The short *implied* clauses the Gaussian reduction derives — every reduced row of width
661    /// `1..=max_width` re-expressed as its CNF gadget. These are no-goods the linear system entails
662    /// but resolution would not find (e.g. the 244 derived units of par32-1); injecting them into the
663    /// CNF shares the XOR strategy's discovered structure with CDCL. Sound: each reduced row is a
664    /// GF(2) sum of implied equations, so its gadget clauses are implied by the formula.
665    pub fn derived_clauses(&self, max_width: usize) -> Vec<Vec<Lit>> {
666        let mut out = Vec::new();
667        for row in &self.rows {
668            let vars = set_bits(&row.bits);
669            let k = vars.len();
670            if k == 0 || k > max_width || k > 31 {
671                continue;
672            }
673            for mask in 0u32..(1u32 << k) {
674                if ((mask.count_ones() % 2) == 1) != row.rhs {
675                    out.push(
676                        (0..k)
677                            .map(|i| Lit::new(vars[i] as u32, (mask >> i) & 1 == 0))
678                            .collect(),
679                    );
680                }
681            }
682        }
683        out
684    }
685
686    /// The number of pivots (= rank of the system under the current assignment).
687    pub fn rank(&self) -> usize {
688        (0..self.num_vars)
689            .filter(|&v| self.value[v].is_none() && self.pivot_row[v].is_some())
690            .count()
691    }
692
693    /// Free/kernel variables that occur in the matrix — the dimension of the choice space (each is a
694    /// candidate break point).
695    pub fn kernel_dim(&self) -> usize {
696        (0..self.num_vars)
697            .filter(|&v| {
698                self.value[v].is_none()
699                    && self.pivot_row[v].is_none()
700                    && self.rows.iter().any(|r| bit_get(&r.bits, v))
701            })
702            .count()
703    }
704
705    /// Assert the occurrence index is in perfect agreement with the matrix: `i ∈ occ[v]` exactly
706    /// when row `i` contains variable `v`, with no duplicate or stale entries. The watch layer is an
707    /// acceleration, so any drift here is a silent miscompile — this is the gate that catches it.
708    #[cfg(test)]
709    fn check_occ(&self) {
710        use std::collections::BTreeSet;
711        for v in 0..self.num_vars {
712            let mut seen = BTreeSet::new();
713            for &i in &self.occ[v] {
714                assert!(seen.insert(i), "occ[{v}] has duplicate row {i}");
715                assert!(
716                    bit_get(&self.rows[i].bits, v),
717                    "occ[{v}] lists row {i} which lacks {v}"
718                );
719            }
720            for (i, row) in self.rows.iter().enumerate() {
721                if bit_get(&row.bits, v) {
722                    assert!(
723                        seen.contains(&i),
724                        "row {i} contains {v} but occ[{v}] omits it"
725                    );
726                }
727            }
728        }
729        // The weight counter mirrors the true popcount, and `low` is a superset of every current
730        // unit/conflict row (so `state` never misses one). `in_low` agrees with `low`'s membership.
731        let in_low_set: std::collections::BTreeSet<usize> = self.low.iter().copied().collect();
732        for (r, row) in self.rows.iter().enumerate() {
733            assert_eq!(self.weight[r], popcount(&row.bits), "weight[{r}] desynced");
734            if self.weight[r] <= 1 {
735                assert!(self.in_low[r], "row {r} is weight ≤1 but in_low is false");
736            }
737            assert_eq!(
738                self.in_low[r],
739                in_low_set.contains(&r),
740                "in_low[{r}] disagrees with low"
741            );
742        }
743    }
744
745    /// The current forced literals (with implied unit reasons), or `Err(conflict clause)`. Reads only
746    /// the low-weight queue — the rows that became units/conflicts since the last call — and keeps the
747    /// still-low ones for next time, so a propagation round is O(touched rows), not O(all rows).
748    pub fn state(&mut self) -> Result<Vec<(Lit, Vec<Lit>)>, Vec<Lit>> {
749        let mut forced = Vec::new();
750        let candidates = std::mem::take(&mut self.low);
751        let mut keep = Vec::with_capacity(candidates.len());
752        let mut conflict: Option<Vec<Lit>> = None;
753        for r in candidates {
754            let w = self.weight[r];
755            if w > 1 {
756                self.in_low[r] = false; // no longer a unit/conflict candidate.
757                continue;
758            }
759            keep.push(r); // still low — re-check it next round.
760            if conflict.is_some() {
761                continue;
762            }
763            match w {
764                0 => {
765                    if self.rows[r].rhs {
766                        conflict = Some(self.gadget(&self.derived(&self.rows[r].prov), None));
767                    }
768                }
769                _ => {
770                    let v = lowest_set(&self.rows[r].bits).unwrap();
771                    let rhs = self.rows[r].rhs;
772                    forced.push((
773                        Lit::new(v as u32, rhs),
774                        self.gadget(&self.derived(&self.rows[r].prov), Some((v, rhs))),
775                    ));
776                }
777            }
778        }
779        self.low = keep;
780        match conflict {
781            Some(c) => Err(c),
782            None => Ok(forced),
783        }
784    }
785}
786
787/// DPLL(XOR): the engine plugs into CDCL's theory hook. At each Boolean fixpoint it hands back the
788/// first XOR-forced unit clause, or a conflict clause, or nothing — every clause implied by the
789/// formula, so the solver stays sound while gaining Gaussian reasoning resolution cannot do.
790impl crate::cdcl::Theory for XorEngine {
791    fn propagate(&mut self, trail: &[Lit]) -> Vec<Vec<Lit>> {
792        let mut a: Vec<Option<bool>> = vec![None; self.num_vars];
793        for &l in trail {
794            a[l.var() as usize] = Some(l.is_positive());
795        }
796        match self.all_consequences(&a) {
797            Err(conflict) => vec![conflict],
798            Ok(forced) => forced.into_iter().map(|(_, reason)| reason).collect(),
799        }
800    }
801}
802
803/// The fast path: [`IncXor`] syncs its incremental matrix to the solver's trail each call — undoing
804/// the divergent suffix (backtrack) and applying the new literals (forward) — then returns the first
805/// XOR-forced unit clause or a conflict. O(work-per-assignment), not O(system-per-fixpoint).
806impl crate::cdcl::Theory for IncXor {
807    fn propagate(&mut self, trail: &[Lit]) -> Vec<Vec<Lit>> {
808        // longest common prefix of what we've applied and the current trail.
809        let mut cp = 0;
810        while cp < self.applied.len()
811            && cp < trail.len()
812            && self.applied[cp] == trail[cp].var() as usize
813        {
814            cp += 1;
815        }
816        while self.applied.len() > cp {
817            self.unassign();
818        }
819        let start = self.applied.len();
820        for &l in &trail[start..] {
821            let v = l.var() as usize;
822            if self.value[v].is_none() {
823                self.assign(v, l.is_positive());
824            }
825        }
826        // Batch every forced literal from one matrix pass (amortise the scan over the round).
827        match self.state() {
828            Err(conflict) => vec![conflict],
829            Ok(forced) => forced.into_iter().map(|(_, reason)| reason).collect(),
830        }
831    }
832}
833
834#[cfg(test)]
835#[path = "../../logicaffeine_tests/unit/logicaffeine_proof/xor_engine/tests.rs"]
836mod tests;