Skip to main content

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;