use crate::cdcl::Lit;
use crate::proof::Perm;
use std::collections::{BTreeSet, HashMap, HashSet};
pub type CanonClauses = Vec<Vec<(u32, bool)>>;
pub type Twist = Vec<(u32, u32, bool)>;
pub fn canon(clauses: &[Vec<Lit>]) -> CanonClauses {
canon_raw(
&clauses
.iter()
.map(|c| c.iter().map(|l| (l.var(), l.is_positive())).collect())
.collect::<Vec<_>>(),
)
}
pub fn canon_raw(clauses: &[Vec<(u32, bool)>]) -> CanonClauses {
let mut out: CanonClauses = clauses
.iter()
.map(|c| {
let mut lits = c.clone();
lits.sort_unstable();
lits.dedup();
lits
})
.collect();
out.sort();
out.dedup();
out
}
pub fn cofactor(clauses: &CanonClauses, x: u32, b: bool) -> CanonClauses {
canon_raw(
&clauses
.iter()
.filter(|c| !c.iter().any(|&(v, pos)| v == x && pos == b))
.map(|c| c.iter().copied().filter(|&(v, _)| v != x).collect())
.collect::<Vec<_>>(),
)
}
pub fn is_leaf(clauses: &CanonClauses) -> bool {
clauses.iter().any(|c| c.is_empty())
}
#[derive(Clone, Debug)]
pub enum Node {
Leaf(CanonClauses),
Internal { clauses: CanonClauses, var: u32, lo: usize, hi: usize },
}
pub fn distinct_cofactor_dag(n: usize, clauses: &CanonClauses) -> Option<(usize, Vec<Node>)> {
let mut nodes: Vec<Node> = Vec::new();
let mut memo: HashMap<(usize, CanonClauses), Option<usize>> = HashMap::new();
fn go(
depth: usize,
n: usize,
clauses: CanonClauses,
nodes: &mut Vec<Node>,
memo: &mut HashMap<(usize, CanonClauses), Option<usize>>,
) -> Option<usize> {
if let Some(&hit) = memo.get(&(depth, clauses.clone())) {
return hit;
}
let result = if clauses.iter().any(|c| c.is_empty()) {
let id = nodes.len();
nodes.push(Node::Leaf(clauses.clone()));
Some(id)
} else if depth == n {
None
} else {
let x = depth as u32;
let lo = go(depth + 1, n, cofactor(&clauses, x, false), nodes, memo);
let hi = go(depth + 1, n, cofactor(&clauses, x, true), nodes, memo);
match (lo, hi) {
(Some(lo), Some(hi)) => {
let id = nodes.len();
nodes.push(Node::Internal { clauses: clauses.clone(), var: x, lo, hi });
Some(id)
}
_ => None,
}
};
memo.insert((depth, clauses), result);
result
}
let root = go(0, n, clauses.clone(), &mut nodes, &mut memo)?;
Some((root, nodes))
}
pub fn check_distinct_dag(root: usize, nodes: &[Node], expected: &CanonClauses) -> bool {
match &nodes[root] {
Node::Leaf(c) | Node::Internal { clauses: c, .. } if c != expected => return false,
_ => {}
}
nodes.iter().all(|node| match node {
Node::Leaf(c) => c.iter().any(|cl| cl.is_empty()),
Node::Internal { clauses, var, lo, hi } => {
let want_lo = cofactor(clauses, *var, false);
let want_hi = cofactor(clauses, *var, true);
let got = |id: usize| match &nodes[id] {
Node::Leaf(c) => c,
Node::Internal { clauses, .. } => clauses,
};
*got(*lo) == want_lo && *got(*hi) == want_hi
}
})
}
pub fn level_widths(n: usize, root: &CanonClauses) -> Vec<usize> {
let mut levels: Vec<HashSet<CanonClauses>> = vec![HashSet::new(); n + 1];
let mut visited: HashSet<(usize, CanonClauses)> = HashSet::new();
fn go(
depth: usize,
n: usize,
clauses: CanonClauses,
levels: &mut Vec<HashSet<CanonClauses>>,
visited: &mut HashSet<(usize, CanonClauses)>,
) {
if !visited.insert((depth, clauses.clone())) {
return;
}
levels[depth].insert(clauses.clone());
if clauses.iter().any(|c| c.is_empty()) || depth == n {
return;
}
let x = depth as u32;
go(depth + 1, n, cofactor(&clauses, x, false), levels, visited);
go(depth + 1, n, cofactor(&clauses, x, true), levels, visited);
}
go(0, n, root.clone(), &mut levels, &mut visited);
levels.iter().map(|s| s.len()).collect()
}
pub fn cofactor_set(n: usize, clauses: &CanonClauses) -> BTreeSet<(usize, CanonClauses)> {
fn go(depth: usize, n: usize, clauses: CanonClauses, set: &mut BTreeSet<(usize, CanonClauses)>) {
if !set.insert((depth, clauses.clone())) {
return;
}
if is_leaf(&clauses) || depth == n {
return;
}
let x = depth as u32;
go(depth + 1, n, cofactor(&clauses, x, false), set);
go(depth + 1, n, cofactor(&clauses, x, true), set);
}
let mut set = BTreeSet::new();
go(0, n, clauses.clone(), &mut set);
set
}
pub fn distinct_width(n: usize, clauses: &CanonClauses) -> usize {
cofactor_set(n, clauses).len()
}
pub fn quotient_class_count<C: Congruence + ?Sized>(
n: usize,
clauses: &CanonClauses,
cong: &C,
) -> usize {
cofactor_set(n, clauses)
.into_iter()
.map(|(d, c)| (d, cong.canonicalize(&c).0))
.collect::<BTreeSet<_>>()
.len()
}
pub trait Congruence {
fn name(&self) -> &str;
fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist);
}
pub fn normalize(clauses: &CanonClauses) -> (CanonClauses, Vec<(u32, u32)>) {
let mut cur = clauses.clone();
let mut total: HashMap<u32, u32> = HashMap::new();
for c in clauses.iter().flatten() {
total.entry(c.0).or_insert(c.0);
}
for _ in 0..3 {
let mut next_name: u32 = 0;
let mut ren: HashMap<u32, u32> = HashMap::new();
for c in &cur {
for &(v, _) in c {
ren.entry(v).or_insert_with(|| {
let x = next_name;
next_name += 1;
x
});
}
}
let renamed: Vec<Vec<(u32, bool)>> =
cur.iter().map(|c| c.iter().map(|&(v, p)| (ren[&v], p)).collect()).collect();
let renamed = canon_raw(&renamed);
for (_, tgt) in total.iter_mut() {
if let Some(&t2) = ren.get(tgt) {
*tgt = t2;
}
}
if renamed == cur {
break;
}
cur = renamed;
}
(cur.clone(), total.into_iter().collect())
}
pub fn apply_twist(clauses: &CanonClauses, twist: &Twist) -> Option<CanonClauses> {
let map: HashMap<u32, (u32, bool)> = twist.iter().map(|&(a, b, f)| (a, (b, f))).collect();
let mut out = Vec::new();
for c in clauses {
let mut nc = Vec::new();
for &(v, pos) in c {
let &(v2, f) = map.get(&v)?;
nc.push((v2, pos ^ f));
}
out.push(nc);
}
Some(canon_raw(&out))
}
pub fn group_canon(clauses: &CanonClauses, group: &[Perm]) -> (CanonClauses, Twist) {
let mut best: Option<(CanonClauses, Twist)> = None;
for g in group {
let mapped: Vec<Vec<(u32, bool)>> = clauses
.iter()
.map(|c| {
c.iter()
.map(|&(v, pos)| {
let img = g.apply(Lit::new(v, pos));
(img.var(), img.is_positive())
})
.collect()
})
.collect();
let mapped = canon_raw(&mapped);
let (normed, ren) = normalize(&mapped);
let ren_map: HashMap<u32, u32> = ren.into_iter().collect();
let twist: Twist = clauses
.iter()
.flatten()
.map(|&(v, _)| {
let img = g.apply(Lit::pos(v));
(v, ren_map[&img.var()], !img.is_positive())
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
if best.as_ref().map_or(true, |(b, _)| normed < *b) {
best = Some((normed, twist));
}
}
best.unwrap_or_else(|| (clauses.clone(), Vec::new()))
}
pub fn iso_canon(clauses: &CanonClauses, cap: usize) -> (CanonClauses, Twist) {
let live: Vec<u32> = clauses
.iter()
.flatten()
.map(|&(v, _)| v)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let k = live.len();
if k == 0 {
return (clauses.clone(), Vec::new());
}
if k > cap {
let (normed, ren) = normalize(clauses);
let twist: Twist = ren
.into_iter()
.map(|(a, b)| (a, b, false))
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
return (normed, twist);
}
let mut best: Option<(CanonClauses, Twist)> = None;
for perm in permutations(k) {
for flip_mask in 0u32..(1u32 << k) {
let map: HashMap<u32, (u32, bool)> = (0..k)
.map(|i| (live[i], (perm[i] as u32, (flip_mask >> i) & 1 == 1)))
.collect();
let mapped: Vec<Vec<(u32, bool)>> = clauses
.iter()
.map(|c| {
c.iter()
.map(|&(v, p)| {
let (v2, f) = map[&v];
(v2, p ^ f)
})
.collect()
})
.collect();
let mapped = canon_raw(&mapped);
if best.as_ref().map_or(true, |(b, _)| mapped < *b) {
let twist: Twist = live
.iter()
.map(|&v| {
let (v2, f) = map[&v];
(v, v2, f)
})
.collect();
best = Some((mapped, twist));
}
}
}
best.unwrap()
}
pub fn unit_propagate(clauses: &CanonClauses) -> CanonClauses {
let mut cur = clauses.clone();
while let Some(&[(v, p)]) = cur.iter().find(|c| c.len() == 1).map(|c| c.as_slice()) {
cur = canon_raw(
&cur.iter()
.filter(|c| !c.iter().any(|&(vv, pp)| vv == v && pp == p))
.map(|c| c.iter().copied().filter(|&(vv, _)| vv != v).collect())
.collect::<Vec<_>>(),
);
if cur.iter().any(|c| c.is_empty()) {
break;
}
}
cur
}
fn pure_eliminate(clauses: &CanonClauses) -> CanonClauses {
let mut pos: HashSet<u32> = HashSet::new();
let mut neg: HashSet<u32> = HashSet::new();
for &(v, p) in clauses.iter().flatten() {
if p {
pos.insert(v);
} else {
neg.insert(v);
}
}
let pure: HashSet<u32> = pos.symmetric_difference(&neg).copied().collect();
if pure.is_empty() {
return clauses.clone();
}
canon_raw(
&clauses
.iter()
.filter(|c| !c.iter().any(|&(v, _)| pure.contains(&v)))
.cloned()
.collect::<Vec<_>>(),
)
}
fn subsume(clauses: &CanonClauses) -> CanonClauses {
canon_raw(
&clauses
.iter()
.filter(|c| {
let cset: BTreeSet<(u32, bool)> = c.iter().copied().collect();
!clauses.iter().any(|d| d.len() < c.len() && d.iter().all(|l| cset.contains(l)))
})
.cloned()
.collect::<Vec<_>>(),
)
}
pub fn reduce(clauses: &CanonClauses) -> CanonClauses {
let mut cur = clauses.clone();
loop {
let next = subsume(&pure_eliminate(&unit_propagate(&cur)));
if is_leaf(&next) || next == cur {
return next;
}
cur = next;
}
}
fn permutations(k: usize) -> Vec<Vec<usize>> {
let items: Vec<usize> = (0..k).collect();
let mut out = Vec::new();
fn rec(remaining: &[usize], acc: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
if remaining.is_empty() {
out.push(acc.clone());
return;
}
for i in 0..remaining.len() {
let mut rest = remaining.to_vec();
let x = rest.remove(i);
acc.push(x);
rec(&rest, acc, out);
acc.pop();
}
}
rec(&items, &mut Vec::new(), &mut out);
out
}
pub struct Raw;
impl Congruence for Raw {
fn name(&self) -> &str {
"raw"
}
fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
let twist: Twist = clauses
.iter()
.flatten()
.map(|&(v, _)| (v, v, false))
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
(clauses.clone(), twist)
}
}
pub struct Rename;
impl Congruence for Rename {
fn name(&self) -> &str {
"rename"
}
fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
let (normed, ren) = normalize(clauses);
let ren_map: HashMap<u32, u32> = ren.into_iter().collect();
let twist: Twist = clauses
.iter()
.flatten()
.map(|&(v, _)| (v, ren_map[&v], false))
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
(normed, twist)
}
}
pub struct GroupInduced {
pub group: Vec<Perm>,
pub label: String,
}
impl Congruence for GroupInduced {
fn name(&self) -> &str {
&self.label
}
fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
group_canon(clauses, &self.group)
}
}
pub struct CofactorIso {
pub cap: usize,
}
impl Congruence for CofactorIso {
fn name(&self) -> &str {
"cofactor-iso"
}
fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
iso_canon(clauses, self.cap)
}
}
pub struct UnitPropIso {
pub cap: usize,
}
impl Congruence for UnitPropIso {
fn name(&self) -> &str {
"unitprop-iso"
}
fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
iso_canon(&unit_propagate(clauses), self.cap)
}
}
pub struct ReduceIso {
pub cap: usize,
}
impl Congruence for ReduceIso {
fn name(&self) -> &str {
"reduce-iso"
}
fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
iso_canon(&reduce(clauses), self.cap)
}
}
fn is_non_resolution_route(r: &crate::solve::Route) -> bool {
use crate::solve::Route::*;
matches!(r, Parity | ModP | ModM | ExactCover | Collapse | HybridXor | Sos | Nullstellensatz | Pigeonhole)
}
fn non_res_crushed() -> CanonClauses {
vec![vec![(u32::MAX, true)]]
}
pub struct StructuredReduceIso {
pub cap: usize,
}
impl Congruence for StructuredReduceIso {
fn name(&self) -> &str {
"struct-reduce-iso"
}
fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
let r = reduce(clauses);
match structured_leaf(&r) {
Some(route) if is_non_resolution_route(&route) => (non_res_crushed(), Vec::new()),
_ => iso_canon(&r, self.cap),
}
}
}
#[derive(Clone, Debug)]
pub enum QNode {
Leaf(CanonClauses),
Internal { clauses: CanonClauses, var: u32, lo: usize, hi: usize, lo_twist: Twist, hi_twist: Twist },
}
pub struct QuotientDag {
pub root: usize,
pub nodes: Vec<QNode>,
pub visits: usize,
}
impl QuotientDag {
pub fn width(&self) -> usize {
self.nodes.len()
}
}
pub fn quotient_dag<C: Congruence + ?Sized>(
n: usize,
clauses: &CanonClauses,
cong: &C,
) -> Option<QuotientDag> {
let mut nodes: Vec<QNode> = Vec::new();
let mut memo: HashMap<(usize, CanonClauses), Option<usize>> = HashMap::new();
fn go<C: Congruence + ?Sized>(
depth: usize,
n: usize,
clauses: CanonClauses,
nodes: &mut Vec<QNode>,
memo: &mut HashMap<(usize, CanonClauses), Option<usize>>,
cong: &C,
) -> Option<usize> {
if let Some(&hit) = memo.get(&(depth, clauses.clone())) {
return hit;
}
let result = if clauses.iter().any(|c| c.is_empty()) {
let id = nodes.len();
nodes.push(QNode::Leaf(clauses.clone()));
Some(id)
} else if clauses.is_empty() || depth > n {
None
} else {
let x = clauses.iter().flatten().map(|&(v, _)| v).min().unwrap();
let mut children: Vec<(usize, Twist)> = Vec::new();
let mut ok = true;
for b in [false, true] {
let cof = cofactor(&clauses, x, b);
let (cn, twist) = cong.canonicalize(&cof);
match go(depth + 1, n, cn, nodes, memo, cong) {
Some(id) => children.push((id, twist)),
None => {
ok = false;
break;
}
}
}
if ok {
let id = nodes.len();
let (lo, lo_twist) = children[0].clone();
let (hi, hi_twist) = children[1].clone();
nodes.push(QNode::Internal { clauses: clauses.clone(), var: x, lo, hi, lo_twist, hi_twist });
Some(id)
} else {
None
}
};
memo.insert((depth, clauses), result);
result
}
let (root_canon, _) = cong.canonicalize(clauses);
let root = go(0, n, root_canon, &mut nodes, &mut memo, cong)?;
let visits = memo.len();
Some(QuotientDag { root, nodes, visits })
}
pub fn quotient_width<C: Congruence + ?Sized>(
n: usize,
clauses: &CanonClauses,
cong: &C,
) -> Option<usize> {
quotient_dag(n, clauses, cong).map(|d| d.width())
}
pub fn check_quotient_dag(nodes: &[QNode]) -> bool {
nodes.iter().all(|node| match node {
QNode::Leaf(c) => c.iter().any(|cl| cl.is_empty()),
QNode::Internal { clauses, var, lo, hi, lo_twist, hi_twist } => {
let child = |id: usize| match &nodes[id] {
QNode::Leaf(c) => c,
QNode::Internal { clauses, .. } => clauses,
};
let ok = |b: bool, id: usize, tw: &Twist| {
apply_twist(&cofactor(clauses, *var, b), tw).map_or(false, |t| t == *child(id))
};
ok(false, *lo, lo_twist) && ok(true, *hi, hi_twist)
}
})
}
fn to_lits(clauses: &CanonClauses) -> Vec<Vec<Lit>> {
clauses.iter().map(|c| c.iter().map(|&(v, p)| Lit::new(v, p)).collect()).collect()
}
pub fn structured_leaf(clauses: &CanonClauses) -> Option<crate::solve::Route> {
if is_leaf(clauses) {
return None; }
let nv = clauses.iter().flatten().map(|&(v, _)| v as usize + 1).max().unwrap_or(0);
if nv == 0 {
return None; }
let solved = crate::solve::structured_prefix(nv, &to_lits(clauses))?;
match solved.answer {
crate::solve::Answer::Unsat
if !matches!(solved.via, crate::solve::Route::Cdcl | crate::solve::Route::Incompressible) =>
{
Some(solved.via)
}
_ => None,
}
}
#[derive(Clone, Debug)]
pub enum SNode {
Trivial(CanonClauses),
Structured { clauses: CanonClauses, route: crate::solve::Route },
Internal { clauses: CanonClauses, var: u32, lo: usize, hi: usize },
}
impl SNode {
fn clauses(&self) -> &CanonClauses {
match self {
SNode::Trivial(c) | SNode::Structured { clauses: c, .. } | SNode::Internal { clauses: c, .. } => c,
}
}
}
pub struct StructuredDag {
pub root: usize,
pub nodes: Vec<SNode>,
}
impl StructuredDag {
pub fn size(&self) -> usize {
self.nodes.len()
}
pub fn structured_leaves(&self) -> usize {
self.nodes.iter().filter(|n| matches!(n, SNode::Structured { .. })).count()
}
}
pub fn structured_leaf_dag(n: usize, clauses: &CanonClauses) -> Option<StructuredDag> {
let mut nodes: Vec<SNode> = Vec::new();
let mut memo: HashMap<(usize, CanonClauses), Option<usize>> = HashMap::new();
fn go(
depth: usize,
n: usize,
clauses: CanonClauses,
nodes: &mut Vec<SNode>,
memo: &mut HashMap<(usize, CanonClauses), Option<usize>>,
) -> Option<usize> {
if let Some(&hit) = memo.get(&(depth, clauses.clone())) {
return hit;
}
let result = if is_leaf(&clauses) {
let id = nodes.len();
nodes.push(SNode::Trivial(clauses.clone()));
Some(id)
} else if let Some(route) = structured_leaf(&clauses) {
let id = nodes.len();
nodes.push(SNode::Structured { clauses: clauses.clone(), route });
Some(id)
} else if depth == n {
None
} else {
let x = depth as u32;
let lo = go(depth + 1, n, cofactor(&clauses, x, false), nodes, memo);
let hi = go(depth + 1, n, cofactor(&clauses, x, true), nodes, memo);
match (lo, hi) {
(Some(lo), Some(hi)) => {
let id = nodes.len();
nodes.push(SNode::Internal { clauses: clauses.clone(), var: x, lo, hi });
Some(id)
}
_ => None,
}
};
memo.insert((depth, clauses), result);
result
}
let root = go(0, n, clauses.clone(), &mut nodes, &mut memo)?;
Some(StructuredDag { root, nodes })
}
pub fn check_structured_dag(nodes: &[SNode]) -> bool {
nodes.iter().all(|node| match node {
SNode::Trivial(c) => is_leaf(c),
SNode::Structured { clauses, .. } => structured_leaf(clauses).is_some(),
SNode::Internal { clauses, var, lo, hi } => {
nodes[*lo].clauses() == &cofactor(clauses, *var, false)
&& nodes[*hi].clauses() == &cofactor(clauses, *var, true)
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hypercube::{minimal_cover_orbits, php_perm_symmetries};
fn php_canon(m: usize) -> (usize, CanonClauses) {
let (php, _) = crate::families::php(m);
(php.num_vars, canon(&php.clauses))
}
fn php_group(m: usize) -> Vec<Perm> {
let nv = m * (m - 1);
let gens = php_perm_symmetries(m);
let key = |p: &Perm| -> Vec<u32> { (0..nv).map(|v| p.apply(Lit::pos(v as u32)).var()).collect() };
let id = Perm::identity(nv);
let mut seen: BTreeSet<Vec<u32>> = [key(&id)].into_iter().collect();
let mut group = vec![id.clone()];
let mut frontier = vec![id];
while let Some(p) = frontier.pop() {
for g in &gens {
let q = p.compose(g);
if seen.insert(key(&q)) {
group.push(q.clone());
frontier.push(q);
}
}
}
group
}
fn xor_cycle(k: usize) -> CanonClauses {
let mut raw: Vec<Vec<(u32, bool)>> = Vec::new();
for i in 0..k {
let j = (i + 1) % k;
raw.push(vec![(i as u32, true), (j as u32, true)]);
raw.push(vec![(i as u32, false), (j as u32, false)]);
}
canon_raw(&raw)
}
#[test]
fn distinct_cofactor_dag_matches_the_prototype_and_the_checker_has_teeth() {
let n = 3usize;
for cover in minimal_cover_orbits(n) {
let clauses = canon(&cover.clauses());
let (root, nodes) = distinct_cofactor_dag(n, &clauses).expect("every UNSAT family unfolds");
assert!(check_distinct_dag(root, &nodes, &clauses), "the strict DAG re-checks");
}
let sat = canon(&[vec![Lit::pos(0), Lit::pos(1)], vec![Lit::neg(2)]]);
assert!(distinct_cofactor_dag(n, &sat).is_none(), "a satisfiable formula has no DAG");
let (root, mut nodes) = distinct_cofactor_dag(3, &xor_cycle(3)).unwrap();
let internal = nodes
.iter()
.position(|nd| matches!(nd, Node::Internal { lo, hi, .. } if lo != hi))
.unwrap();
if let Node::Internal { lo, hi, .. } = &mut nodes[internal] {
std::mem::swap(lo, hi);
}
assert!(!check_distinct_dag(root, &nodes, &xor_cycle(3)), "a corrupted DAG is rejected");
let widths: Vec<usize> = [5usize, 7, 9, 11, 13]
.iter()
.map(|&k| *level_widths(k, &xor_cycle(k)).iter().max().unwrap())
.collect();
assert!(widths.windows(2).all(|w| w[0] == w[1]), "XOR cycle max width constant: {widths:?}");
}
#[test]
fn quotient_dag_reproduces_the_locked_pigeonhole_ratchets() {
for &(m, plain_expected, fused_expected) in &[(3usize, 25usize, 18usize), (4, 103, 60)] {
let (nv, clauses) = php_canon(m);
let plain = quotient_dag(nv, &clauses, &Rename).expect("PHP unfolds");
let group = GroupInduced { group: php_group(m), label: "php-sym".into() };
let fused = quotient_dag(nv, &clauses, &group).expect("PHP unfolds under the group");
assert!(check_quotient_dag(&plain.nodes), "m={m}: plain re-checks");
assert!(check_quotient_dag(&fused.nodes), "m={m}: fused re-checks, twists verified");
assert_eq!(plain.width(), plain_expected, "m={m}: plain quotient width is locked");
assert_eq!(fused.width(), fused_expected, "m={m}: fused quotient width is locked");
assert!(fused.width() < plain.width(), "m={m}: the group compounds the crush");
assert!(fused.visits <= 2 * fused.width() + 2 * nv + 2, "m={m}: work linear in output");
}
let (nv, clauses) = php_canon(3);
let group = GroupInduced { group: php_group(3), label: "php-sym".into() };
let mut dag = quotient_dag(nv, &clauses, &group).unwrap();
let victim = dag
.nodes
.iter()
.position(|n| matches!(n, QNode::Internal { lo_twist, .. } if !lo_twist.is_empty()))
.expect("a nontrivial twist exists");
if let QNode::Internal { lo_twist, .. } = &mut dag.nodes[victim] {
lo_twist[0].2 = !lo_twist[0].2;
}
assert!(!check_quotient_dag(&dag.nodes), "a corrupted twist is rejected");
}
#[test]
fn the_cofactor_class_ladder_is_monotone_and_bounded_by_the_distinct_floor() {
for m in [3usize, 4] {
let (nv, clauses) = php_canon(m);
let distinct = distinct_width(nv, &clauses);
let raw = quotient_class_count(nv, &clauses, &Raw);
let rename = quotient_class_count(nv, &clauses, &Rename);
let iso = quotient_class_count(nv, &clauses, &CofactorIso { cap: 6 });
let unitprop = quotient_class_count(nv, &clauses, &UnitPropIso { cap: 6 });
let reduceiso = quotient_class_count(nv, &clauses, &ReduceIso { cap: 6 });
assert_eq!(raw, distinct, "m={m}: Raw class count == distinct_width");
assert_eq!(level_widths(nv, &clauses).iter().sum::<usize>(), distinct, "m={m}: Σ widths");
assert!(rename <= raw, "m={m}: rename ≤ raw ({rename} ≤ {raw})");
assert!(iso <= rename, "m={m}: iso ≤ rename ({iso} ≤ {rename})");
assert!(unitprop <= iso, "m={m}: unitprop ≤ iso ({unitprop} ≤ {iso})");
assert!(reduceiso <= unitprop, "m={m}: reduce-iso ≤ unitprop ({reduceiso} ≤ {unitprop})");
assert!(iso <= distinct, "m={m}: every class count ≤ the distinct floor ({iso} ≤ {distinct})");
let dag = quotient_dag(nv, &clauses, &CofactorIso { cap: 6 }).expect("PHP unfolds under iso");
assert!(check_quotient_dag(&dag.nodes), "m={m}: the iso certificate re-checks");
eprintln!(
"cofactor-classes[PHP({m})]: distinct {distinct} → rename {rename} → iso {iso} \
(certificate DAG {} nodes, re-checked)",
dag.width()
);
}
}
}