logicaffeine_proof/symmetry_detect.rs
1//! General symmetry detection for CNF — the engine behind certified symmetry breaking.
2//!
3//! A symmetry of a formula is a literal permutation `σ` (respecting negation) that maps the
4//! clause set onto itself: `σ(F) = F`. Such a `σ` permutes models, so adding a lex-leader
5//! predicate that keeps the canonical representative of each orbit is satisfiability-preserving
6//! — and, with the witness `σ`, independently certifiable by the PR checker ([`crate::pr`]).
7//!
8//! The cornerstone is the **soundness gate** [`perm_is_automorphism`]: whatever search produces
9//! a candidate generator, it is re-verified here by clause-multiset invariance before any use.
10//! A detector that emits a non-symmetry is caught and dropped; a detector that misses a symmetry
11//! only costs search speed. So the intricate part — finding generators — is never
12//! soundness-critical; this gate is.
13
14use crate::cdcl::Lit;
15use crate::proof::Perm;
16
17/// Canonical key of a single clause: its `(var, sign)` codes sorted and deduped. Two clauses are
18/// equal as sets of literals iff their keys match.
19pub(crate) fn clause_key(c: &[Lit]) -> Vec<u32> {
20 let mut k: Vec<u32> = c
21 .iter()
22 .map(|l| l.var() * 2 + u32::from(!l.is_positive()))
23 .collect();
24 k.sort_unstable();
25 k.dedup();
26 k
27}
28
29/// Is `sigma` a genuine automorphism of `clauses` — does applying it map the clause set exactly
30/// onto itself? The independent re-verification every generator must pass.
31///
32/// `σ` is an automorphism iff `σ(C) ∈ F` for every clause `C ∈ F` (a literal-permutation is a
33/// bijection on clauses, so mapping `F` into itself forces it *onto* itself). A clause whose
34/// variables are all **fixed** by `σ` maps to itself and is trivially present — so only clauses
35/// touching `σ`'s *support* (its moved variables) need a membership check. For the small-support
36/// generators symmetry breaking actually uses (transpositions, short cycles) this inspects a
37/// handful of clauses instead of re-sorting the whole database, which is the difference between
38/// an `O(n⁴)` and an `O(n³)` certified pigeonhole refutation.
39pub fn perm_is_automorphism(clauses: &[Vec<Lit>], sigma: &Perm) -> bool {
40 let nv = sigma.num_vars();
41 let moved: Vec<bool> = (0..nv)
42 .map(|v| sigma.apply(Lit::pos(v as u32)) != Lit::pos(v as u32))
43 .collect();
44 // Nothing moves ⇒ identity ⇒ automorphism of anything.
45 if moved.iter().all(|&m| !m) {
46 return true;
47 }
48 let set: std::collections::HashSet<Vec<u32>> = clauses.iter().map(|c| clause_key(c)).collect();
49 for c in clauses {
50 let touches_support = c
51 .iter()
52 .any(|l| (l.var() as usize) < nv && moved[l.var() as usize]);
53 if touches_support && !set.contains(&clause_key(&sigma.apply_clause(c))) {
54 return false;
55 }
56 }
57 true
58}
59
60/// An **incrementally-maintained** clause database for fast repeated automorphism checks.
61///
62/// A certified symmetry-breaking refutation re-verifies an automorphism `σ` against the *current*
63/// database once per step, but the database only grows by one clause each step. Rebuilding the
64/// membership set from scratch every call (as the stateless [`perm_is_automorphism`] must) makes
65/// the whole refutation `O(n⁴)`; maintaining the set — and a variable→clause occurrence map —
66/// incrementally drops each check to `O(support of σ)`, and the refutation to `O(n³)`.
67///
68/// The verdict is identical to [`perm_is_automorphism`] (a differential fuzz proves it); this is a
69/// pure acceleration structure, sound by construction: `σ` is an automorphism iff `σ(C) ∈ F` for
70/// every clause `C` it moves, and a clause disjoint from `σ`'s support maps to itself.
71pub struct AutomorphismIndex {
72 nv: usize,
73 clauses: Vec<Vec<Lit>>,
74 keys: std::collections::HashSet<Vec<u32>>,
75 /// Clause key → an index of a clause with that key, so an involution `σ` can mark the partner
76 /// `σ(C)` of a just-verified clause `C` as already-checked (`σ(σ(C)) = C`, so the reverse
77 /// direction is free) — halving the automorphism re-verification on transposition generators.
78 index_by_key: std::collections::HashMap<Vec<u32>, usize>,
79 by_var: Vec<Vec<usize>>,
80 /// The **persistent** unit-propagation fixpoint of every inserted clause — the literals forced
81 /// regardless of any per-call assumption. Maintained incrementally on `insert`, so propagation
82 /// never re-derives the standing units (which on pigeonhole number `O(n²)` and would cost
83 /// `O(n⁴)` to re-seed every step).
84 base: Vec<Option<bool>>,
85 /// Whether the standing clauses already unit-propagate to a conflict (the database is refuted
86 /// by unit propagation alone, independent of any assumption).
87 base_conflict: bool,
88 /// Stamped per-call scratch layered over `base`: variable `v` is temporarily assigned iff
89 /// `temp_gen[v] == gen`, with value `temp_val[v]`. Bumping `gen` resets the whole layer in
90 /// `O(1)`, so a propagation call costs `O(touched)`, never `O(num_vars)`.
91 temp_gen: Vec<u32>,
92 temp_val: Vec<bool>,
93 gen: u32,
94 /// Stamped visited marks for `is_automorphism`, reset the same `O(1)` way.
95 visited_gen: Vec<u32>,
96 vgen: u32,
97}
98
99impl AutomorphismIndex {
100 /// An empty index over `nv` variables.
101 pub fn new(nv: usize) -> Self {
102 AutomorphismIndex {
103 nv,
104 clauses: Vec::new(),
105 keys: std::collections::HashSet::new(),
106 index_by_key: std::collections::HashMap::new(),
107 by_var: vec![Vec::new(); nv],
108 base: vec![None; nv],
109 base_conflict: false,
110 temp_gen: vec![0; nv],
111 temp_val: vec![false; nv],
112 gen: 0,
113 visited_gen: Vec::new(),
114 vgen: 0,
115 }
116 }
117
118 /// An index pre-loaded with `clauses`.
119 pub fn with_clauses(nv: usize, clauses: &[Vec<Lit>]) -> Self {
120 let mut ix = Self::new(nv);
121 for c in clauses {
122 ix.insert(c.clone());
123 }
124 ix
125 }
126
127 /// Add one clause, updating the membership set, the occurrence lists, and the persistent
128 /// unit-propagation base (which may cascade) — all amortized over the life of the index.
129 pub fn insert(&mut self, clause: Vec<Lit>) {
130 let idx = self.clauses.len();
131 let key = clause_key(&clause);
132 self.keys.insert(key.clone());
133 self.index_by_key.insert(key, idx);
134 let mut touched: Vec<usize> = clause
135 .iter()
136 .map(|l| l.var() as usize)
137 .filter(|&v| v < self.nv)
138 .collect();
139 touched.sort_unstable();
140 touched.dedup();
141 for v in touched {
142 self.by_var[v].push(idx);
143 }
144 self.visited_gen.push(0);
145 self.clauses.push(clause);
146 // Fold the new clause into the persistent UP fixpoint: if it is (or becomes) unit under
147 // `base` it forces a literal, which may cascade to other clauses sharing that variable.
148 self.base_propagate(idx);
149 }
150
151 /// Incremental base unit-propagation seeded by a freshly-inserted clause index. Reads `base`,
152 /// writes `base`, never allocates per propagated literal beyond the worklist.
153 fn base_propagate(&mut self, start: usize) {
154 if self.base_conflict {
155 return;
156 }
157 let mut wl = vec![start];
158 let mut wi = 0;
159 while wi < wl.len() {
160 let ci = wl[wi];
161 wi += 1;
162 let mut satisfied = false;
163 let mut unit: Option<Lit> = None;
164 let mut more_than_one = false;
165 for &l in &self.clauses[ci] {
166 let val = self.base[l.var() as usize].map(|b| b == l.is_positive());
167 match val {
168 Some(true) => {
169 satisfied = true;
170 break;
171 }
172 Some(false) => {}
173 None => match unit {
174 None => unit = Some(l),
175 Some(u) if u == l => {}
176 Some(u) if u == l.negated() => {
177 satisfied = true;
178 break;
179 }
180 Some(_) => more_than_one = true,
181 },
182 }
183 }
184 if satisfied {
185 continue;
186 }
187 match unit {
188 None => {
189 self.base_conflict = true;
190 return;
191 }
192 Some(u) if !more_than_one => {
193 self.base[u.var() as usize] = Some(u.is_positive());
194 for &cj in &self.by_var[u.var() as usize] {
195 wl.push(cj);
196 }
197 }
198 _ => {}
199 }
200 }
201 }
202
203 /// Is `sigma` an automorphism of the indexed database? Inspects only the clauses `sigma`
204 /// actually moves — the union of the occurrence lists of its moved variables — using a stamped
205 /// visited mark instead of a fresh `O(|db|)` allocation each call.
206 pub fn is_automorphism(&mut self, sigma: &Perm) -> bool {
207 self.vgen = self.vgen.wrapping_add(1);
208 if self.vgen == 0 {
209 for g in &mut self.visited_gen {
210 *g = 0;
211 }
212 self.vgen = 1;
213 }
214 // If σ is an involution (σ² = id — every transposition, the bread and butter of symmetry
215 // breaking, is one) the moved clauses pair up as {C, σ(C)}: verifying σ(C) ∈ F also settles
216 // σ(σ(C)) = C ∈ F for free, so we mark the partner checked and skip it — halving the work.
217 let involution = (0..self.nv as u32).all(|v| {
218 let pv = Lit::pos(v);
219 sigma.apply(sigma.apply(pv)) == pv
220 });
221 // A single reusable key buffer for the whole call — the σ-image of each visited clause is
222 // built in place and probed against the membership set by slice, so the inner loop performs
223 // ZERO heap allocations (the previous two-Vec-per-clause cost dominated at pigeonhole scale).
224 let mut keybuf: Vec<u32> = Vec::new();
225 for v in 0..self.nv {
226 if sigma.apply(Lit::pos(v as u32)) == Lit::pos(v as u32) {
227 continue;
228 }
229 for k in 0..self.by_var[v].len() {
230 let ci = self.by_var[v][k];
231 if self.visited_gen[ci] == self.vgen {
232 continue;
233 }
234 self.visited_gen[ci] = self.vgen;
235 keybuf.clear();
236 for &l in &self.clauses[ci] {
237 let m = sigma.apply(l);
238 keybuf.push(m.var() * 2 + u32::from(!m.is_positive()));
239 }
240 keybuf.sort_unstable();
241 keybuf.dedup();
242 if !self.keys.contains(keybuf.as_slice()) {
243 return false;
244 }
245 // The partner σ(C) is verified present; for an involution its own image is C, so
246 // mark it checked to avoid re-probing the reverse direction.
247 if involution {
248 if let Some(&partner) = self.index_by_key.get(keybuf.as_slice()) {
249 self.visited_gen[partner] = self.vgen;
250 }
251 }
252 }
253 }
254 true
255 }
256
257 /// Occurrence-driven unit propagation layered over the persistent `base`: assume every literal
258 /// of `assume`, propagate, and report whether a conflict results. Visits only clauses sharing a
259 /// variable with a newly-assigned literal, and starts from the already-derived standing units —
260 /// so a call costs `O(touched)`, the difference between an `O(n⁴)` and an `O(n³)` refutation.
261 ///
262 /// Robust to duplicate literals (`x ∨ x` is the unit `x`) and tautologies (`x ∨ ¬x`), matching
263 /// the trusted `crate::rup::propagate` verdict exactly (a differential fuzz proves it).
264 pub fn propagate_to_conflict(&mut self, _num_vars: usize, assume: &[Lit]) -> bool {
265 if self.base_conflict {
266 return true;
267 }
268 self.gen = self.gen.wrapping_add(1);
269 if self.gen == 0 {
270 for g in &mut self.temp_gen {
271 *g = 0;
272 }
273 self.gen = 1;
274 }
275 let mut queue: Vec<u32> = Vec::new();
276 // Seed the assumptions over the standing base. `None` means assign; an opposite value (in
277 // base or the live temp layer) is an immediate conflict.
278 for &l in assume {
279 let v = l.var() as usize;
280 let cur = if self.temp_gen[v] == self.gen {
281 Some(self.temp_val[v])
282 } else {
283 self.base[v]
284 };
285 match cur {
286 Some(b) if b == l.is_positive() => {}
287 Some(_) => return true,
288 None => {
289 self.temp_gen[v] = self.gen;
290 self.temp_val[v] = l.is_positive();
291 queue.push(v as u32);
292 }
293 }
294 }
295 let mut qi = 0;
296 while qi < queue.len() {
297 let v = queue[qi] as usize;
298 qi += 1;
299 for k in 0..self.by_var[v].len() {
300 let ci = self.by_var[v][k];
301 let mut satisfied = false;
302 let mut unit: Option<Lit> = None;
303 let mut more_than_one = false;
304 for &l in &self.clauses[ci] {
305 let vi = l.var() as usize;
306 let raw = if self.temp_gen[vi] == self.gen {
307 Some(self.temp_val[vi])
308 } else {
309 self.base[vi]
310 };
311 match raw.map(|b| b == l.is_positive()) {
312 Some(true) => {
313 satisfied = true;
314 break;
315 }
316 Some(false) => {}
317 None => match unit {
318 None => unit = Some(l),
319 Some(u) if u == l => {}
320 Some(u) if u == l.negated() => {
321 satisfied = true;
322 break;
323 }
324 Some(_) => more_than_one = true,
325 },
326 }
327 }
328 if satisfied {
329 continue;
330 }
331 match unit {
332 None => return true, // every literal false ⇒ conflict
333 Some(u) if !more_than_one => {
334 let uv = u.var() as usize;
335 self.temp_gen[uv] = self.gen;
336 self.temp_val[uv] = u.is_positive();
337 queue.push(uv as u32);
338 }
339 _ => {}
340 }
341 }
342 }
343 false
344 }
345}
346
347// ===========================================================================================
348// General symmetry detection (saucy/bliss-style individualization-refinement)
349// ===========================================================================================
350//
351// We reduce the CNF to a vertex-colored graph whose color-preserving automorphisms are exactly
352// the symmetries of the formula, then search for a generating set of that automorphism group.
353//
354// Vertices: two per variable — the positive and negative literal — joined by a phase-consistency
355// edge (so an automorphism maps a variable's two literals together, possibly swapping phase); and
356// one per clause, joined to each of its literal vertices. Literal vertices share one color; clause
357// vertices are colored by length, disjoint from literals — so no automorphism maps a clause to a
358// literal or to a clause of a different length. The literal-vertex index is exactly the packed
359// `Lit` code (`2*var + sign`), so the literal half of a graph automorphism reads off as a [`Perm`].
360//
361// Soundness rests entirely on [`perm_is_automorphism`]: every candidate the search proposes is
362// re-verified before it is returned, so a bug in the (intricate) search can only cost
363// completeness, never soundness.
364
365/// Find a generating set of the symmetry group of `clauses` — literal permutations `σ` with
366/// `σ(F) = F`. Each returned generator is independently verified by [`perm_is_automorphism`].
367pub fn find_generators(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Perm> {
368 if num_vars == 0 {
369 return Vec::new();
370 }
371 let (adj, init_colors) = build_graph(num_vars, clauses);
372 let adj_sorted: Vec<Vec<usize>> = adj
373 .iter()
374 .map(|a| {
375 let mut s = a.clone();
376 s.sort_unstable();
377 s
378 })
379 .collect();
380 let base = refine(&adj, &init_colors);
381 let mut ctx = Search {
382 adj: &adj,
383 adj_sorted: &adj_sorted,
384 init_colors: &init_colors,
385 num_vars,
386 clauses,
387 parent: (0..adj.len()).collect(),
388 first_leaf: None,
389 gens: Vec::new(),
390 seen: std::collections::HashSet::new(),
391 budget: 500_000,
392 };
393 ctx.search(base);
394 ctx.gens
395}
396
397/// Search state threaded through the individualization-refinement recursion.
398struct Search<'a> {
399 adj: &'a [Vec<usize>],
400 adj_sorted: &'a [Vec<usize>],
401 init_colors: &'a [u32],
402 num_vars: usize,
403 clauses: &'a [Vec<Lit>],
404 /// Union-find over graph vertices, for orbit pruning (orbits only grow as generators appear).
405 parent: Vec<usize>,
406 /// The first discrete leaf reached — the reference labeling all later leaves compare against.
407 first_leaf: Option<Vec<u32>>,
408 gens: Vec<Perm>,
409 /// Dedup of emitted generators by their literal-image codes.
410 seen: std::collections::HashSet<Vec<u32>>,
411 /// A node budget guarding against blow-up on highly symmetric inputs (sound if exhausted).
412 budget: usize,
413}
414
415impl Search<'_> {
416 fn find(&mut self, mut x: usize) -> usize {
417 while self.parent[x] != x {
418 self.parent[x] = self.parent[self.parent[x]];
419 x = self.parent[x];
420 }
421 x
422 }
423
424 fn union(&mut self, a: usize, b: usize) {
425 let (ra, rb) = (self.find(a), self.find(b));
426 if ra != rb {
427 self.parent[ra] = rb;
428 }
429 }
430
431 fn search(&mut self, coloring: Vec<u32>) {
432 if self.budget == 0 {
433 return;
434 }
435 self.budget -= 1;
436 match target_cell(&coloring) {
437 None => self.visit_leaf(coloring),
438 Some(cell) => {
439 // Explore one representative per orbit: always the first, then any not yet in the
440 // orbit (under generators found so far) of an explored representative.
441 let mut explored: Vec<usize> = Vec::new();
442 for (i, &u) in cell.iter().enumerate() {
443 if i > 0 {
444 let ru = self.find(u);
445 if explored.iter().any(|&r| self.find(r) == ru) {
446 continue;
447 }
448 }
449 explored.push(u);
450 let child = individualize(self.adj, &coloring, u);
451 self.search(child);
452 }
453 }
454 }
455 }
456
457 fn visit_leaf(&mut self, leaf: Vec<u32>) {
458 let ref0 = match &self.first_leaf {
459 None => {
460 self.first_leaf = Some(leaf);
461 return;
462 }
463 Some(r) => r.clone(),
464 };
465 let Some((vperm, lperm)) = self.candidate(&ref0, &leaf) else {
466 return;
467 };
468 let key = perm_key(self.num_vars, &lperm);
469 if !lperm.is_identity()
470 && !self.seen.contains(&key)
471 && perm_is_automorphism(self.clauses, &lperm)
472 {
473 self.seen.insert(key);
474 for u in 0..vperm.len() {
475 self.union(u, vperm[u]);
476 }
477 self.gens.push(lperm);
478 }
479 }
480
481 /// From the reference labeling `ref0` and another discrete `leaf`, form the vertex permutation
482 /// `g(u) = leaf⁻¹(ref0(u))` and, if it preserves colors and adjacency, read off its literal
483 /// part as a [`Perm`]. Returns `None` if the leaves are not related by a graph automorphism.
484 fn candidate(&self, ref0: &[u32], leaf: &[u32]) -> Option<(Vec<usize>, Perm)> {
485 let v = ref0.len();
486 let mut leaf_inv = vec![0usize; v];
487 for (u, &r) in leaf.iter().enumerate() {
488 leaf_inv[r as usize] = u;
489 }
490 let g: Vec<usize> = (0..v).map(|u| leaf_inv[ref0[u] as usize]).collect();
491 for u in 0..v {
492 if self.init_colors[u] != self.init_colors[g[u]] {
493 return None;
494 }
495 let mut mapped: Vec<usize> = self.adj[u].iter().map(|&w| g[w]).collect();
496 mapped.sort_unstable();
497 if mapped != self.adj_sorted[g[u]] {
498 return None;
499 }
500 }
501 let nlit = 2 * self.num_vars;
502 let mut images = Vec::with_capacity(self.num_vars);
503 for var in 0..self.num_vars {
504 let gv = g[2 * var];
505 if gv >= nlit {
506 return None;
507 }
508 images.push(Lit::new((gv / 2) as u32, gv % 2 == 0));
509 }
510 Some((g, Perm::from_images(images)))
511 }
512}
513
514/// Map a literal-permutation automorphism to a permutation of the `2·num_vars` **literal points**
515/// (`2v` = `+xᵥ`, `2v+1` = `¬xᵥ`), so the permutation-group backend ([`crate::permgroup`]) can analyze
516/// it. `σ` respects negation, so the result is a genuine permutation of the literal points.
517fn literal_perm_to_points(num_vars: usize, sigma: &Perm) -> crate::permgroup::Perm {
518 let idx = |l: Lit| 2 * l.var() as usize + usize::from(!l.is_positive());
519 let mut p = vec![0usize; 2 * num_vars];
520 for v in 0..num_vars as u32 {
521 for l in [Lit::pos(v), Lit::neg(v)] {
522 p[idx(l)] = idx(sigma.apply(l));
523 }
524 }
525 p
526}
527
528/// **The formula's automorphism group as a BSGS — Schreier–Sims as the symmetry backend.** The detected
529/// generators ([`find_generators`]) are bridged to permutations of the `2·num_vars` literal points and a
530/// base + strong generating set is built, so the symmetry layer can compute **`|Aut(F)|`**
531/// ([`crate::permgroup::Bsgs::order`]) and decide **membership / cosets**
532/// ([`crate::permgroup::Bsgs::contains`]) in polynomial time — group computations it previously could not
533/// do (it worked only with the raw generators). The stabilizer chain is the symmetry break, generalized
534/// from the abelian linear engines to an arbitrary (non-abelian) permutation group.
535pub fn automorphism_group(num_vars: usize, clauses: &[Vec<Lit>]) -> crate::permgroup::Bsgs {
536 let gens: Vec<crate::permgroup::Perm> = find_generators(num_vars, clauses)
537 .iter()
538 .filter(|g| !g.is_identity())
539 .map(|g| literal_perm_to_points(num_vars, g))
540 .collect();
541 crate::permgroup::schreier_sims(2 * num_vars, &gens)
542}
543
544/// `|Aut(F)|` — the exact number of formula automorphisms, via the Schreier–Sims backend.
545pub fn aut_order(num_vars: usize, clauses: &[Vec<Lit>]) -> u128 {
546 automorphism_group(num_vars, clauses).order()
547}
548
549/// Build the literal+clause colored graph: adjacency lists and initial colors.
550fn build_graph(num_vars: usize, clauses: &[Vec<Lit>]) -> (Vec<Vec<usize>>, Vec<u32>) {
551 let nlit = 2 * num_vars;
552 let vtotal = nlit + clauses.len();
553 let mut adj = vec![Vec::new(); vtotal];
554 let mut color = vec![0u32; vtotal];
555 for var in 0..num_vars {
556 let (a, b) = (2 * var, 2 * var + 1);
557 adj[a].push(b);
558 adj[b].push(a);
559 }
560 for (ci, clause) in clauses.iter().enumerate() {
561 let cv = nlit + ci;
562 color[cv] = 1 + clause.len() as u32;
563 for &l in clause {
564 let lv = (l.var() * 2 + u32::from(!l.is_positive())) as usize;
565 adj[cv].push(lv);
566 adj[lv].push(cv);
567 }
568 }
569 for a in adj.iter_mut() {
570 a.sort_unstable();
571 a.dedup();
572 }
573 (adj, color)
574}
575
576/// Color refinement to an equitable partition (1-dimensional Weisfeiler–Leman), canonicalized to
577/// dense ranks `0..k` deterministically (sorted by `(color, sorted neighbor colors)`).
578fn refine(adj: &[Vec<usize>], colors: &[u32]) -> Vec<u32> {
579 let v = adj.len();
580 let mut cur = colors.to_vec();
581 loop {
582 let sigs: Vec<(u32, Vec<u32>)> = (0..v)
583 .map(|u| {
584 let mut nb: Vec<u32> = adj[u].iter().map(|&w| cur[w]).collect();
585 nb.sort_unstable();
586 (cur[u], nb)
587 })
588 .collect();
589 let mut order: Vec<usize> = (0..v).collect();
590 order.sort_by(|&a, &b| sigs[a].cmp(&sigs[b]));
591 let mut new = vec![0u32; v];
592 let mut rank = 0u32;
593 for i in 0..v {
594 if i > 0 && sigs[order[i]] != sigs[order[i - 1]] {
595 rank += 1;
596 }
597 new[order[i]] = rank;
598 }
599 if new == cur {
600 return new;
601 }
602 cur = new;
603 }
604}
605
606/// Individualize vertex `u` (make it a singleton in its cell) and re-refine.
607fn individualize(adj: &[Vec<usize>], colors: &[u32], u: usize) -> Vec<u32> {
608 let mut c = colors.to_vec();
609 c[u] = u32::MAX;
610 refine(adj, &c)
611}
612
613/// The first non-singleton color class (the branching target), or `None` if the coloring is
614/// discrete (every vertex its own color).
615fn target_cell(colors: &[u32]) -> Option<Vec<usize>> {
616 let maxc = *colors.iter().max().unwrap();
617 for c in 0..=maxc {
618 let members: Vec<usize> = (0..colors.len()).filter(|&u| colors[u] == c).collect();
619 if members.len() > 1 {
620 return Some(members);
621 }
622 }
623 None
624}
625
626/// A `Perm`'s identity key: the packed code of each variable's positive-literal image.
627fn perm_key(num_vars: usize, perm: &Perm) -> Vec<u32> {
628 (0..num_vars as u32)
629 .map(|v| {
630 let l = perm.apply(Lit::pos(v));
631 l.var() * 2 + u32::from(!l.is_positive())
632 })
633 .collect()
634}
635
636#[cfg(test)]
637#[path = "../../logicaffeine_tests/unit/logicaffeine_proof/symmetry_detect/tests.rs"]
638mod tests;