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.iter().map(|l| l.var() * 2 + u32::from(!l.is_positive())).collect();
21    k.sort_unstable();
22    k.dedup();
23    k
24}
25
26/// Is `sigma` a genuine automorphism of `clauses` — does applying it map the clause set exactly
27/// onto itself? The independent re-verification every generator must pass.
28///
29/// `σ` is an automorphism iff `σ(C) ∈ F` for every clause `C ∈ F` (a literal-permutation is a
30/// bijection on clauses, so mapping `F` into itself forces it *onto* itself). A clause whose
31/// variables are all **fixed** by `σ` maps to itself and is trivially present — so only clauses
32/// touching `σ`'s *support* (its moved variables) need a membership check. For the small-support
33/// generators symmetry breaking actually uses (transpositions, short cycles) this inspects a
34/// handful of clauses instead of re-sorting the whole database, which is the difference between
35/// an `O(n⁴)` and an `O(n³)` certified pigeonhole refutation.
36pub fn perm_is_automorphism(clauses: &[Vec<Lit>], sigma: &Perm) -> bool {
37    let nv = sigma.num_vars();
38    let moved: Vec<bool> =
39        (0..nv).map(|v| sigma.apply(Lit::pos(v as u32)) != Lit::pos(v as u32)).collect();
40    // Nothing moves ⇒ identity ⇒ automorphism of anything.
41    if moved.iter().all(|&m| !m) {
42        return true;
43    }
44    let set: std::collections::HashSet<Vec<u32>> = clauses.iter().map(|c| clause_key(c)).collect();
45    for c in clauses {
46        let touches_support =
47            c.iter().any(|l| (l.var() as usize) < nv && moved[l.var() as usize]);
48        if touches_support && !set.contains(&clause_key(&sigma.apply_clause(c))) {
49            return false;
50        }
51    }
52    true
53}
54
55/// An **incrementally-maintained** clause database for fast repeated automorphism checks.
56///
57/// A certified symmetry-breaking refutation re-verifies an automorphism `σ` against the *current*
58/// database once per step, but the database only grows by one clause each step. Rebuilding the
59/// membership set from scratch every call (as the stateless [`perm_is_automorphism`] must) makes
60/// the whole refutation `O(n⁴)`; maintaining the set — and a variable→clause occurrence map —
61/// incrementally drops each check to `O(support of σ)`, and the refutation to `O(n³)`.
62///
63/// The verdict is identical to [`perm_is_automorphism`] (a differential fuzz proves it); this is a
64/// pure acceleration structure, sound by construction: `σ` is an automorphism iff `σ(C) ∈ F` for
65/// every clause `C` it moves, and a clause disjoint from `σ`'s support maps to itself.
66pub struct AutomorphismIndex {
67    nv: usize,
68    clauses: Vec<Vec<Lit>>,
69    keys: std::collections::HashSet<Vec<u32>>,
70    /// Clause key → an index of a clause with that key, so an involution `σ` can mark the partner
71    /// `σ(C)` of a just-verified clause `C` as already-checked (`σ(σ(C)) = C`, so the reverse
72    /// direction is free) — halving the automorphism re-verification on transposition generators.
73    index_by_key: std::collections::HashMap<Vec<u32>, usize>,
74    by_var: Vec<Vec<usize>>,
75    /// The **persistent** unit-propagation fixpoint of every inserted clause — the literals forced
76    /// regardless of any per-call assumption. Maintained incrementally on `insert`, so propagation
77    /// never re-derives the standing units (which on pigeonhole number `O(n²)` and would cost
78    /// `O(n⁴)` to re-seed every step).
79    base: Vec<Option<bool>>,
80    /// Whether the standing clauses already unit-propagate to a conflict (the database is refuted
81    /// by unit propagation alone, independent of any assumption).
82    base_conflict: bool,
83    /// Stamped per-call scratch layered over `base`: variable `v` is temporarily assigned iff
84    /// `temp_gen[v] == gen`, with value `temp_val[v]`. Bumping `gen` resets the whole layer in
85    /// `O(1)`, so a propagation call costs `O(touched)`, never `O(num_vars)`.
86    temp_gen: Vec<u32>,
87    temp_val: Vec<bool>,
88    gen: u32,
89    /// Stamped visited marks for `is_automorphism`, reset the same `O(1)` way.
90    visited_gen: Vec<u32>,
91    vgen: u32,
92}
93
94impl AutomorphismIndex {
95    /// An empty index over `nv` variables.
96    pub fn new(nv: usize) -> Self {
97        AutomorphismIndex {
98            nv,
99            clauses: Vec::new(),
100            keys: std::collections::HashSet::new(),
101            index_by_key: std::collections::HashMap::new(),
102            by_var: vec![Vec::new(); nv],
103            base: vec![None; nv],
104            base_conflict: false,
105            temp_gen: vec![0; nv],
106            temp_val: vec![false; nv],
107            gen: 0,
108            visited_gen: Vec::new(),
109            vgen: 0,
110        }
111    }
112
113    /// An index pre-loaded with `clauses`.
114    pub fn with_clauses(nv: usize, clauses: &[Vec<Lit>]) -> Self {
115        let mut ix = Self::new(nv);
116        for c in clauses {
117            ix.insert(c.clone());
118        }
119        ix
120    }
121
122    /// Add one clause, updating the membership set, the occurrence lists, and the persistent
123    /// unit-propagation base (which may cascade) — all amortized over the life of the index.
124    pub fn insert(&mut self, clause: Vec<Lit>) {
125        let idx = self.clauses.len();
126        let key = clause_key(&clause);
127        self.keys.insert(key.clone());
128        self.index_by_key.insert(key, idx);
129        let mut touched: Vec<usize> = clause.iter().map(|l| l.var() as usize).filter(|&v| v < self.nv).collect();
130        touched.sort_unstable();
131        touched.dedup();
132        for v in touched {
133            self.by_var[v].push(idx);
134        }
135        self.visited_gen.push(0);
136        self.clauses.push(clause);
137        // Fold the new clause into the persistent UP fixpoint: if it is (or becomes) unit under
138        // `base` it forces a literal, which may cascade to other clauses sharing that variable.
139        self.base_propagate(idx);
140    }
141
142    /// Incremental base unit-propagation seeded by a freshly-inserted clause index. Reads `base`,
143    /// writes `base`, never allocates per propagated literal beyond the worklist.
144    fn base_propagate(&mut self, start: usize) {
145        if self.base_conflict {
146            return;
147        }
148        let mut wl = vec![start];
149        let mut wi = 0;
150        while wi < wl.len() {
151            let ci = wl[wi];
152            wi += 1;
153            let mut satisfied = false;
154            let mut unit: Option<Lit> = None;
155            let mut more_than_one = false;
156            for &l in &self.clauses[ci] {
157                let val = self.base[l.var() as usize].map(|b| b == l.is_positive());
158                match val {
159                    Some(true) => {
160                        satisfied = true;
161                        break;
162                    }
163                    Some(false) => {}
164                    None => match unit {
165                        None => unit = Some(l),
166                        Some(u) if u == l => {}
167                        Some(u) if u == l.negated() => {
168                            satisfied = true;
169                            break;
170                        }
171                        Some(_) => more_than_one = true,
172                    },
173                }
174            }
175            if satisfied {
176                continue;
177            }
178            match unit {
179                None => {
180                    self.base_conflict = true;
181                    return;
182                }
183                Some(u) if !more_than_one => {
184                    self.base[u.var() as usize] = Some(u.is_positive());
185                    for &cj in &self.by_var[u.var() as usize] {
186                        wl.push(cj);
187                    }
188                }
189                _ => {}
190            }
191        }
192    }
193
194    /// Is `sigma` an automorphism of the indexed database? Inspects only the clauses `sigma`
195    /// actually moves — the union of the occurrence lists of its moved variables — using a stamped
196    /// visited mark instead of a fresh `O(|db|)` allocation each call.
197    pub fn is_automorphism(&mut self, sigma: &Perm) -> bool {
198        self.vgen = self.vgen.wrapping_add(1);
199        if self.vgen == 0 {
200            for g in &mut self.visited_gen {
201                *g = 0;
202            }
203            self.vgen = 1;
204        }
205        // If σ is an involution (σ² = id — every transposition, the bread and butter of symmetry
206        // breaking, is one) the moved clauses pair up as {C, σ(C)}: verifying σ(C) ∈ F also settles
207        // σ(σ(C)) = C ∈ F for free, so we mark the partner checked and skip it — halving the work.
208        let involution = (0..self.nv as u32).all(|v| {
209            let pv = Lit::pos(v);
210            sigma.apply(sigma.apply(pv)) == pv
211        });
212        // A single reusable key buffer for the whole call — the σ-image of each visited clause is
213        // built in place and probed against the membership set by slice, so the inner loop performs
214        // ZERO heap allocations (the previous two-Vec-per-clause cost dominated at pigeonhole scale).
215        let mut keybuf: Vec<u32> = Vec::new();
216        for v in 0..self.nv {
217            if sigma.apply(Lit::pos(v as u32)) == Lit::pos(v as u32) {
218                continue;
219            }
220            for k in 0..self.by_var[v].len() {
221                let ci = self.by_var[v][k];
222                if self.visited_gen[ci] == self.vgen {
223                    continue;
224                }
225                self.visited_gen[ci] = self.vgen;
226                keybuf.clear();
227                for &l in &self.clauses[ci] {
228                    let m = sigma.apply(l);
229                    keybuf.push(m.var() * 2 + u32::from(!m.is_positive()));
230                }
231                keybuf.sort_unstable();
232                keybuf.dedup();
233                if !self.keys.contains(keybuf.as_slice()) {
234                    return false;
235                }
236                // The partner σ(C) is verified present; for an involution its own image is C, so
237                // mark it checked to avoid re-probing the reverse direction.
238                if involution {
239                    if let Some(&partner) = self.index_by_key.get(keybuf.as_slice()) {
240                        self.visited_gen[partner] = self.vgen;
241                    }
242                }
243            }
244        }
245        true
246    }
247
248    /// Occurrence-driven unit propagation layered over the persistent `base`: assume every literal
249    /// of `assume`, propagate, and report whether a conflict results. Visits only clauses sharing a
250    /// variable with a newly-assigned literal, and starts from the already-derived standing units —
251    /// so a call costs `O(touched)`, the difference between an `O(n⁴)` and an `O(n³)` refutation.
252    ///
253    /// Robust to duplicate literals (`x ∨ x` is the unit `x`) and tautologies (`x ∨ ¬x`), matching
254    /// the trusted [`crate::rup::propagate`] verdict exactly (a differential fuzz proves it).
255    pub fn propagate_to_conflict(&mut self, _num_vars: usize, assume: &[Lit]) -> bool {
256        if self.base_conflict {
257            return true;
258        }
259        self.gen = self.gen.wrapping_add(1);
260        if self.gen == 0 {
261            for g in &mut self.temp_gen {
262                *g = 0;
263            }
264            self.gen = 1;
265        }
266        let mut queue: Vec<u32> = Vec::new();
267        // Seed the assumptions over the standing base. `None` means assign; an opposite value (in
268        // base or the live temp layer) is an immediate conflict.
269        for &l in assume {
270            let v = l.var() as usize;
271            let cur = if self.temp_gen[v] == self.gen { Some(self.temp_val[v]) } else { self.base[v] };
272            match cur {
273                Some(b) if b == l.is_positive() => {}
274                Some(_) => return true,
275                None => {
276                    self.temp_gen[v] = self.gen;
277                    self.temp_val[v] = l.is_positive();
278                    queue.push(v as u32);
279                }
280            }
281        }
282        let mut qi = 0;
283        while qi < queue.len() {
284            let v = queue[qi] as usize;
285            qi += 1;
286            for k in 0..self.by_var[v].len() {
287                let ci = self.by_var[v][k];
288                let mut satisfied = false;
289                let mut unit: Option<Lit> = None;
290                let mut more_than_one = false;
291                for &l in &self.clauses[ci] {
292                    let vi = l.var() as usize;
293                    let raw = if self.temp_gen[vi] == self.gen { Some(self.temp_val[vi]) } else { self.base[vi] };
294                    match raw.map(|b| b == l.is_positive()) {
295                        Some(true) => {
296                            satisfied = true;
297                            break;
298                        }
299                        Some(false) => {}
300                        None => match unit {
301                            None => unit = Some(l),
302                            Some(u) if u == l => {}
303                            Some(u) if u == l.negated() => {
304                                satisfied = true;
305                                break;
306                            }
307                            Some(_) => more_than_one = true,
308                        },
309                    }
310                }
311                if satisfied {
312                    continue;
313                }
314                match unit {
315                    None => return true, // every literal false ⇒ conflict
316                    Some(u) if !more_than_one => {
317                        let uv = u.var() as usize;
318                        self.temp_gen[uv] = self.gen;
319                        self.temp_val[uv] = u.is_positive();
320                        queue.push(uv as u32);
321                    }
322                    _ => {}
323                }
324            }
325        }
326        false
327    }
328}
329
330// ===========================================================================================
331// General symmetry detection (saucy/bliss-style individualization-refinement)
332// ===========================================================================================
333//
334// We reduce the CNF to a vertex-colored graph whose color-preserving automorphisms are exactly
335// the symmetries of the formula, then search for a generating set of that automorphism group.
336//
337// Vertices: two per variable — the positive and negative literal — joined by a phase-consistency
338// edge (so an automorphism maps a variable's two literals together, possibly swapping phase); and
339// one per clause, joined to each of its literal vertices. Literal vertices share one color; clause
340// vertices are colored by length, disjoint from literals — so no automorphism maps a clause to a
341// literal or to a clause of a different length. The literal-vertex index is exactly the packed
342// `Lit` code (`2*var + sign`), so the literal half of a graph automorphism reads off as a [`Perm`].
343//
344// Soundness rests entirely on [`perm_is_automorphism`]: every candidate the search proposes is
345// re-verified before it is returned, so a bug in the (intricate) search can only cost
346// completeness, never soundness.
347
348/// Find a generating set of the symmetry group of `clauses` — literal permutations `σ` with
349/// `σ(F) = F`. Each returned generator is independently verified by [`perm_is_automorphism`].
350pub fn find_generators(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Perm> {
351    if num_vars == 0 {
352        return Vec::new();
353    }
354    let (adj, init_colors) = build_graph(num_vars, clauses);
355    let adj_sorted: Vec<Vec<usize>> = adj
356        .iter()
357        .map(|a| {
358            let mut s = a.clone();
359            s.sort_unstable();
360            s
361        })
362        .collect();
363    let base = refine(&adj, &init_colors);
364    let mut ctx = Search {
365        adj: &adj,
366        adj_sorted: &adj_sorted,
367        init_colors: &init_colors,
368        num_vars,
369        clauses,
370        parent: (0..adj.len()).collect(),
371        first_leaf: None,
372        gens: Vec::new(),
373        seen: std::collections::HashSet::new(),
374        budget: 500_000,
375    };
376    ctx.search(base);
377    ctx.gens
378}
379
380/// Search state threaded through the individualization-refinement recursion.
381struct Search<'a> {
382    adj: &'a [Vec<usize>],
383    adj_sorted: &'a [Vec<usize>],
384    init_colors: &'a [u32],
385    num_vars: usize,
386    clauses: &'a [Vec<Lit>],
387    /// Union-find over graph vertices, for orbit pruning (orbits only grow as generators appear).
388    parent: Vec<usize>,
389    /// The first discrete leaf reached — the reference labeling all later leaves compare against.
390    first_leaf: Option<Vec<u32>>,
391    gens: Vec<Perm>,
392    /// Dedup of emitted generators by their literal-image codes.
393    seen: std::collections::HashSet<Vec<u32>>,
394    /// A node budget guarding against blow-up on highly symmetric inputs (sound if exhausted).
395    budget: usize,
396}
397
398impl Search<'_> {
399    fn find(&mut self, mut x: usize) -> usize {
400        while self.parent[x] != x {
401            self.parent[x] = self.parent[self.parent[x]];
402            x = self.parent[x];
403        }
404        x
405    }
406
407    fn union(&mut self, a: usize, b: usize) {
408        let (ra, rb) = (self.find(a), self.find(b));
409        if ra != rb {
410            self.parent[ra] = rb;
411        }
412    }
413
414    fn search(&mut self, coloring: Vec<u32>) {
415        if self.budget == 0 {
416            return;
417        }
418        self.budget -= 1;
419        match target_cell(&coloring) {
420            None => self.visit_leaf(coloring),
421            Some(cell) => {
422                // Explore one representative per orbit: always the first, then any not yet in the
423                // orbit (under generators found so far) of an explored representative.
424                let mut explored: Vec<usize> = Vec::new();
425                for (i, &u) in cell.iter().enumerate() {
426                    if i > 0 {
427                        let ru = self.find(u);
428                        if explored.iter().any(|&r| self.find(r) == ru) {
429                            continue;
430                        }
431                    }
432                    explored.push(u);
433                    let child = individualize(self.adj, &coloring, u);
434                    self.search(child);
435                }
436            }
437        }
438    }
439
440    fn visit_leaf(&mut self, leaf: Vec<u32>) {
441        let ref0 = match &self.first_leaf {
442            None => {
443                self.first_leaf = Some(leaf);
444                return;
445            }
446            Some(r) => r.clone(),
447        };
448        let Some((vperm, lperm)) = self.candidate(&ref0, &leaf) else { return };
449        let key = perm_key(self.num_vars, &lperm);
450        if !lperm.is_identity() && !self.seen.contains(&key) && perm_is_automorphism(self.clauses, &lperm) {
451            self.seen.insert(key);
452            for u in 0..vperm.len() {
453                self.union(u, vperm[u]);
454            }
455            self.gens.push(lperm);
456        }
457    }
458
459    /// From the reference labeling `ref0` and another discrete `leaf`, form the vertex permutation
460    /// `g(u) = leaf⁻¹(ref0(u))` and, if it preserves colors and adjacency, read off its literal
461    /// part as a [`Perm`]. Returns `None` if the leaves are not related by a graph automorphism.
462    fn candidate(&self, ref0: &[u32], leaf: &[u32]) -> Option<(Vec<usize>, Perm)> {
463        let v = ref0.len();
464        let mut leaf_inv = vec![0usize; v];
465        for (u, &r) in leaf.iter().enumerate() {
466            leaf_inv[r as usize] = u;
467        }
468        let g: Vec<usize> = (0..v).map(|u| leaf_inv[ref0[u] as usize]).collect();
469        for u in 0..v {
470            if self.init_colors[u] != self.init_colors[g[u]] {
471                return None;
472            }
473            let mut mapped: Vec<usize> = self.adj[u].iter().map(|&w| g[w]).collect();
474            mapped.sort_unstable();
475            if mapped != self.adj_sorted[g[u]] {
476                return None;
477            }
478        }
479        let nlit = 2 * self.num_vars;
480        let mut images = Vec::with_capacity(self.num_vars);
481        for var in 0..self.num_vars {
482            let gv = g[2 * var];
483            if gv >= nlit {
484                return None;
485            }
486            images.push(Lit::new((gv / 2) as u32, gv % 2 == 0));
487        }
488        Some((g, Perm::from_images(images)))
489    }
490}
491
492/// Map a literal-permutation automorphism to a permutation of the `2·num_vars` **literal points**
493/// (`2v` = `+xᵥ`, `2v+1` = `¬xᵥ`), so the permutation-group backend ([`crate::permgroup`]) can analyze
494/// it. `σ` respects negation, so the result is a genuine permutation of the literal points.
495fn literal_perm_to_points(num_vars: usize, sigma: &Perm) -> crate::permgroup::Perm {
496    let idx = |l: Lit| 2 * l.var() as usize + usize::from(!l.is_positive());
497    let mut p = vec![0usize; 2 * num_vars];
498    for v in 0..num_vars as u32 {
499        for l in [Lit::pos(v), Lit::neg(v)] {
500            p[idx(l)] = idx(sigma.apply(l));
501        }
502    }
503    p
504}
505
506/// **The formula's automorphism group as a BSGS — Schreier–Sims as the symmetry backend.** The detected
507/// generators ([`find_generators`]) are bridged to permutations of the `2·num_vars` literal points and a
508/// base + strong generating set is built, so the symmetry layer can compute **`|Aut(F)|`**
509/// ([`crate::permgroup::Bsgs::order`]) and decide **membership / cosets**
510/// ([`crate::permgroup::Bsgs::contains`]) in polynomial time — group computations it previously could not
511/// do (it worked only with the raw generators). The stabilizer chain is the symmetry break, generalized
512/// from the abelian linear engines to an arbitrary (non-abelian) permutation group.
513pub fn automorphism_group(num_vars: usize, clauses: &[Vec<Lit>]) -> crate::permgroup::Bsgs {
514    let gens: Vec<crate::permgroup::Perm> = find_generators(num_vars, clauses)
515        .iter()
516        .filter(|g| !g.is_identity())
517        .map(|g| literal_perm_to_points(num_vars, g))
518        .collect();
519    crate::permgroup::schreier_sims(2 * num_vars, &gens)
520}
521
522/// `|Aut(F)|` — the exact number of formula automorphisms, via the Schreier–Sims backend.
523pub fn aut_order(num_vars: usize, clauses: &[Vec<Lit>]) -> u128 {
524    automorphism_group(num_vars, clauses).order()
525}
526
527/// Build the literal+clause colored graph: adjacency lists and initial colors.
528fn build_graph(num_vars: usize, clauses: &[Vec<Lit>]) -> (Vec<Vec<usize>>, Vec<u32>) {
529    let nlit = 2 * num_vars;
530    let vtotal = nlit + clauses.len();
531    let mut adj = vec![Vec::new(); vtotal];
532    let mut color = vec![0u32; vtotal];
533    for var in 0..num_vars {
534        let (a, b) = (2 * var, 2 * var + 1);
535        adj[a].push(b);
536        adj[b].push(a);
537    }
538    for (ci, clause) in clauses.iter().enumerate() {
539        let cv = nlit + ci;
540        color[cv] = 1 + clause.len() as u32;
541        for &l in clause {
542            let lv = (l.var() * 2 + u32::from(!l.is_positive())) as usize;
543            adj[cv].push(lv);
544            adj[lv].push(cv);
545        }
546    }
547    for a in adj.iter_mut() {
548        a.sort_unstable();
549        a.dedup();
550    }
551    (adj, color)
552}
553
554/// Color refinement to an equitable partition (1-dimensional Weisfeiler–Leman), canonicalized to
555/// dense ranks `0..k` deterministically (sorted by `(color, sorted neighbor colors)`).
556fn refine(adj: &[Vec<usize>], colors: &[u32]) -> Vec<u32> {
557    let v = adj.len();
558    let mut cur = colors.to_vec();
559    loop {
560        let sigs: Vec<(u32, Vec<u32>)> = (0..v)
561            .map(|u| {
562                let mut nb: Vec<u32> = adj[u].iter().map(|&w| cur[w]).collect();
563                nb.sort_unstable();
564                (cur[u], nb)
565            })
566            .collect();
567        let mut order: Vec<usize> = (0..v).collect();
568        order.sort_by(|&a, &b| sigs[a].cmp(&sigs[b]));
569        let mut new = vec![0u32; v];
570        let mut rank = 0u32;
571        for i in 0..v {
572            if i > 0 && sigs[order[i]] != sigs[order[i - 1]] {
573                rank += 1;
574            }
575            new[order[i]] = rank;
576        }
577        if new == cur {
578            return new;
579        }
580        cur = new;
581    }
582}
583
584/// Individualize vertex `u` (make it a singleton in its cell) and re-refine.
585fn individualize(adj: &[Vec<usize>], colors: &[u32], u: usize) -> Vec<u32> {
586    let mut c = colors.to_vec();
587    c[u] = u32::MAX;
588    refine(adj, &c)
589}
590
591/// The first non-singleton color class (the branching target), or `None` if the coloring is
592/// discrete (every vertex its own color).
593fn target_cell(colors: &[u32]) -> Option<Vec<usize>> {
594    let maxc = *colors.iter().max().unwrap();
595    for c in 0..=maxc {
596        let members: Vec<usize> = (0..colors.len()).filter(|&u| colors[u] == c).collect();
597        if members.len() > 1 {
598            return Some(members);
599        }
600    }
601    None
602}
603
604/// A `Perm`'s identity key: the packed code of each variable's positive-literal image.
605fn perm_key(num_vars: usize, perm: &Perm) -> Vec<u32> {
606    (0..num_vars as u32)
607        .map(|v| {
608            let l = perm.apply(Lit::pos(v));
609            l.var() * 2 + u32::from(!l.is_positive())
610        })
611        .collect()
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::cdcl::Lit;
618    use crate::families;
619    use crate::proof::Perm;
620
621    /// Build the permutation that swaps two pigeon rows of PHP(n): for every hole, exchange the
622    /// two pigeons' variables. This is a textbook automorphism of the pigeonhole formula.
623    fn swap_pigeon_rows(n: usize, p0: usize, p1: usize) -> Perm {
624        let holes = n - 1;
625        let images: Vec<Lit> = (0..n * holes)
626            .map(|v| {
627                let (p, h) = (v / holes, v % holes);
628                let np = if p == p0 {
629                    p1
630                } else if p == p1 {
631                    p0
632                } else {
633                    p
634                };
635                Lit::pos((np * holes + h) as u32)
636            })
637            .collect();
638        Perm::from_images(images)
639    }
640
641    #[test]
642    fn identity_is_always_an_automorphism() {
643        let (cnf, _) = families::php(4);
644        assert!(perm_is_automorphism(&cnf.clauses, &Perm::identity(cnf.num_vars)));
645    }
646
647    #[test]
648    fn swapping_pigeon_rows_is_an_automorphism_of_php() {
649        let (cnf, _) = families::php(4);
650        for (p0, p1) in [(0, 1), (1, 2), (0, 3), (2, 3)] {
651            let sigma = swap_pigeon_rows(4, p0, p1);
652            assert!(
653                perm_is_automorphism(&cnf.clauses, &sigma),
654                "swapping pigeons {p0},{p1} must preserve PHP(4)"
655            );
656        }
657    }
658
659    #[test]
660    fn support_restricted_check_matches_brute_force_set_equality() {
661        // The independent oracle: σ is an automorphism iff `{σ(C):C∈F}` equals `{C:C∈F}` as sets.
662        // Over many seeded random small formulas and random literal permutations, the fast
663        // support-restricted `perm_is_automorphism` must return the IDENTICAL verdict — the
664        // soundness net for the speedup, robust to absurdity.
665        let mut state = 0xA5A5_5A5A_DEAD_BEEFu64;
666        let mut next = || {
667            state = state.wrapping_add(0x9E3779B97F4A7C15);
668            let mut z = state;
669            z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
670            z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
671            z ^ (z >> 31)
672        };
673        let num_vars = 5usize;
674        let brute = |clauses: &[Vec<Lit>], sigma: &Perm| -> bool {
675            use std::collections::BTreeSet;
676            let orig: BTreeSet<Vec<u32>> = clauses.iter().map(|c| clause_key(c)).collect();
677            let mapped: BTreeSet<Vec<u32>> =
678                clauses.iter().map(|c| clause_key(&sigma.apply_clause(c))).collect();
679            orig == mapped
680        };
681        let mut accepts = 0;
682        for _ in 0..20_000 {
683            let nclauses = next() as usize % 6;
684            let clauses: Vec<Vec<Lit>> = (0..nclauses)
685                .map(|_| {
686                    let len = 1 + (next() as usize % 3);
687                    let mut c = Vec::new();
688                    for _ in 0..len {
689                        let v = (next() as u32) % num_vars as u32;
690                        let lit = Lit::new(v, next() & 1 == 0);
691                        if !c.contains(&lit) && !c.contains(&lit.negated()) {
692                            c.push(lit);
693                        }
694                    }
695                    c
696                })
697                .filter(|c| !c.is_empty())
698                .collect();
699            let sigma = {
700                let mut order: Vec<u32> = (0..num_vars as u32).collect();
701                for i in (1..num_vars).rev() {
702                    let j = next() as usize % (i + 1);
703                    order.swap(i, j);
704                }
705                Perm::from_images((0..num_vars).map(|v| Lit::new(order[v], next() & 1 == 0)).collect())
706            };
707            let fast = perm_is_automorphism(&clauses, &sigma);
708            assert_eq!(fast, brute(&clauses, &sigma), "fast vs brute disagree: clauses={clauses:?}");
709            if fast {
710                accepts += 1;
711            }
712        }
713        assert!(accepts > 0, "the differential must exercise genuine acceptances, not just rejects");
714    }
715
716    #[test]
717    fn incremental_index_matches_stateless_automorphism_check() {
718        // The incremental `AutomorphismIndex` must give the IDENTICAL verdict to the stateless
719        // `perm_is_automorphism` — over many seeded random formulas built up one clause at a time
720        // (exercising `insert`) and many random permutations. The soundness net for the speedup.
721        let mut state = 0x1234_5678_9ABC_DEF0u64;
722        let mut next = || {
723            state = state.wrapping_add(0x9E3779B97F4A7C15);
724            let mut z = state;
725            z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
726            z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
727            z ^ (z >> 31)
728        };
729        let num_vars = 5usize;
730        let mut agree = 0;
731        for _ in 0..20_000 {
732            let nclauses = next() as usize % 7;
733            let clauses: Vec<Vec<Lit>> = (0..nclauses)
734                .map(|_| {
735                    let len = 1 + (next() as usize % 3);
736                    let mut c = Vec::new();
737                    for _ in 0..len {
738                        let v = (next() as u32) % num_vars as u32;
739                        let lit = Lit::new(v, next() & 1 == 0);
740                        if !c.contains(&lit) && !c.contains(&lit.negated()) {
741                            c.push(lit);
742                        }
743                    }
744                    c
745                })
746                .filter(|c| !c.is_empty())
747                .collect();
748            // Build the index incrementally to exercise insert().
749            let mut ix = AutomorphismIndex::new(num_vars);
750            for c in &clauses {
751                ix.insert(c.clone());
752            }
753            let sigma = {
754                let mut order: Vec<u32> = (0..num_vars as u32).collect();
755                for i in (1..num_vars).rev() {
756                    let j = next() as usize % (i + 1);
757                    order.swap(i, j);
758                }
759                Perm::from_images((0..num_vars).map(|v| Lit::new(order[v], next() & 1 == 0)).collect())
760            };
761            assert_eq!(
762                ix.is_automorphism(&sigma),
763                perm_is_automorphism(&clauses, &sigma),
764                "incremental index disagrees with stateless check on {clauses:?}"
765            );
766            agree += 1;
767        }
768        assert_eq!(agree, 20_000);
769    }
770
771    #[test]
772    fn occurrence_propagation_matches_full_scan_propagation() {
773        // The occurrence-driven `propagate_to_conflict` must reach the IDENTICAL conflict verdict
774        // as the trusted full-scan `rup::propagate` — over many seeded random formulas and random
775        // assumption sets (including duplicate-literal and tautological clauses). The soundness
776        // net for routing the SR check's propagation through the index.
777        let mut state = 0x0F0F_F0F0_1357_9BDFu64;
778        let mut next = || {
779            state = state.wrapping_add(0x9E3779B97F4A7C15);
780            let mut z = state;
781            z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
782            z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
783            z ^ (z >> 31)
784        };
785        let num_vars = 6usize;
786        let reference = |clauses: &[Vec<Lit>], assume: &[Lit]| -> bool {
787            let mut assign: Vec<Option<bool>> = vec![None; num_vars];
788            for &l in assume {
789                if !crate::rup::set_true(&mut assign, l) {
790                    return true;
791                }
792            }
793            crate::rup::propagate(clauses, &mut assign)
794        };
795        let mut conflicts = 0;
796        for _ in 0..20_000 {
797            let nclauses = next() as usize % 8;
798            let clauses: Vec<Vec<Lit>> = (0..nclauses)
799                .map(|_| {
800                    let len = 1 + (next() as usize % 4);
801                    (0..len).map(|_| Lit::new((next() as u32) % num_vars as u32, next() & 1 == 0)).collect()
802                })
803                .filter(|c: &Vec<Lit>| !c.is_empty())
804                .collect();
805            let mut ix = AutomorphismIndex::new(num_vars);
806            for c in &clauses {
807                ix.insert(c.clone());
808            }
809            let nassume = next() as usize % 4;
810            let assume: Vec<Lit> =
811                (0..nassume).map(|_| Lit::new((next() as u32) % num_vars as u32, next() & 1 == 0)).collect();
812            let got = ix.propagate_to_conflict(num_vars, &assume);
813            assert_eq!(got, reference(&clauses, &assume), "clauses={clauses:?} assume={assume:?}");
814            if got {
815                conflicts += 1;
816            }
817        }
818        assert!(conflicts > 0, "the differential must exercise genuine conflicts");
819    }
820
821    #[test]
822    fn a_non_symmetry_is_rejected() {
823        // Map only pigeon 0's row to pigeon 1's row but NOT vice-versa: this collapses two rows
824        // onto one, so it is not a bijection-preserving automorphism of the clause set.
825        let (cnf, _) = families::php(3);
826        let holes = 2;
827        let images: Vec<Lit> = (0..cnf.num_vars)
828            .map(|v| {
829                let (p, h) = (v / holes, v % holes);
830                let np = if p == 0 { 1 } else { p };
831                Lit::pos((np * holes + h) as u32)
832            })
833            .collect();
834        assert!(!perm_is_automorphism(&cnf.clauses, &Perm::from_images(images)));
835    }
836
837    // --- the general finder ---
838
839    use std::collections::HashSet;
840
841    fn p(v: u32) -> Lit {
842        Lit::pos(v)
843    }
844    fn neg(v: u32) -> Lit {
845        Lit::neg(v)
846    }
847
848    /// All permutations of `0..n`.
849    fn all_var_perms(n: usize) -> Vec<Vec<usize>> {
850        fn rec(cur: &mut Vec<usize>, rem: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
851            if rem.is_empty() {
852                out.push(cur.clone());
853                return;
854            }
855            for i in 0..rem.len() {
856                let x = rem.remove(i);
857                cur.push(x);
858                rec(cur, rem, out);
859                cur.pop();
860                rem.insert(i, x);
861            }
862        }
863        let mut out = Vec::new();
864        rec(&mut Vec::new(), &mut (0..n).collect(), &mut out);
865        out
866    }
867
868    /// The TRUE symmetry group of `clauses`: every negation-respecting literal permutation
869    /// (variable bijection × per-variable phase) that is an automorphism, as a set of keys.
870    fn brute_force_group(num_vars: usize, clauses: &[Vec<Lit>]) -> HashSet<Vec<u32>> {
871        let mut set = HashSet::new();
872        for vp in all_var_perms(num_vars) {
873            for phase in 0..(1u32 << num_vars) {
874                let images: Vec<Lit> =
875                    (0..num_vars).map(|v| Lit::new(vp[v] as u32, (phase >> v) & 1 == 0)).collect();
876                let perm = Perm::from_images(images);
877                if perm_is_automorphism(clauses, &perm) {
878                    set.insert(perm_key(num_vars, &perm));
879                }
880            }
881        }
882        set
883    }
884
885    /// The set of element keys generated by `gens` (closure under composition, with identity).
886    fn generated_group(num_vars: usize, gens: &[Perm]) -> HashSet<Vec<u32>> {
887        let id = Perm::identity(num_vars);
888        let mut set = HashSet::new();
889        set.insert(perm_key(num_vars, &id));
890        let mut frontier = vec![id];
891        while let Some(x) = frontier.pop() {
892            for g in gens {
893                let y = g.compose(&x);
894                let k = perm_key(num_vars, &y);
895                if set.insert(k) {
896                    frontier.push(y);
897                }
898            }
899        }
900        set
901    }
902
903    #[test]
904    fn finds_the_full_php3_symmetry_group() {
905        let (cnf, _) = families::php(3);
906        let gens = find_generators(cnf.num_vars, &cnf.clauses);
907        let group = generated_group(cnf.num_vars, &gens);
908        // PHP(3): permute 3 pigeons × 2 holes = S_3 × S_2, order 12.
909        assert_eq!(group.len(), 12, "discovered generators must generate the full S_3 × S_2");
910    }
911
912    #[test]
913    fn discovered_generators_match_brute_force_across_cases() {
914        // (num_vars, clauses): PHP(2), PHP(3), a swap-symmetric pair, and an asymmetric formula.
915        let php2 = families::php(2).0;
916        let php3 = families::php(3).0;
917        let cases: Vec<(usize, Vec<Vec<Lit>>)> = vec![
918            (php2.num_vars, php2.clauses),
919            (php3.num_vars, php3.clauses),
920            (2, vec![vec![p(0), p(1)], vec![neg(0), neg(1)]]), // exactly-one(a,b): a↔b symmetric
921            (3, vec![vec![p(0)], vec![p(0), p(1)]]),           // asymmetric: only identity
922            (3, vec![vec![p(0), p(1), p(2)]]),                 // a single symmetric clause: S_3
923        ];
924        for (num_vars, clauses) in cases {
925            let gens = find_generators(num_vars, &clauses);
926            let found = generated_group(num_vars, &gens);
927            let truth = brute_force_group(num_vars, &clauses);
928            assert_eq!(found, truth, "finder must generate exactly Aut(F) for {clauses:?}");
929        }
930    }
931
932    #[test]
933    fn every_discovered_generator_is_a_verified_automorphism() {
934        let (cnf, _) = families::php(4);
935        let gens = find_generators(cnf.num_vars, &cnf.clauses);
936        assert!(!gens.is_empty(), "PHP(4) is highly symmetric — generators must be found");
937        for g in &gens {
938            assert!(perm_is_automorphism(&cnf.clauses, g), "every returned generator is sound");
939        }
940    }
941
942    #[test]
943    fn an_asymmetric_formula_yields_no_nontrivial_generators() {
944        let clauses = vec![vec![p(0)], vec![p(0), p(1)], vec![neg(1), p(2)]];
945        let gens = find_generators(3, &clauses);
946        assert!(gens.iter().all(|g| g.is_identity()), "no non-trivial symmetry to find");
947    }
948
949    /// **Schreier–Sims as the symmetry backend computes `|Aut(F)|` exactly.** PHP(n) has automorphism
950    /// group `S_n` (pigeons) × `S_{n−1}` (holes) ⟹ `|Aut| = n!·(n−1)!`; complete-graph `k`-colouring has
951    /// `S_n × S_k` ⟹ `|Aut| = n!·k!`. The backend reproduces both from the detected generators — a group
952    /// computation the symmetry layer could not previously perform.
953    #[test]
954    fn the_bsgs_backend_computes_the_automorphism_group_order() {
955        let fact = |k: u128| (1..=k).product::<u128>();
956        for n in 3..=5usize {
957            let (cnf, _) = crate::families::php(n);
958            assert_eq!(
959                aut_order(cnf.num_vars, &cnf.clauses),
960                fact(n as u128) * fact((n - 1) as u128),
961                "|Aut(PHP({n}))| = n!·(n-1)!"
962            );
963        }
964        for &(n, k) in &[(4usize, 3usize), (5, 3)] {
965            let (cnf, _) = crate::families::clique_coloring(n, k);
966            assert_eq!(
967                aut_order(cnf.num_vars, &cnf.clauses),
968                fact(n as u128) * fact(k as u128),
969                "|Aut(clique_coloring({n},{k}))| = n!·k!"
970            );
971        }
972    }
973
974    /// The backend decides membership / cosets in the automorphism group: every detected generator and
975    /// the identity are members; a variable swap that is not a global symmetry is rejected.
976    #[test]
977    fn the_bsgs_backend_decides_automorphism_membership() {
978        let (cnf, _) = crate::families::php(3); // |Aut| = 12, S_3 × S_2
979        let nv = cnf.num_vars;
980        let bsgs = automorphism_group(nv, &cnf.clauses);
981        for g in find_generators(nv, &cnf.clauses) {
982            assert!(bsgs.contains(&literal_perm_to_points(nv, &g)), "a detected generator is a member");
983        }
984        assert!(bsgs.contains(&(0..2 * nv).collect::<Vec<_>>()), "the identity is a member");
985        // Swapping only hole 0 / hole 1 of pigeon 0 (variables 0 and 1) is not a global symmetry.
986        let mut bad: Vec<usize> = (0..2 * nv).collect();
987        bad.swap(0, 2); // +x0 ↔ +x1
988        bad.swap(1, 3); // ¬x0 ↔ ¬x1
989        assert!(!bsgs.contains(&bad), "a non-automorphism variable swap must be rejected");
990    }
991}