Skip to main content

logicaffeine_proof/
cofactor.rs

1//! **The cofactor-DAG lens: symmetry above the instance.**
2//!
3//! §4 of `work/PAPER.md` measures symmetry *in the instance* — automorphisms of the cover (`Bₙ`, `AGL`,
4//! shears). The residue (`census::residue_map`) is rigid to all of them; that rigidity is the wall.
5//! This module lifts the lens one level up, onto the formula's **Shannon cofactor DAG**: the DAG
6//! whose nodes are the distinct residual sub-formulas `F|ρ` under partial assignments `ρ`, with
7//! edges the Shannon expansion `F = x·F|_{x=1} + x̄·F|_{x=0}`.
8//!
9//! A hard core has **exponentially many distinct cofactors** — that is exactly what "no small
10//! variable-order decision DAG / exponential resolution width" means, and it is the finite, per-`n`
11//! incompressibility pole ([`crate::ait::incompressible_string_exists`]) one level up. The
12//! hypothesis this module makes executable: those exponentially-many cofactors may fall into
13//! **polynomially many equivalence classes** under a congruence `~` that is *not* induced by any
14//! automorphism of the instance. Quotient the DAG by `~` and the exponential object becomes a
15//! polynomial one; when the certificate is `~`-invariant the quotient DAG **is** the refutation,
16//! with class-sharing playing the role of extension variables (the Resolution → ER/SR jump).
17//!
18//! The collapse measure, and the certificate — kept deliberately separate:
19//!
20//!   - [`distinct_width`] / [`cofactor_set`] — the strict distinct-cofactor set under the fixed
21//!     order `0..n` (the OBDD width): the honest exponential floor for the residue.
22//!   - [`quotient_class_count`] — the number of `~`-classes among that *same fixed set*. Because it
23//!     only quotients a fixed set, `quotient_class_count(Raw) = distinct_width` and coarser
24//!     congruences give monotonically fewer classes: `iso ≤ rename ≤ raw = distinct` are
25//!     **theorems** (not artifacts of a branching order), so they hold on every formula, the
26//!     residue included. This is the clean measure of "do exponentially-many cofactors fall into
27//!     polynomially-many classes?"
28//!   - [`quotient_dag`] — the *certificate realization*: the cofactor DAG quotiented by `~`, every
29//!     edge carrying the explicit [`Twist`] (literal renaming) verified locally by
30//!     [`check_quotient_dag`] (zero trust). It is a sound, poly-time-checkable refutation; its node
31//!     count is *a* certificate size, reported but never conflated with the collapse measure (the
32//!     two constructions are size-incomparable — renaming can re-order the branch variable).
33//!
34//! The congruence ladder, finest → coarsest: [`Raw`] (strict equality) ⊂ [`Rename`] (first-appearance
35//! renaming) ⊂ [`GroupInduced`] (an instance symmetry group's orbits — `Bₙ`, `AGL`) ⊂ [`CofactorIso`]
36//! (full CNF-isomorphism: any variable relabeling **and** polarity flip, not tied to an instance
37//! automorphism — the strongest *decidable* rung, the one that sees symmetry above the instance).
38//! Whether a poly-index congruence exists for the residue past this rung — an SR-definable one — is
39//! `3-SAT ∈ coNP`.
40
41use crate::cdcl::Lit;
42use crate::proof::Perm;
43use std::collections::{BTreeSet, HashMap, HashSet};
44
45/// A canonicalized CNF: clauses of `(var, polarity)` literals, each clause sorted+deduped, the
46/// clause list sorted+deduped. The node currency of every cofactor DAG here.
47pub type CanonClauses = Vec<Vec<(u32, bool)>>;
48
49/// An explicit literal renaming `var → (var′, flip)`, stored on a quotient-DAG edge and verified
50/// by the checker: it witnesses that a recomputed cofactor is `~`-equivalent to its stored child.
51pub type Twist = Vec<(u32, u32, bool)>;
52
53/// Canonicalize a CNF given as packed [`Lit`]s.
54pub fn canon(clauses: &[Vec<Lit>]) -> CanonClauses {
55    canon_raw(
56        &clauses
57            .iter()
58            .map(|c| c.iter().map(|l| (l.var(), l.is_positive())).collect())
59            .collect::<Vec<_>>(),
60    )
61}
62
63/// Canonicalize a CNF given in raw `(var, polarity)` form.
64pub fn canon_raw(clauses: &[Vec<(u32, bool)>]) -> CanonClauses {
65    let mut out: CanonClauses = clauses
66        .iter()
67        .map(|c| {
68            let mut lits = c.clone();
69            lits.sort_unstable();
70            lits.dedup();
71            lits
72        })
73        .collect();
74    out.sort();
75    out.dedup();
76    out
77}
78
79/// The Shannon cofactor `F|_{x=b}`: drop clauses satisfied by `x=b`, delete the `x` literal from the
80/// rest, recanonicalize. An empty clause survives as `⊥` (the branch is UNSAT on the spot).
81pub fn cofactor(clauses: &CanonClauses, x: u32, b: bool) -> CanonClauses {
82    canon_raw(
83        &clauses
84            .iter()
85            .filter(|c| !c.iter().any(|&(v, pos)| v == x && pos == b))
86            .map(|c| c.iter().copied().filter(|&(v, _)| v != x).collect())
87            .collect::<Vec<_>>(),
88    )
89}
90
91/// A leaf: the clause set contains the empty clause `⊥`.
92pub fn is_leaf(clauses: &CanonClauses) -> bool {
93    clauses.iter().any(|c| c.is_empty())
94}
95
96// =============================================================================
97// The strict distinct-cofactor DAG (fixed variable order, no renaming): OBDD width.
98// =============================================================================
99
100/// A node of the strict distinct-cofactor DAG.
101#[derive(Clone, Debug)]
102pub enum Node {
103    /// The clause set contains `⊥` — UNSAT on the spot.
104    Leaf(CanonClauses),
105    /// Branch on `var`: children are the two Shannon cofactors (indices into the node vector).
106    Internal { clauses: CanonClauses, var: u32, lo: usize, hi: usize },
107}
108
109/// Build the memoized strict distinct-cofactor DAG over the fixed order `0..n`. Identical cofactors
110/// at the same depth SHARE a node. Returns `None` iff the formula is satisfiable (some fully
111/// unfolded branch carries no `⊥`). The node count is the number of distinct cofactors — the OBDD
112/// width — and is the honest exponential floor on the residue (no order keeps it small).
113pub fn distinct_cofactor_dag(n: usize, clauses: &CanonClauses) -> Option<(usize, Vec<Node>)> {
114    let mut nodes: Vec<Node> = Vec::new();
115    let mut memo: HashMap<(usize, CanonClauses), Option<usize>> = HashMap::new();
116    fn go(
117        depth: usize,
118        n: usize,
119        clauses: CanonClauses,
120        nodes: &mut Vec<Node>,
121        memo: &mut HashMap<(usize, CanonClauses), Option<usize>>,
122    ) -> Option<usize> {
123        if let Some(&hit) = memo.get(&(depth, clauses.clone())) {
124            return hit;
125        }
126        let result = if clauses.iter().any(|c| c.is_empty()) {
127            let id = nodes.len();
128            nodes.push(Node::Leaf(clauses.clone()));
129            Some(id)
130        } else if depth == n {
131            None
132        } else {
133            let x = depth as u32;
134            let lo = go(depth + 1, n, cofactor(&clauses, x, false), nodes, memo);
135            let hi = go(depth + 1, n, cofactor(&clauses, x, true), nodes, memo);
136            match (lo, hi) {
137                (Some(lo), Some(hi)) => {
138                    let id = nodes.len();
139                    nodes.push(Node::Internal { clauses: clauses.clone(), var: x, lo, hi });
140                    Some(id)
141                }
142                _ => None,
143            }
144        };
145        memo.insert((depth, clauses), result);
146        result
147    }
148    let root = go(0, n, clauses.clone(), &mut nodes, &mut memo)?;
149    Some((root, nodes))
150}
151
152/// **Zero-trust local checker** for the strict DAG: leaves carry `⊥`; every internal node's two
153/// children are exactly its recomputed cofactors. A pass certifies the root UNSAT by structural
154/// induction, in time linear in the DAG.
155pub fn check_distinct_dag(root: usize, nodes: &[Node], expected: &CanonClauses) -> bool {
156    match &nodes[root] {
157        Node::Leaf(c) | Node::Internal { clauses: c, .. } if c != expected => return false,
158        _ => {}
159    }
160    nodes.iter().all(|node| match node {
161        Node::Leaf(c) => c.iter().any(|cl| cl.is_empty()),
162        Node::Internal { clauses, var, lo, hi } => {
163            let want_lo = cofactor(clauses, *var, false);
164            let want_hi = cofactor(clauses, *var, true);
165            let got = |id: usize| match &nodes[id] {
166                Node::Leaf(c) => c,
167                Node::Internal { clauses, .. } => clauses,
168            };
169            *got(*lo) == want_lo && *got(*hi) == want_hi
170        }
171    })
172}
173
174/// The per-level widths of the strict decision DAG: `w[i]` = number of distinct residual clause-sets
175/// reachable after branching on variables `0..i`. The distinct-cofactor count is `Σ w[i]`.
176pub fn level_widths(n: usize, root: &CanonClauses) -> Vec<usize> {
177    let mut levels: Vec<HashSet<CanonClauses>> = vec![HashSet::new(); n + 1];
178    let mut visited: HashSet<(usize, CanonClauses)> = HashSet::new();
179    fn go(
180        depth: usize,
181        n: usize,
182        clauses: CanonClauses,
183        levels: &mut Vec<HashSet<CanonClauses>>,
184        visited: &mut HashSet<(usize, CanonClauses)>,
185    ) {
186        if !visited.insert((depth, clauses.clone())) {
187            return;
188        }
189        levels[depth].insert(clauses.clone());
190        if clauses.iter().any(|c| c.is_empty()) || depth == n {
191            return;
192        }
193        let x = depth as u32;
194        go(depth + 1, n, cofactor(&clauses, x, false), levels, visited);
195        go(depth + 1, n, cofactor(&clauses, x, true), levels, visited);
196    }
197    go(0, n, root.clone(), &mut levels, &mut visited);
198    levels.iter().map(|s| s.len()).collect()
199}
200
201/// The strict distinct-cofactor set: every `(depth, cofactor)` pair reachable by prefix-restricting
202/// the fixed order `0..n`. Its cardinality is [`distinct_width`]; quotienting it by a congruence can
203/// only merge pairs, so every [`quotient_class_count`] is `≤ distinct_width` and coarser congruences
204/// give monotonically fewer classes — both are theorems about quotients of this one fixed set, and
205/// therefore hold on every formula.
206pub fn cofactor_set(n: usize, clauses: &CanonClauses) -> BTreeSet<(usize, CanonClauses)> {
207    fn go(depth: usize, n: usize, clauses: CanonClauses, set: &mut BTreeSet<(usize, CanonClauses)>) {
208        if !set.insert((depth, clauses.clone())) {
209            return;
210        }
211        if is_leaf(&clauses) || depth == n {
212            return;
213        }
214        let x = depth as u32;
215        go(depth + 1, n, cofactor(&clauses, x, false), set);
216        go(depth + 1, n, cofactor(&clauses, x, true), set);
217    }
218    let mut set = BTreeSet::new();
219    go(0, n, clauses.clone(), &mut set);
220    set
221}
222
223/// The distinct-cofactor count — the OBDD width under the fixed order `0..n`. Equal to
224/// `Σ (level widths)` and to `|cofactor_set|`.
225pub fn distinct_width(n: usize, clauses: &CanonClauses) -> usize {
226    cofactor_set(n, clauses).len()
227}
228
229/// **The collapse measure.** The number of `~`-classes among the strict distinct-cofactor set:
230/// canonicalize every `(depth, cofactor)` under `~` and count distinct. Because it is a quotient of
231/// the fixed set [`cofactor_set`], it satisfies — as *theorems*, for every formula —
232/// `quotient_class_count(Raw) = distinct_width` and monotonicity under congruence coarsening
233/// (`iso ≤ rename ≤ raw`). This is the clean instrument for "do exponentially-many cofactors fall
234/// into polynomially-many classes?", with no dependence on any branching order.
235pub fn quotient_class_count<C: Congruence + ?Sized>(
236    n: usize,
237    clauses: &CanonClauses,
238    cong: &C,
239) -> usize {
240    cofactor_set(n, clauses)
241        .into_iter()
242        .map(|(d, c)| (d, cong.canonicalize(&c).0))
243        .collect::<BTreeSet<_>>()
244        .len()
245}
246
247// =============================================================================
248// The quotient DAG: the cofactor DAG merged under a pluggable congruence.
249// =============================================================================
250
251/// An equivalence relation on cofactors, given by a canonical representative of each class plus the
252/// [`Twist`] realizing the merge. To be a sound certificate substrate it must be a **congruence**:
253/// its twists are literal renamings (bijection + polarity flips), which preserve (un)satisfiability,
254/// so isomorphism-invariance of UNSAT makes the local check sound. Two cofactors are `~`-equivalent
255/// iff [`canonicalize`](Congruence::canonicalize) returns the same representative.
256pub trait Congruence {
257    /// A short label for reporting (`"identity"`, `"Bn"`, `"AGL"`, `"cofactor-iso"`).
258    fn name(&self) -> &str;
259    /// The canonical representative of `clauses`'s class, and the twist mapping `clauses` onto it
260    /// (i.e. `apply_twist(clauses, twist) == representative`).
261    fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist);
262}
263
264/// Deterministic name-normalization: rename variables by first appearance over the sorted clause
265/// list, iterated to a fixpoint of (rename, sort). Returns the normalized set and the renaming.
266pub fn normalize(clauses: &CanonClauses) -> (CanonClauses, Vec<(u32, u32)>) {
267    let mut cur = clauses.clone();
268    let mut total: HashMap<u32, u32> = HashMap::new();
269    for c in clauses.iter().flatten() {
270        total.entry(c.0).or_insert(c.0);
271    }
272    for _ in 0..3 {
273        let mut next_name: u32 = 0;
274        let mut ren: HashMap<u32, u32> = HashMap::new();
275        for c in &cur {
276            for &(v, _) in c {
277                ren.entry(v).or_insert_with(|| {
278                    let x = next_name;
279                    next_name += 1;
280                    x
281                });
282            }
283        }
284        let renamed: Vec<Vec<(u32, bool)>> =
285            cur.iter().map(|c| c.iter().map(|&(v, p)| (ren[&v], p)).collect()).collect();
286        let renamed = canon_raw(&renamed);
287        for (_, tgt) in total.iter_mut() {
288            if let Some(&t2) = ren.get(tgt) {
289                *tgt = t2;
290            }
291        }
292        if renamed == cur {
293            break;
294        }
295        cur = renamed;
296    }
297    (cur.clone(), total.into_iter().collect())
298}
299
300/// Apply a [`Twist`] to a clause set; `None` if a live variable is unmapped.
301pub fn apply_twist(clauses: &CanonClauses, twist: &Twist) -> Option<CanonClauses> {
302    let map: HashMap<u32, (u32, bool)> = twist.iter().map(|&(a, b, f)| (a, (b, f))).collect();
303    let mut out = Vec::new();
304    for c in clauses {
305        let mut nc = Vec::new();
306        for &(v, pos) in c {
307            let &(v2, f) = map.get(&v)?;
308            nc.push((v2, pos ^ f));
309        }
310        out.push(nc);
311    }
312    Some(canon_raw(&out))
313}
314
315/// The group-canonical form of a clause set: the minimum, over every group element `g`, of the
316/// name-normalized image — plus the twist realizing it. Two cofactors in the same `G`-orbit
317/// canonicalize identically, whatever corner of the orbit they sit in.
318pub fn group_canon(clauses: &CanonClauses, group: &[Perm]) -> (CanonClauses, Twist) {
319    let mut best: Option<(CanonClauses, Twist)> = None;
320    for g in group {
321        let mapped: Vec<Vec<(u32, bool)>> = clauses
322            .iter()
323            .map(|c| {
324                c.iter()
325                    .map(|&(v, pos)| {
326                        let img = g.apply(Lit::new(v, pos));
327                        (img.var(), img.is_positive())
328                    })
329                    .collect()
330            })
331            .collect();
332        let mapped = canon_raw(&mapped);
333        let (normed, ren) = normalize(&mapped);
334        let ren_map: HashMap<u32, u32> = ren.into_iter().collect();
335        let twist: Twist = clauses
336            .iter()
337            .flatten()
338            .map(|&(v, _)| {
339                let img = g.apply(Lit::pos(v));
340                (v, ren_map[&img.var()], !img.is_positive())
341            })
342            .collect::<BTreeSet<_>>()
343            .into_iter()
344            .collect();
345        if best.as_ref().map_or(true, |(b, _)| normed < *b) {
346            best = Some((normed, twist));
347        }
348    }
349    best.unwrap_or_else(|| (clauses.clone(), Vec::new()))
350}
351
352/// The full CNF-isomorphism canonical form: the lex-least image over **all** relabelings of the live
353/// variables and **all** polarity flips — not tied to any instance automorphism. Above `cap` live
354/// variables it degrades gracefully to first-appearance normalization (the boundary is reported by
355/// callers, never silently exceeded).
356pub fn iso_canon(clauses: &CanonClauses, cap: usize) -> (CanonClauses, Twist) {
357    let live: Vec<u32> = clauses
358        .iter()
359        .flatten()
360        .map(|&(v, _)| v)
361        .collect::<BTreeSet<_>>()
362        .into_iter()
363        .collect();
364    let k = live.len();
365    if k == 0 {
366        return (clauses.clone(), Vec::new());
367    }
368    if k > cap {
369        let (normed, ren) = normalize(clauses);
370        let twist: Twist = ren
371            .into_iter()
372            .map(|(a, b)| (a, b, false))
373            .collect::<BTreeSet<_>>()
374            .into_iter()
375            .collect();
376        return (normed, twist);
377    }
378    let mut best: Option<(CanonClauses, Twist)> = None;
379    for perm in permutations(k) {
380        for flip_mask in 0u32..(1u32 << k) {
381            let map: HashMap<u32, (u32, bool)> = (0..k)
382                .map(|i| (live[i], (perm[i] as u32, (flip_mask >> i) & 1 == 1)))
383                .collect();
384            let mapped: Vec<Vec<(u32, bool)>> = clauses
385                .iter()
386                .map(|c| {
387                    c.iter()
388                        .map(|&(v, p)| {
389                            let (v2, f) = map[&v];
390                            (v2, p ^ f)
391                        })
392                        .collect()
393                })
394                .collect();
395            let mapped = canon_raw(&mapped);
396            if best.as_ref().map_or(true, |(b, _)| mapped < *b) {
397                let twist: Twist = live
398                    .iter()
399                    .map(|&v| {
400                        let (v2, f) = map[&v];
401                        (v, v2, f)
402                    })
403                    .collect();
404                best = Some((mapped, twist));
405            }
406        }
407    }
408    best.unwrap()
409}
410
411/// Unit-propagate a clause set to fixpoint: repeatedly assign a unit clause's literal (drop satisfied
412/// clauses, delete the falsified literal), stopping at a derived `⊥` or when no unit remains. A sound
413/// structural reduction (RUP-certified), monotone under relabeling.
414pub fn unit_propagate(clauses: &CanonClauses) -> CanonClauses {
415    let mut cur = clauses.clone();
416    while let Some(&[(v, p)]) = cur.iter().find(|c| c.len() == 1).map(|c| c.as_slice()) {
417        cur = canon_raw(
418            &cur.iter()
419                .filter(|c| !c.iter().any(|&(vv, pp)| vv == v && pp == p))
420                .map(|c| c.iter().copied().filter(|&(vv, _)| vv != v).collect())
421                .collect::<Vec<_>>(),
422        );
423        if cur.iter().any(|c| c.is_empty()) {
424            break;
425        }
426    }
427    cur
428}
429
430/// Eliminate pure literals: a variable occurring in only one polarity is satisfied, dropping every
431/// clause it appears in. SAT-equivalence-preserving, monotone under relabeling.
432fn pure_eliminate(clauses: &CanonClauses) -> CanonClauses {
433    let mut pos: HashSet<u32> = HashSet::new();
434    let mut neg: HashSet<u32> = HashSet::new();
435    for &(v, p) in clauses.iter().flatten() {
436        if p {
437            pos.insert(v);
438        } else {
439            neg.insert(v);
440        }
441    }
442    let pure: HashSet<u32> = pos.symmetric_difference(&neg).copied().collect();
443    if pure.is_empty() {
444        return clauses.clone();
445    }
446    canon_raw(
447        &clauses
448            .iter()
449            .filter(|c| !c.iter().any(|&(v, _)| pure.contains(&v)))
450            .cloned()
451            .collect::<Vec<_>>(),
452    )
453}
454
455/// Remove subsumed clauses: drop `C` whenever some strictly shorter `D ⊊ C` exists (canon has already
456/// deduped equal clauses, so only strict supersets are removed). SAT-equivalence-preserving.
457fn subsume(clauses: &CanonClauses) -> CanonClauses {
458    canon_raw(
459        &clauses
460            .iter()
461            .filter(|c| {
462                let cset: BTreeSet<(u32, bool)> = c.iter().copied().collect();
463                !clauses.iter().any(|d| d.len() < c.len() && d.iter().all(|l| cset.contains(l)))
464            })
465            .cloned()
466            .collect::<Vec<_>>(),
467    )
468}
469
470/// A sound poly reduction to fixpoint: unit propagation, then pure-literal elimination, then
471/// subsumption, iterated. Each step preserves (un)satisfiability and commutes with relabeling, so
472/// `reduce(π(F)) = π(reduce(F))` — which makes [`ReduceIso`] a legitimate coarsening of [`UnitPropIso`].
473pub fn reduce(clauses: &CanonClauses) -> CanonClauses {
474    let mut cur = clauses.clone();
475    loop {
476        let next = subsume(&pure_eliminate(&unit_propagate(&cur)));
477        if is_leaf(&next) || next == cur {
478            return next;
479        }
480        cur = next;
481    }
482}
483
484/// Every permutation of `0..k` (k! of them). `k` is the live-variable count of a small cofactor.
485fn permutations(k: usize) -> Vec<Vec<usize>> {
486    let items: Vec<usize> = (0..k).collect();
487    let mut out = Vec::new();
488    fn rec(remaining: &[usize], acc: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
489        if remaining.is_empty() {
490            out.push(acc.clone());
491            return;
492        }
493        for i in 0..remaining.len() {
494            let mut rest = remaining.to_vec();
495            let x = rest.remove(i);
496            acc.push(x);
497            rec(&rest, acc, out);
498            acc.pop();
499        }
500    }
501    rec(&items, &mut Vec::new(), &mut out);
502    out
503}
504
505/// Strict equality — no canonicalization at all (the identity twist). The finest rung: it merges
506/// nothing, so `quotient_class_count(Raw) == distinct_width` exactly.
507pub struct Raw;
508
509impl Congruence for Raw {
510    fn name(&self) -> &str {
511        "raw"
512    }
513    fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
514        let twist: Twist = clauses
515            .iter()
516            .flatten()
517            .map(|&(v, _)| (v, v, false))
518            .collect::<BTreeSet<_>>()
519            .into_iter()
520            .collect();
521        (clauses.clone(), twist)
522    }
523}
524
525/// First-appearance renaming (trivial group). One rung coarser than [`Raw`]: it merges cofactors
526/// that coincide after relabeling variables by order of first appearance.
527pub struct Rename;
528
529impl Congruence for Rename {
530    fn name(&self) -> &str {
531        "rename"
532    }
533    fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
534        // First-appearance renaming with the trivial group — the pure-renaming twist (no flips).
535        // Equal to `group_canon(clauses, &[Perm::identity(width)])`, without the Perm-sizing trap.
536        let (normed, ren) = normalize(clauses);
537        let ren_map: HashMap<u32, u32> = ren.into_iter().collect();
538        let twist: Twist = clauses
539            .iter()
540            .flatten()
541            .map(|&(v, _)| (v, ren_map[&v], false))
542            .collect::<BTreeSet<_>>()
543            .into_iter()
544            .collect();
545        (normed, twist)
546    }
547}
548
549/// Merge cofactors lying in the same orbit under an instance-symmetry group (`Bₙ`, `AGL`, …).
550pub struct GroupInduced {
551    pub group: Vec<Perm>,
552    pub label: String,
553}
554
555impl Congruence for GroupInduced {
556    fn name(&self) -> &str {
557        &self.label
558    }
559    fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
560        group_canon(clauses, &self.group)
561    }
562}
563
564/// Merge cofactors that are CNF-isomorphic (any relabeling + polarity flip) — the strongest
565/// decidable rung, the symmetry that need not live in the instance. `cap` bounds the brute force.
566pub struct CofactorIso {
567    pub cap: usize,
568}
569
570impl Congruence for CofactorIso {
571    fn name(&self) -> &str {
572        "cofactor-iso"
573    }
574    fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
575        iso_canon(clauses, self.cap)
576    }
577}
578
579/// Unit-propagate to fixpoint, then canonicalize up to CNF-isomorphism — strictly coarser than
580/// [`CofactorIso`] (cofactors that reduce to isomorphic residuals after propagation merge, and all
581/// unit-refutable cofactors collapse into one). A **collapse-measure** congruence only: unit
582/// propagation is a structural reduction, not a relabeling, so the returned twist is the iso-twist of
583/// the *reduced* form — valid for [`quotient_class_count`] (which uses only the canonical key), not for
584/// the twist-certificate [`quotient_dag`] (a propagation merge is certified by RUP instead).
585pub struct UnitPropIso {
586    pub cap: usize,
587}
588
589impl Congruence for UnitPropIso {
590    fn name(&self) -> &str {
591        "unitprop-iso"
592    }
593    fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
594        iso_canon(&unit_propagate(clauses), self.cap)
595    }
596}
597
598/// Apply the full sound reduction ([`reduce`]: unit-prop + pure-literal + subsumption to fixpoint),
599/// then canonicalize up to CNF-isomorphism — one rung coarser than [`UnitPropIso`]. A collapse-measure
600/// congruence (the reduction is structural, RUP/reduction-certified, not a relabeling), for
601/// [`quotient_class_count`]. The ladder: `reduce-iso ≤ unitprop-iso ≤ iso ≤ rename ≤ raw = distinct`.
602pub struct ReduceIso {
603    pub cap: usize,
604}
605
606impl Congruence for ReduceIso {
607    fn name(&self) -> &str {
608        "reduce-iso"
609    }
610    fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
611        iso_canon(&reduce(clauses), self.cap)
612    }
613}
614
615/// The algebraic / **non-resolution** dispatcher routes — the ones that refute where resolution is
616/// exponential (parity/GF(2), mod-`p`, exact-cover counting, algebraic collapse). Distinguished from
617/// the resolution-simulatable specialists (2-SAT, Horn) so the non-resolution rung can be isolated.
618fn is_non_resolution_route(r: &crate::solve::Route) -> bool {
619    use crate::solve::Route::*;
620    matches!(r, Parity | ModP | ModM | ExactCover | Collapse | HybridXor | Sos | Nullstellensatz | Pigeonhole)
621}
622
623/// A sentinel canonical form for "crushed by a non-resolution specialist" — a clause over the
624/// impossible variable `u32::MAX`, distinct from every real (reduced) cofactor and from `⊥`.
625fn non_res_crushed() -> CanonClauses {
626    vec![vec![(u32::MAX, true)]]
627}
628
629/// The reduction rung **fused with the non-resolution crushers as terminals**: `reduce` to fixpoint,
630/// then if a *non-resolution* specialist (parity/GF(2), mod-`p`, exact-cover, algebraic collapse)
631/// refutes the reduced cofactor, collapse it to one "crushed" class; otherwise canonicalize up to iso.
632/// Coarser than [`ReduceIso`] (all non-resolution-crushable reduced cofactors merge into one), so
633/// `struct-reduce-iso ≤ reduce-iso`. This is the rung that can beat the Chvátal–Szemerédi resolution
634/// cap — GF(2)/mod-`p` are polynomial exactly where resolution is exponential — measured via
635/// [`quotient_class_count`] (specialist verdicts certified by their routes; reduce by RUP).
636pub struct StructuredReduceIso {
637    pub cap: usize,
638}
639
640impl Congruence for StructuredReduceIso {
641    fn name(&self) -> &str {
642        "struct-reduce-iso"
643    }
644    fn canonicalize(&self, clauses: &CanonClauses) -> (CanonClauses, Twist) {
645        let r = reduce(clauses);
646        match structured_leaf(&r) {
647            Some(route) if is_non_resolution_route(&route) => (non_res_crushed(), Vec::new()),
648            _ => iso_canon(&r, self.cap),
649        }
650    }
651}
652
653/// A node of the quotient DAG. Edges carry the [`Twist`] that maps the recomputed cofactor onto the
654/// stored child representative.
655#[derive(Clone, Debug)]
656pub enum QNode {
657    Leaf(CanonClauses),
658    Internal { clauses: CanonClauses, var: u32, lo: usize, hi: usize, lo_twist: Twist, hi_twist: Twist },
659}
660
661/// The cofactor DAG quotiented by a [`Congruence`]: `root` node index, `nodes` (one per class), and
662/// `visits` (the memoized recursion's work — output-sensitive: linear in the collapse found).
663pub struct QuotientDag {
664    pub root: usize,
665    pub nodes: Vec<QNode>,
666    pub visits: usize,
667}
668
669impl QuotientDag {
670    /// The **quotient width** `W(F, ~)` — the number of `~`-classes, i.e. the certificate size.
671    pub fn width(&self) -> usize {
672        self.nodes.len()
673    }
674}
675
676/// Build the cofactor DAG quotiented by `cong`: memoize on the canonical form, branch on the first
677/// live variable of the (canonicalized) subproblem, and merge `~`-equivalent cofactors, recording
678/// the realizing twist on each edge. Returns `None` iff the formula is satisfiable.
679pub fn quotient_dag<C: Congruence + ?Sized>(
680    n: usize,
681    clauses: &CanonClauses,
682    cong: &C,
683) -> Option<QuotientDag> {
684    let mut nodes: Vec<QNode> = Vec::new();
685    let mut memo: HashMap<(usize, CanonClauses), Option<usize>> = HashMap::new();
686    fn go<C: Congruence + ?Sized>(
687        depth: usize,
688        n: usize,
689        clauses: CanonClauses,
690        nodes: &mut Vec<QNode>,
691        memo: &mut HashMap<(usize, CanonClauses), Option<usize>>,
692        cong: &C,
693    ) -> Option<usize> {
694        if let Some(&hit) = memo.get(&(depth, clauses.clone())) {
695            return hit;
696        }
697        let result = if clauses.iter().any(|c| c.is_empty()) {
698            let id = nodes.len();
699            nodes.push(QNode::Leaf(clauses.clone()));
700            Some(id)
701        } else if clauses.is_empty() || depth > n {
702            None
703        } else {
704            let x = clauses.iter().flatten().map(|&(v, _)| v).min().unwrap();
705            let mut children: Vec<(usize, Twist)> = Vec::new();
706            let mut ok = true;
707            for b in [false, true] {
708                let cof = cofactor(&clauses, x, b);
709                let (cn, twist) = cong.canonicalize(&cof);
710                match go(depth + 1, n, cn, nodes, memo, cong) {
711                    Some(id) => children.push((id, twist)),
712                    None => {
713                        ok = false;
714                        break;
715                    }
716                }
717            }
718            if ok {
719                let id = nodes.len();
720                let (lo, lo_twist) = children[0].clone();
721                let (hi, hi_twist) = children[1].clone();
722                nodes.push(QNode::Internal { clauses: clauses.clone(), var: x, lo, hi, lo_twist, hi_twist });
723                Some(id)
724            } else {
725                None
726            }
727        };
728        memo.insert((depth, clauses), result);
729        result
730    }
731    let (root_canon, _) = cong.canonicalize(clauses);
732    let root = go(0, n, root_canon, &mut nodes, &mut memo, cong)?;
733    let visits = memo.len();
734    Some(QuotientDag { root, nodes, visits })
735}
736
737/// The quotient width `W(F, ~)` — `None` iff satisfiable.
738pub fn quotient_width<C: Congruence + ?Sized>(
739    n: usize,
740    clauses: &CanonClauses,
741    cong: &C,
742) -> Option<usize> {
743    quotient_dag(n, clauses, cong).map(|d| d.width())
744}
745
746/// **Zero-trust checker with twist verification**: leaves carry `⊥`; each internal node's recomputed
747/// cofactor, pushed through the stored twist, must equal the child EXACTLY. A pass certifies the
748/// root UNSAT by structural induction (unsatisfiability is isomorphism-invariant and each twist is a
749/// literal renaming), in time linear in the quotient DAG.
750pub fn check_quotient_dag(nodes: &[QNode]) -> bool {
751    nodes.iter().all(|node| match node {
752        QNode::Leaf(c) => c.iter().any(|cl| cl.is_empty()),
753        QNode::Internal { clauses, var, lo, hi, lo_twist, hi_twist } => {
754            let child = |id: usize| match &nodes[id] {
755                QNode::Leaf(c) => c,
756                QNode::Internal { clauses, .. } => clauses,
757            };
758            let ok = |b: bool, id: usize, tw: &Twist| {
759                apply_twist(&cofactor(clauses, *var, b), tw).map_or(false, |t| t == *child(id))
760            };
761            ok(false, *lo, lo_twist) && ok(true, *hi, hi_twist)
762        }
763    })
764}
765
766// =============================================================================
767// The structured-leaf cofactor DAG: every specialist crusher becomes a leaf.
768// =============================================================================
769
770fn to_lits(clauses: &CanonClauses) -> Vec<Vec<Lit>> {
771    clauses.iter().map(|c| c.iter().map(|&(v, p)| Lit::new(v, p)).collect()).collect()
772}
773
774/// A cofactor is a **structured leaf** if a decidable specialist refutes it — the full dispatcher
775/// ([`crate::solve::solve_structured`]) returns UNSAT via a route that is *not* raw CDCL or the
776/// certified-no-shortcut `Incompressible` verdict. So GF(2)/parity (Tseitin), mod-`p` (mod-`p`
777/// Tseitin), counting (pigeonhole/exact-cover), 2-SAT, Horn — every nut the paper already cracks —
778/// is a leaf here, certified internally by the route it fired. Returns that route, or `None` if no
779/// specialist crushes the cofactor (it must be branched, or it is `⊥`/satisfiable).
780pub fn structured_leaf(clauses: &CanonClauses) -> Option<crate::solve::Route> {
781    if is_leaf(clauses) {
782        return None; // ⊥ is a trivial leaf, handled separately
783    }
784    let nv = clauses.iter().flatten().map(|&(v, _)| v as usize + 1).max().unwrap_or(0);
785    if nv == 0 {
786        return None; // no clauses / no variables — satisfiable, not a refutation
787    }
788    // The O(clauses) specialist chain only — NO CDCL fallback. A per-node leaf check must be cheap, and
789    // we already reject the `Cdcl` route, so calling `structured_prefix` (which returns `None` instead
790    // of running the search) is behavior-identical and avoids a full CDCL solve at every cofactor.
791    let solved = crate::solve::structured_prefix(nv, &to_lits(clauses))?;
792    match solved.answer {
793        crate::solve::Answer::Unsat
794            if !matches!(solved.via, crate::solve::Route::Cdcl | crate::solve::Route::Incompressible) =>
795        {
796            Some(solved.via)
797        }
798        _ => None,
799    }
800}
801
802/// A node of the structured-leaf cofactor DAG.
803#[derive(Clone, Debug)]
804pub enum SNode {
805    /// The clause set contains `⊥` — UNSAT on the spot.
806    Trivial(CanonClauses),
807    /// A specialist route refutes this cofactor (`route` fired); certified internally by that route.
808    Structured { clauses: CanonClauses, route: crate::solve::Route },
809    /// Branch on `var`: children are the two Shannon cofactors.
810    Internal { clauses: CanonClauses, var: u32, lo: usize, hi: usize },
811}
812
813impl SNode {
814    fn clauses(&self) -> &CanonClauses {
815        match self {
816            SNode::Trivial(c) | SNode::Structured { clauses: c, .. } | SNode::Internal { clauses: c, .. } => c,
817        }
818    }
819}
820
821/// The cofactor DAG cut off at structured leaves: the whole dispatcher, organized as a decision DAG.
822/// Its size is the number of cofactors that must be branched before a specialist (or `⊥`) fires —
823/// `1` exactly when the root itself is crushed, growing only where no specialist ever fires (the
824/// wall). Reported `structured`/`trivial` leaf counts show which crushers carried the refutation.
825pub struct StructuredDag {
826    pub root: usize,
827    pub nodes: Vec<SNode>,
828}
829
830impl StructuredDag {
831    /// Certificate size — the number of DAG nodes.
832    pub fn size(&self) -> usize {
833        self.nodes.len()
834    }
835    /// Leaves closed by a specialist route (rather than by branching to `⊥`).
836    pub fn structured_leaves(&self) -> usize {
837        self.nodes.iter().filter(|n| matches!(n, SNode::Structured { .. })).count()
838    }
839}
840
841/// Build the structured-leaf cofactor DAG over the fixed order `0..n`: a node is a leaf when it is
842/// `⊥` or when a specialist crushes it ([`structured_leaf`]); otherwise it branches. Identical
843/// cofactors at the same depth share a node. `None` iff the formula is satisfiable.
844pub fn structured_leaf_dag(n: usize, clauses: &CanonClauses) -> Option<StructuredDag> {
845    let mut nodes: Vec<SNode> = Vec::new();
846    let mut memo: HashMap<(usize, CanonClauses), Option<usize>> = HashMap::new();
847    fn go(
848        depth: usize,
849        n: usize,
850        clauses: CanonClauses,
851        nodes: &mut Vec<SNode>,
852        memo: &mut HashMap<(usize, CanonClauses), Option<usize>>,
853    ) -> Option<usize> {
854        if let Some(&hit) = memo.get(&(depth, clauses.clone())) {
855            return hit;
856        }
857        let result = if is_leaf(&clauses) {
858            let id = nodes.len();
859            nodes.push(SNode::Trivial(clauses.clone()));
860            Some(id)
861        } else if let Some(route) = structured_leaf(&clauses) {
862            let id = nodes.len();
863            nodes.push(SNode::Structured { clauses: clauses.clone(), route });
864            Some(id)
865        } else if depth == n {
866            None
867        } else {
868            let x = depth as u32;
869            let lo = go(depth + 1, n, cofactor(&clauses, x, false), nodes, memo);
870            let hi = go(depth + 1, n, cofactor(&clauses, x, true), nodes, memo);
871            match (lo, hi) {
872                (Some(lo), Some(hi)) => {
873                    let id = nodes.len();
874                    nodes.push(SNode::Internal { clauses: clauses.clone(), var: x, lo, hi });
875                    Some(id)
876                }
877                _ => None,
878            }
879        };
880        memo.insert((depth, clauses), result);
881        result
882    }
883    let root = go(0, n, clauses.clone(), &mut nodes, &mut memo)?;
884    Some(StructuredDag { root, nodes })
885}
886
887/// **The checker**: `⊥` leaves carry the empty clause; structured leaves re-fire their specialist
888/// (the dispatcher's route re-checks internally — an idempotent re-verification); internal nodes'
889/// children are exactly their recomputed Shannon cofactors. The internal skeleton is fully zero-trust;
890/// the structured leaves are dispatcher-certified (extracting each route's own witness for end-to-end
891/// zero trust is the next hardening).
892pub fn check_structured_dag(nodes: &[SNode]) -> bool {
893    nodes.iter().all(|node| match node {
894        SNode::Trivial(c) => is_leaf(c),
895        SNode::Structured { clauses, .. } => structured_leaf(clauses).is_some(),
896        SNode::Internal { clauses, var, lo, hi } => {
897            nodes[*lo].clauses() == &cofactor(clauses, *var, false)
898                && nodes[*hi].clauses() == &cofactor(clauses, *var, true)
899        }
900    })
901}
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906    use crate::hypercube::{minimal_cover_orbits, php_perm_symmetries};
907
908    fn php_canon(m: usize) -> (usize, CanonClauses) {
909        let (php, _) = crate::families::php(m);
910        (php.num_vars, canon(&php.clauses))
911    }
912
913    /// BFS closure of the pigeonhole symmetry generators (the small full group).
914    fn php_group(m: usize) -> Vec<Perm> {
915        let nv = m * (m - 1);
916        let gens = php_perm_symmetries(m);
917        let key = |p: &Perm| -> Vec<u32> { (0..nv).map(|v| p.apply(Lit::pos(v as u32)).var()).collect() };
918        let id = Perm::identity(nv);
919        let mut seen: BTreeSet<Vec<u32>> = [key(&id)].into_iter().collect();
920        let mut group = vec![id.clone()];
921        let mut frontier = vec![id];
922        while let Some(p) = frontier.pop() {
923            for g in &gens {
924                let q = p.compose(g);
925                if seen.insert(key(&q)) {
926                    group.push(q.clone());
927                    frontier.push(q);
928                }
929            }
930        }
931        group
932    }
933
934    fn xor_cycle(k: usize) -> CanonClauses {
935        let mut raw: Vec<Vec<(u32, bool)>> = Vec::new();
936        for i in 0..k {
937            let j = (i + 1) % k;
938            raw.push(vec![(i as u32, true), (j as u32, true)]);
939            raw.push(vec![(i as u32, false), (j as u32, false)]);
940        }
941        canon_raw(&raw)
942    }
943
944    /// **The strict distinct-cofactor DAG is a total, locally-checkable certificate on the census,
945    /// and its width is linear on the bounded-width XOR family** — pinning the promoted [`level_widths`]
946    /// / [`distinct_cofactor_dag`] against the `tests/` prototypes verbatim.
947    #[test]
948    fn distinct_cofactor_dag_matches_the_prototype_and_the_checker_has_teeth() {
949        let n = 3usize;
950        for cover in minimal_cover_orbits(n) {
951            let clauses = canon(&cover.clauses());
952            let (root, nodes) = distinct_cofactor_dag(n, &clauses).expect("every UNSAT family unfolds");
953            assert!(check_distinct_dag(root, &nodes, &clauses), "the strict DAG re-checks");
954        }
955        // SAT side refuses.
956        let sat = canon(&[vec![Lit::pos(0), Lit::pos(1)], vec![Lit::neg(2)]]);
957        assert!(distinct_cofactor_dag(n, &sat).is_none(), "a satisfiable formula has no DAG");
958        // Teeth: swap an internal node's children.
959        let (root, mut nodes) = distinct_cofactor_dag(3, &xor_cycle(3)).unwrap();
960        let internal = nodes
961            .iter()
962            .position(|nd| matches!(nd, Node::Internal { lo, hi, .. } if lo != hi))
963            .unwrap();
964        if let Node::Internal { lo, hi, .. } = &mut nodes[internal] {
965            std::mem::swap(lo, hi);
966        }
967        assert!(!check_distinct_dag(root, &nodes, &xor_cycle(3)), "a corrupted DAG is rejected");
968        // The width identity + constant-width XOR family (⟹ O(n) distinct-cofactor count).
969        let widths: Vec<usize> = [5usize, 7, 9, 11, 13]
970            .iter()
971            .map(|&k| *level_widths(k, &xor_cycle(k)).iter().max().unwrap())
972            .collect();
973        assert!(widths.windows(2).all(|w| w[0] == w[1]), "XOR cycle max width constant: {widths:?}");
974    }
975
976    /// **The quotient DAG reproduces the `symmetric_dag_fusion` ratchets exactly** — the promotion is
977    /// behavior-preserving. Rename (first-appearance renaming) gives the plain toll; the PHP group
978    /// compounds the crush; every DAG re-checks; a corrupted twist is rejected.
979    #[test]
980    fn quotient_dag_reproduces_the_locked_pigeonhole_ratchets() {
981        // (m, plain nodes under Rename, fused nodes under the PHP group) — locked 2026-07-03.
982        for &(m, plain_expected, fused_expected) in &[(3usize, 25usize, 18usize), (4, 103, 60)] {
983            let (nv, clauses) = php_canon(m);
984            let plain = quotient_dag(nv, &clauses, &Rename).expect("PHP unfolds");
985            let group = GroupInduced { group: php_group(m), label: "php-sym".into() };
986            let fused = quotient_dag(nv, &clauses, &group).expect("PHP unfolds under the group");
987            assert!(check_quotient_dag(&plain.nodes), "m={m}: plain re-checks");
988            assert!(check_quotient_dag(&fused.nodes), "m={m}: fused re-checks, twists verified");
989            assert_eq!(plain.width(), plain_expected, "m={m}: plain quotient width is locked");
990            assert_eq!(fused.width(), fused_expected, "m={m}: fused quotient width is locked");
991            assert!(fused.width() < plain.width(), "m={m}: the group compounds the crush");
992            // Output-sensitivity: the finder's work is linear in the collapse it finds.
993            assert!(fused.visits <= 2 * fused.width() + 2 * nv + 2, "m={m}: work linear in output");
994        }
995        // Teeth: corrupt one twist.
996        let (nv, clauses) = php_canon(3);
997        let group = GroupInduced { group: php_group(3), label: "php-sym".into() };
998        let mut dag = quotient_dag(nv, &clauses, &group).unwrap();
999        let victim = dag
1000            .nodes
1001            .iter()
1002            .position(|n| matches!(n, QNode::Internal { lo_twist, .. } if !lo_twist.is_empty()))
1003            .expect("a nontrivial twist exists");
1004        if let QNode::Internal { lo_twist, .. } = &mut dag.nodes[victim] {
1005            lo_twist[0].2 = !lo_twist[0].2;
1006        }
1007        assert!(!check_quotient_dag(&dag.nodes), "a corrupted twist is rejected");
1008    }
1009
1010    /// **The cofactor-class ladder is monotone and floored by the distinct count — as theorems.**
1011    /// `quotient_class_count` quotients the one fixed [`cofactor_set`], so `Raw` recovers the
1012    /// distinct-cofactor floor exactly and every coarsening (`Rename`, then `CofactorIso`) can only
1013    /// merge: `iso ≤ rename ≤ raw = distinct`, holding on every formula. This is the clean measure of
1014    /// "do exponentially-many cofactors fall into polynomially-many classes?"; the certificate DAG is
1015    /// verified sound alongside (soundness is independent of the size relationship the old assertion
1016    /// wrongly conflated with this one).
1017    #[test]
1018    fn the_cofactor_class_ladder_is_monotone_and_bounded_by_the_distinct_floor() {
1019        for m in [3usize, 4] {
1020            let (nv, clauses) = php_canon(m);
1021            let distinct = distinct_width(nv, &clauses);
1022            let raw = quotient_class_count(nv, &clauses, &Raw);
1023            let rename = quotient_class_count(nv, &clauses, &Rename);
1024            let iso = quotient_class_count(nv, &clauses, &CofactorIso { cap: 6 });
1025            let unitprop = quotient_class_count(nv, &clauses, &UnitPropIso { cap: 6 });
1026            let reduceiso = quotient_class_count(nv, &clauses, &ReduceIso { cap: 6 });
1027            // Raw = strict equality: exactly the distinct-cofactor floor. And Σ level widths agrees.
1028            assert_eq!(raw, distinct, "m={m}: Raw class count == distinct_width");
1029            assert_eq!(level_widths(nv, &clauses).iter().sum::<usize>(), distinct, "m={m}: Σ widths");
1030            // MONOTONE — coarser congruence, never more classes: a theorem about quotients of one
1031            // fixed set, so it must hold on every formula (the residue included). The full ladder:
1032            // reduce-iso ≤ unitprop ≤ iso ≤ rename ≤ raw = distinct.
1033            assert!(rename <= raw, "m={m}: rename ≤ raw ({rename} ≤ {raw})");
1034            assert!(iso <= rename, "m={m}: iso ≤ rename ({iso} ≤ {rename})");
1035            assert!(unitprop <= iso, "m={m}: unitprop ≤ iso ({unitprop} ≤ {iso})");
1036            assert!(reduceiso <= unitprop, "m={m}: reduce-iso ≤ unitprop ({reduceiso} ≤ {unitprop})");
1037            assert!(iso <= distinct, "m={m}: every class count ≤ the distinct floor ({iso} ≤ {distinct})");
1038            // The certificate DAG re-checks with zero trust (soundness, independent of size).
1039            let dag = quotient_dag(nv, &clauses, &CofactorIso { cap: 6 }).expect("PHP unfolds under iso");
1040            assert!(check_quotient_dag(&dag.nodes), "m={m}: the iso certificate re-checks");
1041            eprintln!(
1042                "cofactor-classes[PHP({m})]: distinct {distinct} → rename {rename} → iso {iso} \
1043                 (certificate DAG {} nodes, re-checked)",
1044                dag.width()
1045            );
1046        }
1047    }
1048}