1use crate::cdcl::{Lit, SolveResult, Solver};
24use crate::complexity::RankedRefutation;
25use crate::proof::{Perm, ProofStep, Witness};
26use crate::symmetry_detect::{perm_is_automorphism, AutomorphismIndex};
27use crate::xorsat::XorEquation;
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub struct LyapunovCertificate {
51 pub initial: u64,
53 pub minimum: u64,
55 pub levels: u64,
57 pub max_dissipation: u64,
59 pub size_bound: u64,
61 pub total_steps: u64,
63 pub monotone: bool,
65 pub strict_descent: bool,
67 pub reaches_goal: bool,
69}
70
71pub fn verify_lyapunov(potential: &[u64], reaches_goal: bool) -> Option<LyapunovCertificate> {
77 if potential.is_empty() {
78 return None;
79 }
80 let monotone = potential.windows(2).all(|w| w[1] <= w[0]);
82 if !monotone {
83 return None;
84 }
85 let mut levels_seq: Vec<u64> = potential.to_vec();
88 levels_seq.dedup();
89 let strict_descent = levels_seq.windows(2).all(|w| w[1] < w[0]);
90 if !strict_descent {
91 return None;
92 }
93 if !reaches_goal {
94 return None;
95 }
96 let mut counts: std::collections::BTreeMap<u64, u64> = std::collections::BTreeMap::new();
98 for &p in potential {
99 *counts.entry(p).or_insert(0) += 1;
100 }
101 let levels = counts.len() as u64;
102 let max_dissipation = counts.values().copied().max().unwrap_or(0);
103 Some(LyapunovCertificate {
104 initial: potential[0],
105 minimum: *potential.last().unwrap(),
106 levels,
107 max_dissipation,
108 size_bound: levels * max_dissipation,
109 total_steps: potential.len() as u64,
110 monotone,
111 strict_descent,
112 reaches_goal,
113 })
114}
115
116pub fn lyapunov_of_symmetry(ranked: &RankedRefutation) -> Option<LyapunovCertificate> {
119 verify_lyapunov(&ranked.ranks, ranked.refuted)
120}
121
122pub fn gaussian_lyapunov(equations: &[XorEquation], num_vars: usize) -> (Vec<u64>, bool) {
132 let words = (num_vars + 1 + 63) / 64;
134 let bit = |row: &mut [u64], i: usize| row[i / 64] ^= 1u64 << (i % 64);
135 let get = |row: &[u64], i: usize| (row[i / 64] >> (i % 64)) & 1 == 1;
136 let mut rows: Vec<Vec<u64>> = equations
137 .iter()
138 .map(|eq| {
139 let mut r = vec![0u64; words];
140 for &v in &eq.vars {
141 if v < num_vars {
142 bit(&mut r, v);
143 }
144 }
145 if eq.rhs {
146 bit(&mut r, num_vars); }
148 r
149 })
150 .collect();
151
152 let mut trajectory: Vec<u64> = Vec::new();
154 let mut remaining = num_vars as u64;
155 let mut used = vec![false; rows.len()];
156
157 for col in 0..num_vars {
158 let pivot = (0..rows.len()).find(|&r| !used[r] && get(&rows[r], col));
161 if let Some(pr) = pivot {
162 used[pr] = true;
163 let pivot_row = rows[pr].clone();
164 for r in 0..rows.len() {
165 if r != pr && get(&rows[r], col) {
166 for w in 0..words {
167 rows[r][w] ^= pivot_row[w];
168 }
169 }
170 }
171 remaining -= 1;
172 trajectory.push(remaining);
173 }
174 }
175 let reached_goal = rows.iter().any(|r| (0..num_vars).all(|v| !get(r, v)) && get(r, num_vars));
179 if reached_goal {
180 trajectory.push(0);
181 }
182 if trajectory.is_empty() {
183 trajectory.push(remaining);
184 }
185 (trajectory, reached_goal)
186}
187
188#[derive(Clone, Copy, Debug, PartialEq, Eq)]
192pub struct CollapsingMeasure {
193 pub items: usize,
194 pub bins: usize,
195}
196
197fn swap_items(num_vars: usize, bins: usize, a: usize, b: usize) -> Perm {
200 Perm::from_images(
201 (0..num_vars)
202 .map(|idx| {
203 let (item, bin) = (idx / bins, idx % bins);
204 let ni = if item == a {
205 b
206 } else if item == b {
207 a
208 } else {
209 item
210 };
211 Lit::pos((ni * bins + bin) as u32)
212 })
213 .collect(),
214 )
215}
216
217pub fn covering_collapse(num_vars: usize, formula: &[Vec<Lit>], items: usize, bins: usize) -> RankedRefutation {
222 let mut db = formula.to_vec();
223 let mut index = AutomorphismIndex::with_clauses(num_vars, formula);
224 let mut steps: Vec<ProofStep> = Vec::new();
225 let mut ranks: Vec<u64> = Vec::new();
226 let var = |i: usize, b: usize| (i * bins + b) as u32;
227 let active = bins + 1;
228
229 if active <= items {
230 for m in (2..=active).rev() {
231 let bin = m - 2;
232 let last = m - 1;
233 for i in 0..last {
234 let clause = vec![Lit::neg(var(i, bin))];
235 let witness = Witness::Substitution(swap_items(num_vars, bins, i, last));
236 if crate::pr::is_pr_indexed(num_vars, &db, &mut index, &clause, &witness) {
237 db.push(clause.clone());
238 index.insert(clause.clone());
239 steps.push(ProofStep::Pr { clause, witness });
240 ranks.push(m as u64);
241 }
242 }
243 }
244 }
245
246 let mut solver = Solver::new(num_vars);
247 for c in &db {
248 solver.add_clause(c.clone());
249 }
250 let refuted = match solver.solve() {
251 SolveResult::Sat(_) => false,
252 SolveResult::Unsat => {
253 for lc in solver.learned() {
254 steps.push(ProofStep::Rup(lc.lits.clone()));
255 ranks.push(1);
256 }
257 crate::pr::check_pr_refutation_fast(num_vars, formula, &steps)
258 }
259 };
260
261 RankedRefutation { refuted, steps, ranks }
262}
263
264pub fn synthesize_measure(num_vars: usize, formula: &[Vec<Lit>]) -> Option<CollapsingMeasure> {
268 for bins in 1..num_vars {
269 if num_vars % bins != 0 {
270 continue;
271 }
272 let items = num_vars / bins;
273 if items <= bins {
274 continue; }
276 if perm_is_automorphism(formula, &swap_items(num_vars, bins, 0, 1)) {
277 return Some(CollapsingMeasure { items, bins });
278 }
279 }
280 None
281}
282
283pub fn solve_by_measure_synthesis(
288 num_vars: usize,
289 formula: &[Vec<Lit>],
290) -> Option<(CollapsingMeasure, RankedRefutation)> {
291 for bins in 1..num_vars {
292 if num_vars % bins != 0 {
293 continue;
294 }
295 let items = num_vars / bins;
296 if items <= bins {
297 continue;
298 }
299 if !perm_is_automorphism(formula, &swap_items(num_vars, bins, 0, 1)) {
300 continue;
301 }
302 let ranked = covering_collapse(num_vars, formula, items, bins);
303 if ranked.refuted {
304 return Some((CollapsingMeasure { items, bins }, ranked));
305 }
306 }
307 None
308}
309
310use crate::cdcl::Lit as Lit_;
339
340pub trait LyapunovMeasure {
343 fn num_vars(&self) -> usize;
345 fn formula(&self) -> &[Vec<Lit_>];
347 fn initial_potential(&self) -> u64;
349 fn width(&self) -> u64;
351 fn descent_step(&self, level: u64, db: &[Vec<Lit_>]) -> Vec<(Vec<Lit_>, Witness)>;
355}
356
357pub fn proof_from_measure<M: LyapunovMeasure>(measure: &M) -> RankedRefutation {
362 let nv = measure.num_vars();
363 let formula = measure.formula();
364 let mut db: Vec<Vec<Lit_>> = formula.to_vec();
365 let mut index = AutomorphismIndex::with_clauses(nv, formula);
366 let mut steps: Vec<ProofStep> = Vec::new();
367 let mut ranks: Vec<u64> = Vec::new();
368
369 let l = measure.initial_potential();
370 for level in (1..=l).rev() {
371 for (clause, witness) in measure.descent_step(level, &db) {
372 if crate::pr::is_pr_indexed(nv, &db, &mut index, &clause, &witness) {
373 db.push(clause.clone());
374 index.insert(clause.clone());
375 steps.push(ProofStep::Pr { clause, witness });
376 ranks.push(level);
377 }
378 }
379 }
380
381 let mut solver = Solver::new(nv);
382 for c in &db {
383 solver.add_clause(c.clone());
384 }
385 let refuted = match solver.solve() {
386 SolveResult::Sat(_) => false,
387 SolveResult::Unsat => {
388 for lc in solver.learned() {
389 steps.push(ProofStep::Rup(lc.lits.clone()));
390 ranks.push(0);
391 }
392 crate::pr::check_pr_refutation_fast(nv, formula, &steps)
393 }
394 };
395 RankedRefutation { refuted, steps, ranks }
396}
397
398pub fn proof_induced_measure(n_steps: usize) -> Vec<u64> {
409 if n_steps == 0 {
410 return vec![0];
411 }
412 (0..n_steps).map(|i| (n_steps - i) as u64).collect() }
414
415#[derive(Clone)]
420pub struct CoveringMeasure {
421 pub num_vars: usize,
422 pub formula: Vec<Vec<Lit_>>,
423 pub items: usize,
424 pub bins: usize,
425}
426
427impl LyapunovMeasure for CoveringMeasure {
428 fn num_vars(&self) -> usize {
429 self.num_vars
430 }
431 fn formula(&self) -> &[Vec<Lit_>] {
432 &self.formula
433 }
434 fn initial_potential(&self) -> u64 {
435 (self.bins + 1).min(self.items) as u64 }
437 fn width(&self) -> u64 {
438 self.items as u64 }
440 fn descent_step(&self, level: u64, _db: &[Vec<Lit_>]) -> Vec<(Vec<Lit_>, Witness)> {
441 let m = level as usize;
442 if m < 2 {
443 return Vec::new();
444 }
445 let bin = m - 2;
446 let last = m - 1;
447 (0..last)
448 .map(|i| {
449 let clause = vec![Lit_::neg((i * self.bins + bin) as u32)];
450 let witness = Witness::Substitution(swap_items(self.num_vars, self.bins, i, last));
451 (clause, witness)
452 })
453 .collect()
454 }
455}
456
457pub fn cutting_planes_lyapunov(n: usize) -> (Vec<u64>, bool) {
468 use crate::pseudo_boolean::PbConstraint;
469 if n < 2 {
470 return (vec![0], true);
471 }
472 let holes = n - 1;
473 let var = |i: usize, h: usize| i * holes + h;
474 let mut constraints: Vec<PbConstraint> = Vec::new();
475 for i in 0..n {
476 let lits: Vec<(usize, bool)> = (0..holes).map(|h| (var(i, h), true)).collect();
477 constraints.push(PbConstraint::clause(&lits)); }
479 for h in 0..holes {
480 let lits: Vec<(usize, bool)> = (0..n).map(|i| (var(i, h), true)).collect();
481 constraints.push(PbConstraint::at_most(&lits, 1)); }
483 let total = constraints.len() as u64;
484 let mut acc: Option<PbConstraint> = None;
485 let mut trajectory: Vec<u64> = Vec::new();
486 for (k, c) in constraints.into_iter().enumerate() {
487 acc = Some(match acc.take() {
488 None => c,
489 Some(a) => a.add(&c),
490 });
491 trajectory.push(total - 1 - k as u64); }
493 let reached_goal = acc.map_or(false, |a| a.is_contradiction());
494 (trajectory, reached_goal)
495}
496
497#[derive(Clone)]
501pub struct PartialCoveringMeasure {
502 pub base: CoveringMeasure,
503 pub lo: usize,
504 pub hi: usize,
505}
506
507impl LyapunovMeasure for PartialCoveringMeasure {
508 fn num_vars(&self) -> usize {
509 self.base.num_vars
510 }
511 fn formula(&self) -> &[Vec<Lit_>] {
512 &self.base.formula
513 }
514 fn initial_potential(&self) -> u64 {
515 (self.hi.saturating_sub(self.lo) + 1) as u64
516 }
517 fn width(&self) -> u64 {
518 self.base.width()
519 }
520 fn descent_step(&self, local_level: u64, db: &[Vec<Lit_>]) -> Vec<(Vec<Lit_>, Witness)> {
521 let abs = self.lo + (local_level as usize).saturating_sub(1);
524 if abs < self.lo || abs > self.hi {
525 return Vec::new();
526 }
527 self.base.descent_step(abs as u64, db)
528 }
529}
530
531pub fn compose_collapses(
540 num_vars: usize,
541 formula: &[Vec<Lit_>],
542 stages: &[&dyn LyapunovMeasure],
543) -> RankedRefutation {
544 let mut db: Vec<Vec<Lit_>> = formula.to_vec();
545 let mut index = AutomorphismIndex::with_clauses(num_vars, formula);
546 let mut steps: Vec<ProofStep> = Vec::new();
547 let mut ranks: Vec<u64> = Vec::new();
548 let mut above: u64 = stages.iter().map(|s| s.initial_potential()).sum();
549
550 for stage in stages {
551 let l = stage.initial_potential();
552 above -= l; for level in (1..=l).rev() {
554 for (clause, witness) in stage.descent_step(level, &db) {
555 if crate::pr::is_pr_indexed(num_vars, &db, &mut index, &clause, &witness) {
556 db.push(clause.clone());
557 index.insert(clause.clone());
558 steps.push(ProofStep::Pr { clause, witness });
559 ranks.push(above + level);
560 }
561 }
562 }
563 }
564
565 let mut solver = Solver::new(num_vars);
566 for c in &db {
567 solver.add_clause(c.clone());
568 }
569 let refuted = match solver.solve() {
570 SolveResult::Sat(_) => false,
571 SolveResult::Unsat => {
572 for lc in solver.learned() {
573 steps.push(ProofStep::Rup(lc.lits.clone()));
574 ranks.push(0);
575 }
576 crate::pr::check_pr_refutation_fast(num_vars, formula, &steps)
577 }
578 };
579 RankedRefutation { refuted, steps, ranks }
580}
581
582pub fn extract_xor(num_vars: usize, clauses: &[Vec<Lit_>]) -> Vec<XorEquation> {
592 use std::collections::HashMap;
593 let mut groups: HashMap<Vec<usize>, Vec<u32>> = HashMap::new(); let mut members: HashMap<Vec<usize>, Vec<Vec<u32>>> = HashMap::new(); for c in clauses {
596 let mut vs: Vec<usize> = c.iter().map(|l| l.var() as usize).collect();
597 vs.sort_unstable();
598 vs.dedup();
599 if vs.len() != c.len() || vs.iter().any(|&v| v >= num_vars) {
600 continue; }
602 let neg = c.iter().filter(|l| !l.is_positive()).count() as u32;
603 groups.entry(vs.clone()).or_default().push(neg % 2);
604 let mut sig: Vec<u32> =
606 c.iter().map(|l| l.var() * 2 + u32::from(!l.is_positive())).collect();
607 sig.sort_unstable();
608 members.entry(vs).or_default().push(sig);
609 }
610 let mut eqs = Vec::new();
611 for (vars, parities) in groups {
612 let k = vars.len();
613 if k == 0 || k > 12 {
614 continue;
615 }
616 let expected = 1usize << (k - 1);
617 if parities.len() != expected || !parities.iter().all(|&p| p == parities[0]) {
619 continue;
620 }
621 let mut sigs = members[&vars].clone();
622 sigs.sort();
623 sigs.dedup();
624 if sigs.len() != expected {
625 continue; }
627 let b = 1 - parities[0]; eqs.push(XorEquation::new(vars, b == 1));
629 }
630 eqs
631}
632
633fn discover_covering(
642 num_vars: usize,
643 formula: &[Vec<Lit_>],
644) -> Option<(Vec<Vec<usize>>, Vec<Vec<usize>>)> {
645 let mut rows: Vec<Vec<usize>> = Vec::new();
646 let mut excl: Vec<(usize, usize)> = Vec::new();
647 for c in formula {
648 if c.is_empty() {
649 return None;
650 }
651 if c.iter().all(|l| l.is_positive()) {
652 rows.push(c.iter().map(|l| l.var() as usize).collect()); } else if c.len() == 2 && c.iter().all(|l| !l.is_positive()) {
654 excl.push((c[0].var() as usize, c[1].var() as usize)); } else {
656 return None;
657 }
658 }
659 if rows.is_empty() {
660 return None;
661 }
662 let mut row_of = vec![usize::MAX; num_vars];
664 for (i, r) in rows.iter().enumerate() {
665 for &v in r {
666 if v >= num_vars || row_of[v] != usize::MAX {
667 return None;
668 }
669 row_of[v] = i;
670 }
671 }
672 let mut parent: Vec<usize> = (0..num_vars).collect();
674 let find = |parent: &mut Vec<usize>, mut x: usize| {
675 while parent[x] != x {
676 parent[x] = parent[parent[x]];
677 x = parent[x];
678 }
679 x
680 };
681 let mut excl_set: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
682 for &(a, b) in &excl {
683 if a >= num_vars || b >= num_vars || row_of[a] == usize::MAX || row_of[b] == usize::MAX {
684 return None; }
686 excl_set.insert((a.min(b), a.max(b)));
687 let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
688 if ra != rb {
689 parent[ra] = rb;
690 }
691 }
692 let mut col_id: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
694 let mut columns: Vec<Vec<usize>> = Vec::new();
695 for v in 0..num_vars {
696 if row_of[v] == usize::MAX {
697 continue;
698 }
699 let root = find(&mut parent, v);
700 let id = *col_id.entry(root).or_insert_with(|| {
701 columns.push(Vec::new());
702 columns.len() - 1
703 });
704 columns[id].push(v);
705 }
706 for col in &columns {
709 for i in 0..col.len() {
710 for j in (i + 1)..col.len() {
711 if !excl_set.contains(&(col[i].min(col[j]), col[i].max(col[j]))) {
712 return None;
713 }
714 }
715 }
716 }
717 Some((rows, columns))
718}
719
720pub fn recover_cardinality_constraints(
735 num_vars: usize,
736 formula: &[Vec<Lit_>],
737) -> Option<Vec<crate::pseudo_boolean::PbConstraint>> {
738 use crate::pseudo_boolean::PbConstraint;
739 let (rows, columns) = discover_covering(num_vars, formula)?;
740 let mut constraints: Vec<PbConstraint> = Vec::new();
741 for r in &rows {
742 constraints.push(PbConstraint::clause(&r.iter().map(|&v| (v, true)).collect::<Vec<_>>())); }
744 for col in &columns {
745 constraints.push(PbConstraint::at_most(&col.iter().map(|&v| (v, true)).collect::<Vec<_>>(), 1)); }
747 Some(constraints)
748}
749
750pub fn cardinality_collapse(num_vars: usize, formula: &[Vec<Lit_>]) -> Option<(Vec<u64>, bool, usize)> {
751 use crate::pseudo_boolean::PbConstraint;
752 let constraints = recover_cardinality_constraints(num_vars, formula)?;
753 let total = constraints.len() as u64;
754 let mut acc: Option<PbConstraint> = None;
755 let mut trajectory: Vec<u64> = Vec::new();
756 for (k, c) in constraints.into_iter().enumerate() {
757 acc = Some(match acc.take() {
758 None => c,
759 Some(a) => a.add(&c),
760 });
761 trajectory.push(total - 1 - k as u64);
762 }
763 let reached_goal = acc.map_or(false, |a| a.is_contradiction());
764 Some((trajectory, reached_goal, total as usize))
765}
766
767pub fn recover_at_most_one(num_vars: usize, clauses: &[Vec<Lit_>]) -> Vec<crate::pseudo_boolean::PbConstraint> {
774 use crate::pseudo_boolean::PbConstraint;
775 use std::collections::HashSet;
776 let mut adj: Vec<HashSet<usize>> = vec![HashSet::new(); num_vars];
777 for c in clauses {
778 if c.len() == 2 && c.iter().all(|l| !l.is_positive()) {
779 let (a, b) = (c[0].var() as usize, c[1].var() as usize);
780 if a != b && a < num_vars && b < num_vars {
781 adj[a].insert(b);
782 adj[b].insert(a);
783 }
784 }
785 }
786 let mut order: Vec<usize> = (0..num_vars).filter(|&v| !adj[v].is_empty()).collect();
790 order.sort_by_key(|&v| std::cmp::Reverse(adj[v].len()));
791 let mut covered: HashSet<(usize, usize)> = HashSet::new();
792 let mut out: Vec<PbConstraint> = Vec::new();
793 for &start in &order {
794 let mut clique = vec![start];
795 let mut cand: Vec<usize> = adj[start].iter().copied().collect();
796 cand.sort_by_key(|&v| std::cmp::Reverse(adj[v].len()));
797 for &v in &cand {
798 if clique.iter().all(|&u| adj[v].contains(&u)) {
799 clique.push(v);
800 }
801 }
802 if clique.len() < 2 {
803 continue;
804 }
805 clique.sort_unstable();
806 let mut fresh = false;
807 for i in 0..clique.len() {
808 for j in (i + 1)..clique.len() {
809 if covered.insert((clique[i], clique[j])) {
810 fresh = true;
811 }
812 }
813 }
814 if fresh {
815 out.push(PbConstraint::at_most(&clique.iter().map(|&v| (v, true)).collect::<Vec<_>>(), 1));
816 }
817 }
818 out
819}
820
821fn for_each_combo<F: FnMut(&[usize]) -> bool>(items: &[usize], width: usize, start: usize, cur: &mut Vec<usize>, f: &mut F) -> bool {
826 if cur.len() == width {
827 return f(cur);
828 }
829 for i in start..items.len() {
830 cur.push(items[i]);
831 let cont = for_each_combo(items, width, i + 1, cur, f);
832 cur.pop();
833 if !cont {
834 return false;
835 }
836 }
837 true
838}
839
840pub fn recover_at_most_k(num_vars: usize, clauses: &[Vec<Lit_>], k: usize) -> Vec<crate::pseudo_boolean::PbConstraint> {
852 use crate::pseudo_boolean::PbConstraint;
853 use std::collections::HashSet;
854 if k == 0 {
855 return Vec::new();
856 }
857 let width = k + 1;
858 let code = |l: &Lit_| (l.var() as usize) * 2 + usize::from(!l.is_positive()); let mut forbidden: HashSet<Vec<usize>> = HashSet::new();
860 let mut cand_set: HashSet<usize> = HashSet::new();
861 for c in clauses {
862 if c.len() != width {
863 continue;
864 }
865 let mut g: Vec<usize> = c.iter().map(|l| code(l) ^ 1).collect(); g.sort_unstable();
867 let distinct_vars = g.windows(2).all(|w| w[0] >> 1 != w[1] >> 1);
868 g.dedup();
869 if g.len() == width && distinct_vars && g.iter().all(|&lc| lc >> 1 < num_vars) {
870 for &lc in &g {
871 cand_set.insert(lc);
872 }
873 forbidden.insert(g);
874 }
875 }
876 if forbidden.is_empty() {
877 return Vec::new();
878 }
879 let mut cand: Vec<usize> = cand_set.iter().copied().collect();
880 cand.sort_unstable();
881 let mut seeds: Vec<Vec<usize>> = forbidden.iter().cloned().collect();
882 seeds.sort();
883 let mut covered: HashSet<Vec<usize>> = HashSet::new();
884 let mut out: Vec<PbConstraint> = Vec::new();
885 let mut budget: usize = 1_000_000;
889 const MAX_GROUP: usize = 96;
890 for seed in seeds {
891 if budget == 0 {
892 break;
893 }
894 if covered.contains(&seed) {
895 continue; }
897 let mut group = seed.clone();
898 let mut group_vars: HashSet<usize> = group.iter().map(|&lc| lc >> 1).collect();
899 for &v in &cand {
900 if group_vars.contains(&(v >> 1)) || group.len() >= MAX_GROUP {
901 continue; }
903 let mut starved = false;
905 let ok = for_each_combo(&group, k, 0, &mut Vec::with_capacity(k), &mut |sub| {
906 if budget == 0 {
907 starved = true;
908 return false;
909 }
910 budget -= 1;
911 let mut s = sub.to_vec();
912 s.push(v);
913 s.sort_unstable();
914 forbidden.contains(&s)
915 });
916 if starved {
917 break;
918 }
919 if ok {
920 group.push(v);
921 group.sort_unstable();
922 group_vars.insert(v >> 1);
923 }
924 }
925 let mut fresh = false;
926 for_each_combo(&group, width, 0, &mut Vec::with_capacity(width), &mut |sub| {
927 if budget == 0 {
928 return false;
929 }
930 budget -= 1;
931 if covered.insert(sub.to_vec()) {
932 fresh = true;
933 }
934 true
935 });
936 if fresh {
937 let lits: Vec<(usize, bool)> = group.iter().map(|&lc| (lc >> 1, lc & 1 == 0)).collect();
938 out.push(PbConstraint::at_most(&lits, k as i64));
939 }
940 }
941 out
942}
943
944pub const MAX_RECOVERED_CARDINALITY: usize = 4;
948
949pub fn recover_cardinality_substructure(num_vars: usize, clauses: &[Vec<Lit_>]) -> Vec<crate::pseudo_boolean::PbConstraint> {
955 let mut out = Vec::new();
956 for k in 1..=MAX_RECOVERED_CARDINALITY {
957 out.extend(recover_at_most_k(num_vars, clauses, k));
958 }
959 out
960}
961
962pub fn fused_parity_cardinality_decide(num_vars: usize, clauses: &[Vec<Lit_>]) -> Option<bool> {
972 if num_vars == 0 {
973 return None;
974 }
975 let eqs = extract_xor(num_vars, clauses);
976 let amo = recover_cardinality_substructure(num_vars, clauses);
977 if eqs.is_empty() || amo.is_empty() {
978 return None;
979 }
980 let mut s = Solver::new(num_vars);
984 for c in clauses {
985 s.add_clause(c.clone());
986 }
987 let mut theories: Vec<Box<dyn crate::cdcl::Theory>> = vec![
988 Box::new(crate::xor_engine::XorEngine::new(num_vars, &eqs)),
989 Box::new(crate::pseudo_boolean::CardinalityTheory::new(num_vars, &amo)),
990 Box::new(SymmetryTheory::new(num_vars, fused_symmetry_group(num_vars, clauses))),
991 ];
992 match s.solve_with(&mut theories) {
993 SolveResult::Sat(m) => clauses
994 .iter()
995 .all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive()))
996 .then_some(true),
997 SolveResult::Unsat => Some(false),
998 }
999}
1000
1001struct SemanticSymmetry {
1008 words: usize,
1009 rhs_bit: usize,
1010 basis: Vec<Vec<u64>>,
1011 pivots: Vec<usize>,
1012 eqs: Vec<(Vec<usize>, bool)>,
1013 non_parity: Vec<Vec<Lit_>>,
1014}
1015
1016fn gf2_row(words: usize, rhs_bit: usize, vars: &[usize], rhs: bool, perm: Option<&[usize]>) -> Vec<u64> {
1017 let mut b = vec![0u64; words];
1018 for &v in vars {
1019 let idx = perm.map(|p| p[v]).unwrap_or(v);
1020 b[idx / 64] ^= 1 << (idx % 64);
1021 }
1022 if rhs {
1023 b[rhs_bit / 64] ^= 1 << (rhs_bit % 64);
1024 }
1025 b
1026}
1027fn gf2_reduce(v: &mut [u64], basis: &[Vec<u64>], pivots: &[usize]) {
1028 for (row, &piv) in basis.iter().zip(pivots) {
1029 if (v[piv / 64] >> (piv % 64)) & 1 == 1 {
1030 for w in 0..v.len() {
1031 v[w] ^= row[w];
1032 }
1033 }
1034 }
1035}
1036fn gf2_lowest(v: &[u64]) -> Option<usize> {
1037 v.iter().enumerate().find_map(|(w, &word)| (word != 0).then(|| w * 64 + word.trailing_zeros() as usize))
1038}
1039
1040impl SemanticSymmetry {
1041 fn new(num_vars: usize, clauses: &[Vec<Lit_>]) -> Self {
1042 let eqs_raw = extract_xor(num_vars, clauses);
1043 let rhs_bit = num_vars;
1044 let words = num_vars.div_ceil(64) + 1;
1045 let eqs: Vec<(Vec<usize>, bool)> = eqs_raw.iter().map(|e| (e.vars.clone(), e.rhs)).collect();
1046 let mut xor_sets: std::collections::HashSet<Vec<usize>> = std::collections::HashSet::new();
1047 for (vars, _) in &eqs {
1048 let mut v = vars.clone();
1049 v.sort_unstable();
1050 v.dedup();
1051 xor_sets.insert(v);
1052 }
1053 let non_parity: Vec<Vec<Lit_>> = clauses
1054 .iter()
1055 .filter(|c| {
1056 let mut vs: Vec<usize> = c.iter().map(|l| l.var() as usize).collect();
1057 vs.sort_unstable();
1058 vs.dedup();
1059 !xor_sets.contains(&vs)
1060 })
1061 .cloned()
1062 .collect();
1063 let mut basis: Vec<Vec<u64>> = Vec::new();
1064 let mut pivots: Vec<usize> = Vec::new();
1065 for (vars, rhs) in &eqs {
1066 let mut v = gf2_row(words, rhs_bit, vars, *rhs, None);
1067 gf2_reduce(&mut v, &basis, &pivots);
1068 if let Some(p) = gf2_lowest(&v) {
1069 basis.push(v);
1070 pivots.push(p);
1071 }
1072 }
1073 SemanticSymmetry { words, rhs_bit, basis, pivots, eqs, non_parity }
1074 }
1075
1076 fn is_symmetry(&self, perm: &[usize]) -> bool {
1078 for (vars, rhs) in &self.eqs {
1079 let mut v = gf2_row(self.words, self.rhs_bit, vars, *rhs, Some(perm));
1080 gf2_reduce(&mut v, &self.basis, &self.pivots);
1081 if gf2_lowest(&v).is_some() {
1082 return false; }
1084 }
1085 let sigma = Perm::from_images(perm.iter().map(|&v| Lit_::pos(v as u32)).collect());
1086 perm_is_automorphism(&self.non_parity, &sigma)
1087 }
1088}
1089
1090pub struct CardinalitySeams {
1098 pub joint: Vec<(usize, usize)>,
1099 pub seams: Vec<(usize, usize)>,
1100}
1101
1102pub fn cardinality_parity_seams(num_vars: usize, clauses: &[Vec<Lit_>]) -> CardinalitySeams {
1106 use std::collections::HashSet;
1107 const PAIR_BUDGET: usize = 20_000;
1108 let cons = recover_cardinality_substructure(num_vars, clauses);
1109 let checker = SemanticSymmetry::new(num_vars, clauses);
1110 let mut seen: HashSet<(usize, usize)> = HashSet::new();
1111 let mut pairs: Vec<(usize, usize)> = Vec::new();
1112 for pb in &cons {
1113 let mut vars: Vec<usize> = pb.terms().map(|(v, _, _)| v).collect();
1114 vars.sort_unstable();
1115 vars.dedup();
1116 for i in 0..vars.len() {
1117 for j in (i + 1)..vars.len() {
1118 if seen.insert((vars[i], vars[j])) {
1119 pairs.push((vars[i], vars[j]));
1120 }
1121 }
1122 }
1123 }
1124 pairs.sort_unstable();
1125 let mut joint = Vec::new();
1126 let mut seams = Vec::new();
1127 for &(a, b) in pairs.iter().take(PAIR_BUDGET) {
1128 let mut perm: Vec<usize> = (0..num_vars).collect();
1129 perm.swap(a, b);
1130 if checker.is_symmetry(&perm) {
1131 joint.push((a, b));
1132 } else {
1133 seams.push((a, b));
1134 }
1135 }
1136 CardinalitySeams { joint, seams }
1137}
1138
1139pub fn cardinality_symmetry_break(num_vars: usize, clauses: &[Vec<Lit_>]) -> Vec<Vec<Lit_>> {
1146 use std::collections::BTreeSet;
1147 let joint = cardinality_parity_seams(num_vars, clauses).joint;
1148 if joint.is_empty() {
1149 return Vec::new();
1150 }
1151 let mut parent: Vec<usize> = (0..num_vars).collect();
1152 fn find(parent: &mut [usize], mut x: usize) -> usize {
1153 while parent[x] != x {
1154 parent[x] = parent[parent[x]];
1155 x = parent[x];
1156 }
1157 x
1158 }
1159 for &(a, b) in &joint {
1160 let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
1161 if ra != rb {
1162 parent[ra] = rb;
1163 }
1164 }
1165 let mut members: std::collections::HashMap<usize, BTreeSet<usize>> = std::collections::HashMap::new();
1166 for &(a, b) in &joint {
1167 let r = find(&mut parent, a);
1168 let set = members.entry(r).or_default();
1169 set.insert(a);
1170 set.insert(b);
1171 }
1172 let mut comps: Vec<Vec<usize>> = members.into_values().map(|s| s.into_iter().collect()).collect();
1173 comps.sort();
1174 let mut out = Vec::new();
1175 for vs in comps {
1176 for w in vs.windows(2) {
1177 out.push(vec![Lit_::pos(w[0] as u32), Lit_::neg(w[1] as u32)]); }
1179 }
1180 out
1181}
1182
1183pub fn cardinality_symmetry_generators(num_vars: usize, clauses: &[Vec<Lit_>]) -> Vec<Vec<usize>> {
1193 let seams = cardinality_parity_seams(num_vars, clauses);
1194 let checker = SemanticSymmetry::new(num_vars, clauses);
1197 let is_auto = |perm: &[usize]| -> bool { checker.is_symmetry(perm) };
1198 let mut gens: Vec<Vec<usize>> = Vec::new();
1200 for &(a, b) in &seams.joint {
1201 let mut p: Vec<usize> = (0..num_vars).collect();
1202 p.swap(a, b);
1203 gens.push(p);
1204 }
1205
1206 let mut parent: Vec<usize> = (0..num_vars).collect();
1208 fn find(p: &mut [usize], mut x: usize) -> usize {
1209 while p[x] != x {
1210 p[x] = p[p[x]];
1211 x = p[x];
1212 }
1213 x
1214 }
1215 for &(a, b) in &seams.joint {
1216 let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
1217 if ra != rb {
1218 parent[ra] = rb;
1219 }
1220 }
1221 let mut orbmap: std::collections::HashMap<usize, std::collections::BTreeSet<usize>> = std::collections::HashMap::new();
1222 for &(a, b) in &seams.joint {
1223 let r = find(&mut parent, a);
1224 let set = orbmap.entry(r).or_default();
1225 set.insert(a);
1226 set.insert(b);
1227 }
1228 let mut orbits: Vec<Vec<usize>> = orbmap.into_values().map(|s| s.into_iter().collect()).collect();
1229 orbits.sort();
1230
1231 let mut blocks: Vec<Vec<usize>> = orbits;
1237 let mut budget: usize = 4000;
1238 loop {
1239 let n = blocks.len();
1240 if n < 2 || budget == 0 {
1241 break;
1242 }
1243 let mut bp: Vec<usize> = (0..n).collect();
1244 let mut level_gens: Vec<Vec<usize>> = Vec::new();
1245 'lvl: for i in 0..n {
1246 for j in (i + 1)..n {
1247 if budget == 0 {
1248 break 'lvl;
1249 }
1250 if blocks[i].len() != blocks[j].len() {
1251 continue;
1252 }
1253 let (ri, rj) = (find(&mut bp, i), find(&mut bp, j));
1254 if ri == rj {
1255 continue; }
1257 budget -= 1;
1258 let mut p: Vec<usize> = (0..num_vars).collect();
1259 for (&a, &b) in blocks[i].iter().zip(&blocks[j]) {
1260 p.swap(a, b);
1261 }
1262 if is_auto(&p) {
1263 bp[ri] = rj; level_gens.push(p);
1265 }
1266 }
1267 }
1268 if level_gens.is_empty() {
1269 break; }
1271 gens.extend(level_gens);
1272 let mut classes: std::collections::BTreeMap<usize, Vec<usize>> = std::collections::BTreeMap::new();
1273 for i in 0..n {
1274 classes.entry(find(&mut bp, i)).or_default().push(i);
1275 }
1276 let mut next: Vec<Vec<usize>> = Vec::new();
1277 for (_, idxs) in classes {
1278 let mut mem: Vec<Vec<usize>> = idxs.iter().map(|&i| blocks[i].clone()).collect();
1279 mem.sort_by_key(|b| b[0]);
1280 next.push(mem.into_iter().flatten().collect());
1281 }
1282 next.sort();
1283 blocks = next;
1284 }
1285 gens
1286}
1287
1288pub fn affine_parity_symmetries(num_vars: usize, clauses: &[Vec<Lit_>]) -> Vec<Vec<(Vec<usize>, bool)>> {
1297 if num_vars == 0 || num_vars > 64 {
1298 return Vec::new();
1299 }
1300 let eqs = extract_xor(num_vars, clauses);
1301 if eqs.is_empty() {
1302 return Vec::new();
1303 }
1304 let rows: Vec<u64> = eqs.iter().map(|e| e.vars.iter().fold(0u64, |a, &v| a | (1u64 << v))).collect();
1305 let rhs: Vec<bool> = eqs.iter().map(|e| e.rhs).collect();
1306 let Some(space) = crate::gf2::solve_gf2(num_vars, &rows, &rhs) else {
1307 return Vec::new(); };
1309 let mut xor_sets: std::collections::HashSet<Vec<usize>> = std::collections::HashSet::new();
1310 for e in &eqs {
1311 let mut v = e.vars.clone();
1312 v.sort_unstable();
1313 v.dedup();
1314 xor_sets.insert(v);
1315 }
1316 let mut moved_forbidden = vec![false; num_vars]; for c in clauses {
1318 let mut vs: Vec<usize> = c.iter().map(|l| l.var() as usize).collect();
1319 vs.sort_unstable();
1320 vs.dedup();
1321 if !xor_sets.contains(&vs) {
1322 for &v in &vs {
1323 moved_forbidden[v] = true;
1324 }
1325 }
1326 }
1327 let mut points: Vec<Vec<bool>> = vec![space.particular.clone()];
1328 for k in &space.kernel_basis {
1329 let mut p = space.particular.clone();
1330 for v in 0..num_vars {
1331 p[v] ^= k[v];
1332 }
1333 points.push(p);
1334 }
1335 let satisfies = |x: &[bool]| eqs.iter().all(|e| e.vars.iter().fold(false, |a, &v| a ^ x[v]) == e.rhs);
1336 let make_map = |moved: &[usize], source: Option<usize>| -> Option<Vec<(Vec<usize>, bool)>> {
1340 if moved.is_empty() || moved.iter().any(|&i| moved_forbidden[i]) {
1341 return None;
1342 }
1343 let preserves = points.iter().all(|x| {
1344 let add = source.map_or(true, |j| x[j]);
1345 if !add {
1346 return true;
1347 }
1348 let mut y = x.clone();
1349 for &i in moved {
1350 y[i] ^= true;
1351 }
1352 satisfies(&y)
1353 });
1354 if !preserves {
1355 return None;
1356 }
1357 let mut spec: Vec<(Vec<usize>, bool)> = (0..num_vars).map(|k| (vec![k], false)).collect();
1358 for &i in moved {
1359 spec[i] = match source {
1360 Some(j) => (vec![i, j], false),
1361 None => (vec![i], true),
1362 };
1363 }
1364 Some(spec)
1365 };
1366 let mut maps: Vec<Vec<(Vec<usize>, bool)>> = Vec::new();
1367 for i in 0..num_vars {
1369 for j in 0..num_vars {
1370 if j != i {
1371 if let Some(m) = make_map(&[i], Some(j)) {
1372 maps.push(m);
1373 }
1374 }
1375 }
1376 }
1377 for kappa in kernel_intersect_p(&space.kernel_basis, &moved_forbidden, num_vars) {
1383 let support: Vec<usize> = (0..num_vars).filter(|&i| kappa[i]).collect();
1384 if let Some(m) = make_map(&support, None) {
1385 maps.push(m);
1386 }
1387 for j in 0..num_vars {
1388 if !kappa[j] {
1389 if let Some(m) = make_map(&support, Some(j)) {
1390 maps.push(m);
1391 }
1392 }
1393 }
1394 }
1395 maps.sort();
1396 maps.dedup();
1397 maps
1398}
1399
1400fn kernel_intersect_p(kernel_basis: &[Vec<bool>], moved_forbidden: &[bool], num_vars: usize) -> Vec<Vec<bool>> {
1405 let d = kernel_basis.len();
1406 if d == 0 || d > 64 {
1407 return Vec::new();
1408 }
1409 let forbidden: Vec<usize> = (0..num_vars).filter(|&c| moved_forbidden[c]).collect();
1410 let rows: Vec<u64> =
1411 forbidden.iter().map(|&c| (0..d).fold(0u64, |acc, t| if kernel_basis[t][c] { acc | (1u64 << t) } else { acc })).collect();
1412 let rhs = vec![false; rows.len()];
1413 let Some(null) = crate::gf2::solve_gf2(d, &rows, &rhs) else {
1414 return Vec::new();
1415 };
1416 null.kernel_basis
1417 .iter()
1418 .map(|a| {
1419 let mut kappa = vec![false; num_vars];
1420 for (t, &at) in a.iter().enumerate().take(d) {
1421 if at {
1422 for v in 0..num_vars {
1423 kappa[v] ^= kernel_basis[t][v];
1424 }
1425 }
1426 }
1427 kappa
1428 })
1429 .collect()
1430}
1431
1432pub fn all_transposition_symmetries(num_vars: usize, clauses: &[Vec<Lit_>]) -> Vec<Vec<usize>> {
1438 if num_vars < 2 || num_vars > 64 {
1439 return Vec::new();
1440 }
1441 let checker = SemanticSymmetry::new(num_vars, clauses);
1442 let mut gens: Vec<Vec<usize>> = Vec::new();
1443 const BUDGET: usize = 4096;
1444 let mut checks = 0usize;
1445 'a: for a in 0..num_vars {
1446 for b in (a + 1)..num_vars {
1447 if checks >= BUDGET {
1448 break 'a;
1449 }
1450 checks += 1;
1451 let mut p: Vec<usize> = (0..num_vars).collect();
1452 p.swap(a, b);
1453 if checker.is_symmetry(&p) {
1454 gens.push(p);
1455 }
1456 }
1457 }
1458 gens
1459}
1460
1461#[derive(Clone, PartialEq, Eq, Hash)]
1465struct AffineMap {
1466 rows: Vec<u64>,
1467 trans: u64,
1468}
1469impl AffineMap {
1470 fn identity(n: usize) -> Self {
1471 AffineMap { rows: (0..n).map(|j| 1u64 << j).collect(), trans: 0 }
1472 }
1473 fn from_perm(p: &[usize]) -> Self {
1474 AffineMap { rows: p.iter().map(|&pj| 1u64 << pj).collect(), trans: 0 }
1475 }
1476 fn from_spec(spec: &[(Vec<usize>, bool)], n: usize) -> Self {
1477 let mut rows = vec![0u64; n];
1478 let mut trans = 0u64;
1479 for (j, (xs, b)) in spec.iter().enumerate().take(n) {
1480 rows[j] = xs.iter().fold(0u64, |a, &v| a | (1u64 << v));
1481 if *b {
1482 trans |= 1u64 << j;
1483 }
1484 }
1485 AffineMap { rows, trans }
1486 }
1487 fn is_identity(&self) -> bool {
1488 self.trans == 0 && self.rows.iter().enumerate().all(|(j, &r)| r == (1u64 << j))
1489 }
1490 fn compose(&self, other: &AffineMap) -> AffineMap {
1493 let n = self.rows.len();
1494 let mut rows = vec![0u64; n];
1495 let mut trans = 0u64;
1496 for j in 0..n {
1497 let (mut r, mut t) = (0u64, (self.trans >> j) & 1);
1498 let mut sr = self.rows[j];
1499 while sr != 0 {
1500 let k = sr.trailing_zeros() as usize;
1501 sr &= sr - 1;
1502 r ^= other.rows[k];
1503 t ^= (other.trans >> k) & 1;
1504 }
1505 rows[j] = r;
1506 trans |= t << j;
1507 }
1508 AffineMap { rows, trans }
1509 }
1510 fn to_spec(&self) -> Vec<(Vec<usize>, bool)> {
1511 self.rows
1512 .iter()
1513 .enumerate()
1514 .map(|(j, &r)| ((0..64).filter(|&b| (r >> b) & 1 == 1).collect(), (self.trans >> j) & 1 == 1))
1515 .collect()
1516 }
1517}
1518
1519fn affine_group_closure(gens: &[AffineMap], num_vars: usize, cap: usize) -> Option<Vec<AffineMap>> {
1523 use std::collections::HashSet;
1524 let id = AffineMap::identity(num_vars);
1525 let mut seen: HashSet<AffineMap> = HashSet::from([id.clone()]);
1526 let mut frontier = vec![id];
1527 while let Some(g) = frontier.pop() {
1528 for gen in gens {
1529 let h = g.compose(gen);
1530 if seen.insert(h.clone()) {
1531 if seen.len() > cap {
1532 return None;
1533 }
1534 frontier.push(h);
1535 }
1536 }
1537 }
1538 Some(seen.into_iter().collect())
1539}
1540
1541fn signed_perm_to_spec(p: &crate::proof::Perm, num_vars: usize) -> Vec<(Vec<usize>, bool)> {
1555 let mut spec: Vec<(Vec<usize>, bool)> = (0..num_vars).map(|w| (vec![w], false)).collect();
1556 for v in 0..num_vars {
1557 let img = p.apply(Lit::pos(v as u32));
1558 let w = img.var() as usize;
1559 if w < num_vars {
1560 spec[w] = (vec![v], !img.is_positive());
1561 }
1562 }
1563 spec
1564}
1565
1566pub fn fused_symmetry_group(num_vars: usize, clauses: &[Vec<Lit_>]) -> Vec<Vec<(Vec<usize>, bool)>> {
1567 let perm_gens = fused_permutation_generators(num_vars, clauses);
1568 if num_vars < 1 || num_vars > 64 {
1569 return perm_gens.iter().map(|p| p.iter().map(|&pi| (vec![pi], false)).collect()).collect();
1571 }
1572 let aff_specs = affine_parity_symmetries(num_vars, clauses);
1573 let syntactic = crate::symmetry_detect::find_generators(num_vars, clauses);
1577 let mut gens: Vec<AffineMap> = perm_gens.iter().map(|p| AffineMap::from_perm(p)).collect();
1578 gens.extend(aff_specs.iter().map(|s| AffineMap::from_spec(s, num_vars)));
1579 gens.extend(syntactic.iter().map(|p| AffineMap::from_spec(&signed_perm_to_spec(p, num_vars), num_vars)));
1580 gens.retain(|g| !g.is_identity());
1581 if gens.is_empty() {
1582 return Vec::new();
1583 }
1584 const CAP: usize = 2048;
1585 match affine_group_closure(&gens, num_vars, CAP) {
1586 Some(group) => group.iter().filter(|g| !g.is_identity()).map(|g| g.to_spec()).collect(),
1587 None => gens.iter().map(|g| g.to_spec()).collect(), }
1589}
1590
1591pub fn fused_permutation_generators(num_vars: usize, clauses: &[Vec<Lit_>]) -> Vec<Vec<usize>> {
1594 let mut g = cardinality_symmetry_generators(num_vars, clauses);
1595 g.extend(all_transposition_symmetries(num_vars, clauses));
1596 g
1597}
1598
1599pub struct SymmetryTheory {
1610 num_vars: usize,
1611 maps: Vec<Vec<(Vec<usize>, bool)>>,
1612}
1613impl SymmetryTheory {
1614 pub fn new(num_vars: usize, maps: Vec<Vec<(Vec<usize>, bool)>>) -> Self {
1615 SymmetryTheory { num_vars, maps }
1616 }
1617 pub fn from_perms(num_vars: usize, perms: Vec<Vec<usize>>) -> Self {
1619 let maps = perms.into_iter().map(|p| p.into_iter().map(|pi| (vec![pi], false)).collect()).collect();
1620 SymmetryTheory { num_vars, maps }
1621 }
1622}
1623impl crate::cdcl::Theory for SymmetryTheory {
1624 fn propagate(&mut self, trail: &[crate::cdcl::Lit]) -> Vec<Vec<crate::cdcl::Lit>> {
1625 use crate::cdcl::Lit;
1626 let n = self.num_vars;
1627 let mut a: Vec<Option<bool>> = vec![None; n];
1628 for &l in trail {
1629 let v = l.var() as usize;
1630 if v < n {
1631 a[v] = Some(l.is_positive());
1632 }
1633 }
1634 let support_witness = |xset: &[usize], a: &[Option<bool>]| -> Vec<Lit> {
1636 xset.iter().filter_map(|&s| a[s].map(|sv| Lit::new(s as u32, !sv))).collect()
1637 };
1638 let mut out = Vec::new();
1639 for map in &self.maps {
1640 let mut prefix: Vec<Lit> = Vec::new();
1641 for i in 0..n {
1642 let (xset, b) = &map[i];
1643 if xset.len() == 1 && xset[0] == i && !*b {
1644 continue; }
1646 let mut val = *b;
1648 let mut free: Vec<usize> = Vec::new();
1649 for &s in xset {
1650 match a[s] {
1651 Some(sv) => val ^= sv,
1652 None => free.push(s),
1653 }
1654 }
1655 match (a[i], free.len()) {
1656 (Some(vi), 0) if vi == val => {
1657 prefix.push(Lit::new(i as u32, !vi));
1659 prefix.extend(support_witness(xset, &a));
1660 }
1661 (Some(false), 0) => break, (Some(true), 0) => {
1665 let mut c = prefix.clone();
1666 c.extend(support_witness(xset, &a));
1667 c.push(Lit::new(i as u32, false));
1668 out.push(c);
1669 break;
1670 }
1671 (None, 0) if !val => {
1672 let mut c = prefix.clone();
1673 c.extend(support_witness(xset, &a));
1674 c.push(Lit::new(i as u32, false));
1675 out.push(c);
1676 break;
1677 }
1678 (Some(true), 1) => {
1679 let s = free[0];
1682 let mut c = prefix.clone();
1683 c.push(Lit::new(i as u32, false)); for &o in xset {
1685 if o != s {
1686 c.push(Lit::new(o as u32, !a[o].unwrap()));
1687 }
1688 }
1689 c.push(Lit::new(s as u32, !val)); out.push(c);
1691 break;
1692 }
1693 _ => break, }
1695 }
1696 }
1697 out
1698 }
1699}
1700
1701#[derive(Clone, Debug)]
1704pub enum AutoCollapse {
1705 Geometric { measure: CollapsingMeasure, ranked: RankedRefutation },
1707 Cardinality { trajectory: Vec<u64>, reached_goal: bool, constraints: usize },
1710 Algebraic { trajectory: Vec<u64>, reached_goal: bool, xor_equations: usize },
1712 None,
1715}
1716
1717pub fn auto_collapse(num_vars: usize, formula: &[Vec<Lit_>]) -> AutoCollapse {
1721 if let Some((measure, ranked)) = solve_by_measure_synthesis(num_vars, formula) {
1724 if ranked.refuted {
1725 return AutoCollapse::Geometric { measure, ranked };
1726 }
1727 }
1728 if let Some((trajectory, reached_goal, constraints)) = cardinality_collapse(num_vars, formula) {
1732 if reached_goal {
1733 return AutoCollapse::Cardinality { trajectory, reached_goal, constraints };
1734 }
1735 }
1736 let eqs = extract_xor(num_vars, formula);
1738 if !eqs.is_empty() {
1739 let (trajectory, reached_goal) = gaussian_lyapunov(&eqs, num_vars);
1740 if reached_goal {
1741 return AutoCollapse::Algebraic { trajectory, reached_goal, xor_equations: eqs.len() };
1742 }
1743 }
1744 AutoCollapse::None
1745}
1746
1747#[cfg(test)]
1748mod tests {
1749 use super::*;
1750 use crate::cdcl::Lit;
1751 use crate::families;
1752
1753 #[test]
1754 fn discovers_the_pigeonhole_measure_without_being_told() {
1755 for n in 3..=7 {
1759 let (cnf, _) = families::php(n);
1760 let (measure, ranked) = solve_by_measure_synthesis(cnf.num_vars, &cnf.clauses)
1761 .unwrap_or_else(|| panic!("must synthesize a measure for PHP({n})"));
1762 assert_eq!((measure.items, measure.bins), (n, n - 1), "discovered the pigeonhole shape");
1763 assert!(ranked.refuted, "the collapse must refute");
1764 let bound = ranked
1765 .certify(cnf.num_vars, &cnf.clauses)
1766 .expect("the fallen-out proof certifies correctness AND its own size");
1767 assert!(bound.bound <= (n as u64) * (n as u64), "self-certified O(n²)");
1768 }
1769 }
1770
1771 #[test]
1772 fn discovers_the_clique_coloring_measure() {
1773 for (n, k) in [(5, 4), (7, 6), (9, 8)] {
1775 let (cnf, _) = families::clique_coloring(n, k);
1776 let (measure, ranked) = solve_by_measure_synthesis(cnf.num_vars, &cnf.clauses)
1777 .unwrap_or_else(|| panic!("must synthesize a measure for clique({n},{k})"));
1778 assert_eq!((measure.items, measure.bins), (n, k), "discovered the coloring shape");
1779 assert!(ranked.refuted);
1780 assert!(ranked.certify(cnf.num_vars, &cnf.clauses).is_some());
1781 }
1782 }
1783
1784 #[test]
1785 fn honest_impossibility_when_no_covering_measure_exists() {
1786 let p = |v: u32| Lit::pos(v);
1790 let n = |v: u32| Lit::neg(v);
1791 let f = vec![vec![p(0), p(1)], vec![n(0), p(1)], vec![n(1)]];
1793 assert!(solve_by_measure_synthesis(2, &f).is_none(), "no covering collapse should be claimed");
1794 }
1795
1796 #[test]
1797 fn characterization_measure_cost_equals_proof_size() {
1798 for n in 4..=7 {
1804 let (cnf, _) = families::php(n);
1805 let m = CoveringMeasure {
1806 num_vars: cnf.num_vars,
1807 formula: cnf.clauses.clone(),
1808 items: n,
1809 bins: n - 1,
1810 };
1811 let ranked = proof_from_measure(&m);
1813 assert!(ranked.refuted);
1814 let proof_size = ranked.steps.len();
1815 assert!(proof_size as u64 <= m.initial_potential() * m.width(), "⟸ : proof ≤ L·w");
1816 let induced = proof_induced_measure(proof_size);
1818 let cert = verify_lyapunov(&induced, ranked.refuted).expect("the proof induces a measure");
1819 assert_eq!(cert.total_steps as usize, proof_size, "⟹ : induced measure cost = proof size");
1820 }
1821 }
1822
1823 #[test]
1824 fn no_measure_is_a_checkable_bounded_lower_bound_witness() {
1825 let mut none_count = 0;
1831 for seed in 0u64..16 {
1832 let cnf = families::random_3sat(18, 80, seed); if matches!(auto_collapse(cnf.num_vars, &cnf.clauses), AutoCollapse::None) {
1834 none_count += 1;
1835 }
1836 }
1837 assert!(
1838 none_count >= 13,
1839 "most hard random instances have no measure in our classes (got {none_count}/16)"
1840 );
1841 }
1842
1843 #[test]
1844 fn compose_collapses_wires_stages_into_one_certified_descent() {
1845 for n in [6usize, 7, 8] {
1851 let (cnf, _) = families::php(n);
1852 let base = CoveringMeasure {
1853 num_vars: cnf.num_vars,
1854 formula: cnf.clauses.clone(),
1855 items: n,
1856 bins: n - 1,
1857 };
1858 let mid = n / 2 + 1;
1859 let s1 = PartialCoveringMeasure { base: base.clone(), lo: mid, hi: n };
1860 let s2 = PartialCoveringMeasure { base: base.clone(), lo: 2, hi: mid - 1 };
1861 let composite = compose_collapses(cnf.num_vars, &cnf.clauses, &[&s1, &s2]);
1862 assert!(composite.refuted, "PHP({n}) composite must refute");
1863 assert!(
1864 crate::pr::check_pr_refutation_fast(cnf.num_vars, &cnf.clauses, &composite.steps),
1865 "the composite re-checks against the original formula"
1866 );
1867 let cert = verify_lyapunov(&composite.ranks, composite.refuted)
1868 .expect("the combined banded potential is a valid Lyapunov certificate");
1869 assert!(
1870 cert.monotone && cert.strict_descent && cert.reaches_goal,
1871 "wiring preserves the descent across the stage boundary"
1872 );
1873 let pr_steps =
1874 composite.steps.iter().filter(|s| matches!(s, ProofStep::Pr { .. })).count();
1875 assert!(pr_steps > 0, "both stages contributed certified steps");
1876 }
1877 }
1878
1879 #[test]
1880 fn three_physics_one_checker_and_pigeonhole_has_two_measures() {
1881 let n = 7;
1885
1886 let (php, _) = families::php(n);
1888 let (_, ranked) = solve_by_measure_synthesis(php.num_vars, &php.clauses).unwrap();
1889 let sym = lyapunov_of_symmetry(&ranked).expect("PHP has a symmetry Lyapunov measure");
1890
1891 let (cp_traj, cp_reached) = cutting_planes_lyapunov(n);
1893 let cp = verify_lyapunov(&cp_traj, cp_reached)
1894 .expect("PHP also has a cutting-planes Lyapunov measure");
1895 assert!(sym.total_steps != cp.total_steps || sym.levels != cp.levels, "the two measures differ");
1897 assert!(cp.reaches_goal && cp.strict_descent, "the cutting-planes descent is a valid Lyapunov fn");
1898
1899 let (eqs, tcnf, _) = families::tseitin_expander(10, 7);
1901 let (gx, gr) = gaussian_lyapunov(&eqs, tcnf.num_vars);
1902 assert!(verify_lyapunov(&gx, gr).is_some(), "Tseitin has a parity Lyapunov measure");
1903 }
1904
1905 #[test]
1906 fn unified_agent_routes_a_whole_suite_correctly() {
1907 for n in [4usize, 5, 6, 7] {
1910 let (php, _) = families::php(n);
1911 assert!(
1912 matches!(auto_collapse(php.num_vars, &php.clauses), AutoCollapse::Geometric { .. }),
1913 "PHP({n}) ⇒ geometric"
1914 );
1915 }
1916 for (n, k) in [(5usize, 4usize), (6, 5), (7, 6), (6, 3), (8, 5)] {
1917 let (cq, _) = families::clique_coloring(n, k);
1918 assert!(
1919 matches!(auto_collapse(cq.num_vars, &cq.clauses), AutoCollapse::Geometric { .. }),
1920 "clique({n},{k}) ⇒ geometric"
1921 );
1922 }
1923 for seed in [1u64, 7, 42, 99] {
1924 let (_, ts, _) = families::tseitin_expander(10, seed);
1925 assert!(
1926 matches!(auto_collapse(ts.num_vars, &ts.clauses), AutoCollapse::Algebraic { .. }),
1927 "Tseitin(seed={seed}) ⇒ algebraic"
1928 );
1929 }
1930 }
1931
1932 #[test]
1933 fn extract_xor_recovers_parity_structure_from_cnf_gadgets() {
1934 for seed in [1u64, 7, 42, 100] {
1937 let (_, cnf, _) = families::tseitin_expander(12, seed);
1938 let eqs = extract_xor(cnf.num_vars, &cnf.clauses);
1939 assert!(!eqs.is_empty(), "must recover XOR constraints from the CNF gadgets");
1940 let (_, reached) = gaussian_lyapunov(&eqs, cnf.num_vars);
1941 assert!(reached, "the recovered parity system must expose the contradiction");
1942 }
1943 }
1944
1945 #[test]
1946 fn auto_collapse_recognizes_and_routes_both_physics() {
1947 let (php, _) = families::php(6);
1950 match auto_collapse(php.num_vars, &php.clauses) {
1951 AutoCollapse::Geometric { ranked, measure } => {
1952 assert!(ranked.refuted, "geometric collapse must refute");
1953 assert_eq!((measure.items, measure.bins), (6, 5), "discovered the pigeonhole shape");
1954 }
1955 other => panic!("PHP must route to the geometric collapse, got {other:?}"),
1956 }
1957 let (_, tseitin, _) = families::tseitin_expander(12, 7);
1958 match auto_collapse(tseitin.num_vars, &tseitin.clauses) {
1959 AutoCollapse::Algebraic { reached_goal, xor_equations, .. } => {
1960 assert!(reached_goal, "algebraic collapse must reach the contradiction");
1961 assert!(xor_equations > 0, "must have routed through the recovered parity system");
1962 }
1963 other => panic!("Tseitin must route to the algebraic collapse, got {other:?}"),
1964 }
1965 }
1966
1967 #[test]
1968 fn auto_collapse_is_sound_never_a_false_collapse() {
1969 let (php, _) = families::php(5);
1973 if let AutoCollapse::Geometric { ranked, .. } = auto_collapse(php.num_vars, &php.clauses) {
1974 assert!(crate::pr::check_pr_refutation_fast(php.num_vars, &php.clauses, &ranked.steps));
1975 }
1976 let (_, ts, _) = families::tseitin_expander(10, 42);
1977 if let AutoCollapse::Algebraic { trajectory, reached_goal, .. } =
1978 auto_collapse(ts.num_vars, &ts.clauses)
1979 {
1980 assert!(verify_lyapunov(&trajectory, reached_goal).is_some(), "valid Lyapunov descent");
1981 }
1982 }
1983
1984 #[test]
1985 fn theorem_poly_measure_implies_poly_checkable_proof() {
1986 let cases: Vec<CoveringMeasure> = vec![
1991 {
1993 let (cnf, _) = families::php(7);
1994 CoveringMeasure { num_vars: cnf.num_vars, formula: cnf.clauses, items: 7, bins: 6 }
1995 },
1996 {
1998 let (cnf, _) = families::clique_coloring(8, 7);
1999 CoveringMeasure { num_vars: cnf.num_vars, formula: cnf.clauses, items: 8, bins: 7 }
2000 },
2001 {
2003 let (cnf, _) = families::clique_coloring(9, 4);
2004 CoveringMeasure { num_vars: cnf.num_vars, formula: cnf.clauses, items: 9, bins: 4 }
2005 },
2006 ];
2007 for m in &cases {
2008 let l = m.initial_potential();
2009 let w = m.width();
2010 let ranked = proof_from_measure(m);
2011 assert!(ranked.refuted, "the measure-driven construction must refute");
2013 assert!(
2014 crate::pr::check_pr_refutation_fast(m.num_vars, &m.formula, &ranked.steps),
2015 "the produced proof re-checks against the original formula"
2016 );
2017 let descent_steps =
2019 ranked.steps.iter().filter(|s| matches!(s, ProofStep::Pr { .. })).count() as u64;
2020 assert!(descent_steps <= l * w, "descent {descent_steps} must be ≤ L·w = {}", l * w);
2021 assert!(verify_lyapunov(&ranked.ranks, ranked.refuted).is_some());
2023 }
2024 }
2025
2026 #[test]
2027 fn killer_question_the_measure_transcends_resolution() {
2028 for n in [8usize, 12, 16] {
2034 let (cnf, _) = families::php(n);
2035 let m = CoveringMeasure { num_vars: cnf.num_vars, formula: cnf.clauses, items: n, bins: n - 1 };
2036 let ranked = proof_from_measure(&m);
2037 assert!(ranked.refuted);
2038 let descent =
2039 ranked.steps.iter().filter(|s| matches!(s, ProofStep::Pr { .. })).count();
2040 assert!(descent <= n * n, "PHP({n}) measure proof is ≤ n² = {} (resolution: 2^Ω(n))", n * n);
2042 }
2043 }
2044
2045 #[test]
2046 fn one_lyapunov_framework_certifies_both_collapse_mechanisms() {
2047 for n in 3..=6 {
2053 let (cnf, _) = families::php(n);
2054 let (_, ranked) = solve_by_measure_synthesis(cnf.num_vars, &cnf.clauses).unwrap();
2055 let cert = lyapunov_of_symmetry(&ranked).expect("PHP carries a valid Lyapunov function");
2056 assert!(cert.monotone && cert.strict_descent && cert.reaches_goal, "all 4 axioms hold");
2057 assert!(cert.total_steps <= cert.size_bound, "descent bounds the size");
2058 assert!(cert.minimum < cert.initial, "the potential genuinely descends from start to goal");
2059 }
2060
2061 for seed in [1u64, 7, 42] {
2064 let (eqs, cnf, _) = families::tseitin_expander(10, seed);
2065 let (traj, reached) = gaussian_lyapunov(&eqs, cnf.num_vars);
2066 let cert = verify_lyapunov(&traj, reached)
2067 .expect("the Tseitin Gaussian collapse carries a valid Lyapunov function");
2068 assert!(cert.reaches_goal && cert.strict_descent, "the dimension strictly descends to ⊥");
2069 assert_eq!(cert.minimum, 0, "the dimension bottoms out at the 0=1 contradiction");
2070 }
2071 }
2072
2073 #[test]
2074 fn verify_lyapunov_is_sound_and_complete_on_random_trajectories() {
2075 let mut state = 0x5151_A5A5_3C3C_9696u64;
2079 let mut next = || {
2080 state = state.wrapping_add(0x9E3779B97F4A7C15);
2081 let mut z = state;
2082 z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
2083 z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
2084 z ^ (z >> 31)
2085 };
2086 let brute = |v: &[u64]| -> bool {
2087 if v.is_empty() {
2088 return false;
2089 }
2090 let monotone = v.windows(2).all(|w| w[1] <= w[0]);
2091 let mut d = v.to_vec();
2092 d.dedup();
2093 let strict = d.windows(2).all(|w| w[1] < w[0]);
2094 monotone && strict
2095 };
2096 let mut accepts = 0;
2097 for _ in 0..20_000 {
2098 let len = 1 + (next() as usize % 8);
2099 let traj: Vec<u64> = (0..len).map(|_| next() % 6).collect();
2100 let reaches = next() & 1 == 0;
2101 let got = verify_lyapunov(&traj, reaches);
2102 assert_eq!(
2103 got.is_some(),
2104 brute(&traj) && reaches,
2105 "verify_lyapunov must accept exactly the valid Lyapunov trajectories: {traj:?} reaches={reaches}"
2106 );
2107 if let Some(c) = got {
2108 assert!(c.total_steps <= c.size_bound, "accepted ⇒ size bound holds: {traj:?}");
2109 accepts += 1;
2110 }
2111 }
2112 assert!(accepts > 0, "the soundness fuzz must exercise genuine acceptances");
2113 }
2114
2115 #[test]
2116 fn a_synthesized_refutation_is_never_unsound() {
2117 for n in 3..=6 {
2120 let (cnf, _) = families::php(n);
2121 if let Some((_, ranked)) = solve_by_measure_synthesis(cnf.num_vars, &cnf.clauses) {
2122 assert!(
2123 crate::pr::check_pr_refutation_fast(cnf.num_vars, &cnf.clauses, &ranked.steps),
2124 "a synthesized PHP({n}) refutation must re-check"
2125 );
2126 }
2127 }
2128 }
2129
2130 fn cl(lits: &[i32]) -> Vec<Lit> {
2132 lits.iter()
2133 .map(|&l| if l > 0 { Lit::pos((l - 1) as u32) } else { Lit::neg((-l - 1) as u32) })
2134 .collect()
2135 }
2136
2137 #[test]
2138 fn cardinality_collapse_refutes_pigeonhole_by_cutting_planes() {
2139 for n in 3..=7 {
2142 let (cnf, _) = families::php(n);
2143 let (traj, reached, constraints) =
2144 cardinality_collapse(cnf.num_vars, &cnf.clauses).expect("PHP is a covering");
2145 assert!(reached, "PHP({n}) must reach 0≥1 by cutting planes");
2146 assert_eq!(constraints, 2 * n - 1, "n rows + (n-1) columns summed");
2147 assert_eq!(*traj.last().unwrap(), 0, "the descent bottoms out at 0");
2148 }
2149 }
2150
2151 #[test]
2152 fn auto_collapse_routes_an_asymmetric_covering_to_cardinality() {
2153 let formula = vec![
2158 cl(&[1, 2]),
2159 cl(&[3, 4]),
2160 cl(&[5]), cl(&[-1, -3]),
2162 cl(&[-1, -5]),
2163 cl(&[-3, -5]), cl(&[-2, -4]), ];
2166 let nv = 5;
2167 assert!(
2169 solve_by_measure_synthesis(nv, &formula).is_none(),
2170 "this asymmetric covering has no covering symmetry to discover"
2171 );
2172 match auto_collapse(nv, &formula) {
2174 AutoCollapse::Cardinality { reached_goal, constraints, .. } => {
2175 assert!(reached_goal, "cutting planes must reach 0≥1 (3 items, 2 bins)");
2176 assert_eq!(constraints, 5, "3 rows + 2 columns");
2177 }
2178 other => panic!("expected Cardinality collapse, got {other:?}"),
2179 }
2180 }
2181
2182 #[test]
2183 fn cardinality_collapse_is_sound_on_a_feasible_covering() {
2184 let formula = vec![cl(&[1, 2]), cl(&[3, 4]), cl(&[-1, -3]), cl(&[-2, -4])];
2187 let (_, reached, _) = cardinality_collapse(4, &formula).expect("is a covering");
2188 assert!(!reached, "a feasible (items ≤ bins) covering must not yield a contradiction");
2189 assert!(
2190 matches!(auto_collapse(4, &formula), AutoCollapse::None),
2191 "no collapse may be claimed on a satisfiable covering"
2192 );
2193 }
2194
2195 #[test]
2196 fn discover_covering_rejects_non_covering_shapes() {
2197 let shared = vec![cl(&[1, 2]), cl(&[1, 3])];
2201 assert!(discover_covering(3, &shared).is_none(), "a variable in two rows is not a covering");
2202 let partial = vec![cl(&[1, 4]), cl(&[2, 5]), cl(&[3, 6]), cl(&[-1, -2]), cl(&[-2, -3])];
2204 assert!(discover_covering(6, &partial).is_none(), "a non-clique column must be rejected");
2206 }
2207
2208 #[test]
2211 fn recover_cardinality_recovers_the_php_covering() {
2212 let (cnf, _) = families::php(4);
2213 let cons = recover_cardinality_constraints(cnf.num_vars, &cnf.clauses).expect("PHP is a clean covering");
2214 assert_eq!(cons.len(), 4 + 3, "4 pigeon rows + 3 hole columns");
2215 let rnd = families::random_3sat(20, 80, 0xBEEF);
2217 assert!(recover_cardinality_constraints(rnd.num_vars, &rnd.clauses).is_none(), "non-covering ⇒ None");
2218 }
2219
2220 #[test]
2223 fn recovered_constraints_are_implied_by_the_cnf() {
2224 let cnf = vec![
2226 vec![Lit::pos(0), Lit::pos(1)],
2227 vec![Lit::pos(2), Lit::pos(3)],
2228 vec![Lit::neg(0), Lit::neg(2)],
2229 vec![Lit::neg(1), Lit::neg(3)],
2230 ];
2231 let cons = recover_cardinality_constraints(4, &cnf).expect("a clean covering");
2232 for x in 0u64..(1 << 4) {
2233 let model_sat = cnf.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive()));
2234 if !model_sat {
2235 continue;
2236 }
2237 for pb in &cons {
2238 let sum: i64 = pb.terms().map(|(v, c, s)| if (((x >> v) & 1 == 1) == s) { c } else { 0 }).sum();
2239 assert!(sum >= pb.degree(), "recovered constraint {pb:?} must hold in model {x:04b}");
2240 }
2241 }
2242 }
2243
2244 #[test]
2248 fn live_cardinality_theory_refutes_php_from_recovered_constraints() {
2249 use crate::pseudo_boolean::CardinalityTheory;
2250 for n in 3..=5 {
2251 let (cnf, _) = families::php(n);
2252 let cons = recover_cardinality_constraints(cnf.num_vars, &cnf.clauses).expect("PHP covering");
2253 let mut s = Solver::new(cnf.num_vars);
2254 let mut t: Vec<Box<dyn crate::cdcl::Theory>> = vec![Box::new(CardinalityTheory::new(cnf.num_vars, &cons))];
2255 assert!(matches!(s.solve_with(&mut t), SolveResult::Unsat), "PHP({n}) is UNSAT via recovered cardinality");
2256 }
2257 }
2258
2259 fn xor_gadget(vars: &[u32], rhs: bool) -> Vec<Vec<Lit>> {
2261 let k = vars.len();
2262 (0u32..(1 << k))
2263 .filter(|mask| ((mask.count_ones() % 2) == 1) != rhs)
2264 .map(|mask| (0..k).map(|i| Lit::new(vars[i], (mask >> i) & 1 == 0)).collect())
2265 .collect()
2266 }
2267
2268 #[test]
2271 fn recover_at_most_one_extracts_a_clique_from_mixed_clauses() {
2272 let mut clauses: Vec<Vec<Lit>> = vec![
2273 vec![Lit::neg(0), Lit::neg(1)],
2274 vec![Lit::neg(0), Lit::neg(2)],
2275 vec![Lit::neg(1), Lit::neg(2)], ];
2277 clauses.extend(xor_gadget(&[3, 4], false)); let amo = recover_at_most_one(5, &clauses);
2279 assert_eq!(amo.len(), 1, "exactly one at-most-one group; got {amo:?}");
2280 assert!(discover_covering(5, &clauses).is_none(), "the whole-formula recognizer declines on the mix");
2281 for x in 0u64..(1 << 5) {
2283 let sat = clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive()));
2284 if !sat {
2285 continue;
2286 }
2287 for pb in &amo {
2288 let sum: i64 = pb.terms().map(|(v, c, s)| if (((x >> v) & 1 == 1) == s) { c } else { 0 }).sum();
2289 assert!(sum >= pb.degree(), "recovered {pb:?} must hold in model {x:05b}");
2290 }
2291 }
2292 }
2293
2294 #[test]
2299 fn fused_decide_refutes_a_mixed_parity_cardinality_instance() {
2300 let mut clauses: Vec<Vec<Lit>> = vec![
2301 vec![Lit::pos(0), Lit::pos(1), Lit::pos(2)], vec![Lit::neg(0), Lit::neg(1)],
2303 vec![Lit::neg(0), Lit::neg(2)],
2304 vec![Lit::neg(1), Lit::neg(2)], ];
2306 for i in 0..3u32 {
2307 clauses.extend(xor_gadget(&[i, i + 3], false)); }
2309 clauses.extend(xor_gadget(&[3, 4, 5], false)); assert!(!extract_xor(6, &clauses).is_empty(), "a parity substructure is present");
2312 assert!(!recover_at_most_one(6, &clauses).is_empty(), "a cardinality substructure is present");
2313 assert_eq!(fused_parity_cardinality_decide(6, &clauses), Some(false), "the mixed instance is UNSAT");
2314 let brute = (0u64..(1 << 6)).any(|x| clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())));
2316 assert!(!brute, "brute force agrees it is UNSAT");
2317 }
2318
2319 #[test]
2323 fn fused_decide_matches_brute_force() {
2324 let mut st = 0xF00D_CAFEu64;
2325 let mut rng = || {
2326 st ^= st << 13;
2327 st ^= st >> 7;
2328 st ^= st << 17;
2329 st
2330 };
2331 for _ in 0..200 {
2332 let n = 6usize;
2333 let mut clauses: Vec<Vec<Lit>> = Vec::new();
2334 let k = 2 + (rng() % 2) as usize;
2336 let pvars: Vec<u32> = (0..k as u32).collect();
2337 clauses.extend(xor_gadget(&pvars, rng() % 2 == 0));
2338 let cvars: Vec<u32> = vec![3, 4, 5].into_iter().filter(|_| rng() % 2 == 0).collect();
2342 let width = if rng() % 2 == 0 { 2 } else { 3 };
2343 if cvars.len() >= width {
2344 for_each_combo(&cvars.iter().map(|&v| v as usize).collect::<Vec<_>>(), width, 0, &mut Vec::new(), &mut |sub| {
2345 clauses.push(sub.iter().map(|&v| Lit::neg(v as u32)).collect());
2346 true
2347 });
2348 }
2349 for _ in 0..(rng() % 4) {
2351 let mut c: Vec<Lit> = Vec::new();
2352 for v in 0..n as u32 {
2353 if rng() % 3 == 0 {
2354 c.push(Lit::new(v, rng() % 2 == 0));
2355 }
2356 }
2357 if !c.is_empty() {
2358 clauses.push(c);
2359 }
2360 }
2361 if let Some(verdict) = fused_parity_cardinality_decide(n, &clauses) {
2362 let brute = (0u64..(1 << n)).any(|x| clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())));
2363 assert_eq!(verdict, brute, "fused verdict must match brute (clauses={clauses:?})");
2364 }
2365 }
2366 }
2367
2368 #[test]
2372 fn fused_decide_refutes_the_scalable_parity_exactly_one_family() {
2373 for n in [4usize, 6, 8, 10, 12] {
2374 let (cnf, verdict) = families::parity_exactly_one(n);
2375 assert_eq!(verdict, families::ExpectedVerdict::Unsat, "the family is UNSAT by construction");
2376 assert!(!extract_xor(cnf.num_vars, &cnf.clauses).is_empty(), "n={n}: a parity substructure is present");
2377 assert!(!recover_at_most_one(cnf.num_vars, &cnf.clauses).is_empty(), "n={n}: a cardinality substructure is present");
2378 assert_eq!(
2379 fused_parity_cardinality_decide(cnf.num_vars, &cnf.clauses),
2380 Some(false),
2381 "n={n}: the fused parity+cardinality route refutes it",
2382 );
2383 if cnf.num_vars <= 16 {
2384 let brute = (0u64..(1u64 << cnf.num_vars))
2385 .any(|x| cnf.clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())));
2386 assert!(!brute, "n={n}: brute force confirms UNSAT");
2387 }
2388 }
2389 }
2390
2391 #[test]
2394 fn recover_at_most_k_recovers_a_ternary_at_most_two_group() {
2395 let triples = [[0u32, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]];
2397 let clauses: Vec<Vec<Lit>> = triples.iter().map(|t| t.iter().map(|&v| Lit::neg(v)).collect()).collect();
2398 let cons = recover_at_most_k(4, &clauses, 2);
2399 assert_eq!(cons.len(), 1, "one at-most-two group; got {cons:?}");
2400 assert!(recover_at_most_one(4, &clauses).is_empty(), "no pairwise exclusions ⇒ no at-most-one");
2401 assert_eq!(recover_cardinality_substructure(4, &clauses).len(), 1, "the combined recognizer finds it");
2402 for x in 0u64..(1 << 4) {
2404 let sat = clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive()));
2405 if !sat {
2406 continue;
2407 }
2408 for pb in &cons {
2409 let sum: i64 = pb.terms().map(|(v, c, s)| if (((x >> v) & 1 == 1) == s) { c } else { 0 }).sum();
2410 assert!(sum >= pb.degree(), "the recovered ≤2 must hold in model {x:04b}");
2411 }
2412 }
2413 let clique = vec![vec![Lit::neg(0), Lit::neg(1)], vec![Lit::neg(0), Lit::neg(2)], vec![Lit::neg(1), Lit::neg(2)]];
2415 assert_eq!(recover_at_most_k(3, &clique, 1).len(), recover_at_most_one(3, &clique).len(), "k=1 ≡ at-most-one");
2416 }
2417
2418 #[test]
2423 fn fused_decide_refutes_a_mixed_at_most_two_parity_instance() {
2424 let mut clauses: Vec<Vec<Lit>> = vec![
2425 vec![Lit::neg(0), Lit::neg(1), Lit::neg(2)], vec![Lit::pos(0), Lit::pos(1)],
2427 vec![Lit::pos(0), Lit::pos(2)],
2428 vec![Lit::pos(1), Lit::pos(2)], ];
2430 for i in 0..3u32 {
2431 clauses.extend(xor_gadget(&[i, i + 3], false)); }
2433 clauses.extend(xor_gadget(&[3, 4, 5], true)); assert!(recover_at_most_one(6, &clauses).is_empty(), "no pairwise exclusions");
2435 assert!(!recover_at_most_k(6, &clauses, 2).is_empty(), "an at-most-two core is present");
2436 assert!(!extract_xor(6, &clauses).is_empty(), "a parity substructure is present");
2437 assert_eq!(fused_parity_cardinality_decide(6, &clauses), Some(false), "the at-most-two mix is UNSAT");
2438 let brute = (0u64..(1 << 6)).any(|x| clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())));
2439 assert!(!brute, "brute force confirms UNSAT");
2440 }
2441
2442 fn exclusion_clique(vars: &[u32], width: usize) -> Vec<Vec<Lit>> {
2444 let items: Vec<usize> = vars.iter().map(|&v| v as usize).collect();
2445 let mut out: Vec<Vec<Lit>> = Vec::new();
2446 for_each_combo(&items, width, 0, &mut Vec::new(), &mut |sub| {
2447 out.push(sub.iter().map(|&v| Lit::neg(v as u32)).collect());
2448 true
2449 });
2450 out
2451 }
2452
2453 #[test]
2456 fn recover_at_most_k_recovers_wider_cores() {
2457 let c3 = exclusion_clique(&[0, 1, 2, 3, 4], 4);
2459 let g3 = recover_at_most_k(5, &c3, 3);
2460 assert_eq!(g3.len(), 1, "one at-most-three group; got {g3:?}");
2461 assert!(!recover_cardinality_substructure(5, &c3).is_empty(), "the combined recognizer (k≤4) finds it");
2462 for x in 0u64..(1 << 5) {
2463 if !c3.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())) {
2464 continue;
2465 }
2466 for pb in &g3 {
2467 let sum: i64 = pb.terms().map(|(v, c, s)| if (((x >> v) & 1 == 1) == s) { c } else { 0 }).sum();
2468 assert!(sum >= pb.degree(), "the recovered ≤3 must hold in {x:05b}");
2469 }
2470 }
2471 let c4 = exclusion_clique(&[0, 1, 2, 3, 4, 5], 5);
2473 let g4 = recover_at_most_k(6, &c4, 4);
2474 assert_eq!(g4.len(), 1, "one at-most-four group; got {g4:?}");
2475 for x in 0u64..(1 << 6) {
2476 if !c4.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())) {
2477 continue;
2478 }
2479 for pb in &g4 {
2480 let sum: i64 = pb.terms().map(|(v, c, s)| if (((x >> v) & 1 == 1) == s) { c } else { 0 }).sum();
2481 assert!(sum >= pb.degree(), "the recovered ≤4 must hold in {x:06b}");
2482 }
2483 }
2484 }
2485
2486 #[test]
2490 fn recover_at_most_k_is_bounded_on_a_large_clique() {
2491 let n = 20u32;
2492 let clauses = exclusion_clique(&(0..n).collect::<Vec<_>>(), 3);
2493 let g = recover_at_most_k(n as usize, &clauses, 2);
2494 assert!(!g.is_empty(), "must recover the at-most-two core");
2495 assert!(g.iter().any(|pb| pb.len() >= 3), "a real multi-member at-most-two group, not just a single triple");
2496 }
2497
2498 #[test]
2503 fn recover_at_most_k_recovers_at_least_two_via_negation() {
2504 let clauses = vec![
2505 vec![Lit::pos(0), Lit::pos(1)],
2506 vec![Lit::pos(0), Lit::pos(2)],
2507 vec![Lit::pos(1), Lit::pos(2)],
2508 ];
2509 let g = recover_at_most_k(3, &clauses, 1);
2510 assert_eq!(g.len(), 1, "one at-most-one group over the negated literals; got {g:?}");
2511 assert_eq!(g[0].degree(), 2, "the normalized constraint is ≥ 2");
2514 assert!(g[0].terms().all(|(_, _, s)| s) && g[0].len() == 3, "over the three positive variables");
2515 assert!(recover_at_most_one(3, &clauses).is_empty(), "the positive-only recognizer misses at-least-two");
2517 for x in 0u64..(1 << 3) {
2518 let sat = clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive()));
2519 let sum: i64 = g[0].terms().map(|(v, c, s)| if (((x >> v) & 1 == 1) == s) { c } else { 0 }).sum();
2520 assert_eq!(sum >= g[0].degree(), sat, "the recovered ≥2 must agree with the clauses on {x:03b}");
2521 }
2522 }
2523
2524 #[test]
2529 fn cardinality_parity_seams_finds_the_parity_boundary() {
2530 let mut clauses = exclusion_clique(&[0, 1, 2, 3], 3); clauses.extend(xor_gadget(&[0, 1], false)); clauses.extend(xor_gadget(&[2, 3], false)); let s = cardinality_parity_seams(4, &clauses);
2534 assert!(s.joint.contains(&(0, 1)), "0↔1 preserves both structures: {:?}", s.joint);
2535 assert!(s.joint.contains(&(2, 3)), "2↔3 preserves both structures: {:?}", s.joint);
2536 for seam in [(0, 2), (0, 3), (1, 2), (1, 3)] {
2537 assert!(s.seams.contains(&seam), "{seam:?} is a seam the parity blocks: {:?}", s.seams);
2538 }
2539 assert!(!s.joint.iter().any(|p| [(0, 2), (0, 3), (1, 2), (1, 3)].contains(p)), "no cross-pair is joint");
2540 }
2541
2542 #[test]
2546 fn cardinality_symmetry_break_is_sound_and_reduces() {
2547 let clauses = exclusion_clique(&[0, 1, 2, 3], 3); let breaks = cardinality_symmetry_break(4, &clauses);
2549 assert!(!breaks.is_empty(), "the joint symmetry yields lex-leader breaks");
2550 let mut broken = clauses.clone();
2551 broken.extend(breaks);
2552 let count = |cs: &[Vec<Lit>]| (0u64..(1 << 4)).filter(|&x| cs.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive()))).count();
2553 let (orig, red) = (count(&clauses), count(&broken));
2554 assert_eq!(orig, 11, "≤2 of four has 11 models");
2555 assert_eq!(red, 3, "the ordered representatives: 0000, 1000, 1100");
2556 assert!(red > 0 && orig > 0, "satisfiability preserved");
2557 }
2558
2559 #[test]
2562 fn cardinality_symmetry_break_preserves_satisfiability() {
2563 let mut st = 0x5EA_5EA_5u64;
2564 let mut rng = || {
2565 st ^= st << 13;
2566 st ^= st >> 7;
2567 st ^= st << 17;
2568 st
2569 };
2570 for _ in 0..150 {
2571 let n = 6usize;
2572 let mut clauses: Vec<Vec<Lit>> = Vec::new();
2573 clauses.extend(xor_gadget(&(0..(2 + (rng() % 2) as u32)).collect::<Vec<_>>(), rng() % 2 == 0));
2574 let cv: Vec<u32> = vec![2, 3, 4, 5].into_iter().filter(|_| rng() % 2 == 0).collect();
2575 let width = 2 + (rng() % 2) as usize;
2576 if cv.len() >= width {
2577 clauses.extend(exclusion_clique(&cv, width));
2578 }
2579 let sat = |cs: &[Vec<Lit>]| (0u64..(1 << n)).any(|x| cs.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())));
2580 let mut broken = clauses.clone();
2581 broken.extend(cardinality_symmetry_break(n, &clauses));
2582 assert_eq!(sat(&clauses), sat(&broken), "break must preserve satisfiability (clauses={clauses:?})");
2583 }
2584 }
2585
2586 #[test]
2592 fn block_symmetry_crosses_the_seams_up_the_chain() {
2593 let mut clauses = exclusion_clique(&[0, 1, 2, 3], 3);
2594 clauses.extend(xor_gadget(&[0, 1], false));
2595 clauses.extend(xor_gadget(&[2, 3], false));
2596 let gens = cardinality_symmetry_generators(4, &clauses);
2597 let has_block = gens.iter().any(|g| g[0] == 2 && g[1] == 3 && g[2] == 0 && g[3] == 1);
2598 assert!(has_block, "a block swap (0 2)(1 3) must cross the seams");
2599 let (sbp, ext) = crate::sym_break::lex_leader_sbp(4, &gens);
2601 assert!(ext >= 4, "the SBP appends prefix-equality aux variables");
2602 let mut broken = clauses.clone();
2603 broken.extend(sbp);
2604 let orig_sat = (0u64..(1 << 4)).any(|x| clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())));
2605 let broken_sat = (0u64..(1u64 << ext)).any(|x| broken.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())));
2606 assert_eq!(orig_sat, broken_sat, "the wreath break preserves satisfiability");
2607 }
2608
2609 #[test]
2614 fn wreath_climb_reaches_the_block_level_above_the_orbits() {
2615 let mut clauses = exclusion_clique(&[0, 1, 2, 3], 3);
2616 clauses.extend(exclusion_clique(&[4, 5, 6, 7], 3));
2617 let gens = cardinality_symmetry_generators(8, &clauses);
2618 assert!(
2619 gens.iter().any(|g| (0..4).all(|k| g[k] == k + 4) && (4..8).all(|k| g[k] == k - 4)),
2620 "the climb must reach the block level (group ↔ group): {gens:?}"
2621 );
2622 for g in &gens {
2623 let sigma = crate::proof::Perm::from_images(g.iter().map(|&v| Lit::pos(v as u32)).collect());
2624 assert!(perm_is_automorphism(&clauses, &sigma), "every emitted generator must be an automorphism: {g:?}");
2625 }
2626 }
2627
2628 #[test]
2634 fn semantic_seams_see_through_the_parity_span() {
2635 let mut clauses = exclusion_clique(&[0, 1, 2, 3], 3);
2636 clauses.extend(xor_gadget(&[0, 1], false));
2637 clauses.extend(xor_gadget(&[0, 2], false));
2638 let syn = crate::proof::Perm::from_images((0..4u32).map(|v| Lit::pos(match v { 0 => 1, 1 => 0, _ => v })).collect());
2639 assert!(!perm_is_automorphism(&clauses, &syn), "0↔1 is a syntactic SEAM (not a clause automorphism)");
2640 let s = cardinality_parity_seams(4, &clauses);
2641 assert!(s.joint.contains(&(0, 1)), "0↔1 is a SEMANTIC joint symmetry the syntactic check misses: {:?}", s.joint);
2642 let sat: Vec<u64> = (0u64..(1 << 4))
2644 .filter(|&x| clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())))
2645 .collect();
2646 for &x in &sat {
2647 let (b0, b1) = (x & 1, (x >> 1) & 1);
2648 let y = (x & !0b11) | (b0 << 1) | b1;
2649 assert!(sat.contains(&y), "swap 0↔1 must map model {x:04b} to a model");
2650 }
2651 }
2652
2653 #[test]
2659 fn affine_shear_symmetry_is_detected_and_breaks_soundly() {
2660 let mut clauses = xor_gadget(&[0, 1], false); clauses.push(vec![Lit::neg(3), Lit::neg(4)]); let n = 5usize;
2663 let maps = affine_parity_symmetries(n, &clauses);
2664 assert!(!maps.is_empty(), "an affine shear symmetry must be detected");
2665 assert!(
2666 maps.iter().any(|m| m[2].0.len() == 2), "at least one detected map is a genuine shear on the free variable: {maps:?}"
2668 );
2669 let apply = |map: &[(Vec<usize>, bool)], x: u64| -> u64 {
2670 let mut y = 0u64;
2671 for (j, (xs, b)) in map.iter().enumerate() {
2672 if xs.iter().fold(*b, |a, &v| a ^ ((x >> v) & 1 == 1)) {
2673 y |= 1 << j;
2674 }
2675 }
2676 y
2677 };
2678 let sat: Vec<u64> = (0u64..(1 << n))
2679 .filter(|&x| clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())))
2680 .collect();
2681 for map in &maps {
2682 for &x in &sat {
2683 assert!(sat.contains(&apply(map, x)), "affine map must send model {x:05b} to a model");
2684 }
2685 }
2686 let (sbp, ext) = crate::sym_break::affine_lex_leader_sbp(n, &maps);
2687 let mut broken = clauses.clone();
2688 broken.extend(sbp);
2689 let broken_sat = (0u64..(1u64 << ext)).any(|x| broken.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())));
2690 assert_eq!(!sat.is_empty(), broken_sat, "the affine break preserves satisfiability");
2691 }
2692
2693 #[test]
2699 fn multi_coordinate_affine_symmetry_the_gl_rung() {
2700 let mut clauses = xor_gadget(&[0, 1], false); clauses.push(vec![Lit::neg(3), Lit::neg(4)]); let n = 5usize;
2703 let maps = affine_parity_symmetries(n, &clauses);
2704 let flip01 = maps.iter().any(|m| {
2705 m[0] == (vec![0], true) && m[1] == (vec![1], true) && (2..n).all(|k| m[k] == (vec![k], false))
2706 });
2707 assert!(flip01, "the GL rung must find the multi-coordinate kernel translation flip{{0,1}}: {maps:?}");
2708 let apply = |map: &[(Vec<usize>, bool)], x: u64| -> u64 {
2710 let mut y = 0u64;
2711 for (j, (xs, b)) in map.iter().enumerate() {
2712 if xs.iter().fold(*b, |a, &v| a ^ ((x >> v) & 1 == 1)) {
2713 y |= 1 << j;
2714 }
2715 }
2716 y
2717 };
2718 let sat: Vec<u64> = (0u64..(1 << n))
2719 .filter(|&x| clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())))
2720 .collect();
2721 for map in &maps {
2722 for &x in &sat {
2723 assert!(sat.contains(&apply(map, x)), "affine generator must send model {x:05b} to a model: {map:?}");
2724 }
2725 }
2726 }
2727
2728 #[test]
2733 fn complete_break_keeps_exactly_one_representative_per_orbit() {
2734 use crate::cdcl::{SolveResult, Solver, Theory};
2735 let clauses = exclusion_clique(&[0, 1, 2, 3], 3);
2736 let group = fused_symmetry_group(4, &clauses);
2737 let mut blocked: Vec<Vec<Lit>> = Vec::new();
2738 let mut count = 0;
2739 loop {
2740 let mut s = Solver::new(4); for c in clauses.iter().chain(blocked.iter()) {
2742 s.add_clause(c.clone());
2743 }
2744 let mut theories: Vec<Box<dyn Theory>> = vec![Box::new(SymmetryTheory::new(4, group.clone()))];
2745 match s.solve_with(&mut theories) {
2746 SolveResult::Sat(m) => {
2747 count += 1;
2748 assert!(count <= 4, "runaway — the complete break should leave only 3");
2749 blocked.push((0..4u32).map(|v| Lit::new(v, !m[v as usize])).collect());
2750 }
2751 SolveResult::Unsat => break,
2752 }
2753 }
2754 assert_eq!(count, 3, "the dynamic complete S₄ break enumerates exactly the 3 orbit representatives");
2755 }
2756
2757 #[test]
2763 fn all_transposition_symmetries_are_sound_and_include_parity_permutations() {
2764 let mut clauses = xor_gadget(&[0, 1, 2], false); clauses.push(vec![Lit::neg(3), Lit::neg(4)]);
2766 let n = 5usize;
2767 let transps = all_transposition_symmetries(n, &clauses);
2768 assert!(
2769 transps.iter().any(|p| p[0] == 1 && p[1] == 0 && (2..n).all(|k| p[k] == k)),
2770 "the parity-variable permutation x0↔x1 must be detected: {transps:?}"
2771 );
2772 let sat: Vec<u64> = (0u64..(1 << n))
2773 .filter(|&x| clauses.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())))
2774 .collect();
2775 for p in &transps {
2777 for &x in &sat {
2778 let mut y = 0u64;
2779 for j in 0..n {
2780 if (x >> p[j]) & 1 == 1 {
2781 y |= 1 << j;
2782 }
2783 }
2784 assert!(sat.contains(&y), "transposition symmetry must map model {x:05b} to a model: {p:?}");
2785 }
2786 }
2787 }
2788
2789 #[test]
2792 fn symmetry_theory_propagates_the_lex_leader() {
2793 use crate::cdcl::{Lit, Theory};
2794 let mut th = SymmetryTheory::from_perms(2, vec![vec![1, 0]]); let forced = th.propagate(&[Lit::new(1, false)]);
2797 assert_eq!(forced.len(), 1, "one forced clause; got {forced:?}");
2798 assert!(forced[0].contains(&Lit::new(0, false)), "must force x0 = 0: {:?}", forced[0]);
2799 let conf = th.propagate(&[Lit::new(0, true), Lit::new(1, false)]);
2801 let is_true = |v: u32| v == 0; assert!(
2803 conf.iter().any(|c| !c.is_empty() && c.iter().all(|l| is_true(l.var()) != l.is_positive())),
2804 "must conflict with an all-false clause: {conf:?}"
2805 );
2806 assert!(th.propagate(&[Lit::new(0, false)]).is_empty(), "x0=0 leaves nothing to force");
2808 }
2809
2810 #[test]
2814 fn symmetry_theory_handles_affine_maps_dynamically() {
2815 use crate::cdcl::{Lit, Theory};
2816 let map = vec![(vec![0usize], false), (vec![1], false), (vec![2, 0], false)]; let mut th = SymmetryTheory::new(3, vec![map]);
2818 let conf = th.propagate(&[Lit::new(0, true), Lit::new(2, true)]);
2819 let is_true = |v: u32| v == 0 || v == 2; assert!(
2821 conf.iter().any(|c| !c.is_empty() && c.iter().all(|l| is_true(l.var()) != l.is_positive())),
2822 "x0=1 ∧ x2=1 must conflict with an all-false clause (violates x2 ≤ x2⊕x0): {conf:?}"
2823 );
2824 assert!(th.propagate(&[Lit::new(0, true), Lit::new(2, false)]).is_empty(), "x0=1 ∧ x2=0 respects the shear");
2825 assert!(th.propagate(&[Lit::new(0, false), Lit::new(2, true)]).is_empty(), "x0=0 leaves the shear inert");
2826 }
2827
2828 #[test]
2835 fn find_generators_contributes_signed_cross_symmetry() {
2836 use crate::cdcl::{Lit, SolveResult, Solver, Theory};
2837 let clauses = vec![vec![Lit::pos(0), Lit::pos(1)], vec![Lit::neg(2), Lit::neg(3)]];
2838
2839 let sigma = crate::proof::Perm::from_images(vec![Lit::neg(2), Lit::neg(3), Lit::neg(0), Lit::neg(1)]);
2841 assert!(perm_is_automorphism(&clauses, &sigma), "the signed cross map is a clause automorphism");
2842
2843 for t in all_transposition_symmetries(4, &clauses) {
2845 for i in 0..4 {
2846 assert!(!((i < 2) != (t[i] < 2)), "an unsigned transposition never crosses the clusters: {t:?}");
2847 }
2848 }
2849
2850 let group = fused_symmetry_group(4, &clauses);
2853 assert!(
2854 group.iter().any(|spec| {
2855 spec.iter().enumerate().any(|(w, (xs, b))| *b && xs.len() == 1 && (w < 2) != (xs[0] < 2))
2856 }),
2857 "the unified group carries the signed cross-cluster symmetry: {group:?}"
2858 );
2859
2860 let count_models = |use_break: bool| -> usize {
2862 let mut blocked: Vec<Vec<Lit>> = Vec::new();
2863 let mut count = 0;
2864 loop {
2865 let mut s = Solver::new(4);
2866 for c in clauses.iter().chain(blocked.iter()) {
2867 s.add_clause(c.clone());
2868 }
2869 let mut theories: Vec<Box<dyn Theory>> =
2870 if use_break { vec![Box::new(SymmetryTheory::new(4, group.clone()))] } else { vec![] };
2871 match s.solve_with(&mut theories) {
2872 SolveResult::Sat(m) => {
2873 count += 1;
2874 assert!(count <= 16, "runaway");
2875 blocked.push((0..4u32).map(|v| Lit::new(v, !m[v as usize])).collect());
2876 }
2877 SolveResult::Unsat => break,
2878 }
2879 }
2880 count
2881 };
2882 assert_eq!(count_models(false), 9, "the raw formula has 9 models");
2883 let broken = count_models(true);
2884 assert!(broken < 9, "the signed symmetry break must reduce the model count, got {broken}");
2885 assert!(broken >= 1, "the break stays satisfiable");
2886 }
2887}