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 { num_vars, var_words, eq_words, orig_vars, orig_rhs }
143    }
144
145    /// Is this system non-trivial enough to be worth running (≥1 equation)?
146    pub fn is_active(&self) -> bool {
147        !self.orig_vars.is_empty()
148    }
149
150    /// The derived equation `E* = Σ_{i∈prov} E_i`: its full variable bitset and parity.
151    fn derived(&self, prov: &[u64]) -> (Vec<u64>, bool) {
152        let mut vars = vec![0u64; self.var_words];
153        let mut rhs = false;
154        for i in set_bits(prov) {
155            xor_into(&mut vars, &self.orig_vars[i]);
156            rhs ^= self.orig_rhs[i];
157        }
158        (vars, rhs)
159    }
160
161    /// Build the gadget clause of derived equation `E*` (variable bitset `dv`, parity `drhs`) that is
162    /// falsified by the current assignment together with `forced` (when `forced` is `Some((v, val))`,
163    /// `v` is the one unassigned variable and `val` its implied value). The clause's literal for each
164    /// variable `u` is the negation of `u`'s value in that falsifying assignment, so the clause is
165    /// fully false there — i.e. unit on `forced`'s literal, or an outright conflict when `forced` is
166    /// `None`.
167    fn gadget_clause(
168        &self,
169        dv: &[u64],
170        assign: &[Option<bool>],
171        forced: Option<(usize, bool)>,
172    ) -> Vec<Lit> {
173        let mut clause = Vec::new();
174        for u in set_bits(dv) {
175            match forced {
176                // The forced variable's literal is `Lit(v, val)`: it is the clause's single
177                // unassigned literal, so unit propagation drives `v` to `val`.
178                Some((fv, fval)) if fv == u => clause.push(Lit::new(u as u32, fval)),
179                // Every other variable is assigned; its literal is false under that value, so the
180                // clause is all-false on the forbidden (wrong-parity) row — unit on `forced`, or a
181                // full conflict when `forced` is `None`.
182                _ => {
183                    let val = assign[u].expect("gadget var must be assigned");
184                    clause.push(Lit::new(u as u32, !val));
185                }
186            }
187        }
188        clause
189    }
190
191    /// The core query: under partial assignment `assign` (`None` = unassigned), return the first
192    /// XOR-forced literal or a conflict, each with an implied, correctly-shaped clause. Complete —
193    /// it finds every linear consequence, not just per-equation ones — via Gaussian elimination over
194    /// the unassigned variables with provenance tracking.
195    pub fn analyze(&self, assign: &[Option<bool>]) -> XorStep {
196        match self.all_consequences(assign) {
197            Err(conflict) => XorStep::Conflict(conflict),
198            Ok(forced) => match forced.into_iter().next() {
199                Some((lit, reason)) => XorStep::Imply { lit, reason },
200                None => XorStep::Sat,
201            },
202        }
203    }
204
205    /// Every XOR-forced literal under `assign` (with implied unit reasons), or `Err(conflict clause)`
206    /// if the system is contradicted. This is the complete, oracle-checkable consequence set.
207    pub fn all_consequences(&self, assign: &[Option<bool>]) -> Result<Vec<(Lit, Vec<Lit>)>, Vec<Lit>> {
208        // Reduce each equation by the current assignment: move assigned variables to the rhs, keeping
209        // only the unassigned variables — and remember the provenance so we can rebuild E*.
210        let mut rows: Vec<Row> = Vec::with_capacity(self.orig_vars.len());
211        for (i, ov) in self.orig_vars.iter().enumerate() {
212            let mut vars = vec![0u64; self.var_words];
213            let mut rhs = self.orig_rhs[i];
214            for v in set_bits(ov) {
215                match assign[v] {
216                    Some(val) => rhs ^= val,        // assigned: fold into the parity
217                    None => bit_set(&mut vars, v),  // unassigned: stays a live coefficient
218                }
219            }
220            let mut prov = vec![0u64; self.eq_words];
221            bit_set(&mut prov, i);
222            rows.push(Row { vars, rhs, prov });
223        }
224
225        // Gaussian elimination over the unassigned variables, carrying provenance through every XOR.
226        // pivot_for[v] = index in `basis` of the row whose pivot (lowest live var) is `v`.
227        let mut basis: Vec<Row> = Vec::new();
228        let mut pivot_at: Vec<Option<usize>> = vec![None; self.num_vars];
229        let mut forced: Vec<(Lit, Vec<Lit>)> = Vec::new();
230
231        for mut row in rows {
232            // Reduce `row` against EVERY pivot column it still contains (not merely its lowest bit —
233            // a higher pivot column left unreduced is what hides a forced variable).
234            loop {
235                let pivot_col = set_bits(&row.vars).into_iter().find(|&p| pivot_at[p].is_some());
236                match pivot_col {
237                    Some(p) => {
238                        let b = &basis[pivot_at[p].unwrap()];
239                        xor_into(&mut row.vars, &b.vars);
240                        row.rhs ^= b.rhs;
241                        xor_into(&mut row.prov, &b.prov);
242                    }
243                    None => break,
244                }
245            }
246            if is_zero(&row.vars) {
247                if row.rhs {
248                    // 0 = 1: contradiction. Explain with the derived equation's gadget under `assign`.
249                    let (dv, _) = self.derived(&row.prov);
250                    return Err(self.gadget_clause(&dv, assign, None));
251                }
252                continue; // 0 = 0: redundant.
253            }
254            let p = lowest_set(&row.vars).unwrap();
255            // Gauss-JORDAN: eliminate the new pivot `p` from every existing basis row, keeping the
256            // basis in REDUCED row echelon form. Without this back-substitution a variable forced
257            // only after a later pivot is found stays hidden inside an earlier row (incompleteness).
258            for b in basis.iter_mut() {
259                if bit_get(&b.vars, p) {
260                    xor_into(&mut b.vars, &row.vars);
261                    b.rhs ^= row.rhs;
262                    xor_into(&mut b.prov, &row.prov);
263                }
264            }
265            pivot_at[p] = Some(basis.len());
266            basis.push(row);
267        }
268
269        // A basis row with a single live variable forces it; explain with the derived gadget.
270        for row in &basis {
271            if popcount(&row.vars) == 1 {
272                let v = lowest_set(&row.vars).unwrap();
273                let val = row.rhs;
274                let (dv, _) = self.derived(&row.prov);
275                let reason = self.gadget_clause(&dv, assign, Some((v, val)));
276                forced.push((Lit::new(v as u32, val), reason));
277            }
278        }
279        Ok(forced)
280    }
281}
282
283/// Incremental GF(2) engine — the *fast* DPLL(XOR) core.
284///
285/// The matrix is held in reduced row-echelon form and updated in O(affected-rows) work per variable
286/// assignment (each assignment is substituted out, with at most one re-pivot), not by re-running
287/// Gaussian over the whole system every call. A first-touch undo trail makes backtrack O(touched).
288/// Provenance is carried through every row XOR, so the forced/conflict explanation clauses are the
289/// same implied gadget clauses the recompute [`XorEngine`] produces. It is differentially tested
290/// against that recompute oracle under random assign/unassign sequences — the fast engine is proven
291/// equivalent to the proven-correct one before it ever drives the solver.
292pub struct IncXor {
293    num_vars: usize,
294    var_words: usize,
295    orig_vars: Vec<Vec<u64>>,
296    orig_rhs: Vec<bool>,
297    rows: Vec<IncRow>,
298    /// `pivot_row[v] = Some(r)` ⇔ free variable `v` is the pivot of row `r`.
299    pivot_row: Vec<Option<usize>>,
300    /// Occurrence index: `occ[v]` lists the rows whose bitset currently contains variable `v`. The
301    /// reduced matrix is SPARSE (avg ~17 of 3176 vars/row on par32), so this lets each assignment
302    /// touch only the handful of rows that mention the variable instead of scanning all rows — the
303    /// watch-layer of the engine.
304    occ: Vec<Vec<usize>>,
305    /// `weight[r]` = popcount of row `r`'s bitset, maintained incrementally. A weight of 1 is a
306    /// forced unit, 0 (with `rhs`) a conflict — so propagation reads the cheap counter instead of
307    /// re-popcounting every row.
308    weight: Vec<u32>,
309    /// Candidate queue: a superset of the rows currently at weight ≤ 1. Pushed whenever a row drops
310    /// to that range; [`IncXor::state`] drains it, reports the genuine units/conflicts, and keeps only
311    /// the still-low rows — so propagation is O(touched), not O(rows). `in_low` dedups it.
312    low: Vec<usize>,
313    in_low: Vec<bool>,
314    value: Vec<Option<bool>>,
315    applied: Vec<usize>,
316    undo: Vec<Frame>,
317}
318
319#[derive(Clone)]
320struct IncRow {
321    bits: Vec<u64>,
322    rhs: bool,
323    prov: Vec<u64>,
324}
325
326/// First-touch snapshots for undoing one assignment.
327struct Frame {
328    var: usize,
329    rows: Vec<(usize, IncRow)>,
330    pivots: Vec<(usize, Option<usize>)>,
331    touched_rows: std::collections::HashSet<usize>,
332    touched_pivots: std::collections::HashSet<usize>,
333}
334
335impl IncXor {
336    /// Build from recovered equations, reducing the matrix to RREF once.
337    pub fn new(num_vars: usize, eqs: &[XorEquation]) -> Self {
338        let var_words = words_for(num_vars);
339        let eq_words = words_for(eqs.len());
340        let mut orig_vars = Vec::new();
341        let mut orig_rhs = Vec::new();
342        let mut staged: Vec<IncRow> = Vec::new();
343        for e in eqs {
344            let mut bits = vec![0u64; var_words];
345            let mut ok = true;
346            for &v in &e.vars {
347                if v >= num_vars {
348                    ok = false;
349                    break;
350                }
351                bit_set(&mut bits, v);
352            }
353            if !ok {
354                continue;
355            }
356            let idx = orig_vars.len();
357            let mut prov = vec![0u64; eq_words];
358            bit_set(&mut prov, idx);
359            orig_vars.push(bits.clone());
360            orig_rhs.push(e.rhs);
361            staged.push(IncRow { bits, rhs: e.rhs, prov });
362        }
363
364        // Gauss-Jordan to RREF.
365        let mut rows: Vec<IncRow> = Vec::new();
366        let mut pivot_row: Vec<Option<usize>> = vec![None; num_vars];
367        for mut row in staged {
368            loop {
369                let pc = set_bits(&row.bits).into_iter().find(|&p| pivot_row[p].is_some());
370                match pc {
371                    Some(p) => {
372                        let b = rows[pivot_row[p].unwrap()].clone();
373                        xor_into(&mut row.bits, &b.bits);
374                        row.rhs ^= b.rhs;
375                        xor_into(&mut row.prov, &b.prov);
376                    }
377                    None => break,
378                }
379            }
380            if is_zero(&row.bits) {
381                if row.rhs {
382                    rows.push(row); // a permanent 0 = 1 conflict row.
383                }
384                continue;
385            }
386            let p = lowest_set(&row.bits).unwrap();
387            let idx = rows.len();
388            for b in rows.iter_mut() {
389                if bit_get(&b.bits, p) {
390                    xor_into(&mut b.bits, &row.bits);
391                    b.rhs ^= row.rhs;
392                    xor_into(&mut b.prov, &row.prov);
393                }
394            }
395            pivot_row[p] = Some(idx);
396            rows.push(row);
397        }
398
399        let mut occ = vec![Vec::new(); num_vars];
400        let mut weight = vec![0u32; rows.len()];
401        let mut low = Vec::new();
402        let mut in_low = vec![false; rows.len()];
403        for (ri, row) in rows.iter().enumerate() {
404            for v in set_bits(&row.bits) {
405                occ[v].push(ri);
406            }
407            weight[ri] = popcount(&row.bits);
408            if weight[ri] <= 1 {
409                low.push(ri);
410                in_low[ri] = true;
411            }
412        }
413
414        IncXor {
415            num_vars,
416            var_words,
417            orig_vars,
418            orig_rhs,
419            rows,
420            pivot_row,
421            occ,
422            weight,
423            low,
424            in_low,
425            value: vec![None; num_vars],
426            applied: Vec::new(),
427            undo: Vec::new(),
428        }
429    }
430
431    /// Record that row `r`'s weight changed; queue it if it is now a unit/conflict candidate.
432    #[inline]
433    fn note_weight(&mut self, r: usize) {
434        if self.weight[r] <= 1 && !self.in_low[r] {
435            self.in_low[r] = true;
436            self.low.push(r);
437        }
438    }
439
440    pub fn is_active(&self) -> bool {
441        !self.orig_vars.is_empty()
442    }
443
444    pub fn assigned_len(&self) -> usize {
445        self.applied.len()
446    }
447
448    fn snap_row(frame: &mut Frame, rows: &[IncRow], i: usize) {
449        if frame.touched_rows.insert(i) {
450            frame.rows.push((i, rows[i].clone()));
451        }
452    }
453    fn snap_pivot(frame: &mut Frame, pivot_row: &[Option<usize>], v: usize) {
454        if frame.touched_pivots.insert(v) {
455            frame.pivots.push((v, pivot_row[v]));
456        }
457    }
458
459    /// Assign `var = val` (it must currently be free), updating the matrix incrementally.
460    pub fn assign(&mut self, var: usize, val: bool) {
461        let mut frame = Frame {
462            var,
463            rows: Vec::new(),
464            pivots: Vec::new(),
465            touched_rows: std::collections::HashSet::new(),
466            touched_pivots: std::collections::HashSet::new(),
467        };
468        if let Some(r0) = self.pivot_row[var] {
469            // `var` is a pivot: in RREF it occurs only in row r0. Substitute it out, then re-pivot.
470            Self::snap_pivot(&mut frame, &self.pivot_row, var);
471            Self::snap_row(&mut frame, &self.rows, r0);
472            self.rows[r0].rhs ^= val;
473            bit_clear(&mut self.rows[r0].bits, var);
474            self.weight[r0] -= 1;
475            self.note_weight(r0);
476            occ_remove(&mut self.occ[var], r0);
477            self.pivot_row[var] = None;
478            self.re_pivot(r0, &mut frame);
479        } else {
480            // free non-pivot: clear it from every row that mentions it (pivots are untouched). The
481            // occurrence index gives exactly those rows — `var` vanishes from the matrix, so its
482            // list empties out.
483            let rows_with_var = std::mem::take(&mut self.occ[var]);
484            for i in rows_with_var {
485                if bit_get(&self.rows[i].bits, var) {
486                    Self::snap_row(&mut frame, &self.rows, i);
487                    self.rows[i].rhs ^= val;
488                    bit_clear(&mut self.rows[i].bits, var);
489                    self.weight[i] -= 1;
490                    self.note_weight(i);
491                }
492            }
493        }
494        self.value[var] = Some(val);
495        self.applied.push(var);
496        self.undo.push(frame);
497    }
498
499    /// XOR `pivot` into row `j`, keeping the occurrence index and row weight in lock-step: every
500    /// variable the XOR toggles is added to / removed from its row list, and the weight tracks the
501    /// net change. Returns the new weight of row `j`.
502    fn xor_row(rows: &mut [IncRow], occ: &mut [Vec<usize>], weight: &mut [u32], j: usize, pivot: &IncRow) {
503        let mut delta: i64 = 0;
504        for b in set_bits(&pivot.bits) {
505            if bit_get(&rows[j].bits, b) {
506                occ_remove(&mut occ[b], j);
507                delta -= 1;
508            } else {
509                occ[b].push(j);
510                delta += 1;
511            }
512        }
513        xor_into(&mut rows[j].bits, &pivot.bits);
514        rows[j].rhs ^= pivot.rhs;
515        xor_into(&mut rows[j].prov, &pivot.prov);
516        weight[j] = (weight[j] as i64 + delta) as u32;
517    }
518
519    /// Re-establish a pivot for row `r0` (whose pivot was just substituted out) and keep RREF. Only
520    /// the rows that actually contain the new pivot column `q` are reduced — the occurrence index
521    /// hands them over directly instead of a full-matrix scan.
522    fn re_pivot(&mut self, r0: usize, frame: &mut Frame) {
523        let Some(q) = lowest_set(&self.rows[r0].bits) else {
524            return; // empty row: 0=0 (redundant) or 0=1 (conflict, detected in `state`).
525        };
526        let pivot = self.rows[r0].clone();
527        let targets = self.occ[q].clone();
528        for j in targets {
529            if j != r0 && bit_get(&self.rows[j].bits, q) {
530                Self::snap_row(frame, &self.rows, j);
531                Self::xor_row(&mut self.rows, &mut self.occ, &mut self.weight, j, &pivot);
532                self.note_weight(j);
533            }
534        }
535        Self::snap_pivot(frame, &self.pivot_row, q);
536        self.pivot_row[q] = Some(r0);
537    }
538
539    /// Undo the most recent `assign`. Each touched row is rolled back to its snapshot, and the
540    /// occurrence index is restored by diffing the row's current bits against that snapshot — every
541    /// variable whose membership flipped is re-added or removed for that row.
542    pub fn unassign(&mut self) {
543        let Some(frame) = self.undo.pop() else { return };
544        for (i, old) in frame.rows.into_iter().rev() {
545            for w in 0..self.var_words {
546                let mut diff = self.rows[i].bits[w] ^ old.bits[w];
547                while diff != 0 {
548                    let b = w * 64 + diff.trailing_zeros() as usize;
549                    diff &= diff - 1;
550                    if bit_get(&old.bits, b) {
551                        self.occ[b].push(i); // present in the snapshot, absent now: re-add.
552                    } else {
553                        occ_remove(&mut self.occ[b], i); // absent in the snapshot, present now: drop.
554                    }
555                }
556            }
557            self.weight[i] = popcount(&old.bits);
558            self.note_weight(i);
559            self.rows[i] = old;
560        }
561        for (v, old) in frame.pivots.into_iter().rev() {
562            self.pivot_row[v] = old;
563        }
564        self.value[frame.var] = None;
565        self.applied.pop();
566    }
567
568    fn derived(&self, prov: &[u64]) -> Vec<u64> {
569        let mut vars = vec![0u64; self.var_words];
570        for i in set_bits(prov) {
571            xor_into(&mut vars, &self.orig_vars[i]);
572        }
573        vars
574    }
575
576    fn gadget(&self, dv: &[u64], forced: Option<(usize, bool)>) -> Vec<Lit> {
577        let mut clause = Vec::new();
578        for u in set_bits(dv) {
579            match forced {
580                Some((fv, fval)) if fv == u => clause.push(Lit::new(u as u32, fval)),
581                _ => clause.push(Lit::new(u as u32, !self.value[u].expect("gadget var must be assigned"))),
582            }
583        }
584        clause
585    }
586
587    /// Matrix density after the initial reduction: (rows, average row weight, max row weight). A
588    /// dense reduced matrix (high average weight) is why eager Gauss-Jordan is slow and why the
589    /// watch-based engine must avoid full densification.
590    pub fn density(&self) -> (usize, f64, usize) {
591        let weights: Vec<usize> = self.rows.iter().map(|r| popcount(&r.bits) as usize).collect();
592        let n = weights.len().max(1);
593        let sum: usize = weights.iter().sum();
594        (weights.len(), sum as f64 / n as f64, weights.iter().copied().max().unwrap_or(0))
595    }
596
597    /// The next variable to branch ("break") on: an unassigned **non-pivot** — a free/kernel degree
598    /// of freedom the linear system does not determine. Deciding it lets Gaussian propagation force a
599    /// fresh batch of pivots, collapsing the residual. Prefers a free variable that actually occurs in
600    /// the matrix (a true kernel direction) over one the linear system never mentions.
601    pub fn next_branch(&self) -> Option<usize> {
602        let mut fallback = None;
603        for v in 0..self.num_vars {
604            if self.value[v].is_some() || self.pivot_row[v].is_some() {
605                continue;
606            }
607            if self.rows.iter().any(|r| bit_get(&r.bits, v)) {
608                return Some(v);
609            }
610            if fallback.is_none() {
611                fallback = Some(v);
612            }
613        }
614        fallback
615    }
616
617    /// The variables CDCL should branch on under DPLL(XOR): every NON-pivot variable. The pivots are
618    /// determined by Gaussian propagation (the theory forces them), so the search only ranges over the
619    /// kernel free variables (plus any variables the linear system never mentions).
620    pub fn decision_vars(&self) -> Vec<usize> {
621        (0..self.num_vars).filter(|&v| self.pivot_row[v].is_none()).collect()
622    }
623
624    /// The short *implied* clauses the Gaussian reduction derives — every reduced row of width
625    /// `1..=max_width` re-expressed as its CNF gadget. These are no-goods the linear system entails
626    /// but resolution would not find (e.g. the 244 derived units of par32-1); injecting them into the
627    /// CNF shares the XOR strategy's discovered structure with CDCL. Sound: each reduced row is a
628    /// GF(2) sum of implied equations, so its gadget clauses are implied by the formula.
629    pub fn derived_clauses(&self, max_width: usize) -> Vec<Vec<Lit>> {
630        let mut out = Vec::new();
631        for row in &self.rows {
632            let vars = set_bits(&row.bits);
633            let k = vars.len();
634            if k == 0 || k > max_width || k > 31 {
635                continue;
636            }
637            for mask in 0u32..(1u32 << k) {
638                if ((mask.count_ones() % 2) == 1) != row.rhs {
639                    out.push((0..k).map(|i| Lit::new(vars[i] as u32, (mask >> i) & 1 == 0)).collect());
640                }
641            }
642        }
643        out
644    }
645
646    /// The number of pivots (= rank of the system under the current assignment).
647    pub fn rank(&self) -> usize {
648        (0..self.num_vars).filter(|&v| self.value[v].is_none() && self.pivot_row[v].is_some()).count()
649    }
650
651    /// Free/kernel variables that occur in the matrix — the dimension of the choice space (each is a
652    /// candidate break point).
653    pub fn kernel_dim(&self) -> usize {
654        (0..self.num_vars)
655            .filter(|&v| self.value[v].is_none() && self.pivot_row[v].is_none() && self.rows.iter().any(|r| bit_get(&r.bits, v)))
656            .count()
657    }
658
659    /// Assert the occurrence index is in perfect agreement with the matrix: `i ∈ occ[v]` exactly
660    /// when row `i` contains variable `v`, with no duplicate or stale entries. The watch layer is an
661    /// acceleration, so any drift here is a silent miscompile — this is the gate that catches it.
662    #[cfg(test)]
663    fn check_occ(&self) {
664        use std::collections::BTreeSet;
665        for v in 0..self.num_vars {
666            let mut seen = BTreeSet::new();
667            for &i in &self.occ[v] {
668                assert!(seen.insert(i), "occ[{v}] has duplicate row {i}");
669                assert!(bit_get(&self.rows[i].bits, v), "occ[{v}] lists row {i} which lacks {v}");
670            }
671            for (i, row) in self.rows.iter().enumerate() {
672                if bit_get(&row.bits, v) {
673                    assert!(seen.contains(&i), "row {i} contains {v} but occ[{v}] omits it");
674                }
675            }
676        }
677        // The weight counter mirrors the true popcount, and `low` is a superset of every current
678        // unit/conflict row (so `state` never misses one). `in_low` agrees with `low`'s membership.
679        let in_low_set: std::collections::BTreeSet<usize> = self.low.iter().copied().collect();
680        for (r, row) in self.rows.iter().enumerate() {
681            assert_eq!(self.weight[r], popcount(&row.bits), "weight[{r}] desynced");
682            if self.weight[r] <= 1 {
683                assert!(self.in_low[r], "row {r} is weight ≤1 but in_low is false");
684            }
685            assert_eq!(self.in_low[r], in_low_set.contains(&r), "in_low[{r}] disagrees with low");
686        }
687    }
688
689    /// The current forced literals (with implied unit reasons), or `Err(conflict clause)`. Reads only
690    /// the low-weight queue — the rows that became units/conflicts since the last call — and keeps the
691    /// still-low ones for next time, so a propagation round is O(touched rows), not O(all rows).
692    pub fn state(&mut self) -> Result<Vec<(Lit, Vec<Lit>)>, Vec<Lit>> {
693        let mut forced = Vec::new();
694        let candidates = std::mem::take(&mut self.low);
695        let mut keep = Vec::with_capacity(candidates.len());
696        let mut conflict: Option<Vec<Lit>> = None;
697        for r in candidates {
698            let w = self.weight[r];
699            if w > 1 {
700                self.in_low[r] = false; // no longer a unit/conflict candidate.
701                continue;
702            }
703            keep.push(r); // still low — re-check it next round.
704            if conflict.is_some() {
705                continue;
706            }
707            match w {
708                0 => {
709                    if self.rows[r].rhs {
710                        conflict = Some(self.gadget(&self.derived(&self.rows[r].prov), None));
711                    }
712                }
713                _ => {
714                    let v = lowest_set(&self.rows[r].bits).unwrap();
715                    let rhs = self.rows[r].rhs;
716                    forced.push((Lit::new(v as u32, rhs), self.gadget(&self.derived(&self.rows[r].prov), Some((v, rhs)))));
717                }
718            }
719        }
720        self.low = keep;
721        match conflict {
722            Some(c) => Err(c),
723            None => Ok(forced),
724        }
725    }
726}
727
728/// DPLL(XOR): the engine plugs into CDCL's theory hook. At each Boolean fixpoint it hands back the
729/// first XOR-forced unit clause, or a conflict clause, or nothing — every clause implied by the
730/// formula, so the solver stays sound while gaining Gaussian reasoning resolution cannot do.
731impl crate::cdcl::Theory for XorEngine {
732    fn propagate(&mut self, trail: &[Lit]) -> Vec<Vec<Lit>> {
733        let mut a: Vec<Option<bool>> = vec![None; self.num_vars];
734        for &l in trail {
735            a[l.var() as usize] = Some(l.is_positive());
736        }
737        match self.all_consequences(&a) {
738            Err(conflict) => vec![conflict],
739            Ok(forced) => forced.into_iter().map(|(_, reason)| reason).collect(),
740        }
741    }
742}
743
744/// The fast path: [`IncXor`] syncs its incremental matrix to the solver's trail each call — undoing
745/// the divergent suffix (backtrack) and applying the new literals (forward) — then returns the first
746/// XOR-forced unit clause or a conflict. O(work-per-assignment), not O(system-per-fixpoint).
747impl crate::cdcl::Theory for IncXor {
748    fn propagate(&mut self, trail: &[Lit]) -> Vec<Vec<Lit>> {
749        // longest common prefix of what we've applied and the current trail.
750        let mut cp = 0;
751        while cp < self.applied.len() && cp < trail.len() && self.applied[cp] == trail[cp].var() as usize {
752            cp += 1;
753        }
754        while self.applied.len() > cp {
755            self.unassign();
756        }
757        let start = self.applied.len();
758        for &l in &trail[start..] {
759            let v = l.var() as usize;
760            if self.value[v].is_none() {
761                self.assign(v, l.is_positive());
762            }
763        }
764        // Batch every forced literal from one matrix pass (amortise the scan over the round).
765        match self.state() {
766            Err(conflict) => vec![conflict],
767            Ok(forced) => forced.into_iter().map(|(_, reason)| reason).collect(),
768        }
769    }
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775    use crate::xorsat::XorEquation;
776
777    /// Brute-force oracle: enumerate every assignment of the unassigned variables consistent with
778    /// `assign` that satisfies all equations. Returns `None` if there are none (UNSAT under `assign`),
779    /// else `Some(per-variable forced value)` where a variable is forced iff it takes the same value
780    /// across every consistent completion.
781    fn oracle(num_vars: usize, eqs: &[XorEquation], assign: &[Option<bool>]) -> Option<Vec<Option<bool>>> {
782        let free: Vec<usize> = (0..num_vars).filter(|&v| assign[v].is_none()).collect();
783        let mut seen: Vec<Option<bool>> = vec![None; num_vars];
784        let mut first = true;
785        let mut any = false;
786        assert!(free.len() <= 16, "oracle only for small systems");
787        for mask in 0u32..(1u32 << free.len()) {
788            let mut full = vec![false; num_vars];
789            for v in 0..num_vars {
790                full[v] = assign[v].unwrap_or(false);
791            }
792            for (i, &v) in free.iter().enumerate() {
793                full[v] = (mask >> i) & 1 == 1;
794            }
795            let ok = eqs.iter().all(|e| e.vars.iter().fold(false, |a, &v| a ^ full[v]) == e.rhs);
796            if !ok {
797                continue;
798            }
799            any = true;
800            if first {
801                for v in 0..num_vars {
802                    seen[v] = Some(full[v]);
803                }
804                first = false;
805            } else {
806                for v in 0..num_vars {
807                    if seen[v] != Some(full[v]) {
808                        seen[v] = None; // varies ⇒ not forced
809                    }
810                }
811            }
812        }
813        if any {
814            // Only the unassigned variables count as "forced" discoveries.
815            for v in 0..num_vars {
816                if assign[v].is_some() {
817                    seen[v] = None;
818                }
819            }
820            Some(seen)
821        } else {
822            None
823        }
824    }
825
826    fn lit_sat(clause: &[Lit], full: &[bool]) -> bool {
827        clause.iter().any(|l| full[l.var() as usize] == l.is_positive())
828    }
829
830    #[test]
831    fn forces_a_variable_by_combining_two_equations() {
832        // x0⊕x1=1, x0⊕x2=1 ⇒ x1⊕x2=0 (cross-equation). Assign x1=true ⇒ x2 forced true; x0 false too.
833        let eqs = vec![XorEquation::new(vec![0, 1], true), XorEquation::new(vec![0, 2], true)];
834        let eng = XorEngine::new(3, &eqs);
835        let assign = vec![None, Some(true), None];
836        let forced = eng.all_consequences(&assign).expect("system is consistent");
837        let (x2, reason) = forced.iter().find(|(l, _)| l.var() == 2).expect("x2 must be forced");
838        assert!(x2.is_positive(), "x2 must be forced true");
839        // the reason must be unit on x2 (its only unassigned literal).
840        let unassigned: Vec<_> = reason.iter().filter(|l| assign[l.var() as usize].is_none()).collect();
841        assert_eq!(unassigned.len(), 1);
842        assert_eq!(unassigned[0].var(), 2);
843    }
844
845    #[test]
846    fn dpll_xor_decides_a_small_mixed_instance_via_the_theory() {
847        use crate::cdcl::{SolveResult, Solver};
848        // CNF = the 4 gadget clauses of x0⊕x1⊕x2=0, plus units x0 and x1 ⇒ SAT with x2=false.
849        let gadget = vec![
850            vec![Lit::new(0, true), Lit::new(1, true), Lit::new(2, false)],
851            vec![Lit::new(0, true), Lit::new(1, false), Lit::new(2, true)],
852            vec![Lit::new(0, false), Lit::new(1, true), Lit::new(2, true)],
853            vec![Lit::new(0, false), Lit::new(1, false), Lit::new(2, false)],
854            vec![Lit::new(0, true)],
855            vec![Lit::new(1, true)],
856        ];
857        let mut solver = Solver::new(3);
858        for c in &gadget {
859            solver.add_clause(c.clone());
860        }
861        let engine = XorEngine::new(3, &[XorEquation::new(vec![0, 1, 2], false)]);
862        let mut theories: Vec<Box<dyn crate::cdcl::Theory>> = vec![Box::new(engine)];
863        match solver.solve_with(&mut theories) {
864            SolveResult::Sat(m) => {
865                for c in &gadget {
866                    assert!(c.iter().any(|l| m[l.var() as usize] == l.is_positive()), "model fails {c:?}");
867                }
868                assert!(m[0] && m[1] && !m[2], "expected x0,x1 true and x2 false");
869            }
870            SolveResult::Unsat => panic!("instance is SAT"),
871        }
872    }
873
874    #[test]
875    fn live_dpll_xor_matches_plain_cdcl_on_random_xor_formulas() {
876        use crate::cdcl::{SolveResult, Solver};
877        // The decisive integration gate: running the live IncXor as a CDCL theory must give the SAME
878        // verdict as plain CDCL on the identical formula — for hundreds of random instances built
879        // from real XOR structure plus residual clauses. The theory may only *carry* globally-valid
880        // no-goods (every XOR consequence holds in every model), so it can never change the answer,
881        // only the path. On SAT the returned model must satisfy every clause.
882        let mut rng: u64 = 0x2545_F491_4F6C_DD1D;
883        let mut next = || {
884            rng ^= rng << 13;
885            rng ^= rng >> 7;
886            rng ^= rng << 17;
887            rng
888        };
889        // Expand a GF(2) equation to its CNF gadget clauses (each globally valid).
890        let gadget_of = |e: &XorEquation, _nv: usize| -> Vec<Vec<Lit>> {
891            let vs = &e.vars;
892            let k = vs.len();
893            let mut out = Vec::new();
894            for mask in 0u32..(1u32 << k) {
895                if ((mask.count_ones() % 2) == 1) != e.rhs {
896                    out.push((0..k).map(|i| Lit::new(vs[i] as u32, (mask >> i) & 1 == 0)).collect());
897                }
898            }
899            out
900        };
901        for _ in 0..400 {
902            let num_vars = 4 + (next() % 7) as usize; // 4..=10
903            let n_eqs = 1 + (next() % 4) as usize;
904            let mut eqs = Vec::new();
905            let mut clauses: Vec<Vec<Lit>> = Vec::new();
906            for _ in 0..n_eqs {
907                let mut vars: Vec<usize> = (0..num_vars).filter(|_| next() % 2 == 0).collect();
908                vars.truncate(4); // keep gadgets small
909                if vars.is_empty() {
910                    continue;
911                }
912                let e = XorEquation::new(vars, next() % 2 == 0);
913                clauses.extend(gadget_of(&e, num_vars));
914                eqs.push(e);
915            }
916            if eqs.is_empty() {
917                continue;
918            }
919            // A few residual clauses (units/binaries) to bias toward the hard SAT/UNSAT boundary.
920            for _ in 0..(2 + next() % 4) {
921                let w = 1 + (next() % 2) as usize;
922                let c: Vec<Lit> = (0..w)
923                    .map(|_| Lit::new((next() % num_vars as u64) as u32, next() % 2 == 0))
924                    .collect();
925                clauses.push(c);
926            }
927
928            let mut plain = Solver::new(num_vars);
929            for c in &clauses {
930                plain.add_clause(c.clone());
931            }
932            let truth = plain.solve();
933
934            let mut live = Solver::new(num_vars);
935            for c in &clauses {
936                live.add_clause(c.clone());
937            }
938            let engine = IncXor::new(num_vars, &eqs);
939            let mut theories: Vec<Box<dyn crate::cdcl::Theory>> = vec![Box::new(engine)];
940            let got = live.solve_with(&mut theories);
941
942            match (&truth, &got) {
943                (SolveResult::Sat(_), SolveResult::Sat(m)) => {
944                    assert!(
945                        clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
946                        "live model fails a clause (eqs={eqs:?})"
947                    );
948                }
949                (SolveResult::Unsat, SolveResult::Unsat) => {}
950                _ => panic!("verdict mismatch: plain={truth:?} live={got:?} (eqs={eqs:?} clauses={clauses:?})"),
951            }
952        }
953    }
954
955    #[test]
956    #[ignore = "diagnostic: reads par32 from disk and prints the GF(2) search dimensions"]
957    fn par32_search_dimensions() {
958        for name in ["par16-1-c", "par32-1-c", "par32-1"] {
959            let path = format!("../../benchmarks/arena/instances/sat/crafted_parity/{name}.cnf");
960            let Ok(text) = std::fs::read_to_string(&path) else {
961                eprintln!("{name}: missing");
962                continue;
963            };
964            let cnf = crate::dimacs::parse(&text).expect("valid dimacs");
965            let eqs = crate::lyapunov::extract_xor(cnf.num_vars, &cnf.clauses);
966            let engine = IncXor::new(cnf.num_vars, &eqs);
967            let (rows, avgw, maxw) = engine.density();
968            eprintln!(
969                "{name}: vars={} clauses={} xor_eqs={} | rank={} kernel_dim={} decision_vars={} | rows={} avg_w={:.1} max_w={}",
970                cnf.num_vars,
971                cnf.clauses.len(),
972                eqs.len(),
973                engine.rank(),
974                engine.kernel_dim(),
975                engine.decision_vars().len(),
976                rows,
977                avgw,
978                maxw,
979            );
980        }
981    }
982
983    #[test]
984    #[ignore = "diagnostic: measures how far probing collapses par32's GF(2) kernel"]
985    fn par32_probing_collapses_kernel() {
986        use crate::xorsat::XorEquation;
987        // Full propagation under a single assumed literal: residual-clause BCP interleaved with live
988        // Gaussian, to a fixpoint. Returns the forced assignment, or None on conflict (a failed literal).
989        fn probe(num_vars: usize, clauses: &[Vec<Lit>], eqs: &[XorEquation], start: Lit) -> Option<Vec<Option<bool>>> {
990            let mut val: Vec<Option<bool>> = vec![None; num_vars];
991            let mut in_engine = vec![false; num_vars];
992            let mut engine = IncXor::new(num_vars, eqs);
993            val[start.var() as usize] = Some(start.is_positive());
994            let mut progress = true;
995            while progress {
996                progress = false;
997                for v in 0..num_vars {
998                    if val[v].is_some() && !in_engine[v] {
999                        engine.assign(v, val[v].unwrap());
1000                        in_engine[v] = true;
1001                    }
1002                }
1003                match engine.state() {
1004                    Err(_) => return None,
1005                    Ok(forced) => {
1006                        for (lit, _) in forced {
1007                            let v = lit.var() as usize;
1008                            match val[v] {
1009                                None => {
1010                                    val[v] = Some(lit.is_positive());
1011                                    progress = true;
1012                                }
1013                                Some(b) => {
1014                                    if b != lit.is_positive() {
1015                                        return None;
1016                                    }
1017                                }
1018                            }
1019                        }
1020                    }
1021                }
1022                for c in clauses {
1023                    let mut sat = false;
1024                    let mut unset: Option<Lit> = None;
1025                    let mut n_unset = 0;
1026                    for &l in c {
1027                        match val[l.var() as usize] {
1028                            Some(b) if b == l.is_positive() => {
1029                                sat = true;
1030                                break;
1031                            }
1032                            Some(_) => {}
1033                            None => {
1034                                n_unset += 1;
1035                                unset = Some(l);
1036                            }
1037                        }
1038                    }
1039                    if sat {
1040                        continue;
1041                    }
1042                    if n_unset == 0 {
1043                        return None;
1044                    }
1045                    if n_unset == 1 {
1046                        let l = unset.unwrap();
1047                        if val[l.var() as usize].is_none() {
1048                            val[l.var() as usize] = Some(l.is_positive());
1049                            progress = true;
1050                        }
1051                    }
1052                }
1053            }
1054            Some(val)
1055        }
1056
1057        for name in ["par16-1-c", "par32-1-c"] {
1058            let path = format!("../../benchmarks/arena/instances/sat/crafted_parity/{name}.cnf");
1059            let Ok(text) = std::fs::read_to_string(&path) else {
1060                eprintln!("{name}: missing");
1061                continue;
1062            };
1063            let cnf = crate::dimacs::parse(&text).expect("valid dimacs");
1064            let eqs = crate::lyapunov::extract_xor(cnf.num_vars, &cnf.clauses);
1065            let base = IncXor::new(cnf.num_vars, &eqs);
1066            let kernel = base.decision_vars();
1067            let kernel0 = base.kernel_dim();
1068
1069            let mut folded = eqs.clone();
1070            let (mut failed, mut backbone, mut equiv) = (0usize, 0usize, 0usize);
1071            for &x in &kernel {
1072                let f1 = probe(cnf.num_vars, &cnf.clauses, &eqs, Lit::new(x as u32, true));
1073                let f0 = probe(cnf.num_vars, &cnf.clauses, &eqs, Lit::new(x as u32, false));
1074                match (&f1, &f0) {
1075                    (None, None) => {} // both polarities conflict ⇒ the whole instance is UNSAT-under-XOR
1076                    (None, Some(_)) => {
1077                        failed += 1;
1078                        folded.push(XorEquation::new(vec![x], false));
1079                    }
1080                    (Some(_), None) => {
1081                        failed += 1;
1082                        folded.push(XorEquation::new(vec![x], true));
1083                    }
1084                    (Some(a1), Some(a0)) => {
1085                        for y in 0..cnf.num_vars {
1086                            if y == x {
1087                                continue;
1088                            }
1089                            if let (Some(v1), Some(v0)) = (a1[y], a0[y]) {
1090                                if v1 == v0 {
1091                                    backbone += 1;
1092                                    folded.push(XorEquation::new(vec![y], v1));
1093                                } else {
1094                                    equiv += 1;
1095                                    folded.push(XorEquation::new(vec![x, y], v0)); // x=F⇒y=v0 ⇒ x⊕y=v0
1096                                }
1097                            }
1098                        }
1099                    }
1100                }
1101            }
1102            let after = IncXor::new(cnf.num_vars, &folded);
1103            eprintln!(
1104                "{name}: kernel {} → {} | probes found failed_lits={} backbone_hits={} equiv_pairs={} (raw counts, pre-dedup)",
1105                kernel0,
1106                after.kernel_dim(),
1107                failed,
1108                backbone,
1109                equiv,
1110            );
1111        }
1112    }
1113
1114    #[test]
1115    fn detects_a_linear_contradiction() {
1116        // x0⊕x1=0, x1⊕x2=0, x0⊕x2=1 sum to 0=1: UNSAT with no assignment. The certificate is the
1117        // empty clause (an unconditional contradiction), which is correct.
1118        let eqs = vec![
1119            XorEquation::new(vec![0, 1], false),
1120            XorEquation::new(vec![1, 2], false),
1121            XorEquation::new(vec![0, 2], true),
1122        ];
1123        let eng = XorEngine::new(3, &eqs);
1124        match eng.analyze(&vec![None, None, None]) {
1125            XorStep::Conflict(_) => {}
1126            other => panic!("expected conflict, got {other:?}"),
1127        }
1128    }
1129
1130    #[test]
1131    fn next_branch_picks_a_free_kernel_var_and_breaking_it_collapses_the_rest() {
1132        // x0⊕x1=0, x2⊕x3=0: pivots x0,x2; free (kernel) x1,x3. The engine must offer a free var as
1133        // the break point, and breaking it forces its pivot partner (the collapse).
1134        let eqs = vec![XorEquation::new(vec![0, 1], false), XorEquation::new(vec![2, 3], false)];
1135        let mut inc = IncXor::new(4, &eqs);
1136        assert_eq!(inc.rank(), 2);
1137        assert_eq!(inc.kernel_dim(), 2);
1138        let b = inc.next_branch().expect("a free kernel var to break on");
1139        assert!(b == 1 || b == 3, "break point must be a non-pivot free var, got {b}");
1140        inc.assign(b, true);
1141        let forced = inc.state().expect("consistent");
1142        assert!(!forced.is_empty(), "breaking a kernel var must force its pivot partner");
1143    }
1144
1145    #[test]
1146    fn incremental_engine_matches_the_recompute_oracle_under_random_trails() {
1147        // The fast IncXor, driven by random assign/unassign (push/pop) sequences, must report the
1148        // SAME forced-literal set and conflict status as the recompute XorEngine at EVERY step.
1149        let mut rng: u64 = 0xD1B5_4A32_D192_ED03;
1150        let mut next = || {
1151            rng ^= rng << 13;
1152            rng ^= rng >> 7;
1153            rng ^= rng << 17;
1154            rng
1155        };
1156        let forced_set = |r: &Result<Vec<(Lit, Vec<Lit>)>, Vec<Lit>>| -> Option<std::collections::BTreeSet<(u32, bool)>> {
1157            match r {
1158                Err(_) => None, // conflict
1159                Ok(f) => Some(f.iter().map(|(l, _)| (l.var(), l.is_positive())).collect()),
1160            }
1161        };
1162        for _ in 0..2000 {
1163            let num_vars = 3 + (next() % 6) as usize; // 3..=8
1164            let n_eqs = 1 + (next() % 6) as usize;
1165            let mut eqs = Vec::new();
1166            for _ in 0..n_eqs {
1167                let vars: Vec<usize> = (0..num_vars).filter(|_| next() % 2 == 0).collect();
1168                if !vars.is_empty() {
1169                    eqs.push(XorEquation::new(vars, next() % 2 == 0));
1170                }
1171            }
1172            if eqs.is_empty() {
1173                continue;
1174            }
1175            let oracle = XorEngine::new(num_vars, &eqs);
1176            let mut inc = IncXor::new(num_vars, &eqs);
1177            let mut assign: Vec<Option<bool>> = vec![None; num_vars];
1178            let mut stack: Vec<usize> = Vec::new();
1179
1180            for _step in 0..40 {
1181                let free: Vec<usize> = (0..num_vars).filter(|&v| assign[v].is_none()).collect();
1182                let push = !free.is_empty() && (stack.is_empty() || next() % 2 == 0);
1183                if push {
1184                    let v = free[(next() % free.len() as u64) as usize];
1185                    let val = next() % 2 == 0;
1186                    inc.assign(v, val);
1187                    assign[v] = Some(val);
1188                    stack.push(v);
1189                } else if let Some(v) = stack.pop() {
1190                    inc.unassign();
1191                    assign[v] = None;
1192                }
1193                inc.check_occ();
1194
1195                let got = inc.state();
1196                let truth = oracle.all_consequences(&assign);
1197                // conflict status must match
1198                assert_eq!(got.is_err(), truth.is_err(),
1199                    "conflict mismatch: inc={:?} oracle_err={} (eqs={eqs:?} assign={assign:?})",
1200                    got.is_err(), truth.is_err());
1201                if got.is_ok() {
1202                    assert_eq!(forced_set(&got), forced_set(&truth),
1203                        "forced-set mismatch (eqs={eqs:?} assign={assign:?})");
1204                    // every incremental reason must be unit on its literal (implied shape).
1205                    if let Ok(f) = &got {
1206                        for (lit, reason) in f {
1207                            let un: Vec<_> = reason.iter().filter(|l| assign[l.var() as usize].is_none()).collect();
1208                            assert_eq!(un.len(), 1, "reason must be unit");
1209                            assert_eq!(un[0].var(), lit.var());
1210                        }
1211                    }
1212                }
1213            }
1214        }
1215    }
1216
1217    #[test]
1218    fn matches_the_brute_force_oracle_exhaustively() {
1219        // Robustness to absurdity: random small GF(2) systems × random partial assignments, the
1220        // engine's forced-set and consistency must match the brute-force oracle EXACTLY, and every
1221        // emitted clause must be implied (true in all solutions) and correctly unit/falsified.
1222        let mut rng: u64 = 0x9E3779B97F4A7C15;
1223        let mut next = || {
1224            rng ^= rng << 13;
1225            rng ^= rng >> 7;
1226            rng ^= rng << 17;
1227            rng
1228        };
1229        for _ in 0..3000 {
1230            let num_vars = 3 + (next() % 5) as usize; // 3..=7
1231            let n_eqs = 1 + (next() % 6) as usize;
1232            let mut eqs = Vec::new();
1233            for _ in 0..n_eqs {
1234                let vars: Vec<usize> = (0..num_vars).filter(|_| next() % 2 == 0).collect();
1235                if vars.is_empty() {
1236                    continue;
1237                }
1238                eqs.push(XorEquation::new(vars, next() % 2 == 0));
1239            }
1240            if eqs.is_empty() {
1241                continue;
1242            }
1243            // random partial assignment
1244            let assign: Vec<Option<bool>> =
1245                (0..num_vars).map(|_| match next() % 3 { 0 => Some(true), 1 => Some(false), _ => None }).collect();
1246
1247            let eng = XorEngine::new(num_vars, &eqs);
1248            let got = eng.all_consequences(&assign);
1249            let truth = oracle(num_vars, &eqs, &assign);
1250
1251            match (got, truth) {
1252                (Err(conflict), None) => {
1253                    // A genuine conflict: every literal is false under the current assignment...
1254                    for l in &conflict {
1255                        assert_eq!(assign[l.var() as usize], Some(!l.is_positive()),
1256                            "conflict literal must be false under the assignment");
1257                    }
1258                    // ...and the clause is IMPLIED — true in every full solution of the system.
1259                    for mask in 0u32..(1u32 << num_vars) {
1260                        let full: Vec<bool> = (0..num_vars).map(|v| (mask >> v) & 1 == 1).collect();
1261                        if eqs.iter().all(|e| e.vars.iter().fold(false, |a, &x| a ^ full[x]) == e.rhs) {
1262                            assert!(lit_sat(&conflict, &full), "conflict clause must be implied");
1263                        }
1264                    }
1265                }
1266                (Ok(forced), Some(truth_forced)) => {
1267                    // Every engine-forced var must agree with the oracle.
1268                    for (lit, reason) in &forced {
1269                        let v = lit.var() as usize;
1270                        assert_eq!(truth_forced[v], Some(lit.is_positive()),
1271                            "engine forced x{v}={} but oracle disagrees (eqs={eqs:?}, assign={assign:?})", lit.is_positive());
1272                        // reason must be unit: exactly one unassigned literal, and it is `lit`.
1273                        let un: Vec<_> = reason.iter().filter(|l| assign[l.var() as usize].is_none()).collect();
1274                        assert_eq!(un.len(), 1, "reason must be unit");
1275                        assert_eq!(un[0].var(), lit.var());
1276                        // reason must be IMPLIED: true in every consistent completion.
1277                        for mask in 0u32..(1u32 << num_vars) {
1278                            let full: Vec<bool> = (0..num_vars).map(|v| (mask >> v) & 1 == 1).collect();
1279                            let sat_all = eqs.iter().all(|e| e.vars.iter().fold(false, |a, &x| a ^ full[x]) == e.rhs);
1280                            if sat_all {
1281                                assert!(lit_sat(reason, &full), "reason clause must hold in every solution");
1282                            }
1283                        }
1284                    }
1285                    // Completeness: every oracle-forced unassigned var must be found by the engine.
1286                    let engine_vars: std::collections::HashSet<usize> =
1287                        forced.iter().map(|(l, _)| l.var() as usize).collect();
1288                    for v in 0..num_vars {
1289                        if assign[v].is_none() && truth_forced[v].is_some() {
1290                            assert!(engine_vars.contains(&v),
1291                                "engine missed forced x{v} (eqs={eqs:?}, assign={assign:?})");
1292                        }
1293                    }
1294                }
1295                (Ok(forced), None) => panic!("engine missed a contradiction: forced={forced:?} eqs={eqs:?} assign={assign:?}"),
1296                (Err(c), Some(_)) => panic!("engine reported a false conflict {c:?}: eqs={eqs:?} assign={assign:?}"),
1297            }
1298        }
1299    }
1300}