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;