use crate::cdcl::{Lit, SolveResult, Solver};
use crate::permgroup::Perm;
pub fn is_lex_leader(group: &[Perm], a: &[bool]) -> bool {
let lit_group: Vec<Vec<Lit>> = group.iter().map(|g| perm_to_litsym(g)).collect();
is_lex_leader_lit(&lit_group, a)
}
pub fn is_lex_leader_lit(group: &[Vec<Lit>], a: &[bool]) -> bool {
let eval = |l: &Lit| if l.is_positive() { a[l.var() as usize] } else { !a[l.var() as usize] };
group.iter().all(|img| {
for j in 0..a.len() {
let (x, y) = (a[j], eval(&img[j]));
if x != y {
return !x && y;
}
}
true
})
}
pub fn lex_leader_sbp(num_vars: usize, group: &[Perm]) -> (Vec<Vec<Lit>>, usize) {
let lit_group: Vec<Vec<Lit>> = group.iter().map(|g| perm_to_litsym(g)).collect();
lex_leader_sbp_lit(num_vars, &lit_group)
}
pub fn lex_leader_sbp_lit(num_vars: usize, group: &[Vec<Lit>]) -> (Vec<Vec<Lit>>, usize) {
let mut clauses = Vec::new();
let mut aux = num_vars;
for img in group {
if (0..num_vars).all(|j| img[j] == Lit::pos(j as u32)) {
continue; }
encode_lex_le(num_vars, img, &mut aux, &mut clauses);
}
(clauses, aux)
}
fn perm_to_litsym(g: &Perm) -> Vec<Lit> {
g.iter().map(|&j| Lit::pos(j as u32)).collect()
}
pub fn affine_lex_leader_sbp(num_vars: usize, maps: &[Vec<(Vec<usize>, bool)>]) -> (Vec<Vec<Lit>>, usize) {
let mut clauses = Vec::new();
let mut aux = num_vars;
for map in maps {
let mut img: Vec<Lit> = (0..num_vars).map(|j| Lit::pos(j as u32)).collect();
for (j, (xset, b)) in map.iter().enumerate() {
if j >= num_vars {
break;
}
if xset.len() == 1 && xset[0] == j && !b {
continue; }
img[j] = tseitin_xor(&mut aux, xset, *b, &mut clauses);
}
if (0..num_vars).all(|j| img[j] == Lit::pos(j as u32)) {
continue; }
encode_lex_le(num_vars, &img, &mut aux, &mut clauses);
}
(clauses, aux)
}
fn tseitin_xor(aux: &mut usize, xset: &[usize], b: bool, clauses: &mut Vec<Vec<Lit>>) -> Lit {
let mut fresh = |aux: &mut usize| {
let v = *aux as u32;
*aux += 1;
Lit::pos(v)
};
let mut acc = Lit::pos(xset[0] as u32);
for &v in &xset[1..] {
let vv = Lit::pos(v as u32);
let t = fresh(aux);
clauses.push(vec![t, acc.negated(), vv]);
clauses.push(vec![t, acc, vv.negated()]);
clauses.push(vec![t.negated(), acc, vv]);
clauses.push(vec![t.negated(), acc.negated(), vv.negated()]);
acc = t;
}
if b {
acc.negated()
} else {
acc
}
}
fn encode_lex_le(num_vars: usize, img: &[Lit], aux: &mut usize, clauses: &mut Vec<Vec<Lit>>) {
let mut fresh = |aux: &mut usize| {
let v = *aux as u32;
*aux += 1;
Lit::pos(v)
};
let positions: Vec<usize> = (0..num_vars).filter(|&j| img[j] != Lit::pos(j as u32)).collect();
let mut prev_e: Option<Lit> = None; for (k, &j) in positions.iter().enumerate() {
let aj = Lit::pos(j as u32);
let cj = img[j];
match prev_e {
None => clauses.push(vec![aj.negated(), cj]),
Some(e) => clauses.push(vec![e.negated(), aj.negated(), cj]),
}
if k + 1 == positions.len() {
break; }
let eq = fresh(aux);
clauses.push(vec![eq.negated(), aj.negated(), cj]);
clauses.push(vec![eq.negated(), aj, cj.negated()]);
clauses.push(vec![eq, aj.negated(), cj.negated()]);
clauses.push(vec![eq, aj, cj]);
let e_next = fresh(aux);
match prev_e {
None => {
clauses.push(vec![e_next.negated(), eq]);
clauses.push(vec![e_next, eq.negated()]);
}
Some(e) => {
clauses.push(vec![e_next.negated(), e]);
clauses.push(vec![e_next.negated(), eq]);
clauses.push(vec![e_next, e.negated(), eq.negated()]);
}
}
prev_e = Some(e_next);
}
}
pub fn variable_automorphism_generators(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<Perm>> {
let lit_gens = crate::symmetry_detect::find_generators(num_vars, clauses);
let mut var_gens: Vec<Perm> = Vec::new();
for g in &lit_gens {
if g.is_identity() {
continue;
}
let mut vp = vec![0usize; num_vars];
for v in 0..num_vars as u32 {
let img = g.apply(Lit::pos(v));
if !img.is_positive() {
return None; }
vp[v as usize] = img.var() as usize;
}
var_gens.push(vp);
}
Some(var_gens)
}
pub fn variable_automorphism_group(num_vars: usize, clauses: &[Vec<Lit>], cap: usize) -> Option<Vec<Perm>> {
let gens = variable_automorphism_generators(num_vars, clauses)?;
crate::permgroup::schreier_sims(num_vars, &gens).elements(cap)
}
fn lit_idx(l: Lit) -> usize {
2 * l.var() as usize + usize::from(!l.is_positive())
}
pub fn litsym_to_points(img: &[Lit], num_vars: usize) -> Perm {
let mut p = vec![0usize; 2 * num_vars];
for (j, &l) in img.iter().enumerate() {
p[lit_idx(Lit::pos(j as u32))] = lit_idx(l);
p[lit_idx(Lit::neg(j as u32))] = lit_idx(l.negated());
}
p
}
pub fn litsym_from_points(p: &[usize], num_vars: usize) -> Vec<Lit> {
(0..num_vars)
.map(|j| {
let q = p[2 * j];
Lit::new((q / 2) as u32, q % 2 == 0)
})
.collect()
}
pub fn literal_automorphism_generators(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Vec<Lit>> {
crate::symmetry_detect::find_generators(num_vars, clauses)
.iter()
.filter(|g| !g.is_identity())
.map(|g| (0..num_vars as u32).map(|v| g.apply(Lit::pos(v))).collect())
.collect()
}
fn simplify_under(clauses: &[Vec<Lit>], fixed: &[Lit]) -> Vec<Vec<Lit>> {
let true_lits: std::collections::HashSet<(u32, bool)> =
fixed.iter().map(|l| (l.var(), l.is_positive())).collect();
let mut out = Vec::new();
'clause: for c in clauses {
let mut nc = Vec::new();
for &l in c {
if true_lits.contains(&(l.var(), l.is_positive())) {
continue 'clause; }
if true_lits.contains(&(l.var(), !l.is_positive())) {
continue; }
nc.push(l);
}
out.push(nc);
}
out
}
pub fn conditional_symmetry_generators(num_vars: usize, clauses: &[Vec<Lit>], fixed: &[Lit]) -> Vec<Vec<Lit>> {
literal_automorphism_generators(num_vars, &simplify_under(clauses, fixed))
}
pub fn literal_automorphism_group(num_vars: usize, clauses: &[Vec<Lit>], cap: usize) -> Option<Vec<Vec<Lit>>> {
let gens = literal_automorphism_generators(num_vars, clauses);
let point_gens: Vec<Perm> = gens.iter().map(|s| litsym_to_points(s, num_vars)).collect();
let elems = crate::permgroup::schreier_sims(2 * num_vars, &point_gens).elements(cap)?;
Some(elems.iter().map(|p| litsym_from_points(p, num_vars)).collect())
}
fn count_projected_models(total_vars: usize, num_orig: usize, clauses: &[Vec<Lit>]) -> usize {
let mut seen: std::collections::BTreeSet<Vec<bool>> = std::collections::BTreeSet::new();
loop {
let mut s = Solver::new(total_vars);
for c in clauses {
s.add_clause(c.clone());
}
for proj in &seen {
s.add_clause((0..num_orig).map(|v| Lit::new(v as u32, !proj[v])).collect());
}
match s.solve() {
SolveResult::Sat(m) => {
seen.insert((0..num_orig).map(|v| m[v]).collect());
}
SolveResult::Unsat => break,
}
}
seen.len()
}
pub fn count_models_modulo_symmetry(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<usize> {
let group = literal_automorphism_group(num_vars, clauses, 100_000)?;
let (sbp, total) = lex_leader_sbp_lit(num_vars, &group);
let mut broken = clauses.to_vec();
broken.extend(sbp);
Some(count_projected_models(total, num_vars, &broken))
}
pub fn hierarchical_break(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<(Vec<Vec<Lit>>, usize)> {
let gens = variable_automorphism_generators(num_vars, clauses)?;
if gens.is_empty() {
return None;
}
let bsgs = crate::permgroup::schreier_sims(num_vars, &gens);
let blocks = crate::permgroup::minimal_block_system(num_vars, &gens)?; let (k, m) = (blocks.len(), blocks[0].len());
let mut structured: Vec<Vec<Lit>> = Vec::new();
for i in 0..k.saturating_sub(1) {
let mut p: Vec<usize> = (0..num_vars).collect();
for j in 0..m {
p[blocks[i][j]] = blocks[i + 1][j];
p[blocks[i + 1][j]] = blocks[i][j];
}
if bsgs.contains(&p) {
structured.push(perm_to_litsym(&p));
}
}
for j in 0..m.saturating_sub(1) {
let mut p: Vec<usize> = (0..num_vars).collect();
for b in &blocks {
p[b[j]] = b[j + 1];
p[b[j + 1]] = b[j];
}
if bsgs.contains(&p) {
structured.push(perm_to_litsym(&p));
}
}
if structured.is_empty() {
return None;
}
Some(lex_leader_sbp_lit(num_vars, &structured))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cdcl::{SolveResult, Solver};
use std::collections::BTreeSet;
fn models(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Vec<bool>> {
(0u64..(1u64 << num_vars))
.filter_map(|x| {
let a: Vec<bool> = (0..num_vars).map(|i| (x >> i) & 1 == 1).collect();
clauses
.iter()
.all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
.then_some(a)
})
.collect()
}
fn orbit_count(group: &[Perm], models: &[Vec<bool>]) -> usize {
let mut seen: BTreeSet<Vec<bool>> = BTreeSet::new();
let mut count = 0;
for m in models {
if seen.contains(m) {
continue;
}
count += 1;
for g in group {
seen.insert((0..m.len()).map(|j| m[g[j]]).collect());
}
}
count
}
fn count_projected_models(total_vars: usize, num_orig: usize, clauses: &[Vec<Lit>]) -> usize {
let mut seen: BTreeSet<Vec<bool>> = BTreeSet::new();
loop {
let mut s = Solver::new(total_vars);
for c in clauses {
s.add_clause(c.clone());
}
for proj in &seen {
s.add_clause((0..num_orig).map(|v| Lit::new(v as u32, !proj[v])).collect());
}
match s.solve() {
SolveResult::Sat(m) => {
seen.insert((0..num_orig).map(|v| m[v]).collect());
}
SolveResult::Unsat => break,
}
}
seen.len()
}
fn all_s_n(n: usize) -> Vec<Perm> {
let mut out = Vec::new();
let mut p: Perm = (0..n).collect();
loop {
out.push(p.clone());
let Some(i) = (0..n.saturating_sub(1)).rev().find(|&i| p[i] < p[i + 1]) else { break };
let j = (i + 1..n).rev().find(|&j| p[j] > p[i]).unwrap();
p.swap(i, j);
p[i + 1..].reverse();
}
out
}
#[test]
fn lex_leaders_are_one_per_orbit_under_s_n() {
for n in 2..=5usize {
let group = all_s_n(n);
let all: Vec<Vec<bool>> = (0u64..(1u64 << n)).map(|x| (0..n).map(|i| (x >> i) & 1 == 1).collect()).collect();
let leaders = all.iter().filter(|a| is_lex_leader(&group, a)).count();
assert_eq!(leaders, n + 1, "S_{n} on the cube has n+1 weight-orbits, one leader each");
assert_eq!(leaders, orbit_count(&group, &all), "leaders == orbit count");
}
}
#[test]
fn the_cnf_sbp_accepts_exactly_the_lex_leaders() {
for n in 2..=4usize {
let group = all_s_n(n);
let (sbp, total) = lex_leader_sbp(n, &group);
let semantic =
(0u64..(1u64 << n)).filter(|&x| is_lex_leader(&group, &(0..n).map(|i| (x >> i) & 1 == 1).collect::<Vec<_>>())).count();
assert_eq!(count_projected_models(total, n, &sbp), semantic, "CNF SBP accepts exactly the leaders");
assert_eq!(semantic, n + 1, "and there are n+1 of them");
}
}
#[test]
fn affine_lex_leader_encodes_the_lex_predicate() {
let n = 3usize;
let map = vec![(vec![0usize, 1], false), (vec![1], false), (vec![2, 0], false)];
let (sbp, total) = affine_lex_leader_sbp(n, &[map]);
let alpha = |x: u64| -> u64 {
let a0 = (x & 1) ^ ((x >> 1) & 1);
let a1 = (x >> 1) & 1;
let a2 = ((x >> 2) & 1) ^ (x & 1);
a0 | (a1 << 1) | (a2 << 2)
};
let lex_le = |x: u64, y: u64| -> bool {
for j in 0..n {
let (xj, yj) = ((x >> j) & 1, (y >> j) & 1);
if xj != yj {
return xj < yj;
}
}
true
};
for x in 0u64..(1 << n) {
let accepted = (0u64..(1u64 << (total - n))).any(|aux| {
let full = x | (aux << n);
sbp.iter().all(|c| c.iter().any(|l| ((full >> l.var()) & 1 == 1) == l.is_positive()))
});
assert_eq!(accepted, lex_le(x, alpha(x)), "SBP must accept x={x:03b} iff x ≤_lex α(x)={:03b}", alpha(x));
}
}
#[test]
fn partial_generator_breaking_is_sound_but_weaker_than_complete() {
let n = 3;
let full = all_s_n(n); let gens: Vec<Perm> = vec![vec![1, 0, 2], vec![1, 2, 0]]; let (complete, ct) = lex_leader_sbp(n, &full);
let (partial, pt) = lex_leader_sbp(n, &gens);
let complete_survivors = count_projected_models(ct, n, &complete);
let partial_survivors = count_projected_models(pt, n, &partial);
assert_eq!(complete_survivors, n + 1, "complete keeps exactly one per orbit (n+1 weight classes)");
assert!(
partial_survivors >= complete_survivors && partial_survivors <= (1 << n),
"partial keeps a superset (≥ complete, ≤ all): {partial_survivors} vs {complete_survivors}"
);
let f = vec![vec![Lit::pos(0), Lit::pos(1), Lit::pos(2)]];
let mut with_partial = f.clone();
with_partial.extend(partial);
assert!(count_projected_models(pt, n, &with_partial) >= 1, "partial preserves satisfiability");
}
#[test]
fn stabilizer_chain_break_is_between_complete_and_generators() {
let n = 4;
let gens: Vec<Perm> = vec![vec![1, 0, 2, 3], vec![1, 2, 3, 0]]; let bsgs = crate::permgroup::schreier_sims(n, &gens);
let complete = bsgs.elements(100_000).unwrap();
let mut chain = gens.clone();
chain.extend(bsgs.transversal_elements());
let survivors = |group: &[Perm]| {
let (sbp, t) = lex_leader_sbp(n, group);
count_projected_models(t, n, &sbp)
};
let (c, ch, g) = (survivors(&complete), survivors(&chain), survivors(&gens));
assert_eq!(c, n + 1, "complete keeps exactly one per orbit (n+1 weight classes)");
assert!(c <= ch && ch <= g, "complete ≤ stabilizer-chain ≤ generators: {c} ≤ {ch} ≤ {g}");
assert!(g <= (1usize << n), "all sound (≤ total assignments)");
}
#[test]
fn value_phase_symmetry_is_broken() {
let f = vec![vec![Lit::pos(0), Lit::pos(1)], vec![Lit::neg(0), Lit::pos(1)]];
assert!(
variable_automorphism_generators(2, &f).is_none_or(|g| g.is_empty()),
"the phase-free variable scheme is blind to the value symmetry"
);
let gens = literal_automorphism_generators(2, &f);
assert!(!gens.is_empty(), "the value symmetry x₀ ↦ ¬x₀ is detected as a literal symmetry");
for s in &gens {
assert_eq!(litsym_from_points(&litsym_to_points(s, 2), 2), *s, "litsym ↔ points round-trips");
}
let (sbp, total) = lex_leader_sbp_lit(2, &gens);
let mut broken = f.clone();
broken.extend(sbp);
assert_eq!(count_projected_models(total, 2, &broken), 1, "value symmetry broken to one model");
assert_eq!(models(2, &f).len(), 2, "F has two models, one orbit under the flip");
}
#[test]
fn complete_lex_leader_keeps_one_model_per_orbit_end_to_end() {
let (cnf, _) = crate::families::clique_coloring(3, 3);
let group = variable_automorphism_group(cnf.num_vars, &cnf.clauses, 100_000)
.expect("clique colouring has a phase-free, small automorphism group");
let ms = models(cnf.num_vars, &cnf.clauses);
let orbits = orbit_count(&group, &ms);
let (sbp, total) = lex_leader_sbp(cnf.num_vars, &group);
let mut broken = cnf.clauses.clone();
broken.extend(sbp);
let surviving = count_projected_models(total, cnf.num_vars, &broken);
assert_eq!(surviving, orbits, "complete SBP leaves exactly one model per orbit");
assert!(orbits >= 1 && surviving < ms.len(), "and it strictly breaks the symmetry");
let (php, _) = crate::families::php(3);
let pg = variable_automorphism_group(php.num_vars, &php.clauses, 100_000).expect("PHP group");
let (psbp, ptotal) = lex_leader_sbp(php.num_vars, &pg);
let mut pbroken = php.clauses.clone();
pbroken.extend(psbp);
assert_eq!(count_projected_models(ptotal, php.num_vars, &pbroken), 0, "UNSAT stays UNSAT");
}
#[test]
fn conditional_symmetry_emerges_under_a_partial_assignment() {
let f = vec![
vec![Lit::neg(0), Lit::pos(1), Lit::pos(2)], vec![Lit::neg(0), Lit::neg(1), Lit::neg(2)], vec![Lit::pos(0), Lit::pos(1)], ];
let swaps_12 = |gens: &[Vec<Lit>]| {
gens.iter().any(|img| img[1] == Lit::pos(2) && img[2] == Lit::pos(1))
};
assert!(!swaps_12(&literal_automorphism_generators(3, &f)), "F has no global x1↔x2 symmetry");
let local = conditional_symmetry_generators(3, &f, &[Lit::pos(0)]);
assert!(swaps_12(&local), "the residual under x0=true has the x1↔x2 symmetry: {local:?}");
}
#[test]
fn hierarchical_block_wise_breaking_is_sound_and_polynomial() {
let (cnf, _) = crate::families::clique_coloring(3, 3);
let nv = cnf.num_vars;
let (sbp, total) = hierarchical_break(nv, &cnf.clauses).expect("clique colouring is a grid symmetry");
let ms = models(nv, &cnf.clauses);
let var_group = variable_automorphism_group(nv, &cnf.clauses, 100_000).unwrap();
let orbits = orbit_count(&var_group, &ms);
let mut broken = cnf.clauses.clone();
broken.extend(sbp);
let surviving = count_projected_models(total, nv, &broken);
assert!(surviving >= orbits, "hierarchical break is sound: ≥ one model per orbit ({surviving} ≥ {orbits})");
assert!(surviving < ms.len(), "and it breaks the symmetry: fewer survivors than the {} models", ms.len());
}
#[test]
fn php_symmetry_is_an_imprimitive_grid() {
let n = 4;
let (cnf, _) = crate::families::php(n);
let gens = variable_automorphism_generators(cnf.num_vars, &cnf.clauses).expect("phase-free");
assert!(
!crate::permgroup::is_primitive(cnf.num_vars, &gens),
"PHP's symmetry is imprimitive — it is a grid, not an atom"
);
let blocks =
crate::permgroup::minimal_block_system(cnf.num_vars, &gens).expect("PHP has a block system");
assert!(blocks.iter().all(|b| b.len() == n - 1), "blocks are pigeon-rows of size n-1: {blocks:?}");
assert_eq!(blocks.len(), n, "there are n pigeon-rows");
for b in &blocks {
let pigeon = b[0] / (n - 1);
assert!(b.iter().all(|&v| v / (n - 1) == pigeon), "each block is exactly one pigeon's row: {b:?}");
}
}
#[test]
fn count_modulo_symmetry_equals_burnside_and_brute_orbit_count() {
let (cnf, _) = crate::families::clique_coloring(3, 3);
let nv = cnf.num_vars;
let group = literal_automorphism_group(nv, &cnf.clauses, 100_000).unwrap();
let ms = models(nv, &cnf.clauses);
let image = |img: &[Lit], a: &[bool]| -> Vec<bool> {
(0..a.len())
.map(|j| if img[j].is_positive() { a[img[j].var() as usize] } else { !a[img[j].var() as usize] })
.collect()
};
let brute = {
let mut seen: BTreeSet<Vec<bool>> = BTreeSet::new();
let mut c = 0;
for m in &ms {
if seen.contains(m) {
continue;
}
c += 1;
for s in &group {
seen.insert(image(s, m));
}
}
c
};
let fixed: usize = group.iter().map(|s| ms.iter().filter(|&m| image(s, m) == *m).count()).sum();
let burnside = fixed / group.len();
let counted = count_models_modulo_symmetry(nv, &cnf.clauses).unwrap();
assert_eq!(counted, brute, "complete-SBP count == brute orbit count");
assert_eq!(counted, burnside, "== Burnside count");
assert_eq!(counted, 1, "clique_coloring(3,3): all six proper colourings are one orbit");
assert_eq!(ms.len(), 6, "and there are six of them");
}
}