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| {
29        if l.is_positive() {
30            a[l.var() as usize]
31        } else {
32            !a[l.var() as usize]
33        }
34    };
35    group.iter().all(|img| {
36        for j in 0..a.len() {
37            let (x, y) = (a[j], eval(&img[j]));
38            if x != y {
39                return !x && y;
40            }
41        }
42        true
43    })
44}
45
46/// The lex-leader symmetry-breaking predicate as CNF: for every non-identity `g ∈ group`, clauses
47/// asserting `a ≤_lex a∘g`. Returns the extra clauses plus the new total variable count (prefix-equality
48/// aux variables are appended above `num_vars`). It is satisfiability-preserving for any set of
49/// automorphisms (the lex-least model of each orbit always survives). Pass the **whole group** for the
50/// COMPLETE break (exactly one model per orbit), or just a **generating set** for a sound POLYNOMIAL
51/// PARTIAL break that scales to arbitrarily large groups — both keep at least one representative per orbit.
52pub fn lex_leader_sbp(num_vars: usize, group: &[Perm]) -> (Vec<Vec<Lit>>, usize) {
53    let lit_group: Vec<Vec<Lit>> = group.iter().map(|g| perm_to_litsym(g)).collect();
54    lex_leader_sbp_lit(num_vars, &lit_group)
55}
56
57/// The lex-leader SBP over **literal** symmetries (`group[k][j]` = the image literal of variable `j`),
58/// which breaks **variable and value/phase** symmetry alike. As with [`lex_leader_sbp`], pass the whole
59/// group for the complete break or a generating set for the polynomial partial break.
60pub fn lex_leader_sbp_lit(num_vars: usize, group: &[Vec<Lit>]) -> (Vec<Vec<Lit>>, usize) {
61    let mut clauses = Vec::new();
62    let mut aux = num_vars;
63    for img in group {
64        if (0..num_vars).all(|j| img[j] == Lit::pos(j as u32)) {
65            continue; // identity contributes nothing
66        }
67        encode_lex_le(num_vars, img, &mut aux, &mut clauses);
68    }
69    (clauses, aux)
70}
71
72/// A variable permutation as a literal symmetry (no phase flips): `imgⱼ = +x_{g[j]}`.
73fn perm_to_litsym(g: &Perm) -> Vec<Lit> {
74    g.iter().map(|&j| Lit::pos(j as u32)).collect()
75}
76
77/// The lex-leader SBP for **affine** maps `α: x ↦ Ax ⊕ b` over GF(2) — the machinery that breaks the affine
78/// parity symmetries a variable/literal permutation SBP ([`lex_leader_sbp`]) structurally cannot express (an
79/// image bit is an XOR of *several* variables, not one literal). Each `maps[k]` is a per-output spec:
80/// `maps[k][j] = (A_j, b_j)` with `α(x)[j] = ⊕_{i∈A_j} x_i ⊕ b_j`. Each non-identity output is Tseitin-encoded
81/// as a fresh variable, then the standard prefix-equality chain `encode_lex_le` enforces `x ≤_lex α(x)`.
82/// Satisfiability-preserving for any model-set affine symmetry (the lex-least model of each orbit survives).
83/// Returns the extra clauses and the new total variable count (aux appended above `num_vars`).
84pub fn affine_lex_leader_sbp(
85    num_vars: usize,
86    maps: &[Vec<(Vec<usize>, bool)>],
87) -> (Vec<Vec<Lit>>, usize) {
88    let mut clauses = Vec::new();
89    let mut aux = num_vars;
90    for map in maps {
91        let mut img: Vec<Lit> = (0..num_vars).map(|j| Lit::pos(j as u32)).collect();
92        for (j, (xset, b)) in map.iter().enumerate() {
93            if j >= num_vars {
94                break;
95            }
96            if xset.len() == 1 && xset[0] == j && !b {
97                continue; // identity output — `img[j]` stays `+xⱼ`
98            }
99            img[j] = tseitin_xor(&mut aux, xset, *b, &mut clauses);
100        }
101        if (0..num_vars).all(|j| img[j] == Lit::pos(j as u32)) {
102            continue; // the identity map contributes nothing
103        }
104        encode_lex_le(num_vars, &img, &mut aux, &mut clauses);
105    }
106    (clauses, aux)
107}
108
109/// Tseitin-encode `c = ⊕_{i∈xset} x_i ⊕ b` and return the literal `c` (a fresh chain variable, negated when
110/// `b`). Chains binary XOR gates `t = acc ⊕ v` (four clauses each). `xset` is assumed non-empty (an affine
111/// bijection's output bits each depend on ≥ 1 variable).
112fn tseitin_xor(aux: &mut usize, xset: &[usize], b: bool, clauses: &mut Vec<Vec<Lit>>) -> Lit {
113    let mut fresh = |aux: &mut usize| {
114        let v = *aux as u32;
115        *aux += 1;
116        Lit::pos(v)
117    };
118    let mut acc = Lit::pos(xset[0] as u32);
119    for &v in &xset[1..] {
120        let vv = Lit::pos(v as u32);
121        let t = fresh(aux);
122        clauses.push(vec![t, acc.negated(), vv]);
123        clauses.push(vec![t, acc, vv.negated()]);
124        clauses.push(vec![t.negated(), acc, vv]);
125        clauses.push(vec![t.negated(), acc.negated(), vv.negated()]);
126        acc = t;
127    }
128    if b {
129        acc.negated()
130    } else {
131        acc
132    }
133}
134
135/// Encode `a ≤_lex aˢ` where `aˢ[j]` is the value of the image literal `img[j]` under `a` (so a phase
136/// flip negates the compared bit — this is how **value/phase** symmetry is broken, not just variable
137/// symmetry). Prefix-equality Tseitin chain over fresh `aux` variables: at each non-fixed position `j`,
138/// `eₚ → (a[j] ≤ img[j])`, and `eₚ` advances only while the prefix stays equal. Positions where
139/// `img[j] = +xⱼ` are always equal and are skipped.
140fn encode_lex_le(num_vars: usize, img: &[Lit], aux: &mut usize, clauses: &mut Vec<Vec<Lit>>) {
141    let mut fresh = |aux: &mut usize| {
142        let v = *aux as u32;
143        *aux += 1;
144        Lit::pos(v)
145    };
146    let positions: Vec<usize> = (0..num_vars)
147        .filter(|&j| img[j] != Lit::pos(j as u32))
148        .collect();
149    let mut prev_e: Option<Lit> = None; // None = the constant TRUE (e₀)
150    for (k, &j) in positions.iter().enumerate() {
151        let aj = Lit::pos(j as u32);
152        let cj = img[j];
153        // lex constraint at j: prefix-equal ⟹ a[j] ≤ c[j], i.e. (¬a[j] ∨ c[j]).
154        match prev_e {
155            None => clauses.push(vec![aj.negated(), cj]),
156            Some(e) => clauses.push(vec![e.negated(), aj.negated(), cj]),
157        }
158        if k + 1 == positions.len() {
159            break; // last moved position — no need to carry equality further
160        }
161        // eq ⟺ (a[j] == c[j])
162        let eq = fresh(aux);
163        clauses.push(vec![eq.negated(), aj.negated(), cj]);
164        clauses.push(vec![eq.negated(), aj, cj.negated()]);
165        clauses.push(vec![eq, aj.negated(), cj.negated()]);
166        clauses.push(vec![eq, aj, cj]);
167        // e_next ⟺ prefix-equal ∧ eq
168        let e_next = fresh(aux);
169        match prev_e {
170            None => {
171                clauses.push(vec![e_next.negated(), eq]);
172                clauses.push(vec![e_next, eq.negated()]);
173            }
174            Some(e) => {
175                clauses.push(vec![e_next.negated(), e]);
176                clauses.push(vec![e_next.negated(), eq]);
177                clauses.push(vec![e_next, e.negated(), eq.negated()]);
178            }
179        }
180        prev_e = Some(e_next);
181    }
182}
183
184/// The **variable**-permutation automorphism GENERATORS of a CNF (phase-free symmetries), without
185/// enumerating the group — fast, no size cap. `None` if a detected symmetry flips a phase (a value
186/// symmetry this variable scheme does not cover). An empty vector means no non-trivial symmetry.
187pub fn variable_automorphism_generators(
188    num_vars: usize,
189    clauses: &[Vec<Lit>],
190) -> Option<Vec<Perm>> {
191    let lit_gens = crate::symmetry_detect::find_generators(num_vars, clauses);
192    let mut var_gens: Vec<Perm> = Vec::new();
193    for g in &lit_gens {
194        if g.is_identity() {
195            continue;
196        }
197        let mut vp = vec![0usize; num_vars];
198        for v in 0..num_vars as u32 {
199            let img = g.apply(Lit::pos(v));
200            if !img.is_positive() {
201                return None; // a phase flip ⟹ not a pure variable symmetry
202            }
203            vp[v as usize] = img.var() as usize;
204        }
205        var_gens.push(vp);
206    }
207    Some(var_gens)
208}
209
210/// The **variable**-permutation automorphism group of a CNF, fully enumerated via the Schreier–Sims
211/// backend (for *complete* symmetry breaking). `None` if a generator flips a phase, or `|G| > cap` (then
212/// the caller should use per-generator *partial* breaking on [`variable_automorphism_generators`] instead,
213/// which scales to arbitrarily large groups).
214pub fn variable_automorphism_group(
215    num_vars: usize,
216    clauses: &[Vec<Lit>],
217    cap: usize,
218) -> Option<Vec<Perm>> {
219    let gens = variable_automorphism_generators(num_vars, clauses)?;
220    crate::permgroup::schreier_sims(num_vars, &gens).elements(cap)
221}
222
223/// The literal-point index of a literal: `2v` for `+xᵥ`, `2v+1` for `¬xᵥ`.
224fn lit_idx(l: Lit) -> usize {
225    2 * l.var() as usize + usize::from(!l.is_positive())
226}
227
228/// A literal symmetry (`img[j]` = image literal of variable `j`) as a permutation of the `2·num_vars`
229/// literal points — for the Schreier–Sims backend (order, enumeration). Negation is respected.
230pub fn litsym_to_points(img: &[Lit], num_vars: usize) -> Perm {
231    let mut p = vec![0usize; 2 * num_vars];
232    for (j, &l) in img.iter().enumerate() {
233        p[lit_idx(Lit::pos(j as u32))] = lit_idx(l);
234        p[lit_idx(Lit::neg(j as u32))] = lit_idx(l.negated());
235    }
236    p
237}
238
239/// A permutation of the `2·num_vars` literal points back to a literal symmetry (`img[j]` from where `+xⱼ`
240/// goes). Inverse of [`litsym_to_points`].
241pub fn litsym_from_points(p: &[usize], num_vars: usize) -> Vec<Lit> {
242    (0..num_vars)
243        .map(|j| {
244            let q = p[2 * j];
245            Lit::new((q / 2) as u32, q % 2 == 0)
246        })
247        .collect()
248}
249
250/// The **literal**-permutation automorphism GENERATORS — variable AND **value/phase** symmetry — as
251/// image-literal vectors (`imgⱼ = σ(+xⱼ)`). Unlike [`variable_automorphism_generators`], phase flips are
252/// kept, so this captures the symmetry of formulas invariant under negating variables. Empty if none.
253pub fn literal_automorphism_generators(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Vec<Lit>> {
254    crate::symmetry_detect::find_generators(num_vars, clauses)
255        .iter()
256        .filter(|g| !g.is_identity())
257        .map(|g| (0..num_vars as u32).map(|v| g.apply(Lit::pos(v))).collect())
258        .collect()
259}
260
261/// Simplify `clauses` under the partial assignment `fixed` (a list of literals set true): drop clauses
262/// satisfied by a fixed literal, and drop falsified literals from the rest. The result is the residual
263/// `F|ρ` down the branch where `fixed` holds.
264fn simplify_under(clauses: &[Vec<Lit>], fixed: &[Lit]) -> Vec<Vec<Lit>> {
265    let true_lits: std::collections::HashSet<(u32, bool)> =
266        fixed.iter().map(|l| (l.var(), l.is_positive())).collect();
267    let mut out = Vec::new();
268    'clause: for c in clauses {
269        let mut nc = Vec::new();
270        for &l in c {
271            if true_lits.contains(&(l.var(), l.is_positive())) {
272                continue 'clause; // a literal is true ⟹ the clause is satisfied
273            }
274            if true_lits.contains(&(l.var(), !l.is_positive())) {
275                continue; // a literal is false ⟹ drop it
276            }
277            nc.push(l);
278        }
279        out.push(nc);
280    }
281    out
282}
283
284/// **Conditional (local) symmetry** — the symmetry of the RESIDUAL formula after a partial assignment.
285/// A formula can be globally asymmetric yet its residual `F|ρ` symmetric: symmetries that emerge only
286/// down a branch, invisible to a global automorphism search. Returns the residual's literal-symmetry
287/// generators (image-literal form). This is a different symmetry *source* — the basis for **local
288/// symmetry breaking** during search, where each decision can unlock fresh symmetry to exploit.
289pub fn conditional_symmetry_generators(
290    num_vars: usize,
291    clauses: &[Vec<Lit>],
292    fixed: &[Lit],
293) -> Vec<Vec<Lit>> {
294    literal_automorphism_generators(num_vars, &simplify_under(clauses, fixed))
295}
296
297/// The full literal-automorphism group (variable + value symmetry) enumerated via the Schreier–Sims
298/// backend on the `2·num_vars` literal points, for the *complete* break. `None` if `|G| > cap` (use the
299/// generators for the polynomial partial break instead).
300pub fn literal_automorphism_group(
301    num_vars: usize,
302    clauses: &[Vec<Lit>],
303    cap: usize,
304) -> Option<Vec<Vec<Lit>>> {
305    let gens = literal_automorphism_generators(num_vars, clauses);
306    let point_gens: Vec<Perm> = gens.iter().map(|s| litsym_to_points(s, num_vars)).collect();
307    let elems = crate::permgroup::schreier_sims(2 * num_vars, &point_gens).elements(cap)?;
308    Some(
309        elems
310            .iter()
311            .map(|p| litsym_from_points(p, num_vars))
312            .collect(),
313    )
314}
315
316/// Count the distinct projections onto the first `num_orig` variables among the models of `clauses` over
317/// `total_vars`, by CDCL model-enumeration with blocking clauses. Exponential in the number of distinct
318/// projections — for small/moderate instances.
319fn count_projected_models(total_vars: usize, num_orig: usize, clauses: &[Vec<Lit>]) -> usize {
320    let mut seen: std::collections::BTreeSet<Vec<bool>> = std::collections::BTreeSet::new();
321    loop {
322        let mut s = Solver::new(total_vars);
323        for c in clauses {
324            s.add_clause(c.clone());
325        }
326        for proj in &seen {
327            s.add_clause(
328                (0..num_orig)
329                    .map(|v| Lit::new(v as u32, !proj[v]))
330                    .collect(),
331            );
332        }
333        match s.solve() {
334            SolveResult::Sat(m) => {
335                seen.insert((0..num_orig).map(|v| m[v]).collect());
336            }
337            SolveResult::Unsat => break,
338        }
339    }
340    seen.len()
341}
342
343/// **The number of essentially-distinct solutions** — models counted up to the formula's symmetry: the
344/// orbit count of the solution set (`#SAT modulo G`). The complete lex-leader keeps exactly one model per
345/// orbit, so counting the symmetry-broken formula's models *is* the orbit count. `None` if the symmetry
346/// group is too large to enumerate for the complete break. The counting face of symmetry breaking — and,
347/// by Burnside, `(1/|G|)·Σ_σ #{models fixed by σ}`.
348pub fn count_models_modulo_symmetry(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<usize> {
349    let group = literal_automorphism_group(num_vars, clauses, 100_000)?;
350    let (sbp, total) = lex_leader_sbp_lit(num_vars, &group);
351    let mut broken = clauses.to_vec();
352    broken.extend(sbp);
353    Some(count_projected_models(total, num_vars, &broken))
354}
355
356/// **Hierarchical (block-wise) symmetry breaking.** For an imprimitive symmetry — a grid like PHP or
357/// graph colouring — the minimal block system splits the variables into equal blocks (e.g. the rows). The
358/// **adjacent block-swaps** (inter-block) and the **uniform adjacent within-block swaps** (intra-block)
359/// are STRUCTURED generators; each is verified to actually lie in the group (`Bsgs::contains`), then their
360/// lex-leader is the "sorted blocks, sorted within" break — a POLYNOMIAL set of `O(blocks + block-size)`
361/// constraints that breaks the wreath/product symmetry for which the complete enumeration would need `|G|`
362/// (exponential) clauses. Sound (it only uses verified group elements). `None` if the group is primitive,
363/// has no phase-free symmetry, or no structured generator lies in it. Scope: variable (phase-free) grids.
364pub fn hierarchical_break(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<(Vec<Vec<Lit>>, usize)> {
365    let gens = variable_automorphism_generators(num_vars, clauses)?;
366    if gens.is_empty() {
367        return None;
368    }
369    let bsgs = crate::permgroup::schreier_sims(num_vars, &gens);
370    let blocks = crate::permgroup::minimal_block_system(num_vars, &gens)?; // None if primitive / intransitive
371    let (k, m) = (blocks.len(), blocks[0].len());
372    let mut structured: Vec<Vec<Lit>> = Vec::new();
373
374    // Inter-block: adjacent block swaps b_{i,j} ↔ b_{i+1,j} (swap two whole blocks position-wise).
375    for i in 0..k.saturating_sub(1) {
376        let mut p: Vec<usize> = (0..num_vars).collect();
377        for j in 0..m {
378            p[blocks[i][j]] = blocks[i + 1][j];
379            p[blocks[i + 1][j]] = blocks[i][j];
380        }
381        if bsgs.contains(&p) {
382            structured.push(perm_to_litsym(&p));
383        }
384    }
385    // Intra-block: uniform adjacent within-block swaps b_{i,j} ↔ b_{i,j+1} across every block at once.
386    for j in 0..m.saturating_sub(1) {
387        let mut p: Vec<usize> = (0..num_vars).collect();
388        for b in &blocks {
389            p[b[j]] = b[j + 1];
390            p[b[j + 1]] = b[j];
391        }
392        if bsgs.contains(&p) {
393            structured.push(perm_to_litsym(&p));
394        }
395    }
396    if structured.is_empty() {
397        return None;
398    }
399    Some(lex_leader_sbp_lit(num_vars, &structured))
400}
401
402#[cfg(test)]
403#[path = "../../logicaffeine_tests/unit/logicaffeine_proof/sym_break/tests.rs"]
404mod tests;