Skip to main content

logicaffeine_proof/
sym_break.rs

1//! **Complete lex-leader symmetry breaking, driven by the Schreier–Sims backend.**
2//!
3//! Per-generator symmetry breaking keeps a *superset* of the canonical representatives. With the whole
4//! group in hand — enumerated from a BSGS ([`crate::permgroup`]) — we add the COMPLETE lex-leader
5//! predicate `a ≤_lex a∘g` for *every* `g ∈ G`, which keeps EXACTLY the lexicographically-least model of
6//! each orbit: the maximal sound symmetry break. Satisfiability is preserved (every orbit keeps one
7//! representative), and the number of surviving models equals the number of *orbits* of models.
8//!
9//! This is feasible when `|G|` is small/moderate — the BSGS reports the order, so the caller gates on it
10//! before enumerating. Huge-symmetry families (PHP at scale, `|Aut| = n!·(n−1)!`) are left to the
11//! dedicated polynomial specialists; complete lex-leader is for the moderate-symmetry instances those do
12//! not target. Scope: **variable** permutations (phase-free automorphisms — the symmetry of the
13//! covering/colouring families), acting on assignments by `(a∘g)[j] = a[g[j]]`.
14
15use crate::cdcl::{Lit, SolveResult, Solver};
16use crate::permgroup::Perm;
17
18/// Is `a` the lexicographic leader of its orbit — `a ≤_lex a∘g` for every `g`? The semantic canonical
19/// test; the CNF predicate [`lex_leader_sbp`] accepts exactly these assignments.
20pub fn is_lex_leader(group: &[Perm], a: &[bool]) -> bool {
21    let lit_group: Vec<Vec<Lit>> = group.iter().map(|g| perm_to_litsym(g)).collect();
22    is_lex_leader_lit(&lit_group, a)
23}
24
25/// `is_lex_leader` over **literal** symmetries: `aˢ[j]` is the value of the image literal `img[j]` under
26/// `a` (a phase flip negates the compared bit).
27pub fn is_lex_leader_lit(group: &[Vec<Lit>], a: &[bool]) -> bool {
28    let eval = |l: &Lit| if l.is_positive() { a[l.var() as usize] } else { !a[l.var() as usize] };
29    group.iter().all(|img| {
30        for j in 0..a.len() {
31            let (x, y) = (a[j], eval(&img[j]));
32            if x != y {
33                return !x && y;
34            }
35        }
36        true
37    })
38}
39
40/// The lex-leader symmetry-breaking predicate as CNF: for every non-identity `g ∈ group`, clauses
41/// asserting `a ≤_lex a∘g`. Returns the extra clauses plus the new total variable count (prefix-equality
42/// aux variables are appended above `num_vars`). It is satisfiability-preserving for any set of
43/// automorphisms (the lex-least model of each orbit always survives). Pass the **whole group** for the
44/// COMPLETE break (exactly one model per orbit), or just a **generating set** for a sound POLYNOMIAL
45/// PARTIAL break that scales to arbitrarily large groups — both keep at least one representative per orbit.
46pub fn lex_leader_sbp(num_vars: usize, group: &[Perm]) -> (Vec<Vec<Lit>>, usize) {
47    let lit_group: Vec<Vec<Lit>> = group.iter().map(|g| perm_to_litsym(g)).collect();
48    lex_leader_sbp_lit(num_vars, &lit_group)
49}
50
51/// The lex-leader SBP over **literal** symmetries (`group[k][j]` = the image literal of variable `j`),
52/// which breaks **variable and value/phase** symmetry alike. As with [`lex_leader_sbp`], pass the whole
53/// group for the complete break or a generating set for the polynomial partial break.
54pub fn lex_leader_sbp_lit(num_vars: usize, group: &[Vec<Lit>]) -> (Vec<Vec<Lit>>, usize) {
55    let mut clauses = Vec::new();
56    let mut aux = num_vars;
57    for img in group {
58        if (0..num_vars).all(|j| img[j] == Lit::pos(j as u32)) {
59            continue; // identity contributes nothing
60        }
61        encode_lex_le(num_vars, img, &mut aux, &mut clauses);
62    }
63    (clauses, aux)
64}
65
66/// A variable permutation as a literal symmetry (no phase flips): `imgⱼ = +x_{g[j]}`.
67fn perm_to_litsym(g: &Perm) -> Vec<Lit> {
68    g.iter().map(|&j| Lit::pos(j as u32)).collect()
69}
70
71/// The lex-leader SBP for **affine** maps `α: x ↦ Ax ⊕ b` over GF(2) — the machinery that breaks the affine
72/// parity symmetries a variable/literal permutation SBP ([`lex_leader_sbp`]) structurally cannot express (an
73/// image bit is an XOR of *several* variables, not one literal). Each `maps[k]` is a per-output spec:
74/// `maps[k][j] = (A_j, b_j)` with `α(x)[j] = ⊕_{i∈A_j} x_i ⊕ b_j`. Each non-identity output is Tseitin-encoded
75/// as a fresh variable, then the standard prefix-equality chain [`encode_lex_le`] enforces `x ≤_lex α(x)`.
76/// Satisfiability-preserving for any model-set affine symmetry (the lex-least model of each orbit survives).
77/// Returns the extra clauses and the new total variable count (aux appended above `num_vars`).
78pub fn affine_lex_leader_sbp(num_vars: usize, maps: &[Vec<(Vec<usize>, bool)>]) -> (Vec<Vec<Lit>>, usize) {
79    let mut clauses = Vec::new();
80    let mut aux = num_vars;
81    for map in maps {
82        let mut img: Vec<Lit> = (0..num_vars).map(|j| Lit::pos(j as u32)).collect();
83        for (j, (xset, b)) in map.iter().enumerate() {
84            if j >= num_vars {
85                break;
86            }
87            if xset.len() == 1 && xset[0] == j && !b {
88                continue; // identity output — `img[j]` stays `+xⱼ`
89            }
90            img[j] = tseitin_xor(&mut aux, xset, *b, &mut clauses);
91        }
92        if (0..num_vars).all(|j| img[j] == Lit::pos(j as u32)) {
93            continue; // the identity map contributes nothing
94        }
95        encode_lex_le(num_vars, &img, &mut aux, &mut clauses);
96    }
97    (clauses, aux)
98}
99
100/// Tseitin-encode `c = ⊕_{i∈xset} x_i ⊕ b` and return the literal `c` (a fresh chain variable, negated when
101/// `b`). Chains binary XOR gates `t = acc ⊕ v` (four clauses each). `xset` is assumed non-empty (an affine
102/// bijection's output bits each depend on ≥ 1 variable).
103fn tseitin_xor(aux: &mut usize, xset: &[usize], b: bool, clauses: &mut Vec<Vec<Lit>>) -> Lit {
104    let mut fresh = |aux: &mut usize| {
105        let v = *aux as u32;
106        *aux += 1;
107        Lit::pos(v)
108    };
109    let mut acc = Lit::pos(xset[0] as u32);
110    for &v in &xset[1..] {
111        let vv = Lit::pos(v as u32);
112        let t = fresh(aux);
113        clauses.push(vec![t, acc.negated(), vv]);
114        clauses.push(vec![t, acc, vv.negated()]);
115        clauses.push(vec![t.negated(), acc, vv]);
116        clauses.push(vec![t.negated(), acc.negated(), vv.negated()]);
117        acc = t;
118    }
119    if b {
120        acc.negated()
121    } else {
122        acc
123    }
124}
125
126/// Encode `a ≤_lex aˢ` where `aˢ[j]` is the value of the image literal `img[j]` under `a` (so a phase
127/// flip negates the compared bit — this is how **value/phase** symmetry is broken, not just variable
128/// symmetry). Prefix-equality Tseitin chain over fresh `aux` variables: at each non-fixed position `j`,
129/// `eₚ → (a[j] ≤ img[j])`, and `eₚ` advances only while the prefix stays equal. Positions where
130/// `img[j] = +xⱼ` are always equal and are skipped.
131fn encode_lex_le(num_vars: usize, img: &[Lit], aux: &mut usize, clauses: &mut Vec<Vec<Lit>>) {
132    let mut fresh = |aux: &mut usize| {
133        let v = *aux as u32;
134        *aux += 1;
135        Lit::pos(v)
136    };
137    let positions: Vec<usize> = (0..num_vars).filter(|&j| img[j] != Lit::pos(j as u32)).collect();
138    let mut prev_e: Option<Lit> = None; // None = the constant TRUE (e₀)
139    for (k, &j) in positions.iter().enumerate() {
140        let aj = Lit::pos(j as u32);
141        let cj = img[j];
142        // lex constraint at j: prefix-equal ⟹ a[j] ≤ c[j], i.e. (¬a[j] ∨ c[j]).
143        match prev_e {
144            None => clauses.push(vec![aj.negated(), cj]),
145            Some(e) => clauses.push(vec![e.negated(), aj.negated(), cj]),
146        }
147        if k + 1 == positions.len() {
148            break; // last moved position — no need to carry equality further
149        }
150        // eq ⟺ (a[j] == c[j])
151        let eq = fresh(aux);
152        clauses.push(vec![eq.negated(), aj.negated(), cj]);
153        clauses.push(vec![eq.negated(), aj, cj.negated()]);
154        clauses.push(vec![eq, aj.negated(), cj.negated()]);
155        clauses.push(vec![eq, aj, cj]);
156        // e_next ⟺ prefix-equal ∧ eq
157        let e_next = fresh(aux);
158        match prev_e {
159            None => {
160                clauses.push(vec![e_next.negated(), eq]);
161                clauses.push(vec![e_next, eq.negated()]);
162            }
163            Some(e) => {
164                clauses.push(vec![e_next.negated(), e]);
165                clauses.push(vec![e_next.negated(), eq]);
166                clauses.push(vec![e_next, e.negated(), eq.negated()]);
167            }
168        }
169        prev_e = Some(e_next);
170    }
171}
172
173/// The **variable**-permutation automorphism GENERATORS of a CNF (phase-free symmetries), without
174/// enumerating the group — fast, no size cap. `None` if a detected symmetry flips a phase (a value
175/// symmetry this variable scheme does not cover). An empty vector means no non-trivial symmetry.
176pub fn variable_automorphism_generators(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<Perm>> {
177    let lit_gens = crate::symmetry_detect::find_generators(num_vars, clauses);
178    let mut var_gens: Vec<Perm> = Vec::new();
179    for g in &lit_gens {
180        if g.is_identity() {
181            continue;
182        }
183        let mut vp = vec![0usize; num_vars];
184        for v in 0..num_vars as u32 {
185            let img = g.apply(Lit::pos(v));
186            if !img.is_positive() {
187                return None; // a phase flip ⟹ not a pure variable symmetry
188            }
189            vp[v as usize] = img.var() as usize;
190        }
191        var_gens.push(vp);
192    }
193    Some(var_gens)
194}
195
196/// The **variable**-permutation automorphism group of a CNF, fully enumerated via the Schreier–Sims
197/// backend (for *complete* symmetry breaking). `None` if a generator flips a phase, or `|G| > cap` (then
198/// the caller should use per-generator *partial* breaking on [`variable_automorphism_generators`] instead,
199/// which scales to arbitrarily large groups).
200pub fn variable_automorphism_group(num_vars: usize, clauses: &[Vec<Lit>], cap: usize) -> Option<Vec<Perm>> {
201    let gens = variable_automorphism_generators(num_vars, clauses)?;
202    crate::permgroup::schreier_sims(num_vars, &gens).elements(cap)
203}
204
205/// The literal-point index of a literal: `2v` for `+xᵥ`, `2v+1` for `¬xᵥ`.
206fn lit_idx(l: Lit) -> usize {
207    2 * l.var() as usize + usize::from(!l.is_positive())
208}
209
210/// A literal symmetry (`img[j]` = image literal of variable `j`) as a permutation of the `2·num_vars`
211/// literal points — for the Schreier–Sims backend (order, enumeration). Negation is respected.
212pub fn litsym_to_points(img: &[Lit], num_vars: usize) -> Perm {
213    let mut p = vec![0usize; 2 * num_vars];
214    for (j, &l) in img.iter().enumerate() {
215        p[lit_idx(Lit::pos(j as u32))] = lit_idx(l);
216        p[lit_idx(Lit::neg(j as u32))] = lit_idx(l.negated());
217    }
218    p
219}
220
221/// A permutation of the `2·num_vars` literal points back to a literal symmetry (`img[j]` from where `+xⱼ`
222/// goes). Inverse of [`litsym_to_points`].
223pub fn litsym_from_points(p: &[usize], num_vars: usize) -> Vec<Lit> {
224    (0..num_vars)
225        .map(|j| {
226            let q = p[2 * j];
227            Lit::new((q / 2) as u32, q % 2 == 0)
228        })
229        .collect()
230}
231
232/// The **literal**-permutation automorphism GENERATORS — variable AND **value/phase** symmetry — as
233/// image-literal vectors (`imgⱼ = σ(+xⱼ)`). Unlike [`variable_automorphism_generators`], phase flips are
234/// kept, so this captures the symmetry of formulas invariant under negating variables. Empty if none.
235pub fn literal_automorphism_generators(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Vec<Lit>> {
236    crate::symmetry_detect::find_generators(num_vars, clauses)
237        .iter()
238        .filter(|g| !g.is_identity())
239        .map(|g| (0..num_vars as u32).map(|v| g.apply(Lit::pos(v))).collect())
240        .collect()
241}
242
243/// Simplify `clauses` under the partial assignment `fixed` (a list of literals set true): drop clauses
244/// satisfied by a fixed literal, and drop falsified literals from the rest. The result is the residual
245/// `F|ρ` down the branch where `fixed` holds.
246fn simplify_under(clauses: &[Vec<Lit>], fixed: &[Lit]) -> Vec<Vec<Lit>> {
247    let true_lits: std::collections::HashSet<(u32, bool)> =
248        fixed.iter().map(|l| (l.var(), l.is_positive())).collect();
249    let mut out = Vec::new();
250    'clause: for c in clauses {
251        let mut nc = Vec::new();
252        for &l in c {
253            if true_lits.contains(&(l.var(), l.is_positive())) {
254                continue 'clause; // a literal is true ⟹ the clause is satisfied
255            }
256            if true_lits.contains(&(l.var(), !l.is_positive())) {
257                continue; // a literal is false ⟹ drop it
258            }
259            nc.push(l);
260        }
261        out.push(nc);
262    }
263    out
264}
265
266/// **Conditional (local) symmetry** — the symmetry of the RESIDUAL formula after a partial assignment.
267/// A formula can be globally asymmetric yet its residual `F|ρ` symmetric: symmetries that emerge only
268/// down a branch, invisible to a global automorphism search. Returns the residual's literal-symmetry
269/// generators (image-literal form). This is a different symmetry *source* — the basis for **local
270/// symmetry breaking** during search, where each decision can unlock fresh symmetry to exploit.
271pub fn conditional_symmetry_generators(num_vars: usize, clauses: &[Vec<Lit>], fixed: &[Lit]) -> Vec<Vec<Lit>> {
272    literal_automorphism_generators(num_vars, &simplify_under(clauses, fixed))
273}
274
275/// The full literal-automorphism group (variable + value symmetry) enumerated via the Schreier–Sims
276/// backend on the `2·num_vars` literal points, for the *complete* break. `None` if `|G| > cap` (use the
277/// generators for the polynomial partial break instead).
278pub fn literal_automorphism_group(num_vars: usize, clauses: &[Vec<Lit>], cap: usize) -> Option<Vec<Vec<Lit>>> {
279    let gens = literal_automorphism_generators(num_vars, clauses);
280    let point_gens: Vec<Perm> = gens.iter().map(|s| litsym_to_points(s, num_vars)).collect();
281    let elems = crate::permgroup::schreier_sims(2 * num_vars, &point_gens).elements(cap)?;
282    Some(elems.iter().map(|p| litsym_from_points(p, num_vars)).collect())
283}
284
285/// Count the distinct projections onto the first `num_orig` variables among the models of `clauses` over
286/// `total_vars`, by CDCL model-enumeration with blocking clauses. Exponential in the number of distinct
287/// projections — for small/moderate instances.
288fn count_projected_models(total_vars: usize, num_orig: usize, clauses: &[Vec<Lit>]) -> usize {
289    let mut seen: std::collections::BTreeSet<Vec<bool>> = std::collections::BTreeSet::new();
290    loop {
291        let mut s = Solver::new(total_vars);
292        for c in clauses {
293            s.add_clause(c.clone());
294        }
295        for proj in &seen {
296            s.add_clause((0..num_orig).map(|v| Lit::new(v as u32, !proj[v])).collect());
297        }
298        match s.solve() {
299            SolveResult::Sat(m) => {
300                seen.insert((0..num_orig).map(|v| m[v]).collect());
301            }
302            SolveResult::Unsat => break,
303        }
304    }
305    seen.len()
306}
307
308/// **The number of essentially-distinct solutions** — models counted up to the formula's symmetry: the
309/// orbit count of the solution set (`#SAT modulo G`). The complete lex-leader keeps exactly one model per
310/// orbit, so counting the symmetry-broken formula's models *is* the orbit count. `None` if the symmetry
311/// group is too large to enumerate for the complete break. The counting face of symmetry breaking — and,
312/// by Burnside, `(1/|G|)·Σ_σ #{models fixed by σ}`.
313pub fn count_models_modulo_symmetry(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<usize> {
314    let group = literal_automorphism_group(num_vars, clauses, 100_000)?;
315    let (sbp, total) = lex_leader_sbp_lit(num_vars, &group);
316    let mut broken = clauses.to_vec();
317    broken.extend(sbp);
318    Some(count_projected_models(total, num_vars, &broken))
319}
320
321/// **Hierarchical (block-wise) symmetry breaking.** For an imprimitive symmetry — a grid like PHP or
322/// graph colouring — the minimal block system splits the variables into equal blocks (e.g. the rows). The
323/// **adjacent block-swaps** (inter-block) and the **uniform adjacent within-block swaps** (intra-block)
324/// are STRUCTURED generators; each is verified to actually lie in the group (`Bsgs::contains`), then their
325/// lex-leader is the "sorted blocks, sorted within" break — a POLYNOMIAL set of `O(blocks + block-size)`
326/// constraints that breaks the wreath/product symmetry for which the complete enumeration would need `|G|`
327/// (exponential) clauses. Sound (it only uses verified group elements). `None` if the group is primitive,
328/// has no phase-free symmetry, or no structured generator lies in it. Scope: variable (phase-free) grids.
329pub fn hierarchical_break(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<(Vec<Vec<Lit>>, usize)> {
330    let gens = variable_automorphism_generators(num_vars, clauses)?;
331    if gens.is_empty() {
332        return None;
333    }
334    let bsgs = crate::permgroup::schreier_sims(num_vars, &gens);
335    let blocks = crate::permgroup::minimal_block_system(num_vars, &gens)?; // None if primitive / intransitive
336    let (k, m) = (blocks.len(), blocks[0].len());
337    let mut structured: Vec<Vec<Lit>> = Vec::new();
338
339    // Inter-block: adjacent block swaps b_{i,j} ↔ b_{i+1,j} (swap two whole blocks position-wise).
340    for i in 0..k.saturating_sub(1) {
341        let mut p: Vec<usize> = (0..num_vars).collect();
342        for j in 0..m {
343            p[blocks[i][j]] = blocks[i + 1][j];
344            p[blocks[i + 1][j]] = blocks[i][j];
345        }
346        if bsgs.contains(&p) {
347            structured.push(perm_to_litsym(&p));
348        }
349    }
350    // Intra-block: uniform adjacent within-block swaps b_{i,j} ↔ b_{i,j+1} across every block at once.
351    for j in 0..m.saturating_sub(1) {
352        let mut p: Vec<usize> = (0..num_vars).collect();
353        for b in &blocks {
354            p[b[j]] = b[j + 1];
355            p[b[j + 1]] = b[j];
356        }
357        if bsgs.contains(&p) {
358            structured.push(perm_to_litsym(&p));
359        }
360    }
361    if structured.is_empty() {
362        return None;
363    }
364    Some(lex_leader_sbp_lit(num_vars, &structured))
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::cdcl::{SolveResult, Solver};
371    use std::collections::BTreeSet;
372
373    /// All satisfying assignments of `clauses` over `num_vars`, by brute force.
374    fn models(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Vec<bool>> {
375        (0u64..(1u64 << num_vars))
376            .filter_map(|x| {
377                let a: Vec<bool> = (0..num_vars).map(|i| (x >> i) & 1 == 1).collect();
378                clauses
379                    .iter()
380                    .all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
381                    .then_some(a)
382            })
383            .collect()
384    }
385
386    /// The number of orbits of `models` under `group` (action `(a∘g)[j] = a[g[j]]`).
387    fn orbit_count(group: &[Perm], models: &[Vec<bool>]) -> usize {
388        let mut seen: BTreeSet<Vec<bool>> = BTreeSet::new();
389        let mut count = 0;
390        for m in models {
391            if seen.contains(m) {
392                continue;
393            }
394            count += 1;
395            for g in group {
396                seen.insert((0..m.len()).map(|j| m[g[j]]).collect());
397            }
398        }
399        count
400    }
401
402    /// Count the distinct projections onto the first `num_orig` variables among the models of `clauses`
403    /// over `total_vars`, by CDCL model-enumeration with blocking clauses.
404    fn count_projected_models(total_vars: usize, num_orig: usize, clauses: &[Vec<Lit>]) -> usize {
405        let mut seen: BTreeSet<Vec<bool>> = BTreeSet::new();
406        loop {
407            let mut s = Solver::new(total_vars);
408            for c in clauses {
409                s.add_clause(c.clone());
410            }
411            for proj in &seen {
412                s.add_clause((0..num_orig).map(|v| Lit::new(v as u32, !proj[v])).collect());
413            }
414            match s.solve() {
415                SolveResult::Sat(m) => {
416                    seen.insert((0..num_orig).map(|v| m[v]).collect());
417                }
418                SolveResult::Unsat => break,
419            }
420        }
421        seen.len()
422    }
423
424    fn all_s_n(n: usize) -> Vec<Perm> {
425        let mut out = Vec::new();
426        let mut p: Perm = (0..n).collect();
427        loop {
428            out.push(p.clone());
429            let Some(i) = (0..n.saturating_sub(1)).rev().find(|&i| p[i] < p[i + 1]) else { break };
430            let j = (i + 1..n).rev().find(|&j| p[j] > p[i]).unwrap();
431            p.swap(i, j);
432            p[i + 1..].reverse();
433        }
434        out
435    }
436
437    /// **The semantic leader test keeps exactly one per orbit.** Under the full `S_n` permuting `n`
438    /// coordinates, the orbits of `{0,1}ⁿ` are the Hamming-weight classes (`n+1` of them), and the
439    /// lex-leaders are exactly one per orbit.
440    #[test]
441    fn lex_leaders_are_one_per_orbit_under_s_n() {
442        for n in 2..=5usize {
443            let group = all_s_n(n);
444            let all: Vec<Vec<bool>> = (0u64..(1u64 << n)).map(|x| (0..n).map(|i| (x >> i) & 1 == 1).collect()).collect();
445            let leaders = all.iter().filter(|a| is_lex_leader(&group, a)).count();
446            assert_eq!(leaders, n + 1, "S_{n} on the cube has n+1 weight-orbits, one leader each");
447            assert_eq!(leaders, orbit_count(&group, &all), "leaders == orbit count");
448        }
449    }
450
451    /// **The CNF predicate accepts exactly the semantic leaders.** Enumerating the models of the SBP
452    /// (projected to the original variables) by CDCL reproduces the semantic leader count — the Tseitin
453    /// lex encoding is correct.
454    #[test]
455    fn the_cnf_sbp_accepts_exactly_the_lex_leaders() {
456        for n in 2..=4usize {
457            let group = all_s_n(n);
458            let (sbp, total) = lex_leader_sbp(n, &group);
459            let semantic =
460                (0u64..(1u64 << n)).filter(|&x| is_lex_leader(&group, &(0..n).map(|i| (x >> i) & 1 == 1).collect::<Vec<_>>())).count();
461            assert_eq!(count_projected_models(total, n, &sbp), semantic, "CNF SBP accepts exactly the leaders");
462            assert_eq!(semantic, n + 1, "and there are n+1 of them");
463        }
464    }
465
466    /// **The affine-map SBP encodes the lex predicate exactly.** For an affine map `α` whose image bits are
467    /// XORs of *several* variables, the SBP (extended with its Tseitin/prefix-equality aux) is satisfiable
468    /// over `x` iff `x ≤_lex α(x)` — the machinery that breaks affine symmetries a permutation SBP cannot.
469    #[test]
470    fn affine_lex_leader_encodes_the_lex_predicate() {
471        let n = 3usize;
472        // α(x)[0] = x0 ⊕ x1, α(x)[1] = x1, α(x)[2] = x2 ⊕ x0.
473        let map = vec![(vec![0usize, 1], false), (vec![1], false), (vec![2, 0], false)];
474        let (sbp, total) = affine_lex_leader_sbp(n, &[map]);
475        let alpha = |x: u64| -> u64 {
476            let a0 = (x & 1) ^ ((x >> 1) & 1);
477            let a1 = (x >> 1) & 1;
478            let a2 = ((x >> 2) & 1) ^ (x & 1);
479            a0 | (a1 << 1) | (a2 << 2)
480        };
481        let lex_le = |x: u64, y: u64| -> bool {
482            for j in 0..n {
483                let (xj, yj) = ((x >> j) & 1, (y >> j) & 1);
484                if xj != yj {
485                    return xj < yj;
486                }
487            }
488            true
489        };
490        for x in 0u64..(1 << n) {
491            let accepted = (0u64..(1u64 << (total - n))).any(|aux| {
492                let full = x | (aux << n);
493                sbp.iter().all(|c| c.iter().any(|l| ((full >> l.var()) & 1 == 1) == l.is_positive()))
494            });
495            assert_eq!(accepted, lex_le(x, alpha(x)), "SBP must accept x={x:03b} iff x ≤_lex α(x)={:03b}", alpha(x));
496        }
497    }
498
499    /// **Partial (per-generator) breaking is sound but weaker than complete.** Passing only a generating
500    /// set keeps a *superset* of the canonical representatives — at least one per orbit (sound,
501    /// satisfiability-preserving), but generally more than the complete break's exactly-one. It is the
502    /// polynomial, scalable fallback for groups too large to enumerate.
503    #[test]
504    fn partial_generator_breaking_is_sound_but_weaker_than_complete() {
505        let n = 3;
506        let full = all_s_n(n); // 6 elements
507        let gens: Vec<Perm> = vec![vec![1, 0, 2], vec![1, 2, 0]]; // (0 1) and (0 1 2) generate S_3
508        let (complete, ct) = lex_leader_sbp(n, &full);
509        let (partial, pt) = lex_leader_sbp(n, &gens);
510        let complete_survivors = count_projected_models(ct, n, &complete);
511        let partial_survivors = count_projected_models(pt, n, &partial);
512        assert_eq!(complete_survivors, n + 1, "complete keeps exactly one per orbit (n+1 weight classes)");
513        assert!(
514            partial_survivors >= complete_survivors && partial_survivors <= (1 << n),
515            "partial keeps a superset (≥ complete, ≤ all): {partial_survivors} vs {complete_survivors}"
516        );
517        // Soundness on a satisfiable symmetric formula: at-least-one-true keeps a model under both.
518        let f = vec![vec![Lit::pos(0), Lit::pos(1), Lit::pos(2)]];
519        let mut with_partial = f.clone();
520        with_partial.extend(partial);
521        assert!(count_projected_models(pt, n, &with_partial) >= 1, "partial preserves satisfiability");
522    }
523
524    /// **The stabilizer-chain break sits between complete and generators — stronger than the bare
525    /// generators, still polynomial.** Generators ∪ transversal coset reps is a *superset* of the
526    /// generators, so its lex-leader break is at least as strong (fewer-or-equal survivors), and it is a
527    /// subset of the whole group, so the complete break is at least as strong as it: complete ≤ chain ≤
528    /// generators. All are sound (every survivor count ≥ the orbit count).
529    #[test]
530    fn stabilizer_chain_break_is_between_complete_and_generators() {
531        let n = 4;
532        let gens: Vec<Perm> = vec![vec![1, 0, 2, 3], vec![1, 2, 3, 0]]; // (0 1), (0 1 2 3) generate S_4
533        let bsgs = crate::permgroup::schreier_sims(n, &gens);
534        let complete = bsgs.elements(100_000).unwrap();
535        let mut chain = gens.clone();
536        chain.extend(bsgs.transversal_elements());
537        let survivors = |group: &[Perm]| {
538            let (sbp, t) = lex_leader_sbp(n, group);
539            count_projected_models(t, n, &sbp)
540        };
541        let (c, ch, g) = (survivors(&complete), survivors(&chain), survivors(&gens));
542        assert_eq!(c, n + 1, "complete keeps exactly one per orbit (n+1 weight classes)");
543        assert!(c <= ch && ch <= g, "complete ≤ stabilizer-chain ≤ generators: {c} ≤ {ch} ≤ {g}");
544        assert!(g <= (1usize << n), "all sound (≤ total assignments)");
545    }
546
547    /// **Value (phase) symmetry is broken — what the variable-only scheme cannot see.** `F = (x₀∨x₁) ∧
548    /// (¬x₀∨x₁)` is invariant under *flipping* `x₀` (`x₀ ↦ ¬x₀`); its two models `(0,1),(1,1)` form one
549    /// orbit under the flip, and the literal lex-leader keeps exactly one. The phase-free variable scheme
550    /// is blind to it (its only generator flips a phase).
551    #[test]
552    fn value_phase_symmetry_is_broken() {
553        let f = vec![vec![Lit::pos(0), Lit::pos(1)], vec![Lit::neg(0), Lit::pos(1)]];
554        assert!(
555            variable_automorphism_generators(2, &f).is_none_or(|g| g.is_empty()),
556            "the phase-free variable scheme is blind to the value symmetry"
557        );
558        let gens = literal_automorphism_generators(2, &f);
559        assert!(!gens.is_empty(), "the value symmetry x₀ ↦ ¬x₀ is detected as a literal symmetry");
560        // It must satisfy the negation-respecting round-trip through the literal points.
561        for s in &gens {
562            assert_eq!(litsym_from_points(&litsym_to_points(s, 2), 2), *s, "litsym ↔ points round-trips");
563        }
564        let (sbp, total) = lex_leader_sbp_lit(2, &gens);
565        let mut broken = f.clone();
566        broken.extend(sbp);
567        assert_eq!(count_projected_models(total, 2, &broken), 1, "value symmetry broken to one model");
568        // Sanity: F itself has two models, so the symmetry genuinely halved them.
569        assert_eq!(models(2, &f).len(), 2, "F has two models, one orbit under the flip");
570    }
571
572    /// **Complete symmetry breaking on a real formula: one model per orbit, satisfiability preserved.**
573    /// `clique_coloring(3,3)` (proper 3-colourings of K₃) has 6 models in a single orbit under
574    /// `S₃(vertices) × S₃(colours)`; the complete lex-leader SBP leaves exactly one, and the formula stays
575    /// SAT. PHP(3) is UNSAT and stays UNSAT under the SBP (soundness on the unsatisfiable side).
576    #[test]
577    fn complete_lex_leader_keeps_one_model_per_orbit_end_to_end() {
578        // SAT, symmetric.
579        let (cnf, _) = crate::families::clique_coloring(3, 3);
580        let group = variable_automorphism_group(cnf.num_vars, &cnf.clauses, 100_000)
581            .expect("clique colouring has a phase-free, small automorphism group");
582        let ms = models(cnf.num_vars, &cnf.clauses);
583        let orbits = orbit_count(&group, &ms);
584        let (sbp, total) = lex_leader_sbp(cnf.num_vars, &group);
585        let mut broken = cnf.clauses.clone();
586        broken.extend(sbp);
587        let surviving = count_projected_models(total, cnf.num_vars, &broken);
588        assert_eq!(surviving, orbits, "complete SBP leaves exactly one model per orbit");
589        assert!(orbits >= 1 && surviving < ms.len(), "and it strictly breaks the symmetry");
590
591        // UNSAT, symmetric: the SBP must preserve unsatisfiability.
592        let (php, _) = crate::families::php(3);
593        let pg = variable_automorphism_group(php.num_vars, &php.clauses, 100_000).expect("PHP group");
594        let (psbp, ptotal) = lex_leader_sbp(php.num_vars, &pg);
595        let mut pbroken = php.clauses.clone();
596        pbroken.extend(psbp);
597        assert_eq!(count_projected_models(ptotal, php.num_vars, &pbroken), 0, "UNSAT stays UNSAT");
598    }
599
600    /// **Conditional (local) symmetry emerges under a partial assignment.** `F` is globally asymmetric —
601    /// the clause `x₀∨x₁` singles out `x₁` — so there is no global `x₁↔x₂` symmetry. But under `x₀ = true`
602    /// the residual is `(x₁∨x₂) ∧ (¬x₁∨¬x₂)`, symmetric under swapping `x₁ ↔ x₂`: a symmetry that exists
603    /// only down that branch. A different symmetry source — the residual's, not the formula's.
604    #[test]
605    fn conditional_symmetry_emerges_under_a_partial_assignment() {
606        let f = vec![
607            vec![Lit::neg(0), Lit::pos(1), Lit::pos(2)], // ¬x0 ∨ x1 ∨ x2
608            vec![Lit::neg(0), Lit::neg(1), Lit::neg(2)], // ¬x0 ∨ ¬x1 ∨ ¬x2
609            vec![Lit::pos(0), Lit::pos(1)],              // x0 ∨ x1   (breaks the global x1↔x2 symmetry)
610        ];
611        let swaps_12 = |gens: &[Vec<Lit>]| {
612            gens.iter().any(|img| img[1] == Lit::pos(2) && img[2] == Lit::pos(1))
613        };
614        // No global x1↔x2 symmetry.
615        assert!(!swaps_12(&literal_automorphism_generators(3, &f)), "F has no global x1↔x2 symmetry");
616        // Conditionally on x0 = true, the residual is symmetric under x1↔x2.
617        let local = conditional_symmetry_generators(3, &f, &[Lit::pos(0)]);
618        assert!(swaps_12(&local), "the residual under x0=true has the x1↔x2 symmetry: {local:?}");
619    }
620
621    /// **Hierarchical (block-wise) breaking: a polynomial break of an exponential grid symmetry.** On
622    /// `clique_coloring(3,3)` (`S₃×S₃`, `|G| = 36`), the block-wise break uses only the adjacent
623    /// vertex-row swaps and uniform colour swaps — a handful of structured generators — yet is sound
624    /// (keeps ≥ one model per orbit) and genuinely breaks the symmetry (fewer survivors than models).
625    #[test]
626    fn hierarchical_block_wise_breaking_is_sound_and_polynomial() {
627        let (cnf, _) = crate::families::clique_coloring(3, 3);
628        let nv = cnf.num_vars;
629        let (sbp, total) = hierarchical_break(nv, &cnf.clauses).expect("clique colouring is a grid symmetry");
630        let ms = models(nv, &cnf.clauses);
631        let var_group = variable_automorphism_group(nv, &cnf.clauses, 100_000).unwrap();
632        let orbits = orbit_count(&var_group, &ms);
633        let mut broken = cnf.clauses.clone();
634        broken.extend(sbp);
635        let surviving = count_projected_models(total, nv, &broken);
636        assert!(surviving >= orbits, "hierarchical break is sound: ≥ one model per orbit ({surviving} ≥ {orbits})");
637        assert!(surviving < ms.len(), "and it breaks the symmetry: fewer survivors than the {} models", ms.len());
638    }
639
640    /// **PHP's symmetry is an imprimitive grid — symmetry within the symmetry.** PHP(n)'s variable group
641    /// `S_n × S_{n-1}` acts on the `n × (n-1)` grid of variables `x_{p,h}`; it is **imprimitive**, and the
642    /// pigeon rows are the minimal block system (`n` blocks of size `n-1`). The block structure lays the
643    /// grid bare — the internal structure of the formula's symmetry, recovered group-theoretically.
644    #[test]
645    fn php_symmetry_is_an_imprimitive_grid() {
646        let n = 4;
647        let (cnf, _) = crate::families::php(n);
648        let gens = variable_automorphism_generators(cnf.num_vars, &cnf.clauses).expect("phase-free");
649        assert!(
650            !crate::permgroup::is_primitive(cnf.num_vars, &gens),
651            "PHP's symmetry is imprimitive — it is a grid, not an atom"
652        );
653        let blocks =
654            crate::permgroup::minimal_block_system(cnf.num_vars, &gens).expect("PHP has a block system");
655        assert!(blocks.iter().all(|b| b.len() == n - 1), "blocks are pigeon-rows of size n-1: {blocks:?}");
656        assert_eq!(blocks.len(), n, "there are n pigeon-rows");
657        for b in &blocks {
658            let pigeon = b[0] / (n - 1);
659            assert!(b.iter().all(|&v| v / (n - 1) == pigeon), "each block is exactly one pigeon's row: {b:?}");
660        }
661    }
662
663    /// **Counting up to symmetry = the orbit count, three ways.** The number of essentially-distinct
664    /// solutions equals the complete-SBP model count, the brute-force orbit count, AND Burnside's lemma
665    /// `(1/|G|)·Σ_σ #{models fixed by σ}` — the canonical orbit-counting theorem. On `clique_coloring(3,3)`
666    /// all six proper colourings lie in a single orbit, so there is essentially one solution.
667    #[test]
668    fn count_modulo_symmetry_equals_burnside_and_brute_orbit_count() {
669        let (cnf, _) = crate::families::clique_coloring(3, 3);
670        let nv = cnf.num_vars;
671        let group = literal_automorphism_group(nv, &cnf.clauses, 100_000).unwrap();
672        let ms = models(nv, &cnf.clauses);
673        let image = |img: &[Lit], a: &[bool]| -> Vec<bool> {
674            (0..a.len())
675                .map(|j| if img[j].is_positive() { a[img[j].var() as usize] } else { !a[img[j].var() as usize] })
676                .collect()
677        };
678        // Brute-force orbit count under the literal action.
679        let brute = {
680            let mut seen: BTreeSet<Vec<bool>> = BTreeSet::new();
681            let mut c = 0;
682            for m in &ms {
683                if seen.contains(m) {
684                    continue;
685                }
686                c += 1;
687                for s in &group {
688                    seen.insert(image(s, m));
689                }
690            }
691            c
692        };
693        // Burnside: the average number of fixed models over the group.
694        let fixed: usize = group.iter().map(|s| ms.iter().filter(|&m| image(s, m) == *m).count()).sum();
695        let burnside = fixed / group.len();
696        let counted = count_models_modulo_symmetry(nv, &cnf.clauses).unwrap();
697        assert_eq!(counted, brute, "complete-SBP count == brute orbit count");
698        assert_eq!(counted, burnside, "== Burnside count");
699        assert_eq!(counted, 1, "clique_coloring(3,3): all six proper colourings are one orbit");
700        assert_eq!(ms.len(), 6, "and there are six of them");
701    }
702}