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;