Skip to main content

logicaffeine_proof/
solve.rs

1//! Structure-detecting solve front-end — the auto-dispatcher that puts our whole arsenal behind a
2//! single entry point.
3//!
4//! An opaque CNF is offered, in cost order, to a battery of CHEAP (O(clauses)) structure
5//! recognizers — each *polynomial* on structure that costs plain CDCL/resolution exponentially, and
6//! each FAIL-CLOSED: it claims a verdict only with a re-checked certificate (a Hall witness, a
7//! cutting-plane derivation, a GF(2) refutation, a covering-measure collapse, or — the complete
8//! deciders — a 2-SAT/Horn model). Anything no recognizer fires on is decided by the authoritative
9//! CDCL core. Every recognizer is cheap to apply AND to reject, so the dispatcher is never slower
10//! than the plain solver — only faster when structure is present.
11//!
12//! Routes, in order:
13//! 1. **2-SAT** (`twosat`) — every clause ≤ 2 literals: linear, decides SAT (with model) or UNSAT.
14//! 2. **Horn** (`hornsat`) — every clause ≤ 1 positive literal: linear least-model / refutation.
15//! 3. **LLL** (`lll`) — *satisfiability from sparsity*: when every clause shares variables with few
16//!    others (`e·2^{-w}·(d+1) ≤ 1`), a model is guaranteed and constructed by Moser–Tardos. The
17//!    SAT-side specialist, dual to the UNSAT recognizers below.
18//! 4. **Pigeonhole / Hall** (`pigeonhole::decide_pigeonhole_unsat`) — bipartite-matching infeasibility:
19//!    crushes pigeonhole (PHP) instances of any size in microseconds.
20//! 5. **Cutting planes** (`pseudo_boolean::refute_clausal`) — cardinality refutation.
21//! 6. **Parity / GF(2)** (`xorsat::refute_via_parity`) — XOR/Tseitin linear systems.
22//! 7. **Covering collapse** (`lyapunov::auto_collapse`) — auto-discovers the covering symmetry
23//!    (pigeonhole, clique-colouring, …) or parity collapse with a Lyapunov-certified artifact.
24//! 8. **CDCL** — the authoritative fallback (model on SAT, RUP proof on UNSAT).
25
26use std::collections::HashMap;
27use std::collections::HashSet;
28
29use crate::cdcl::{BudgetedResult, Lit, SolveResult, Solver};
30use crate::lyapunov::{auto_collapse, extract_xor, AutoCollapse};
31use crate::proof::ProofStep;
32use crate::xor_engine::IncXor;
33use crate::xorsat::XorOutcome;
34use crate::ProofExpr;
35
36// NOTE: generic graph-automorphism detection (`symmetry_detect::find_generators`) and its SEL search
37// are deliberately NOT on this hot path — `find_generators` does not scale (measured ~57s on a
38// 1359-variable circuit instance CDCL solves in 4ms), and SEL spins on easy SAT instances. They are
39// *discovery* tools, not competition moves: run offline to learn a family's symmetry, then distill
40// it into a cheap O(clauses) fingerprint here. Everything below is O(clauses) to apply and reject,
41// so the dispatcher is never slower than the plain CDCL solver.
42
43/// Which engine decided the instance.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum Route {
46    TwoSat,
47    Horn,
48    Lll,
49    Pigeonhole,
50    CuttingPlanes,
51    Parity,
52    /// Exactly-one groups harvested from the raw clauses (all-positive clause + full pairwise
53    /// at-most-one) yield `Σ_{v∈g} x_v = 1` over every modulus; Gaussian elimination over small
54    /// fields finds the counting obstruction — the parity sum, the signed bipartite combination —
55    /// with the refutation re-checked fail-closed. The covering-encoded counting crusher.
56    ExactCover,
57    ModP,
58    ModM,
59    Collapse,
60    HybridXor,
61    Sos,
62    Nullstellensatz,
63    SymmetryBreak,
64    NestedSymmetry,
65    Sel,
66    LocalSymmetry,
67    OrbitalBranch,
68    SymmetricProbe,
69    SymmetricBinary,
70    OrbitWeightQuotient,
71    SymmetryPropagate,
72    SymmetricComponent,
73    SymmetrySimplify,
74    SemanticSymmetry,
75    AlmostSymmetry,
76    DeclaredSymmetry,
77    RecursiveBreak,
78    /// The formula is certified to carry no linear/parity symmetry shortcut and is provably rigid, so
79    /// the symmetry arsenal is useless — decided by CDCL with that honest "no shortcut" verdict on record.
80    Incompressible,
81    /// The formula split into independent components with no symmetry relating them; each was solved apart
82    /// through the full arsenal and the verdicts combined (the plain-decomposition analogue of separability).
83    Component,
84    /// Certified bounded variable elimination (Davis–Putnam, non-growing) reduced the formula to `⊥`.
85    /// The missing preprocessing crusher: it refutes bounded-treewidth cores the symmetry/algebra chain
86    /// declared `Incompressible`. Its RUP resolvents + clause deletions are the independently checkable proof.
87    BoundedVarElim,
88    /// The binary-implication graph's SCCs forced `x ≡ ¬x` — the 2-clauses alone refute the formula. Catches
89    /// general (non-pure-2SAT) formulas whose binary sub-part is contradictory, which the pure-TwoSat route
90    /// misses. Certified by a short RUP chain (`(x)`, `(¬x)`, `⊥`), re-checkable by propagation.
91    EquivLit,
92    /// Davis–Putnam bucket elimination refuted the formula with every resolvent width ≤ a cap — a bounded-
93    /// treewidth resolution certificate (`2^w·n`). Covers the medium-treewidth families that BVE's non-growing
94    /// rule misses; declines (leaving `Incompressible`) on the high-treewidth residue where width would blow up.
95    TreeWidth,
96    Cdcl,
97}
98
99/// The verdict; a model on SAT, with any UNSAT certificate carried in [`Solved::proof`].
100#[derive(Clone, Debug)]
101pub enum Answer {
102    Sat(Vec<bool>),
103    Unsat,
104}
105
106/// A decided instance.
107#[derive(Clone, Debug)]
108pub struct Solved {
109    pub answer: Answer,
110    pub via: Route,
111    /// UNSAT certificate as a proof stream where one exists (RUP for the CDCL route); empty for the
112    /// polynomial specialists, which certify internally.
113    pub proof: Vec<ProofStep>,
114    /// CDCL conflicts spent (0 whenever a specialist collapsed the instance without search).
115    pub conflicts: u64,
116}
117
118impl Solved {
119    fn unsat(via: Route) -> Self {
120        Solved { answer: Answer::Unsat, via, proof: Vec::new(), conflicts: 0 }
121    }
122}
123
124/// Decide `clauses` over `num_vars` variables, routing through every cheap specialist before CDCL — the
125/// fast default front-end. For the full arsenal (heavy algebraic + complete symmetry breaking before the
126/// fallback) use [`solve_comprehensive`].
127pub fn solve_structured(num_vars: usize, clauses: &[Vec<Lit>]) -> Solved {
128    structured_prefix(num_vars, clauses).unwrap_or_else(|| cdcl_fallback(num_vars, clauses))
129}
130
131/// The cheap-specialist chain — every route O(clauses) to apply and reject, so the front-end is never
132/// slower than plain CDCL. Returns the decision if a specialist fires, or `None` (the CDCL fallback is
133/// needed). Public so callers that only want the O(clauses) specialist verdict — e.g. a per-node
134/// cofactor-DAG leaf check — can skip the expensive CDCL search entirely.
135pub fn structured_prefix(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
136    // 1. 2-SAT: a complete polynomial decision procedure with a model.
137    if let Some(binary) = as_two_sat(clauses) {
138        return Some(match crate::twosat::solve(&binary, num_vars) {
139            crate::twosat::TwoSatOutcome::Sat(model) => {
140                Solved { answer: Answer::Sat(model), via: Route::TwoSat, proof: Vec::new(), conflicts: 0 }
141            }
142            crate::twosat::TwoSatOutcome::Unsat(_) => Solved::unsat(Route::TwoSat),
143        });
144    }
145
146    // 2. Horn: linear least-model / forward-chaining refutation.
147    if let Some(horn) = as_horn(clauses) {
148        return Some(match crate::hornsat::solve(&horn, num_vars) {
149            crate::hornsat::HornOutcome::Sat(model) => {
150                Solved { answer: Answer::Sat(model), via: Route::Horn, proof: Vec::new(), conflicts: 0 }
151            }
152            crate::hornsat::HornOutcome::Unsat(_) => Solved::unsat(Route::Horn),
153        });
154    }
155
156    // 3. LLL: satisfiability from sparsity. If the local-lemma condition holds a model is guaranteed;
157    //    construct it with Moser–Tardos. The check is O(clauses) and an empty clause fails it, so this
158    //    only fires on genuinely sparse (SAT) instances and never claims SAT without a re-checked model.
159    if !clauses.is_empty() && crate::lll::lll_certifies_sat(clauses).is_some() {
160        let budget = 1000 + 64 * clauses.len();
161        if let Some(model) = crate::lll::moser_tardos_witness(num_vars, clauses, 0x10C0_5EED_C0DE_F00D, budget)
162        {
163            // Fail-closed: only accept a witness that genuinely satisfies every clause.
164            if clauses
165                .iter()
166                .all(|c| c.iter().any(|l| model.get(l.var() as usize).copied().unwrap_or(false) == l.is_positive()))
167            {
168                return Some(Solved { answer: Answer::Sat(model), via: Route::Lll, proof: Vec::new(), conflicts: 0 });
169            }
170        }
171    }
172
173    // 4-6. Polynomial UNSAT recognizers over the formula's ProofExpr view.
174    if let Some(expr) = cnf_to_expr(clauses) {
175        if crate::pigeonhole::decide_pigeonhole_unsat(&expr) {
176            return Some(Solved::unsat(Route::Pigeonhole));
177        }
178        if crate::pseudo_boolean::refute_clausal(&expr) {
179            return Some(Solved::unsat(Route::CuttingPlanes));
180        }
181        if crate::xorsat::refute_via_parity(&expr) {
182            return Some(Solved::unsat(Route::Parity));
183        }
184    }
185
186    // 6¼. Exact-cover lift: harvest exactly-one groups (covering encodings — modular counting,
187    //     domino tilings) and Gaussian-eliminate their `Σ x = 1` equations over GF(2), GF(3), GF(5).
188    //     The equations are consequences over EVERY modulus, so any inconsistency — re-checked
189    //     fail-closed — is a certified UNSAT with zero search. Never claims SAT (declines onward).
190    if let Some(solved) = exact_cover_route(num_vars, clauses) {
191        return Some(solved);
192    }
193
194    // 6½. GF(p)/ℤ/m lift: recover a mod-m one-hot system from the raw clauses and decide it by Gaussian
195    //     elimination over the right field/ring — the parity cut carried to every modulus. Crushes the
196    //     mod-m counting obstruction that GF(2) is blind to and resolution (CDCL, Z3, Kissat) needs
197    //     2^Ω(n) for. Sound: UNSAT carries the certified refutation; a SAT model is re-checked.
198    if let Some(solved) = modp_route(num_vars, clauses) {
199        return Some(solved);
200    }
201
202    // 6. Covering / algebraic collapse: auto-discover the symmetry or parity that flattens it.
203    match auto_collapse(num_vars, clauses) {
204        AutoCollapse::None => {}
205        // Any recognized collapse (covering symmetry, parity, cardinality, …) is a certified UNSAT.
206        _ => return Some(Solved::unsat(Route::Collapse)),
207    }
208
209    // 7. Hybrid XOR: for XOR-heavy formulas (e.g. parity-learning) that are neither pure-XOR nor
210    //    caught above, solve the recovered GF(2) subsystem and seed CDCL with that assignment so it
211    //    starts on the linear-system's solution manifold and only repairs the residual clauses.
212    if let Some(solved) = hybrid_xor(num_vars, clauses) {
213        return Some(solved);
214    }
215
216    // 7½. Sum-of-Squares / Positivstellensatz: a degree-2 algebraic refutation over ℚ for a small core
217    //     no cheaper recognizer caught — the ordered-field cut beyond the linear/parity engines (it
218    //     closes integrality gaps GF(2) cannot see). Sound and bounded (num_vars ≤ 6). A certified-
219    //     specialist backstop; the standalone engine (`crate::sos`) is where its degree-2 power lives.
220    if num_vars <= 6 && crate::sos::sos_refutes(num_vars, clauses) {
221        return Some(Solved::unsat(Route::Sos));
222    }
223
224    // 7¾. Bounded-degree **Nullstellensatz (degree ≤ 3)** — the algebraic obstruction *beyond* SoS's
225    //      degree-2. The residue-map census showed the symmetric-but-uncrushed families sit at
226    //      Nullstellensatz degree 3: their symmetry is *already fully broken* (0 underbroken), so no
227    //      symmetry route can touch them — the only remaining lens is the deeper algebraic one. Small-core
228    //      gated (num_vars ≤ 12) so the fast router stays bounded; sound (a genuine degree-d NS refutation).
229    if num_vars <= 12 {
230        for d in 2..=num_vars.min(3) {
231            if crate::polycalc::nullstellensatz_refutes(num_vars, clauses, d) {
232                return Some(Solved::unsat(Route::Nullstellensatz));
233            }
234        }
235    }
236
237    None
238}
239
240/// **The exact-cover lift.** Harvest every exactly-one group — an all-positive clause of size ≥ 2
241/// whose variables are pairwise at-most-one-forbidden — and read off the equation `Σ_{v∈g} x_v = 1`.
242/// Exactly-one semantics makes the equation a valid consequence over *every* modulus, so the group
243/// system is checked for inconsistency by Gaussian elimination over `GF(2)` (via `xorsat`) and
244/// `GF(3)`, `GF(5)` (via `modp`); every refutation is re-checked fail-closed before the route
245/// reports. This is the covering-encoded face of the counting cut: `Count_2`'s parity sum, the
246/// mutilated chessboard's black-minus-white combination (a `GF(3)` linear dependency the Gaussian
247/// finds unaided), the mod-3/5 sums of `Count_{3,5}`. UNSAT-only: a consistent system proves
248/// nothing about the rest of the formula, so the route declines rather than guess.
249fn exact_cover_route(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
250    use std::collections::HashSet;
251    let mut amo: HashSet<(u32, u32)> = HashSet::new();
252    for c in clauses {
253        if c.len() == 2 && c.iter().all(|l| !l.is_positive()) {
254            let (a, b) = (c[0].var(), c[1].var());
255            amo.insert((a.min(b), a.max(b)));
256        }
257    }
258    if amo.is_empty() {
259        return None;
260    }
261    let mut groups: Vec<Vec<u32>> = Vec::new();
262    for c in clauses {
263        if c.len() < 2 || !c.iter().all(|l| l.is_positive()) {
264            continue;
265        }
266        let mut g: Vec<u32> = c.iter().map(|l| l.var()).collect();
267        g.sort_unstable();
268        g.dedup();
269        if g.len() != c.len() {
270            continue;
271        }
272        let full_amo = (0..g.len())
273            .all(|i| (i + 1..g.len()).all(|j| amo.contains(&(g[i], g[j]))));
274        if full_amo {
275            groups.push(g);
276        }
277    }
278    if groups.len() < 2 {
279        return None; // one equation is never inconsistent — nothing to combine
280    }
281    // GF(2): the parity rung, certificate re-checked by the XOR checker.
282    let eqs: Vec<crate::xorsat::XorEquation> = groups
283        .iter()
284        .map(|g| crate::xorsat::XorEquation::new(g.iter().map(|&v| v as usize).collect::<Vec<_>>(), true))
285        .collect();
286    if let crate::xorsat::XorOutcome::Unsat(refutation) = crate::xorsat::solve(&eqs, num_vars) {
287        if crate::xorsat::is_refutation(&eqs, num_vars, &refutation) {
288            return Some(Solved::unsat(Route::ExactCover));
289        }
290    }
291    // GF(3), GF(5): the higher rungs of the same harvest, re-checked by the mod-p checker.
292    for p in [3u64, 5] {
293        let meqs: Vec<crate::modp::ModpEquation> = groups
294            .iter()
295            .map(|g| {
296                crate::modp::ModpEquation::new(
297                    g.iter().map(|&v| (v as usize, 1u64)).collect::<Vec<_>>(),
298                    1,
299                )
300            })
301            .collect();
302        if let crate::modp::ModpOutcome::Unsat(combo) = crate::modp::solve(&meqs, num_vars, p) {
303            if crate::modp::is_refutation(&meqs, num_vars, p, &combo) {
304                return Some(Solved::unsat(Route::ExactCover));
305            }
306        }
307    }
308    None
309}
310
311/// The authoritative CDCL fallback — enriched with the mined clause bundle: every structure-mining
312/// contributor's implied no-goods, unioned into the formula so CDCL inherits the discovered structure.
313/// Sound (only implied clauses) and never-worse.
314fn cdcl_fallback(num_vars: usize, clauses: &[Vec<Lit>]) -> Solved {
315    let mut solver = Solver::new(num_vars);
316    for c in clauses {
317        solver.add_clause(c.clone());
318    }
319    for c in mine_clauses(num_vars, clauses) {
320        solver.add_clause(c);
321    }
322    match solver.solve() {
323        SolveResult::Sat(model) => {
324            Solved { answer: Answer::Sat(model), via: Route::Cdcl, proof: Vec::new(), conflicts: solver.conflicts() }
325        }
326        SolveResult::Unsat => {
327            let proof = solver.learned().iter().map(|lc| ProofStep::Rup(lc.lits.clone())).collect();
328            Solved { answer: Answer::Unsat, via: Route::Cdcl, proof, conflicts: solver.conflicts() }
329        }
330    }
331}
332
333/// **The full arsenal in one call** — the opt-in power-mode solver. Runs the cheap specialists
334/// ([`structured_prefix`]); then, *before* the exponential CDCL fallback, the heavier engines the fast
335/// path skips: bounded-degree **Nullstellensatz / Polynomial Calculus** (the nonlinear algebraic
336/// refutation, [`crate::polycalc`]) and **complete lex-leader symmetry breaking** ([`crate::sym_break`]:
337/// the whole automorphism group from the Schreier–Sims backend, feeding CDCL a one-model-per-orbit
338/// formula). Slower than [`solve_structured`] (it may run Nullstellensatz and automorphism detection),
339/// so it is the maximum-power entry, not the default. Always correct — the CDCL fallback is complete.
340pub fn solve_comprehensive(num_vars: usize, clauses: &[Vec<Lit>]) -> Solved {
341    if let Some(s) = structured_prefix(num_vars, clauses) {
342        return s;
343    }
344    // Certified bounded variable elimination: eliminate every non-growing variable (Davis–Putnam) to a
345    // fixpoint. If the residue contains `⊥`, that IS a certified (RUP) refutation — the bounded-treewidth
346    // crusher the fast chain lacks. Runs before the incompressibility verdict so bve-easy cores are decided
347    // honestly (they were never truly rigid-hard) rather than mislabeled `Incompressible`.
348    // Certified equivalent-literal detection: the binary-implication graph's SCCs. If some `x ≡ ¬x` the
349    // 2-clauses alone refute the formula (a contradictory binary sub-part the pure-TwoSat route, which needs a
350    // fully-binary formula, does not see). Cheap (Tarjan on the 2-clauses) and certified by a short RUP chain.
351    if let crate::inprocess::EquivResult::Unsat(steps) = crate::inprocess::equivalent_literal_scc(num_vars, clauses) {
352        return Solved { answer: Answer::Unsat, via: Route::EquivLit, proof: steps, conflicts: 0 };
353    }
354    let (reduced, steps) = crate::inprocess::bve(num_vars, clauses);
355    if reduced.iter().any(|c| c.is_empty()) {
356        return Solved { answer: Answer::Unsat, via: Route::BoundedVarElim, proof: steps, conflicts: 0 };
357    }
358    // Certified bucket elimination (tree-width): full Davis–Putnam in min-degree order, capping resolvent width.
359    // A refutation within the cap is a `2^cap·n` resolution certificate — crushes bounded-treewidth families
360    // BVE's non-growing rule misses. Declines on the high-treewidth residue (width exceeds the cap).
361    if let Some(steps) = crate::inprocess::bucket_elimination_refute(num_vars, clauses, 12) {
362        return Solved { answer: Answer::Unsat, via: Route::TreeWidth, proof: steps, conflicts: 0 };
363    }
364    // Certified incompressibility: if the formula's parity structure is fully exposed AND it is provably
365    // rigid (|Aut| = 1, no symmetry to exploit — the exact check is size-gated so it stays cheap), then
366    // the entire symmetry arsenal below is provably useless on it. Decide it with CDCL and record the
367    // honest "no shortcut of this class" route. Fail-closed: any doubt falls through unchanged.
368    if crate::ait::incompressibility_gate(num_vars, clauses).is_some() {
369        let mut solved = cdcl_fallback(num_vars, clauses);
370        solved.via = Route::Incompressible;
371        return solved;
372    }
373    // Symmetric COMPONENT decomposition: when the formula splits into independent components and the
374    // automorphism group maps some onto each other (isomorphic copies), solve one representative per
375    // component-orbit and replicate its model through the symmetry — `k` identical sub-problems for the
376    // price of one. Each representative goes back through the full arsenal recursively.
377    if let Some(s) = symmetric_component_solve(num_vars, clauses) {
378        return s;
379    }
380    // Symmetry hidden by units: propagate the formula's unit clauses, then detect symmetry on the
381    // SIMPLIFIED residual. Units can mask automorphisms the raw-formula routes structurally cannot see;
382    // once revealed, the residual is solved with the full arsenal and the forced assignment re-applied.
383    if let Some(s) = symmetry_via_simplification_solve(num_vars, clauses) {
384        return s;
385    }
386    // Orbit-weight QUOTIENT: when the symmetry group is the full product of symmetric groups on its
387    // orbits, satisfiability depends only on the per-orbit weights — so the whole 2ⁿ space collapses to
388    // Π(|Oᵢ|+1) weight-tuple representatives, decided exactly by evaluation. Complete, not just sound.
389    if let Some(s) = orbit_weight_quotient_solve(num_vars, clauses) {
390        return s;
391    }
392    // Nonlinear algebraic: a degree-d Nullstellensatz refutation (subsumes parity at d=1; d ≥ 2 reaches
393    // counting-style obstructions). Bounded to the explicit-monomial regime.
394    if num_vars <= 16 {
395        for d in 2..=num_vars.min(3) {
396            if crate::polycalc::nullstellensatz_refutes(num_vars, clauses, d) {
397                return Solved::unsat(Route::Nullstellensatz);
398            }
399        }
400    }
401    // Dynamic in-CDCL symmetry breaking — Symmetric Explanation Learning. Amplify each learned clause by
402    // the symmetry group during a budgeted search, so symmetric conflicts are never re-derived (the
403    // conflict count collapses on symmetry-rich instances). For symmetric UNSAT it usually wins outright;
404    // a SAT model or an honest Unknown falls through to the static / complete routes.
405    if let Some(s) = dynamic_sel(num_vars, clauses) {
406        return s;
407    }
408    // Symmetric inference (not breaking): probe one representative per literal-orbit for a failed literal
409    // (`F ∧ ℓ` UNSAT ⟹ `F ⊨ ¬ℓ`); symmetry then forces the *whole orbit* false from that single probe.
410    // The strengthened formula is solved directly. Sound — it only adds implied units.
411    if let Some(s) = symmetric_probe_solve(num_vars, clauses) {
412        return s;
413    }
414    // Symmetric hyper-binary inference: where the probe derives a UNIT (a failed literal), this derives an
415    // IMPLICATION — BCP under `ℓ` forcing `m` means `F ⊨ ℓ→m` — and symmetry adds the whole orbit of that
416    // binary clause from one probe. Sound (only implied clauses); strengthens the formula, then solves.
417    if let Some(s) = symmetric_binary_inference_solve(num_vars, clauses) {
418        return s;
419    }
420    // Nested (multi-dimensional) symmetry: a grid of three or more axes has a TOWER of block systems
421    // (cells ⊂ lines ⊂ planes ⊂ …), not just one. Break structured swaps at every level of the tower,
422    // each verified to lie in the group — the d-dimensional generalisation of the 2-D double-lex break.
423    if let Some(s) = nested_symmetry_solve(num_vars, clauses) {
424        return s;
425    }
426    // Orbital branching — break symmetry in the decision tree, not by clauses. When a large variable orbit
427    // exists, fixing one representative collapses all its symmetric "some-member-true" branches into one,
428    // so the search explores a single branch where a complete lex-leader would still enumerate the orbit.
429    if let Some(s) = orbital_branch_solve(num_vars, clauses) {
430        return s;
431    }
432    // Symmetry breaking DURING search (SBDS): a lex-leader propagator enforces `a ≤ₗₑₓ a∘g` for each
433    // generator dynamically through the DPLL(T) interface — no static SBP clauses, no aux variables.
434    if let Some(s) = symmetry_propagate_solve(num_vars, clauses) {
435        return s;
436    }
437    // Static complete/partial symmetry breaking (handles SAT, and is complete): add the lex-leader
438    // predicate and let CDCL decide the broken formula.
439    if let Some(s) = symmetry_break_solve(num_vars, clauses) {
440        return s;
441    }
442    // Local symmetry breaking: split on a variable whose residuals carry the most conditional symmetry,
443    // break each residual's symmetry, and solve the branches — exploiting symmetry that emerges only down
444    // a branch, invisible to the global routes above.
445    if let Some(s) = local_symmetry_solve(num_vars, clauses) {
446        return s;
447    }
448    // Semantic symmetry: a permutation that preserves the MODEL SET (`F ≡ σ(F)`) without preserving the
449    // clause set — invisible to the syntactic detector. Detected by logical implication, broken by the
450    // (sound-for-any-model-preserving-permutation) lex-leader. The last symmetry route before search.
451    if let Some(s) = semantic_symmetry_solve(num_vars, clauses) {
452        return s;
453    }
454    // Almost-symmetry: a swap that preserves all but a few clauses is an automorphism of the rest, so it
455    // maps a model to a model only when the broken clauses' images also hold — break it CONDITIONALLY,
456    // guarded by those images. The weakest, most general symmetry; the last route before search.
457    if let Some(s) = almost_symmetry_solve(num_vars, clauses) {
458        return s;
459    }
460    // The hard ones: nothing above decided it. Try the RECURSIVE breaker — iterate detect-and-break to a
461    // fixpoint (composing symmetry that earlier single passes leave behind), then decide the reduced
462    // formula by search. Fires only when it actually breaks symmetry; otherwise the plain CDCL fallback.
463    if let Some(s) = recursive_break_solve(num_vars, clauses) {
464        return s;
465    }
466    // Fused parity + cardinality — applied BEFORE the affine reduction so a *mixed* instance (one carrying
467    // BOTH a recovered GF(2) parity substructure AND an at-most-one cardinality substructure) is decided by
468    // the two live theories reasoning together on one trail, rather than letting the affine rung reduce only
469    // its linear half. Gated on both substructures, so pure-parity / pure-cardinality instances fall through
470    // untouched to the affine and covering routes below.
471    if let Some(s) = fused_modular_solve(num_vars, clauses) {
472        return s;
473    }
474    // Affine (GF(2)) reduction — the symmetry every breaker above is *structurally* blind to: a shear
475    // `xᵢ↦xᵢ⊕xⱼ` maps a clause to a non-subcube, so no clause permutation can reach it. Recover the
476    // formula's linear substructure and Gauss-eliminate it — an inconsistent linear core refutes outright
477    // (a GF(2) obstruction, hence Route::Parity), and otherwise the derived forced units / equivalences
478    // (linear consequences no single clause states) strengthen the formula for the CDCL fallback.
479    match crate::affine::affine_canonicalize(num_vars, clauses) {
480        crate::affine::AffineCanon::Refuted(drat) => {
481            // Carry the xor_drat certificate: the GF(2) linear-dependency refutation compiled to RUP
482            // resolvents, drat-trim-checkable against the original CNF (None only if the resolution
483            // expansion overran its budget — the verdict still stands on the algebraic certificate).
484            let proof = drat.map(|res| res.into_iter().map(ProofStep::Rup).collect()).unwrap_or_default();
485            return Solved { answer: Answer::Unsat, via: Route::Parity, proof, conflicts: 0 };
486        }
487        crate::affine::AffineCanon::Canonical(canon) => {
488            // The canonical RREF break: solve the formula reduced to its free generators, then lift the
489            // model back through the affine quotient. UNSAT of the reduced ⇒ UNSAT of the original.
490            let sub = cdcl_fallback(canon.num_vars, &canon.clauses);
491            return match sub.answer {
492                Answer::Unsat => Solved { answer: Answer::Unsat, via: sub.via, proof: Vec::new(), conflicts: sub.conflicts },
493                Answer::Sat(model) => {
494                    let lifted = canon.lift(&model);
495                    // Fail-closed: the lifted model must satisfy the original formula.
496                    if clauses.iter().all(|c| c.iter().any(|l| lifted[l.var() as usize] == l.is_positive())) {
497                        Solved { answer: Answer::Sat(lifted), via: sub.via, proof: Vec::new(), conflicts: sub.conflicts }
498                    } else {
499                        cdcl_fallback(num_vars, clauses) // defensive: re-solve raw (unreachable for a sound lift)
500                    }
501                }
502            };
503        }
504        crate::affine::AffineCanon::Unchanged => {}
505    }
506    // The GF(p) affine break — the one-hot mod-p analogue of the GF(2) RREF reduction above. An
507    // inconsistent mod-p core refutes (Route::ModP); otherwise *eliminate* the determined one-hot groups
508    // (forced bits → constants, value-permuted linked bits → aliases), solve the reduced formula, and lift
509    // the model back through the affine quotient (fail-closed). UNSAT of the reduced ⇒ UNSAT of the original.
510    match crate::affine_gfp::affine_p_canonicalize(num_vars, clauses) {
511        crate::affine_gfp::AffinePCanon::Refuted(drat) => {
512            let proof = drat.map(|res| res.into_iter().map(ProofStep::Rup).collect()).unwrap_or_default();
513            return Solved { answer: Answer::Unsat, via: Route::ModP, proof, conflicts: 0 };
514        }
515        crate::affine_gfp::AffinePCanon::Canonical(canon) => {
516            let sub = cdcl_fallback(canon.num_vars, &canon.clauses);
517            return match sub.answer {
518                Answer::Unsat => Solved { answer: Answer::Unsat, via: sub.via, proof: Vec::new(), conflicts: sub.conflicts },
519                Answer::Sat(model) => {
520                    let lifted = canon.lift(&model);
521                    if clauses.iter().all(|c| c.iter().any(|l| lifted[l.var() as usize] == l.is_positive())) {
522                        Solved { answer: Answer::Sat(lifted), via: sub.via, proof: Vec::new(), conflicts: sub.conflicts }
523                    } else {
524                        cdcl_fallback(num_vars, clauses) // defensive: re-solve raw (unreachable for a sound lift)
525                    }
526                }
527            };
528        }
529        crate::affine_gfp::AffinePCanon::Unchanged => {}
530    }
531    // The composite ℤ/m affine break — the same eliminate-and-lift move over a one-hot encoding with a
532    // composite modulus, decomposed by CRT into the prime-power components (Smith normal form per ring). An
533    // inconsistent ring core refutes (Route::ModM); otherwise the forced / partially-forced / ring-linked
534    // groups are eliminated, the reduced formula solved, and its model lifted (fail-closed).
535    match crate::affine_gfp::affine_m_canonicalize(num_vars, clauses) {
536        crate::affine_gfp::AffinePCanon::Refuted(drat) => {
537            let proof = drat.map(|res| res.into_iter().map(ProofStep::Rup).collect()).unwrap_or_default();
538            return Solved { answer: Answer::Unsat, via: Route::ModM, proof, conflicts: 0 };
539        }
540        crate::affine_gfp::AffinePCanon::Canonical(canon) => {
541            let sub = cdcl_fallback(canon.num_vars, &canon.clauses);
542            return match sub.answer {
543                Answer::Unsat => Solved { answer: Answer::Unsat, via: sub.via, proof: Vec::new(), conflicts: sub.conflicts },
544                Answer::Sat(model) => {
545                    let lifted = canon.lift(&model);
546                    if clauses.iter().all(|c| c.iter().any(|l| lifted[l.var() as usize] == l.is_positive())) {
547                        Solved { answer: Answer::Sat(lifted), via: sub.via, proof: Vec::new(), conflicts: sub.conflicts }
548                    } else {
549                        cdcl_fallback(num_vars, clauses) // defensive: re-solve raw (unreachable for a sound lift)
550                    }
551                }
552            };
553        }
554        crate::affine_gfp::AffinePCanon::Unchanged => {}
555    }
556    // Plain component decomposition: nothing above split it, but if the formula still separates into
557    // independent components, solve each apart through the full arsenal — a component may match a route the
558    // whole formula did not — and combine, fail-closed. The last structural move before raw search.
559    if let Some(s) = component_solve(num_vars, clauses) {
560        return s;
561    }
562    cdcl_fallback(num_vars, clauses)
563}
564
565/// Decide via the **recursive breaker** as a dispatcher route for hard instances: iterate symmetry
566/// detection and breaking to a fixpoint ([`break_all_symmetry`], aux-free), then solve the reduced formula
567/// with CDCL. Sound — the breaker is equisatisfiable, so a model satisfies the original (re-checked
568/// fail-closed) and an UNSAT verdict carries over. `None` when no symmetry is broken (the plain fallback
569/// then handles it).
570fn recursive_break_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
571    if num_vars == 0 || num_vars > 64 {
572        return None;
573    }
574    let broken = break_all_symmetry(num_vars, clauses);
575    if broken.len() == clauses.len() {
576        return None; // nothing broken ⇒ defer to the plain CDCL fallback
577    }
578    let mut solver = Solver::new(num_vars);
579    for c in &broken {
580        solver.add_clause(c.clone());
581    }
582    match solver.solve() {
583        SolveResult::Sat(model) => {
584            let projected: Vec<bool> = model[..num_vars].to_vec();
585            clauses
586                .iter()
587                .all(|c| c.iter().any(|l| projected[l.var() as usize] == l.is_positive()))
588                .then_some(Solved {
589                    answer: Answer::Sat(projected),
590                    via: Route::RecursiveBreak,
591                    proof: Vec::new(),
592                    conflicts: solver.conflicts(),
593                })
594        }
595        SolveResult::Unsat => {
596            Some(Solved { answer: Answer::Unsat, via: Route::RecursiveBreak, proof: Vec::new(), conflicts: solver.conflicts() })
597        }
598    }
599}
600
601/// Decide by a one-level **local-symmetry branch**: pick the variable whose two residuals carry the most
602/// conditional symmetry (fixing that variable), split on it, break each residual's local symmetry, and
603/// solve the branches with CDCL. `F` is SAT iff either branch is. Sound: a residual symmetry `σ` is used
604/// only if it FIXES the branched variable (so it permutes that branch's models), making the lex-leader
605/// sound for the branch. `None` if no branch reveals usable local symmetry (CDCL handles it) or the
606/// instance is too large for the per-variable residual scan.
607fn local_symmetry_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
608    if num_vars == 0 || num_vars > 24 {
609        return None;
610    }
611    // Residual symmetry generators for the branch `lit`, keeping only those that fix the branched variable.
612    let fixed_gens = |lit: Lit| -> Vec<Vec<Lit>> {
613        let v = lit.var() as usize;
614        crate::sym_break::conditional_symmetry_generators(num_vars, clauses, &[lit])
615            .into_iter()
616            .filter(|img| img[v] == Lit::pos(v as u32))
617            .collect()
618    };
619    let mut best: Option<usize> = None;
620    let mut best_score = 0usize;
621    for v in 0..num_vars {
622        let score = fixed_gens(Lit::pos(v as u32)).len() + fixed_gens(Lit::neg(v as u32)).len();
623        if score > best_score {
624            best_score = score;
625            best = Some(v);
626        }
627    }
628    let v = best?; // no branch reveals usable local symmetry
629
630    let mut conflicts = 0u64;
631    for polarity in [false, true] {
632        let lit = Lit::new(v as u32, polarity);
633        let (sbp, total) = crate::sym_break::lex_leader_sbp_lit(num_vars, &fixed_gens(lit));
634        let mut solver = Solver::new(total.max(num_vars));
635        for c in clauses {
636            solver.add_clause(c.clone());
637        }
638        solver.add_clause(vec![lit]); // commit the branch
639        for c in &sbp {
640            solver.add_clause(c.clone());
641        }
642        match solver.solve() {
643            SolveResult::Sat(model) => {
644                return Some(Solved {
645                    answer: Answer::Sat(model[..num_vars].to_vec()),
646                    via: Route::LocalSymmetry,
647                    proof: Vec::new(),
648                    conflicts: conflicts + solver.conflicts(),
649                });
650            }
651            SolveResult::Unsat => conflicts += solver.conflicts(),
652        }
653    }
654    Some(Solved { answer: Answer::Unsat, via: Route::LocalSymmetry, proof: Vec::new(), conflicts })
655}
656
657/// Dynamic in-CDCL symmetry breaking — Symmetric Explanation Learning ([`crate::sym_dynamic::sel_refute`]).
658/// During a budgeted CDCL search, each learned clause is multiplied by the formula's symmetry group, so
659/// symmetric conflicts are learned for free and symmetric subtrees are never re-explored. Sound:
660/// UNSAT carries a checked PR/RUP proof, SAT a model, else an honest Unknown. Gated by a quick symmetry
661/// check (skip if the formula is asymmetric — SEL would just burn budget) and `num_vars ≤ 64`.
662fn dynamic_sel(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
663    use crate::sym_dynamic::{sel_refute, SelOutcome};
664    if num_vars > 64 || crate::sym_break::literal_automorphism_generators(num_vars, clauses).is_empty() {
665        return None;
666    }
667    match sel_refute(num_vars, clauses) {
668        SelOutcome::Unsat { steps, conflicts, .. } => {
669            Some(Solved { answer: Answer::Unsat, via: Route::Sel, proof: steps, conflicts })
670        }
671        SelOutcome::Sat(model) => {
672            Some(Solved { answer: Answer::Sat(model), via: Route::Sel, proof: Vec::new(), conflicts: 0 })
673        }
674        SelOutcome::Unknown { .. } => None,
675    }
676}
677
678/// Decide via complete symmetry breaking: detect the variable-automorphism group (Schreier–Sims
679/// backend), add the complete lex-leader SBP, and run CDCL on the broken formula. `None` when there is no
680/// usable (phase-free, moderate, non-trivial) symmetry, the instance is too large for automorphism
681/// detection, or the model fails the fail-closed re-check.
682fn symmetry_break_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
683    if num_vars > 64 {
684        return None; // automorphism detection does not scale; leave it to CDCL
685    }
686    // Literal symmetries — variable AND value/phase — as image-literal vectors, on the 2·num_vars points.
687    let gens = crate::sym_break::literal_automorphism_generators(num_vars, clauses);
688    if gens.is_empty() {
689        return None; // no non-trivial symmetry
690    }
691    let point_gens: Vec<_> =
692        gens.iter().map(|s| crate::sym_break::litsym_to_points(s, num_vars)).collect();
693    let bsgs = crate::permgroup::schreier_sims(2 * num_vars, &point_gens);
694    if bsgs.order() <= 1 {
695        return None;
696    }
697    // Choose the break by group size and structure:
698    //   • small group       → COMPLETE break (exactly one model per orbit, optimal);
699    //   • large grid         → HIERARCHICAL block-wise break (polynomial, structured — for the imprimitive
700    //                          product symmetries the complete enumeration could never touch);
701    //   • large non-grid     → stabilizer-chain break (generators ∪ transversal coset reps, polynomial).
702    let (sbp, total) = match bsgs.elements(50_000) {
703        Some(pts) => crate::sym_break::lex_leader_sbp_lit(
704            num_vars,
705            &pts.iter().map(|p| crate::sym_break::litsym_from_points(p, num_vars)).collect::<Vec<_>>(),
706        ),
707        None => crate::sym_break::hierarchical_break(num_vars, clauses).unwrap_or_else(|| {
708            let mut s = gens;
709            s.extend(
710                bsgs.transversal_elements().iter().map(|p| crate::sym_break::litsym_from_points(p, num_vars)),
711            );
712            crate::sym_break::lex_leader_sbp_lit(num_vars, &s)
713        }),
714    };
715    let mut solver = Solver::new(total);
716    for c in clauses {
717        solver.add_clause(c.clone());
718    }
719    for c in &sbp {
720        solver.add_clause(c.clone());
721    }
722    match solver.solve() {
723        SolveResult::Sat(model) => {
724            let projected: Vec<bool> = model[..num_vars].to_vec();
725            // Fail-closed: the projection must satisfy the original formula.
726            clauses
727                .iter()
728                .all(|c| c.iter().any(|l| projected[l.var() as usize] == l.is_positive()))
729                .then_some(Solved {
730                    answer: Answer::Sat(projected),
731                    via: Route::SymmetryBreak,
732                    proof: Vec::new(),
733                    conflicts: solver.conflicts(),
734                })
735        }
736        SolveResult::Unsat => Some(Solved::unsat(Route::SymmetryBreak)),
737    }
738}
739
740/// Decide via **recursive orbital branching** (Margot) — symmetry breaking in the *decision tree* rather
741/// than by added clauses, applied down the whole tree. For a variable orbit `O` under the residual
742/// automorphism group, "*some* variable in `O` is true" is symmetric to "*the representative* `O[0]` is
743/// true" (the group is transitive on `O`, and an automorphism maps models to models). So
744///
745/// > `F` is SAT  ⟺  `F ∧ (rep = true)` is SAT  **or**  `F ∧ (all of O false)` is SAT,
746///
747/// collapsing the `|O|` symmetric "some-`O`-true" branches into a single representative branch. The
748/// representative branch still carries the residual symmetry (the generators fixing `rep`), so we recurse
749/// — at every node taking the largest free-variable orbit of the generators that fix the committed
750/// variables — until the residual is asymmetric and plain CDCL finishes. Sound at each level by the
751/// orbital argument; the filtered generators are a genuine subgroup of the residual's automorphisms (an
752/// under-approximation: always correct, never enumerates more than the true group would). Every model is
753/// re-checked fail-closed; UNSAT is returned only when both branches are genuinely UNSAT. `None` when
754/// there is no phase-free variable symmetry with an orbit of size ≥ 3 (nothing to collapse) or the
755/// instance is too large for automorphism detection.
756fn orbital_branch_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
757    if num_vars == 0 || num_vars > 64 {
758        return None;
759    }
760    // Variable (phase-free) automorphisms — orbital branching reasons about variable orbits, so a phase
761    // flip (which sends a literal to its negation) is not a usable generator here.
762    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses)?;
763    if gens.is_empty() {
764        return None;
765    }
766    // Gate: a ≥3 orbit somewhere is the reduction over the plain two-branch baseline that earns the route.
767    if !crate::permgroup::orbits(num_vars, &gens).iter().any(|o| o.len() >= 3) {
768        return None;
769    }
770    let mut conflicts = 0u64;
771    let mut budget = 1024u64; // node cap; on exhaustion a node finishes its residual with plain CDCL
772    let answer = orbital_node(num_vars, clauses, &gens, &mut Vec::new(), &mut budget, &mut conflicts)?;
773    Some(Solved { answer, via: Route::OrbitalBranch, proof: Vec::new(), conflicts })
774}
775
776/// One node of the recursive orbital-branching tree over the residual `clauses ∧ committed`. Returns the
777/// node's verdict, or `None` if a returned model fails its fail-closed re-check (the caller must then
778/// decline rather than conclude UNSAT).
779fn orbital_node(
780    num_vars: usize,
781    clauses: &[Vec<Lit>],
782    gens: &[crate::permgroup::Perm],
783    committed: &mut Vec<Lit>,
784    budget: &mut u64,
785    conflicts: &mut u64,
786) -> Option<Answer> {
787    let checked = |m: Vec<bool>| -> Option<Answer> {
788        clauses
789            .iter()
790            .all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive()))
791            .then_some(Answer::Sat(m))
792    };
793    // Residual symmetry: the generators that fix every committed variable (so they preserve the unit
794    // commitments) — a genuine subgroup of the residual formula's automorphisms.
795    let committed_vars: HashSet<usize> = committed.iter().map(|l| l.var() as usize).collect();
796    let live: Vec<crate::permgroup::Perm> = if committed_vars.is_empty() {
797        gens.to_vec()
798    } else {
799        gens.iter().filter(|g| committed_vars.iter().all(|&v| g[v] == v)).cloned().collect()
800    };
801    // Largest free-variable orbit (committed variables are fixed points of `live`, hence singletons).
802    let orbit = (*budget > 0)
803        .then(|| {
804            crate::permgroup::orbits(num_vars, &live)
805                .into_iter()
806                .filter(|o| o.len() >= 2 && o.iter().all(|v| !committed_vars.contains(v)))
807                .max_by_key(|o| o.len())
808        })
809        .flatten();
810    let orbit = match orbit {
811        Some(o) => o,
812        None => return solve_residual(num_vars, clauses, committed, conflicts), // asymmetric / budget out
813    };
814    *budget -= 1;
815    let rep = orbit[0];
816
817    // Branch A: rep = true — the representative of every model in which some orbit member is true.
818    committed.push(Lit::pos(rep as u32));
819    let a = orbital_node(num_vars, clauses, gens, committed, budget, conflicts);
820    committed.pop();
821    match a {
822        Some(Answer::Sat(m)) => return checked(m),
823        None => return None, // branch A undecidable ⟹ this node is undecidable (fail-closed)
824        Some(Answer::Unsat) => {}
825    }
826
827    // Branch B: every orbit member false — the only models branch A does not cover.
828    let n0 = committed.len();
829    committed.extend(orbit.iter().map(|&v| Lit::neg(v as u32)));
830    let b = orbital_node(num_vars, clauses, gens, committed, budget, conflicts);
831    committed.truncate(n0);
832    match b {
833        Some(Answer::Sat(m)) => checked(m),
834        Some(Answer::Unsat) => Some(Answer::Unsat), // both branches genuinely UNSAT ⟹ node UNSAT
835        None => None,
836    }
837}
838
839/// CDCL the residual `clauses ∧ committed` — the base case of [`orbital_node`].
840fn solve_residual(
841    num_vars: usize,
842    clauses: &[Vec<Lit>],
843    committed: &[Lit],
844    conflicts: &mut u64,
845) -> Option<Answer> {
846    let mut s = Solver::new(num_vars);
847    for c in clauses {
848        s.add_clause(c.clone());
849    }
850    for &l in committed {
851        s.add_clause(vec![l]);
852    }
853    let r = s.solve();
854    *conflicts += s.conflicts();
855    Some(match r {
856        SolveResult::Sat(model) => Answer::Sat(model[..num_vars].to_vec()),
857        SolveResult::Unsat => Answer::Unsat,
858    })
859}
860
861/// Decide via **symmetric failed-literal inference** — symmetry used to *derive* consequences rather than
862/// to *break* the search. A literal `ℓ` is a *failed literal* when `F ∧ ℓ` is UNSAT, which means `F ⊨ ¬ℓ`.
863/// If `ℓ` is failed then *every* literal in its automorphism orbit is failed (an automorphism maps the
864/// refutation of `F ∧ ℓ` onto a refutation of `F ∧ σ(ℓ)`), so a single probe forces the whole orbit. We
865/// probe one representative per literal-orbit (budgeted, so a hard probe is simply skipped), accumulate the
866/// implied units over the orbits, and solve the strengthened formula. Sound in both directions: only
867/// *implied* units are added, so `F ∧ forced` is equisatisfiable with `F` (a SAT model is re-checked, and
868/// UNSAT of the strengthened formula is UNSAT of `F`). `None` when there is no non-trivial symmetry or no
869/// orbit yields a failed literal — the other routes then decide.
870fn symmetric_probe_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
871    if num_vars == 0 || num_vars > 64 {
872        return None;
873    }
874    // Literal symmetries (variable AND phase) as image-literal vectors, lifted to the 2·num_vars points.
875    let gens = crate::sym_break::literal_automorphism_generators(num_vars, clauses);
876    if gens.is_empty() {
877        return None;
878    }
879    let point_gens: Vec<_> =
880        gens.iter().map(|s| crate::sym_break::litsym_to_points(s, num_vars)).collect();
881    let lit_orbits = crate::permgroup::orbits(2 * num_vars, &point_gens);
882    let point_to_lit = |p: usize| Lit::new((p / 2) as u32, p % 2 == 0);
883
884    // A budgeted probe: `F ∧ ℓ` proven UNSAT within budget ⟹ `ℓ` is a failed literal (`F ⊨ ¬ℓ`).
885    const PROBE_BUDGET: u64 = 200;
886    let probe_fails = |lit: Lit| -> bool {
887        let mut s = Solver::new(num_vars);
888        for c in clauses {
889            s.add_clause(c.clone());
890        }
891        s.add_clause(vec![lit]);
892        matches!(s.solve_budgeted(PROBE_BUDGET), BudgetedResult::Unsat)
893    };
894
895    // One probe per multi-element literal-orbit (a singleton offers no symmetry amplification); a failed
896    // representative forces ¬m for every m in its orbit.
897    let mut forced: Vec<Lit> = Vec::new();
898    for orbit in &lit_orbits {
899        if orbit.len() < 2 {
900            continue;
901        }
902        if probe_fails(point_to_lit(orbit[0])) {
903            forced.extend(orbit.iter().map(|&p| point_to_lit(p).negated()));
904        }
905    }
906    if forced.is_empty() {
907        return None; // nothing inferred — defer to the breaking routes
908    }
909
910    let mut solver = Solver::new(num_vars);
911    for c in clauses {
912        solver.add_clause(c.clone());
913    }
914    for &l in &forced {
915        solver.add_clause(vec![l]);
916    }
917    let r = solver.solve();
918    let conflicts = solver.conflicts();
919    match r {
920        SolveResult::Sat(model) => {
921            let projected: Vec<bool> = model[..num_vars].to_vec();
922            clauses
923                .iter()
924                .all(|c| c.iter().any(|l| projected[l.var() as usize] == l.is_positive()))
925                .then_some(Solved {
926                    answer: Answer::Sat(projected),
927                    via: Route::SymmetricProbe,
928                    proof: Vec::new(),
929                    conflicts,
930                })
931        }
932        SolveResult::Unsat => {
933            Some(Solved { answer: Answer::Unsat, via: Route::SymmetricProbe, proof: Vec::new(), conflicts })
934        }
935    }
936}
937
938/// Root-level Boolean constraint propagation from a single assumed literal. Returns the literals forced by
939/// unit propagation (including the assumption), or `None` if it propagates to a conflict (`ℓ` is a failed
940/// literal). Every forced `m ≠ ℓ` is a sound consequence: `F ∧ ℓ ⊨ m`, i.e. `F ⊨ ℓ → m`.
941fn bcp_forced(num_vars: usize, clauses: &[Vec<Lit>], assume: Lit) -> Option<Vec<Lit>> {
942    let mut val: Vec<Option<bool>> = vec![None; num_vars];
943    val[assume.var() as usize] = Some(assume.is_positive());
944    let mut forced = vec![assume];
945    loop {
946        let mut changed = false;
947        for c in clauses {
948            let mut sat = false;
949            let mut unassigned: Option<Lit> = None;
950            let mut count = 0;
951            for &l in c {
952                match val[l.var() as usize] {
953                    Some(b) if b == l.is_positive() => {
954                        sat = true;
955                        break;
956                    }
957                    Some(_) => {}
958                    None => {
959                        count += 1;
960                        unassigned = Some(l);
961                    }
962                }
963            }
964            if sat {
965                continue;
966            }
967            if count == 0 {
968                return None; // conflict
969            }
970            if count == 1 {
971                let u = unassigned.unwrap();
972                val[u.var() as usize] = Some(u.is_positive());
973                forced.push(u);
974                changed = true;
975            }
976        }
977        if !changed {
978            break;
979        }
980    }
981    Some(forced)
982}
983
984/// Decide via **symmetric hyper-binary inference**. For one representative per variable-orbit, propagate
985/// each polarity ([`bcp_forced`]); a non-conflicting probe of `ℓ` that forces `m` yields the implied
986/// binary `¬ℓ ∨ m` (`F ⊨ ℓ → m`). Each such implication is then expanded over its **variable-symmetry
987/// orbit** — `{¬σ(ℓ) ∨ σ(m)}` — so a single probe contributes the whole orbit of implied binaries, which
988/// strengthen the formula before CDCL decides it. Sound: every added clause is a logical consequence of
989/// `F` (a BCP implication, or an automorphic image of one), so `F` with them is equisatisfiable with `F`
990/// (a SAT model is re-checked). `None` when there is no phase-free variable symmetry or no probe yields a
991/// binary not already present (nothing new to learn — the other routes decide).
992fn symmetric_binary_inference_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
993    if num_vars == 0 || num_vars > 64 {
994        return None;
995    }
996    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses)?;
997    if gens.is_empty() {
998        return None;
999    }
1000    let key2 = |a: Lit, b: Lit| -> [(u32, bool); 2] {
1001        let mut k = [(a.var(), a.is_positive()), (b.var(), b.is_positive())];
1002        k.sort_unstable();
1003        k
1004    };
1005    let lit_apply = |g: &[usize], l: Lit| Lit::new(g[l.var() as usize] as u32, l.is_positive());
1006
1007    // Already-present binaries — only genuinely new implications are worth adding.
1008    let mut seen: HashSet<[(u32, bool); 2]> =
1009        clauses.iter().filter(|c| c.len() == 2).map(|c| key2(c[0], c[1])).collect();
1010
1011    const CAP: usize = 4096;
1012    let mut new_bins: Vec<Vec<Lit>> = Vec::new();
1013    'outer: for orbit in crate::permgroup::orbits(num_vars, &gens) {
1014        let v = orbit[0];
1015        for pol in [false, true] {
1016            let lit = Lit::new(v as u32, pol);
1017            let Some(forced) = bcp_forced(num_vars, clauses, lit) else {
1018                continue; // a failed literal — symmetric_probe_solve handles units
1019            };
1020            for &m in &forced {
1021                if m.var() == lit.var() {
1022                    continue;
1023                }
1024                // ℓ → m, i.e. the binary ¬ℓ ∨ m; expand its variable-symmetry orbit.
1025                let mut local: HashSet<[(u32, bool); 2]> = HashSet::new();
1026                let mut stack = vec![(lit.negated(), m)];
1027                while let Some((a, b)) = stack.pop() {
1028                    if a.var() == b.var() {
1029                        continue;
1030                    }
1031                    let k = key2(a, b);
1032                    if !local.insert(k) {
1033                        continue;
1034                    }
1035                    if seen.insert(k) {
1036                        new_bins.push(vec![a, b]);
1037                        if new_bins.len() >= CAP {
1038                            break 'outer;
1039                        }
1040                    }
1041                    for g in &gens {
1042                        stack.push((lit_apply(g, a), lit_apply(g, b)));
1043                    }
1044                }
1045            }
1046        }
1047    }
1048    if new_bins.is_empty() {
1049        return None; // nothing new to learn
1050    }
1051
1052    let mut solver = Solver::new(num_vars);
1053    for c in clauses {
1054        solver.add_clause(c.clone());
1055    }
1056    for c in &new_bins {
1057        solver.add_clause(c.clone());
1058    }
1059    let r = solver.solve();
1060    let conflicts = solver.conflicts();
1061    match r {
1062        SolveResult::Sat(model) => {
1063            let projected: Vec<bool> = model[..num_vars].to_vec();
1064            clauses
1065                .iter()
1066                .all(|c| c.iter().any(|l| projected[l.var() as usize] == l.is_positive()))
1067                .then_some(Solved {
1068                    answer: Answer::Sat(projected),
1069                    via: Route::SymmetricBinary,
1070                    proof: Vec::new(),
1071                    conflicts,
1072                })
1073        }
1074        SolveResult::Unsat => {
1075            Some(Solved { answer: Answer::Unsat, via: Route::SymmetricBinary, proof: Vec::new(), conflicts })
1076        }
1077    }
1078}
1079
1080/// Decide by collapsing the formula onto its **orbit-weight quotient**. When the variable-automorphism
1081/// group is the *full* product of symmetric groups on its orbits — `G = S_{O₁} × … × S_{O_k}` — every
1082/// assignment is `G`-equivalent to one determined solely by its *weight* per orbit (how many variables in
1083/// each orbit are true): the sorting permutation lies in `G`, and an automorphism maps models to models.
1084/// So `F` is satisfiable iff some weight-tuple representative satisfies it, and the `2ⁿ` search space
1085/// collapses to `Π(|Oᵢ|+1)` representatives decided by direct evaluation. This is **complete and exact**
1086/// (not merely sound): a real decision, with an exponential collapse on fully-interchangeable instances.
1087///
1088/// The full-product gate is checked without factorials: for each orbit, every adjacent transposition
1089/// (swapping two members, fixing all else) must lie in the group ([`Bsgs::contains`]) — those generate
1090/// each `S_{Oᵢ}`, and the group is always a *subgroup* of the product, so membership of all of them gives
1091/// equality. `None` when the group is not the full product, there is no phase-free symmetry, or the
1092/// representative count is not a genuine (bounded) collapse over `2ⁿ`.
1093fn orbit_weight_quotient_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
1094    if num_vars == 0 || num_vars > 64 {
1095        return None;
1096    }
1097    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses)?;
1098    if gens.is_empty() {
1099        return None;
1100    }
1101    let orbits = crate::permgroup::orbits(num_vars, &gens);
1102    let bsgs = crate::permgroup::schreier_sims(num_vars, &gens);
1103
1104    // Full-product gate: every adjacent transposition within every orbit must be in the group.
1105    for orbit in &orbits {
1106        for w in orbit.windows(2) {
1107            let mut t: Vec<usize> = (0..num_vars).collect();
1108            t.swap(w[0], w[1]);
1109            if !bsgs.contains(&t) {
1110                return None; // an orbit on which G is not the full symmetric group
1111            }
1112        }
1113    }
1114
1115    // Representative count Π(|Oᵢ|+1) — bounded, and a genuine collapse over 2ⁿ.
1116    let mut num_reps: u128 = 1;
1117    for o in &orbits {
1118        num_reps = num_reps.saturating_mul(o.len() as u128 + 1);
1119    }
1120    if num_reps > 200_000 || num_reps >= (1u128 << num_vars) {
1121        return None;
1122    }
1123
1124    // Enumerate every weight-tuple (w₀,…,w_{k-1}), build its representative (the first wᵢ members of orbit
1125    // i set true), and evaluate. The first satisfying representative is a genuine model of F.
1126    let dims: Vec<usize> = orbits.iter().map(|o| o.len() + 1).collect();
1127    let total = num_reps as usize;
1128    let satisfies = |a: &[bool]| -> bool {
1129        clauses.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
1130    };
1131    for idx in 0..total {
1132        let mut rem = idx;
1133        let mut assign = vec![false; num_vars];
1134        for (oi, orbit) in orbits.iter().enumerate() {
1135            let w = rem % dims[oi];
1136            rem /= dims[oi];
1137            for &v in orbit.iter().take(w) {
1138                assign[v] = true;
1139            }
1140        }
1141        if satisfies(&assign) {
1142            return Some(Solved {
1143                answer: Answer::Sat(assign),
1144                via: Route::OrbitWeightQuotient,
1145                proof: Vec::new(),
1146                conflicts: 0,
1147            });
1148        }
1149    }
1150    // No weight-tuple representative satisfies F, and every assignment reduces to one ⟹ UNSAT.
1151    Some(Solved::unsat(Route::OrbitWeightQuotient))
1152}
1153
1154/// A **lex-leader propagator** (Symmetry Breaking During Search) presented as a DPLL(T) theory. For each
1155/// generator `g` it enforces the symmetry-breaking constraint `a ≤_lex a∘g` (with `(a∘g)[j] = a[g[j]]`)
1156/// *dynamically* against the trail — no static SBP clauses, no auxiliary variables. Walking the variable
1157/// order, while the prefix is equal it records the equality-break literals; at the first position `j`
1158/// where `a[j]` and `a[g[j]]` are not (yet) equal it forms the lex clause
1159/// `(prefix equalities break) ∨ ¬a[j] ∨ a[g[j]]` — "if the prefix is equal then `a[j] ≤ a[g[j]]`" — and
1160/// emits it only when it is currently unit or falsified, so the core propagates or conflicts. Every
1161/// emitted clause is a sound consequence of `a ≤_lex a∘g`, which is satisfiability-preserving (the lex-min
1162/// of each orbit survives), so `F` with the theory is equisatisfiable with `F`.
1163struct LexLeaderTheory {
1164    num_vars: usize,
1165    generators: Vec<crate::permgroup::Perm>,
1166}
1167
1168impl crate::cdcl::Theory for LexLeaderTheory {
1169    fn propagate(&mut self, trail: &[Lit]) -> Vec<Vec<Lit>> {
1170        let mut val: Vec<Option<bool>> = vec![None; self.num_vars];
1171        for &l in trail {
1172            let v = l.var() as usize;
1173            if v < self.num_vars {
1174                val[v] = Some(l.is_positive());
1175            }
1176        }
1177        // Keep a clause only if it is currently unit (one unassigned, rest false) or falsified (all false);
1178        // a satisfied or under-determined clause carries no immediate force and is dropped.
1179        let actionable = |c: &[Lit]| -> bool {
1180            let mut unassigned = 0;
1181            for &lit in c {
1182                match val[lit.var() as usize] {
1183                    Some(b) if b == lit.is_positive() => return false, // already satisfied
1184                    Some(_) => {}
1185                    None => unassigned += 1,
1186                }
1187            }
1188            unassigned <= 1
1189        };
1190
1191        let mut out: Vec<Vec<Lit>> = Vec::new();
1192        for g in &self.generators {
1193            let mut prefix: Vec<Lit> = Vec::new();
1194            for j in 0..self.num_vars {
1195                let k = g[j];
1196                if k == j {
1197                    continue; // a[j] = a[g[j]] identically; the prefix is unaffected
1198                }
1199                match (val[j], val[k]) {
1200                    (Some(a), Some(b)) if a == b => {
1201                        // prefix still equal: the clause is satisfied if either side flips off this value
1202                        prefix.push(Lit::new(j as u32, !a));
1203                        prefix.push(Lit::new(k as u32, !b));
1204                    }
1205                    _ => {
1206                        // first position where the prefix is not (yet) equal: enforce a[j] ≤ a[g[j]].
1207                        let mut c = prefix.clone();
1208                        c.push(Lit::new(j as u32, false)); // ¬a[j]
1209                        c.push(Lit::new(k as u32, true)); //  a[g[j]]
1210                        if actionable(&c) {
1211                            out.push(c);
1212                        }
1213                        break;
1214                    }
1215                }
1216            }
1217        }
1218        out
1219    }
1220}
1221
1222/// Decide via **symmetry breaking during search**: drive CDCL with the [`LexLeaderTheory`] propagator so
1223/// non-canonical (non-lex-leader) assignments are pruned on the fly through the DPLL(T) interface, rather
1224/// than by the static lex-leader clauses of [`symmetry_break_solve`]. Sound: the propagator only adds
1225/// consequences of `a ≤_lex a∘g`, which is satisfiability-preserving, so the verdict is correct (a SAT
1226/// model is re-checked fail-closed). `None` when there is no phase-free variable symmetry.
1227fn symmetry_propagate_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
1228    if num_vars == 0 || num_vars > 64 {
1229        return None;
1230    }
1231    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses)?;
1232    if gens.is_empty() {
1233        return None;
1234    }
1235    let mut solver = Solver::new(num_vars);
1236    for c in clauses {
1237        solver.add_clause(c.clone());
1238    }
1239    let mut theories: Vec<Box<dyn crate::cdcl::Theory>> =
1240        vec![Box::new(LexLeaderTheory { num_vars, generators: gens })];
1241    match solver.solve_with(&mut theories) {
1242        SolveResult::Sat(model) => {
1243            let projected: Vec<bool> = model[..num_vars].to_vec();
1244            clauses
1245                .iter()
1246                .all(|c| c.iter().any(|l| projected[l.var() as usize] == l.is_positive()))
1247                .then_some(Solved {
1248                    answer: Answer::Sat(projected),
1249                    via: Route::SymmetryPropagate,
1250                    proof: Vec::new(),
1251                    conflicts: solver.conflicts(),
1252                })
1253        }
1254        SolveResult::Unsat => Some(Solved {
1255            answer: Answer::Unsat,
1256            via: Route::SymmetryPropagate,
1257            proof: Vec::new(),
1258            conflicts: solver.conflicts(),
1259        }),
1260    }
1261}
1262
1263/// Union-find root with path halving.
1264fn uf_find(parent: &mut [usize], mut x: usize) -> usize {
1265    while parent[x] != x {
1266        parent[x] = parent[parent[x]];
1267        x = parent[x];
1268    }
1269    x
1270}
1271
1272/// Decide by **plain component decomposition** — the payoff of separability in the solver. When the formula
1273/// splits into independent components (variables linked when they share a clause) with NO symmetry relating
1274/// them — exactly the case [`symmetric_component_solve`] declines — solve each component apart through the
1275/// full arsenal and combine: `F` is UNSAT iff any component is, and a model is the disjoint union of the
1276/// components' models (re-checked fail-closed). Solving apart avoids wrestling the whole formula at once and
1277/// lets a specialized route fire on a component that stumped it on the whole. Sound in both directions:
1278/// a component UNSAT ⟹ `F` UNSAT; and disjoint variable sets make the assembled model conflict-free.
1279/// `None` for a single component (nothing to split).
1280fn component_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
1281    if num_vars == 0 || num_vars > 64 || clauses.is_empty() {
1282        return None;
1283    }
1284    let mut parent: Vec<usize> = (0..num_vars).collect();
1285    for c in clauses {
1286        if c.is_empty() {
1287            return Some(Solved::unsat(Route::Component)); // an empty clause is UNSAT
1288        }
1289        let r0 = uf_find(&mut parent, c[0].var() as usize);
1290        for l in &c[1..] {
1291            let r = uf_find(&mut parent, l.var() as usize);
1292            parent[r] = r0;
1293        }
1294    }
1295    let mut comp_clauses: Vec<Vec<Vec<Lit>>> = vec![Vec::new(); num_vars];
1296    for c in clauses {
1297        let r = uf_find(&mut parent, c[0].var() as usize);
1298        comp_clauses[r].push(c.clone());
1299    }
1300    let roots: Vec<usize> = (0..num_vars).filter(|&r| !comp_clauses[r].is_empty()).collect();
1301    if roots.len() <= 1 {
1302        return None; // a single component — nothing to decompose
1303    }
1304    let mut model = vec![false; num_vars];
1305    let mut conflicts = 0u64;
1306    for &r in &roots {
1307        let sub = solve_comprehensive(num_vars, &comp_clauses[r]);
1308        conflicts += sub.conflicts;
1309        match sub.answer {
1310            Answer::Unsat => {
1311                return Some(Solved { answer: Answer::Unsat, via: Route::Component, proof: Vec::new(), conflicts });
1312            }
1313            Answer::Sat(m) => {
1314                let vars: std::collections::BTreeSet<usize> =
1315                    comp_clauses[r].iter().flatten().map(|l| l.var() as usize).collect();
1316                for v in vars {
1317                    model[v] = m[v];
1318                }
1319            }
1320        }
1321    }
1322    // Fail-closed: the assembled model must satisfy every clause, or defer.
1323    clauses
1324        .iter()
1325        .all(|c| c.iter().any(|l| model[l.var() as usize] == l.is_positive()))
1326        .then_some(Solved { answer: Answer::Sat(model), via: Route::Component, proof: Vec::new(), conflicts })
1327}
1328
1329/// Decide via **plain component decomposition** as a public dispatcher route: split into independent
1330/// components and solve each apart through the full arsenal, combining the verdicts. A single component (or
1331/// a model that fails the fail-closed recheck) defers to the plain CDCL fallback.
1332pub fn solve_by_components(num_vars: usize, clauses: &[Vec<Lit>]) -> Solved {
1333    component_solve(num_vars, clauses).unwrap_or_else(|| cdcl_fallback(num_vars, clauses))
1334}
1335
1336/// Decide by **symmetric component decomposition** — divide-and-conquer with symmetry. The formula splits
1337/// into independent components (variables linked when they share a clause); `F` is SAT iff every component
1338/// is. The automorphism group permutes the components, and components in the same orbit are *isomorphic
1339/// copies*, so we solve **one representative per component-orbit** (recursively, through the full arsenal)
1340/// and replicate its model through the symmetry — `k` identical sub-problems solved once. Sound: a
1341/// component is UNSAT ⟹ `F` is UNSAT; and for a copy `C = ρ(rep)` with `ρ` an automorphism, `ρ(rep-model)`
1342/// satisfies `C`'s clauses, so the assembled assignment (re-checked fail-closed) is a genuine model of
1343/// `F`. `None` for a single component, no phase-free symmetry, or no orbit with ≥ 2 components (no
1344/// symmetric copies to exploit — plain CDCL handles the rest).
1345fn symmetric_component_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
1346    if num_vars == 0 || num_vars > 64 || clauses.is_empty() {
1347        return None;
1348    }
1349    // Connected components of the variable-interaction graph.
1350    let mut parent: Vec<usize> = (0..num_vars).collect();
1351    for c in clauses {
1352        if c.is_empty() {
1353            return Some(Solved::unsat(Route::SymmetricComponent)); // an empty clause is UNSAT
1354        }
1355        let r0 = uf_find(&mut parent, c[0].var() as usize);
1356        for l in &c[1..] {
1357            let r = uf_find(&mut parent, l.var() as usize);
1358            parent[r] = r0;
1359        }
1360    }
1361    let mut appears = vec![false; num_vars];
1362    for c in clauses {
1363        for l in c {
1364            appears[l.var() as usize] = true;
1365        }
1366    }
1367    let mut comp_vars: Vec<Vec<usize>> = vec![Vec::new(); num_vars];
1368    for v in 0..num_vars {
1369        if appears[v] {
1370            let r = uf_find(&mut parent, v);
1371            comp_vars[r].push(v);
1372        }
1373    }
1374    let roots: Vec<usize> = (0..num_vars).filter(|&r| !comp_vars[r].is_empty()).collect();
1375    if roots.len() <= 1 {
1376        return None; // a single component — nothing to decompose
1377    }
1378    let mut comp_clauses: Vec<Vec<Vec<Lit>>> = vec![Vec::new(); num_vars];
1379    for c in clauses {
1380        let r = uf_find(&mut parent, c[0].var() as usize);
1381        comp_clauses[r].push(c.clone());
1382    }
1383    // Phase-free variable automorphisms identify isomorphic components.
1384    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses)?;
1385    if gens.is_empty() {
1386        return None;
1387    }
1388    // Component orbits, each component tagged with the permutation mapping the orbit representative onto it.
1389    let identity: Vec<usize> = (0..num_vars).collect();
1390    let mut orbit_of: Vec<Option<usize>> = vec![None; num_vars];
1391    let mut orbits: Vec<Vec<(usize, Vec<usize>)>> = Vec::new();
1392    for &r in &roots {
1393        if orbit_of[r].is_some() {
1394            continue;
1395        }
1396        let oi = orbits.len();
1397        orbit_of[r] = Some(oi);
1398        let mut orbit: Vec<(usize, Vec<usize>)> = vec![(r, identity.clone())];
1399        let mut i = 0;
1400        while i < orbit.len() {
1401            let (cr, perm) = orbit[i].clone();
1402            i += 1;
1403            for g in &gens {
1404                let img_root = uf_find(&mut parent, g[comp_vars[cr][0]]);
1405                if orbit_of[img_root].is_none() {
1406                    orbit_of[img_root] = Some(oi);
1407                    let new_perm: Vec<usize> = (0..num_vars).map(|v| g[perm[v]]).collect();
1408                    orbit.push((img_root, new_perm));
1409                }
1410            }
1411        }
1412        orbits.push(orbit);
1413    }
1414    // A genuine symmetric copy is required — otherwise there is no symmetry redundancy to exploit here.
1415    if !orbits.iter().any(|o| o.len() >= 2) {
1416        return None;
1417    }
1418    // Solve one representative per orbit (recursively, through the full arsenal); assemble or refute.
1419    let mut model = vec![false; num_vars];
1420    let mut conflicts = 0u64;
1421    for orbit in &orbits {
1422        let rep_root = orbit[0].0;
1423        let solved = solve_comprehensive(num_vars, &comp_clauses[rep_root]);
1424        conflicts += solved.conflicts;
1425        match solved.answer {
1426            Answer::Unsat => {
1427                return Some(Solved {
1428                    answer: Answer::Unsat,
1429                    via: Route::SymmetricComponent,
1430                    proof: Vec::new(),
1431                    conflicts,
1432                });
1433            }
1434            Answer::Sat(rep_model) => {
1435                for (_, perm) in orbit {
1436                    for &v in &comp_vars[rep_root] {
1437                        model[perm[v]] = rep_model[v];
1438                    }
1439                }
1440            }
1441        }
1442    }
1443    clauses
1444        .iter()
1445        .all(|c| c.iter().any(|l| model[l.var() as usize] == l.is_positive()))
1446        .then_some(Solved { answer: Answer::Sat(model), via: Route::SymmetricComponent, proof: Vec::new(), conflicts })
1447}
1448
1449/// Propagate the formula's unit clauses to a fixpoint. Returns the forced literals and the simplified
1450/// residual (satisfied clauses dropped, falsified literals removed — so forced variables appear in no
1451/// residual clause and the residual has no units), or `None` if propagation reaches a conflict.
1452fn root_propagate(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<(Vec<Lit>, Vec<Vec<Lit>>)> {
1453    let mut val: Vec<Option<bool>> = vec![None; num_vars];
1454    loop {
1455        let mut changed = false;
1456        for c in clauses {
1457            let mut sat = false;
1458            let mut unit: Option<Lit> = None;
1459            let mut count = 0;
1460            for &l in c {
1461                match val[l.var() as usize] {
1462                    Some(b) if b == l.is_positive() => {
1463                        sat = true;
1464                        break;
1465                    }
1466                    Some(_) => {}
1467                    None => {
1468                        count += 1;
1469                        unit = Some(l);
1470                    }
1471                }
1472            }
1473            if sat {
1474                continue;
1475            }
1476            if count == 0 {
1477                return None; // conflict
1478            }
1479            if count == 1 {
1480                let u = unit.unwrap();
1481                val[u.var() as usize] = Some(u.is_positive());
1482                changed = true;
1483            }
1484        }
1485        if !changed {
1486            break;
1487        }
1488    }
1489    let forced: Vec<Lit> =
1490        (0..num_vars).filter_map(|v| val[v].map(|b| Lit::new(v as u32, b))).collect();
1491    let mut residual: Vec<Vec<Lit>> = Vec::new();
1492    for c in clauses {
1493        let mut sat = false;
1494        let mut shrunk: Vec<Lit> = Vec::new();
1495        for &l in c {
1496            match val[l.var() as usize] {
1497                Some(b) if b == l.is_positive() => {
1498                    sat = true;
1499                    break;
1500                }
1501                Some(_) => {} // a falsified literal — drop it
1502                None => shrunk.push(l),
1503            }
1504        }
1505        if !sat {
1506            if shrunk.is_empty() {
1507                return None; // an emptied clause (cannot occur past the fixpoint, but stay safe)
1508            }
1509            residual.push(shrunk);
1510        }
1511    }
1512    Some((forced, residual))
1513}
1514
1515/// Decide via **symmetry unlocked by simplification**. Unit clauses can mask automorphisms — a symmetry of
1516/// the residual `F|ρ` need not be a symmetry of the raw `F`, so the raw-formula detectors structurally miss
1517/// it. This route propagates the units ([`root_propagate`]), detects symmetry on the simplified residual,
1518/// and fires only when that symmetry is genuinely *new* (some residual generator is not a symmetry of the
1519/// raw clauses). It then solves the residual with the full arsenal — which now sees the revealed symmetry —
1520/// and re-applies the forced assignment. Sound: `ρ` is implied (BCP), `F` is equisatisfiable with `F|ρ`,
1521/// and the assembled model is re-checked fail-closed. `None` when nothing propagates, the residual is
1522/// asymmetric, or its symmetry was already visible on the raw formula.
1523fn symmetry_via_simplification_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
1524    if num_vars == 0 || num_vars > 64 {
1525        return None;
1526    }
1527    let (rho, residual) = match root_propagate(num_vars, clauses) {
1528        None => return Some(Solved::unsat(Route::SymmetrySimplify)), // refuted by propagation
1529        Some(x) => x,
1530    };
1531    if rho.is_empty() {
1532        return None; // nothing to simplify — no symmetry could be unlocked
1533    }
1534    // Detect on the residual with the forced literals re-pinned as units: this keeps the forced variables
1535    // constrained (an isolated variable carries a spurious phase symmetry that would mask the real ones)
1536    // while still exposing the symmetry that simplification revealed.
1537    let mut detect = residual.clone();
1538    for &l in &rho {
1539        detect.push(vec![l]);
1540    }
1541    let res_gens = crate::sym_break::variable_automorphism_generators(num_vars, &detect)
1542        .unwrap_or_default();
1543    if res_gens.is_empty() {
1544        return None; // the residual carries no usable symmetry
1545    }
1546    // Fire only if simplification revealed symmetry the raw formula did not already have.
1547    let raw_set: HashSet<Vec<(u32, bool)>> = clauses
1548        .iter()
1549        .map(|c| {
1550            let mut k: Vec<(u32, bool)> = c.iter().map(|l| (l.var(), l.is_positive())).collect();
1551            k.sort_unstable();
1552            k
1553        })
1554        .collect();
1555    let is_raw_symmetry = |g: &[usize]| -> bool {
1556        clauses.iter().all(|c| {
1557            let mut img: Vec<(u32, bool)> =
1558                c.iter().map(|l| (g[l.var() as usize] as u32, l.is_positive())).collect();
1559            img.sort_unstable();
1560            raw_set.contains(&img)
1561        })
1562    };
1563    if res_gens.iter().all(|g| is_raw_symmetry(g)) {
1564        return None; // the raw-formula routes already see this symmetry
1565    }
1566
1567    // Solve the simplified residual with the full arsenal, then re-apply the forced assignment.
1568    let solved = solve_comprehensive(num_vars, &residual);
1569    match solved.answer {
1570        Answer::Unsat => Some(Solved {
1571            answer: Answer::Unsat,
1572            via: Route::SymmetrySimplify,
1573            proof: Vec::new(),
1574            conflicts: solved.conflicts,
1575        }),
1576        Answer::Sat(mut model) => {
1577            for &l in &rho {
1578                model[l.var() as usize] = l.is_positive();
1579            }
1580            clauses
1581                .iter()
1582                .all(|c| c.iter().any(|l| model[l.var() as usize] == l.is_positive()))
1583                .then_some(Solved {
1584                    answer: Answer::Sat(model),
1585                    via: Route::SymmetrySimplify,
1586                    proof: Vec::new(),
1587                    conflicts: solved.conflicts,
1588                })
1589        }
1590    }
1591}
1592
1593/// Build a **lex-leader SBP from the whole tower of block systems** — multi-dimensional symmetry breaking.
1594/// `hierarchical_break` breaks a single (minimal) block system: the inter-block and intra-block swaps of a
1595/// 2-D row/column grid. A `d`-dimensional grid (`S_{n₁} × … × S_{n_d}`) has nested block systems
1596/// `cells ⊂ lines ⊂ planes ⊂ …`; this ascends the tower via the group's *induced action on blocks*,
1597/// emitting the structured swaps at every level. Each candidate swap is verified to lie in the group
1598/// ([`Bsgs::contains`]), so the SBP only ever uses genuine automorphisms and is sound (it keeps the
1599/// lex-leader of each orbit). `None` when there is no phase-free, transitive, imprimitive symmetry.
1600fn nested_block_tower_break(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<(Vec<Vec<Lit>>, usize)> {
1601    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses)?;
1602    if gens.is_empty() {
1603        return None;
1604    }
1605    let bsgs = crate::permgroup::schreier_sims(num_vars, &gens);
1606    let to_litsym = |p: &[usize]| -> Vec<Lit> { (0..num_vars).map(|v| Lit::pos(p[v] as u32)).collect() };
1607
1608    let mut structured: Vec<Vec<Lit>> = Vec::new();
1609    let mut blocks = crate::permgroup::minimal_block_system(num_vars, &gens)?; // None if primitive/intransitive
1610    for _level in 0..num_vars {
1611        let k = blocks.len();
1612        let m = blocks[0].len();
1613        if blocks.iter().any(|b| b.len() != m) {
1614            break; // irregular tower — stop ascending
1615        }
1616        // Inter-block adjacent swaps: exchange two whole adjacent blocks position-wise.
1617        for i in 0..k.saturating_sub(1) {
1618            let mut p: Vec<usize> = (0..num_vars).collect();
1619            for j in 0..m {
1620                p[blocks[i][j]] = blocks[i + 1][j];
1621                p[blocks[i + 1][j]] = blocks[i][j];
1622            }
1623            if bsgs.contains(&p) {
1624                structured.push(to_litsym(&p));
1625            }
1626        }
1627        // Intra-block uniform adjacent swaps: exchange positions j, j+1 within every block at once.
1628        for j in 0..m.saturating_sub(1) {
1629            let mut p: Vec<usize> = (0..num_vars).collect();
1630            for b in &blocks {
1631                p[b[j]] = b[j + 1];
1632                p[b[j + 1]] = b[j];
1633            }
1634            if bsgs.contains(&p) {
1635                structured.push(to_litsym(&p));
1636            }
1637        }
1638        if k <= 1 {
1639            break;
1640        }
1641        // Ascend: the group's induced action on the k blocks, then that action's block system.
1642        let mut block_of = vec![usize::MAX; num_vars];
1643        for (bi, b) in blocks.iter().enumerate() {
1644            for &v in b {
1645                block_of[v] = bi;
1646            }
1647        }
1648        let induced: Vec<Vec<usize>> = gens
1649            .iter()
1650            .map(|g| (0..k).map(|bi| block_of[g[blocks[bi][0]]]).collect())
1651            .collect();
1652        let Some(super_blocks) = crate::permgroup::minimal_block_system(k, &induced) else {
1653            break; // the blocks are primitive — top of the tower
1654        };
1655        let mut next: Vec<Vec<usize>> = Vec::new();
1656        for sb in &super_blocks {
1657            let mut nb = Vec::new();
1658            for &bi in sb {
1659                nb.extend_from_slice(&blocks[bi]);
1660            }
1661            next.push(nb);
1662        }
1663        if next.len() >= blocks.len() {
1664            break; // no genuine coarsening
1665        }
1666        blocks = next;
1667    }
1668    if structured.is_empty() {
1669        return None;
1670    }
1671    Some(crate::sym_break::lex_leader_sbp_lit(num_vars, &structured))
1672}
1673
1674/// Decide via **multi-dimensional (nested block-tower) symmetry breaking** — see [`nested_block_tower_break`].
1675/// Adds the tower's lex-leader SBP and lets CDCL decide the broken formula; sound (verified group elements
1676/// only), with a fail-closed re-check on the returned model. `None` when there is no usable nested grid
1677/// symmetry.
1678fn nested_symmetry_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
1679    if num_vars == 0 || num_vars > 64 {
1680        return None;
1681    }
1682    let (sbp, total) = nested_block_tower_break(num_vars, clauses)?;
1683    let mut solver = Solver::new(total);
1684    for c in clauses {
1685        solver.add_clause(c.clone());
1686    }
1687    for c in &sbp {
1688        solver.add_clause(c.clone());
1689    }
1690    match solver.solve() {
1691        SolveResult::Sat(model) => {
1692            let projected: Vec<bool> = model[..num_vars].to_vec();
1693            clauses
1694                .iter()
1695                .all(|c| c.iter().any(|l| projected[l.var() as usize] == l.is_positive()))
1696                .then_some(Solved {
1697                    answer: Answer::Sat(projected),
1698                    via: Route::NestedSymmetry,
1699                    proof: Vec::new(),
1700                    conflicts: solver.conflicts(),
1701                })
1702        }
1703        SolveResult::Unsat => {
1704            Some(Solved { answer: Answer::Unsat, via: Route::NestedSymmetry, proof: Vec::new(), conflicts: solver.conflicts() })
1705        }
1706    }
1707}
1708
1709/// The sorted (var, polarity) signature of a clause, for set membership.
1710fn canon_clause(c: &[Lit]) -> Vec<(u32, bool)> {
1711    let mut k: Vec<(u32, bool)> = c.iter().map(|l| (l.var(), l.is_positive())).collect();
1712    k.sort_unstable();
1713    k
1714}
1715
1716/// A clause with variables `a` and `b` interchanged (polarities preserved).
1717fn swap_clause_vars(c: &[Lit], a: usize, b: usize) -> Vec<Lit> {
1718    c.iter()
1719        .map(|l| {
1720            let v = l.var() as usize;
1721            let nv = if v == a {
1722                b
1723            } else if v == b {
1724                a
1725            } else {
1726                v
1727            };
1728            Lit::new(nv as u32, l.is_positive())
1729        })
1730        .collect()
1731}
1732
1733/// Is the clause `c` a logical consequence of `clauses`? (`F ∧ ¬c` is UNSAT.)
1734fn clause_is_implied(num_vars: usize, clauses: &[Vec<Lit>], c: &[Lit]) -> bool {
1735    let mut s = Solver::new(num_vars);
1736    for cl in clauses {
1737        s.add_clause(cl.clone());
1738    }
1739    for &l in c {
1740        s.add_clause(vec![l.negated()]);
1741    }
1742    matches!(s.solve(), SolveResult::Unsat)
1743}
1744
1745/// The **semantic** symmetries (variable transpositions) of a CNF: pairs `(a,b)` whose swap preserves the
1746/// MODEL SET, `F ≡ swap(F)`, even when it does NOT preserve the clause set. Checked by implication: `F ⊨
1747/// swap(F)` (and, by the involution `swap² = id`, that is full equivalence). Returns the semantic pairs
1748/// and whether any is *non-syntactic* (clause set changed) — i.e. genuinely beyond what the syntactic
1749/// detector ([`crate::sym_break::variable_automorphism_generators`]) can see.
1750pub fn semantic_symmetry_pairs(num_vars: usize, clauses: &[Vec<Lit>]) -> (Vec<(usize, usize)>, bool) {
1751    let clause_set: HashSet<Vec<(u32, bool)>> = clauses.iter().map(|c| canon_clause(c)).collect();
1752    let mut pairs = Vec::new();
1753    let mut any_non_syntactic = false;
1754    for a in 0..num_vars {
1755        for b in (a + 1)..num_vars {
1756            let syntactic =
1757                clauses.iter().all(|c| clause_set.contains(&canon_clause(&swap_clause_vars(c, a, b))));
1758            let semantic = syntactic
1759                || clauses.iter().all(|c| {
1760                    let sc = swap_clause_vars(c, a, b);
1761                    clause_set.contains(&canon_clause(&sc)) || clause_is_implied(num_vars, clauses, &sc)
1762                });
1763            if semantic {
1764                pairs.push((a, b));
1765                if !syntactic {
1766                    any_non_syntactic = true;
1767                }
1768            }
1769        }
1770    }
1771    (pairs, any_non_syntactic)
1772}
1773
1774/// Decide via **semantic symmetry breaking**. Detects variable transpositions that preserve the model set
1775/// without preserving the clause set ([`semantic_symmetry_pairs`]) — symmetries the syntactic detector
1776/// structurally cannot find — and breaks them with the lex-leader, which is sound for any model-set-
1777/// preserving permutation. Fires only when there is a genuinely *non-syntactic* symmetry (otherwise
1778/// [`symmetry_break_solve`] already covers it). Detection is `O(n²)` implication checks, so it is gated to
1779/// small instances and runs last, just before search. SAT models are re-checked fail-closed.
1780fn semantic_symmetry_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
1781    if num_vars == 0 || num_vars > 20 || clauses.len() > 256 {
1782        return None; // O(n² · clauses) implication probing — bound it
1783    }
1784    let (pairs, any_non_syntactic) = semantic_symmetry_pairs(num_vars, clauses);
1785    if pairs.is_empty() || !any_non_syntactic {
1786        return None; // nothing, or nothing beyond the syntactic routes
1787    }
1788    let gens: Vec<Vec<Lit>> = pairs
1789        .iter()
1790        .map(|&(a, b)| {
1791            (0..num_vars)
1792                .map(|v| {
1793                    let img = if v == a {
1794                        b
1795                    } else if v == b {
1796                        a
1797                    } else {
1798                        v
1799                    };
1800                    Lit::pos(img as u32)
1801                })
1802                .collect()
1803        })
1804        .collect();
1805    let (sbp, total) = crate::sym_break::lex_leader_sbp_lit(num_vars, &gens);
1806    let mut solver = Solver::new(total);
1807    for c in clauses {
1808        solver.add_clause(c.clone());
1809    }
1810    for c in &sbp {
1811        solver.add_clause(c.clone());
1812    }
1813    match solver.solve() {
1814        SolveResult::Sat(model) => {
1815            let projected: Vec<bool> = model[..num_vars].to_vec();
1816            clauses
1817                .iter()
1818                .all(|c| c.iter().any(|l| projected[l.var() as usize] == l.is_positive()))
1819                .then_some(Solved {
1820                    answer: Answer::Sat(projected),
1821                    via: Route::SemanticSymmetry,
1822                    proof: Vec::new(),
1823                    conflicts: solver.conflicts(),
1824                })
1825        }
1826        SolveResult::Unsat => {
1827            Some(Solved { answer: Answer::Unsat, via: Route::SemanticSymmetry, proof: Vec::new(), conflicts: solver.conflicts() })
1828        }
1829    }
1830}
1831
1832/// The **almost-symmetries** (variable transpositions) of a CNF: pairs `(a,b)` whose swap preserves all but
1833/// at most `max_broken` clauses. Returns each pair with the **broken images** `σ(B) = { swap(c) : swap(c) ∉
1834/// F }` — the clauses that must hold for the swap to map a model to a model, i.e. the guard. An empty image
1835/// set is a true (syntactic) symmetry and is excluded.
1836pub fn almost_symmetry_pairs(
1837    num_vars: usize,
1838    clauses: &[Vec<Lit>],
1839    max_broken: usize,
1840) -> Vec<(usize, usize, Vec<Vec<Lit>>)> {
1841    let clause_set: HashSet<Vec<(u32, bool)>> = clauses.iter().map(|c| canon_clause(c)).collect();
1842    let mut out = Vec::new();
1843    for a in 0..num_vars {
1844        for b in (a + 1)..num_vars {
1845            let mut images = Vec::new();
1846            for c in clauses {
1847                let sc = swap_clause_vars(c, a, b);
1848                if !clause_set.contains(&canon_clause(&sc)) {
1849                    images.push(sc);
1850                }
1851            }
1852            if !images.is_empty() && images.len() <= max_broken {
1853                out.push((a, b, images));
1854            }
1855        }
1856    }
1857    out
1858}
1859
1860/// Decide via **almost-symmetry breaking** (conditional). A transposition `σ = (a,b)` that breaks only a
1861/// few clauses is an automorphism of `F` minus those clauses, so it maps a model `m` of `F` to a model of
1862/// `F` *exactly when* `σ(m)` also satisfies the broken clauses — equivalently when `m` satisfies their
1863/// images `σ(B)`. We therefore add the **guarded** break `(⋀ σ(B)) → (a@0 ≤ b@0)`, encoded with a
1864/// reification `z_c ↔ c` per image clause. Sound: where the guard holds, both `m` and `σ(m)` are models and
1865/// the ordering keeps one; where it does not, `m` is untouched. We break the single almost-symmetry with
1866/// the fewest broken clauses (composing partial breaks can be unsound), so a SAT model is re-checked and an
1867/// UNSAT verdict is faithful. `None` when no transposition breaks few enough clauses.
1868fn almost_symmetry_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
1869    if num_vars == 0 || num_vars > 20 || clauses.len() > 256 {
1870        return None;
1871    }
1872    let mut pairs = almost_symmetry_pairs(num_vars, clauses, 2);
1873    if pairs.is_empty() {
1874        return None;
1875    }
1876    // The fewest-broken almost-symmetry: most often active, and we break exactly one for soundness.
1877    pairs.sort_by_key(|(_, _, imgs)| imgs.len());
1878    let (a, b, images) = &pairs[0];
1879
1880    let mut aux = num_vars as u32;
1881    let mut extra: Vec<Vec<Lit>> = Vec::new();
1882    let mut guard_neg: Vec<Lit> = Vec::new();
1883    for img in images {
1884        let z = aux;
1885        aux += 1;
1886        // z ↔ (img is satisfied): (each literal → z) and (z → the clause).
1887        for &l in img {
1888            extra.push(vec![l.negated(), Lit::pos(z)]);
1889        }
1890        let mut zc = vec![Lit::neg(z)];
1891        zc.extend(img.iter().copied());
1892        extra.push(zc);
1893        guard_neg.push(Lit::neg(z));
1894    }
1895    // (⋀ images) → (a ≤ b)   ≡   [⋁ ¬z_c] ∨ ¬a ∨ b
1896    let mut guarded = guard_neg;
1897    guarded.push(Lit::neg(*a as u32));
1898    guarded.push(Lit::pos(*b as u32));
1899    extra.push(guarded);
1900
1901    let mut solver = Solver::new(aux as usize);
1902    for c in clauses {
1903        solver.add_clause(c.clone());
1904    }
1905    for c in &extra {
1906        solver.add_clause(c.clone());
1907    }
1908    match solver.solve() {
1909        SolveResult::Sat(model) => {
1910            let projected: Vec<bool> = model[..num_vars].to_vec();
1911            clauses
1912                .iter()
1913                .all(|c| c.iter().any(|l| projected[l.var() as usize] == l.is_positive()))
1914                .then_some(Solved {
1915                    answer: Answer::Sat(projected),
1916                    via: Route::AlmostSymmetry,
1917                    proof: Vec::new(),
1918                    conflicts: solver.conflicts(),
1919                })
1920        }
1921        SolveResult::Unsat => {
1922            Some(Solved { answer: Answer::Unsat, via: Route::AlmostSymmetry, proof: Vec::new(), conflicts: solver.conflicts() })
1923        }
1924    }
1925}
1926
1927/// A clause with its variables permuted by `g`.
1928fn apply_perm_to_clause(c: &[Lit], g: &[usize]) -> Vec<Lit> {
1929    c.iter().map(|l| Lit::new(g[l.var() as usize] as u32, l.is_positive())).collect()
1930}
1931
1932/// Whether the variable permutation `g` preserves the clause SET of `clauses` (`σ(F) = F`) — i.e. `g` is a
1933/// phase-free automorphism of the formula. `g` is a variable bijection, so it maps the (deduplicated)
1934/// clause set injectively into itself; if every image is present, it permutes the set.
1935fn perm_preserves_clause_set(clauses: &[Vec<Lit>], g: &[usize]) -> bool {
1936    let set: HashSet<Vec<(u32, bool)>> = clauses.iter().map(|c| canon_clause(c)).collect();
1937    clauses.iter().all(|c| set.contains(&canon_clause(&apply_perm_to_clause(c, g))))
1938}
1939
1940/// The **common variable automorphisms** of two formulas — phase-free permutations `σ` with `σ(F)=F` AND
1941/// `σ(S)=S`, a generating set for a subgroup of `Aut(F) ∩ Aut(S)`. Detected from each formula's own
1942/// automorphisms and then VERIFIED against *both* clause sets, so every returned generator is a genuine
1943/// common automorphism (a wrong one can never slip through). This is the group under which a *disagreement*
1944/// between `F` and `S` is symmetric: if `σ` fixes both, then `F` and `S` disagree at `a` iff they disagree
1945/// at `σ(a)` — so equivalence checking can be reduced along its orbits.
1946pub fn common_automorphism_generators(
1947    num_vars: usize,
1948    f: &[Vec<Lit>],
1949    s: &[Vec<Lit>],
1950) -> Vec<crate::permgroup::Perm> {
1951    let mut candidates = crate::sym_break::variable_automorphism_generators(num_vars, f).unwrap_or_default();
1952    candidates.extend(crate::sym_break::variable_automorphism_generators(num_vars, s).unwrap_or_default());
1953    let mut out: Vec<Vec<usize>> = Vec::new();
1954    for g in candidates {
1955        let well_formed = g.len() == num_vars;
1956        let nontrivial = g.iter().enumerate().any(|(i, &x)| i != x);
1957        if well_formed
1958            && nontrivial
1959            && perm_preserves_clause_set(f, &g)
1960            && perm_preserves_clause_set(s, &g)
1961            && !out.contains(&g)
1962        {
1963            out.push(g);
1964        }
1965    }
1966    out
1967}
1968
1969/// The orbits (as index sets) of `clauses` under the group generated by `gens`, acting by
1970/// `σ(C) = {σ(l) : l ∈ C}`. A clause and its symmetric images share an orbit.
1971fn clause_orbits(clauses: &[Vec<Lit>], gens: &[crate::permgroup::Perm]) -> Vec<Vec<usize>> {
1972    let index: HashMap<Vec<(u32, bool)>, usize> =
1973        clauses.iter().enumerate().map(|(i, c)| (canon_clause(c), i)).collect();
1974    let n = clauses.len();
1975    let mut parent: Vec<usize> = (0..n).collect();
1976    fn find(parent: &mut [usize], mut x: usize) -> usize {
1977        while parent[x] != x {
1978            parent[x] = parent[parent[x]];
1979            x = parent[x];
1980        }
1981        x
1982    }
1983    for (i, c) in clauses.iter().enumerate() {
1984        for g in gens {
1985            if let Some(&j) = index.get(&canon_clause(&apply_perm_to_clause(c, g))) {
1986                let (a, b) = (find(&mut parent, i), find(&mut parent, j));
1987                parent[a] = b;
1988            }
1989        }
1990    }
1991    let mut groups: std::collections::BTreeMap<usize, Vec<usize>> = std::collections::BTreeMap::new();
1992    for i in 0..n {
1993        let r = find(&mut parent, i);
1994        groups.entry(r).or_default().push(i);
1995    }
1996    groups.into_values().collect()
1997}
1998
1999/// A model of `clauses ∧ ¬c` — a witness that `c` is NOT entailed by `clauses` — or `None` if `clauses ⊨ c`.
2000fn entailment_counterexample(num_vars: usize, clauses: &[Vec<Lit>], c: &[Lit]) -> Option<Vec<bool>> {
2001    let mut s = Solver::new(num_vars);
2002    for cl in clauses {
2003        s.add_clause(cl.clone());
2004    }
2005    for &l in c {
2006        s.add_clause(vec![l.negated()]);
2007    }
2008    match s.solve() {
2009        SolveResult::Sat(m) => Some(m),
2010        SolveResult::Unsat => None,
2011    }
2012}
2013
2014/// The verdict of a symmetry-reduced equivalence check.
2015#[derive(Clone, Debug, PartialEq, Eq)]
2016pub enum EquivVerdict {
2017    /// `F` and `S` denote the same Boolean function (same models).
2018    Equivalent,
2019    /// They differ; the assignment satisfies exactly one of the two formulas (a distinguishing witness).
2020    Differ(Vec<bool>),
2021}
2022
2023/// **Logical equivalence `F ≡ S`, symmetry-reduced.** `F ≡ S` iff `F ⊨ S` and `S ⊨ F`, and `F ⊨ S` iff `F`
2024/// entails every clause of `S`. The key reduction: for a common automorphism `σ ∈ Aut(F) ∩ Aut(S)`,
2025/// `F ⊨ C ⟺ F ⊨ σ(C)` (a model of `F ∧ ¬C` maps under `σ` to a model of `F ∧ ¬σ(C)`), so it suffices to
2026/// check **one clause per orbit** of `S`'s clauses under the common symmetry (and dually for `F`). The
2027/// verdict is unchanged from the naive check — only the work shrinks — and a `Differ` witness is a concrete,
2028/// re-checkable disagreement (it satisfies one formula and violates a clause of the other). This is the
2029/// symmetry-aware companion to [`crate::sat::prove_equivalence`].
2030pub fn equivalent_modulo_symmetry(num_vars: usize, f: &[Vec<Lit>], s: &[Vec<Lit>]) -> EquivVerdict {
2031    let gens = common_automorphism_generators(num_vars, f, s);
2032    // F ⊨ S: one representative clause per orbit of S under the shared symmetry.
2033    for orbit in clause_orbits(s, &gens) {
2034        if let Some(m) = entailment_counterexample(num_vars, f, &s[orbit[0]]) {
2035            return EquivVerdict::Differ(m); // satisfies F, violates a clause of S ⇒ F true, S false
2036        }
2037    }
2038    // S ⊨ F: dually.
2039    for orbit in clause_orbits(f, &gens) {
2040        if let Some(m) = entailment_counterexample(num_vars, s, &f[orbit[0]]) {
2041            return EquivVerdict::Differ(m);
2042        }
2043    }
2044    EquivVerdict::Equivalent
2045}
2046
2047/// `(checks_with_symmetry, naive_checks)` — the number of entailment checks the symmetry reduction performs
2048/// (one per clause-orbit, both directions) versus the naive per-clause count. Equal when there is no usable
2049/// common symmetry; strictly smaller when the shared automorphism group fuses clauses into orbits.
2050pub fn equivalence_check_counts(num_vars: usize, f: &[Vec<Lit>], s: &[Vec<Lit>]) -> (usize, usize) {
2051    let gens = common_automorphism_generators(num_vars, f, s);
2052    (clause_orbits(s, &gens).len() + clause_orbits(f, &gens).len(), s.len() + f.len())
2053}
2054
2055/// The symmetries of an **optimization** problem `(F, weights)`: variable automorphisms of `F` that *also*
2056/// preserve the objective (`weights[g[v]] = weights[v]` for all `v`). Under such a `σ` the objective is
2057/// constant on orbits, so every optimal model's whole orbit is optimal — the group along which the optimum
2058/// may be symmetry-reduced. Verified against both `F` and the weights, so every generator is genuine.
2059pub fn optimization_symmetry_generators(
2060    num_vars: usize,
2061    clauses: &[Vec<Lit>],
2062    weights: &[i64],
2063) -> Vec<crate::permgroup::Perm> {
2064    crate::sym_break::variable_automorphism_generators(num_vars, clauses)
2065        .unwrap_or_default()
2066        .into_iter()
2067        .filter(|g| g.len() == num_vars && (0..num_vars).all(|v| weights[g[v]] == weights[v]))
2068        .collect()
2069}
2070
2071/// `F` augmented with sound single-bit lex-leader clauses for the optimization symmetry. Each generator `g`
2072/// contributes `x_v ≤ x_{g[v]}` at its least moved point `v`; the lex-minimum of any orbit satisfies all of
2073/// them, so — since the objective is constant on orbits — an *optimal* model always survives. The optimum is
2074/// therefore unchanged while the model space shrinks.
2075fn optimization_break(num_vars: usize, clauses: &[Vec<Lit>], weights: &[i64]) -> Vec<Vec<Lit>> {
2076    let mut broken = clauses.to_vec();
2077    for g in optimization_symmetry_generators(num_vars, clauses, weights) {
2078        if let Some(v) = (0..num_vars).find(|&v| g[v] != v) {
2079            broken.push(vec![Lit::new(v as u32, false), Lit::new(g[v] as u32, true)]); // ¬x_v ∨ x_{g[v]}
2080        }
2081    }
2082    broken
2083}
2084
2085/// Enumerate the models of `clauses` (CDCL + blocking) and return the minimum-weight one with the number of
2086/// models visited: `(optimum, witness, models_enumerated)`, or `None` if unsatisfiable. A FRESH solver is
2087/// built each round with the accumulated blocking clauses — our CDCL core is not incrementally re-solvable
2088/// (the same pattern the model-counting routines use), so reusing one solver across `solve()` calls would
2089/// loop.
2090fn min_weight_model(num_vars: usize, clauses: &[Vec<Lit>], weights: &[i64]) -> Option<(i64, Vec<bool>, usize)> {
2091    let mut working = clauses.to_vec();
2092    let mut best: Option<(i64, Vec<bool>)> = None;
2093    let mut enumerated = 0usize;
2094    loop {
2095        let mut solver = Solver::new(num_vars);
2096        for c in &working {
2097            solver.add_clause(c.clone());
2098        }
2099        match solver.solve() {
2100            SolveResult::Unsat => break,
2101            SolveResult::Sat(model) => {
2102                enumerated += 1;
2103                let w: i64 = (0..num_vars).filter(|&i| model[i]).map(|i| weights[i]).sum();
2104                if best.as_ref().map_or(true, |(bw, _)| w < *bw) {
2105                    best = Some((w, model[..num_vars].to_vec()));
2106                }
2107                working.push((0..num_vars).map(|i| Lit::new(i as u32, !model[i])).collect());
2108            }
2109        }
2110    }
2111    best.map(|(w, m)| (w, m, enumerated))
2112}
2113
2114/// **Weighted minimization, symmetry-reduced.** Minimise `Σ weights[i]·x_i` over the models of `clauses`,
2115/// exploiting [`optimization_symmetry_generators`]: break the objective-preserving symmetry, then search the
2116/// reduced model space. SOUND — the optimum is identical to the unbroken problem (an optimal orbit's
2117/// lex-leader survives the break and has the same objective) and the witness is a genuine model of the
2118/// original `F`. `None` iff `F` is unsatisfiable. The symmetry-aware optimizer (a new problem class beyond
2119/// the decision/counting/equivalence faces).
2120pub fn optimize_modulo_symmetry(
2121    num_vars: usize,
2122    clauses: &[Vec<Lit>],
2123    weights: &[i64],
2124) -> Option<(i64, Vec<bool>)> {
2125    let broken = optimization_break(num_vars, clauses, weights);
2126    min_weight_model(num_vars, &broken, weights).map(|(w, m, _)| (w, m))
2127}
2128
2129/// `(models_with_symmetry, models_without_symmetry)` — how many candidate models the optimizer enumerates
2130/// with the symmetry break versus the naive enumeration. Equal with no usable symmetry, smaller when the
2131/// objective-preserving group fuses optimal (and non-optimal) models into orbits.
2132pub fn optimize_enumeration_counts(num_vars: usize, clauses: &[Vec<Lit>], weights: &[i64]) -> (usize, usize) {
2133    let broken = optimization_break(num_vars, clauses, weights);
2134    let with = min_weight_model(num_vars, &broken, weights).map_or(0, |(_, _, c)| c);
2135    let without = min_weight_model(num_vars, clauses, weights).map_or(0, |(_, _, c)| c);
2136    (with, without)
2137}
2138
2139/// The full orbit of a Boolean assignment `m` under the group generated by `gens` (action `σ(m)[g[v]] =
2140/// m[v]`), as a set of complete assignments — no projection, so it is exact for counting.
2141fn full_assignment_orbit(num_vars: usize, m: &[bool], gens: &[crate::permgroup::Perm]) -> Vec<Vec<bool>> {
2142    let mut seen: HashSet<Vec<bool>> = HashSet::from([m.to_vec()]);
2143    let mut out = vec![m.to_vec()];
2144    let mut i = 0;
2145    while i < out.len() {
2146        let cur = out[i].clone();
2147        i += 1;
2148        for g in gens {
2149            let mut pm = vec![false; num_vars];
2150            for v in 0..num_vars {
2151                pm[g[v]] = cur[v];
2152            }
2153            if seen.insert(pm.clone()) {
2154                out.push(pm);
2155            }
2156        }
2157    }
2158    out
2159}
2160
2161/// **Symmetry-accelerated weighted model counting** — the partition function `Z = Σ_{m ⊨ F} W(m)` with
2162/// literal weights `W(m) = Π_v weight[v].{1 if m[v] else 0}`. The formula's variable symmetry partitions the
2163/// models into orbits, so each `solve()` recovers one model, its whole orbit is enumerated and its members'
2164/// weights summed, then the entire orbit is blocked — **one solve per orbit** instead of per model. Exact for
2165/// ARBITRARY weights (every model's true weight is summed; the symmetry only groups the search), the
2166/// symmetry-aware analogue of weighted #SAT / lifted inference. Returns `Z`.
2167pub fn weighted_model_count(num_vars: usize, clauses: &[Vec<Lit>], weight: &[(i64, i64)]) -> i128 {
2168    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
2169    let weight_of = |m: &[bool]| -> i128 {
2170        (0..num_vars).map(|v| if m[v] { weight[v].1 as i128 } else { weight[v].0 as i128 }).product()
2171    };
2172    let mut working = clauses.to_vec();
2173    let mut z = 0i128;
2174    loop {
2175        let mut solver = Solver::new(num_vars);
2176        for c in &working {
2177            solver.add_clause(c.clone());
2178        }
2179        match solver.solve() {
2180            SolveResult::Unsat => break,
2181            SolveResult::Sat(model) => {
2182                let orbit = full_assignment_orbit(num_vars, &model[..num_vars], &gens);
2183                z += orbit.iter().map(|m| weight_of(m)).sum::<i128>();
2184                for m in &orbit {
2185                    working.push((0..num_vars).map(|i| Lit::new(i as u32, !m[i])).collect());
2186                }
2187            }
2188        }
2189    }
2190    z
2191}
2192
2193/// `(orbit_solves, total_models)` — how many `solve()` calls the symmetry-accelerated weighted count makes
2194/// (one per model-orbit) versus the number of models. Equal with no usable symmetry, smaller when symmetry
2195/// fuses models into orbits.
2196pub fn weighted_model_count_solve_counts(num_vars: usize, clauses: &[Vec<Lit>]) -> (usize, usize) {
2197    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
2198    let mut working = clauses.to_vec();
2199    let (mut solves, mut models) = (0usize, 0usize);
2200    loop {
2201        let mut solver = Solver::new(num_vars);
2202        for c in &working {
2203            solver.add_clause(c.clone());
2204        }
2205        match solver.solve() {
2206            SolveResult::Unsat => break,
2207            SolveResult::Sat(model) => {
2208                solves += 1;
2209                let orbit = full_assignment_orbit(num_vars, &model[..num_vars], &gens);
2210                models += orbit.len();
2211                for m in &orbit {
2212                    working.push((0..num_vars).map(|i| Lit::new(i as u32, !m[i])).collect());
2213                }
2214            }
2215        }
2216    }
2217    (solves, models)
2218}
2219
2220/// Re-label a list of signatures to dense ranks `0..k` in sorted order — equal signatures get equal ranks.
2221/// The canonical relabelling that makes color refinement's fixpoint test stable.
2222fn rank_signatures<S: Ord + Clone>(sigs: &[S]) -> Vec<usize> {
2223    let mut distinct: Vec<S> = sigs.to_vec();
2224    distinct.sort();
2225    distinct.dedup();
2226    sigs.iter().map(|s| distinct.binary_search(s).expect("present")).collect()
2227}
2228
2229/// **Color refinement (1-dimensional Weisfeiler–Leman)** of a formula's variable–clause incidence graph —
2230/// the coarsest *equitable* partition of the variables, computed in polynomial time. Variables and clauses
2231/// are coloured (clauses initialised by width); each round recolours a clause by the sorted multiset of its
2232/// incident `(variable colour, sign)` pairs and a variable by the sorted multiset of its incident
2233/// `(clause colour, sign)` pairs, to a fixpoint. The result `cell[v]` is the variable's colour.
2234///
2235/// This is the polynomial *foundation* of symmetry detection (the pre-filter saucy/nauty run before the
2236/// exponential search) and a SOUND over-approximation of the orbit partition: every phase-free variable
2237/// automorphism preserves the colouring, so `orbit(v) ⊆ cell(v)` — variables of different colours are
2238/// provably in different orbits. Returns dense cell indices `0..k`.
2239pub fn color_refinement(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<usize> {
2240    equitable_refine(num_vars, clauses, &vec![0usize; num_vars])
2241}
2242
2243/// Color refinement to a stable (equitable) partition, starting from an arbitrary initial variable coloring
2244/// `var_init` (rather than the all-equal coloring [`color_refinement`] uses). This is the refinement step of
2245/// individualization–refinement: after individualizing a vertex, re-stabilize from the perturbed coloring.
2246fn equitable_refine(num_vars: usize, clauses: &[Vec<Lit>], var_init: &[usize]) -> Vec<usize> {
2247    let mut var_color = rank_signatures(var_init);
2248    let mut clause_color: Vec<usize> = rank_signatures(&clauses.iter().map(|c| c.len()).collect::<Vec<_>>());
2249    loop {
2250        // Recolour clauses by the multiset of incident (variable colour, sign).
2251        let clause_sig: Vec<(usize, Vec<(usize, bool)>)> = clauses
2252            .iter()
2253            .enumerate()
2254            .map(|(ci, c)| {
2255                let mut nbrs: Vec<(usize, bool)> =
2256                    c.iter().map(|l| (var_color[l.var() as usize], l.is_positive())).collect();
2257                nbrs.sort_unstable();
2258                (clause_color[ci], nbrs)
2259            })
2260            .collect();
2261        let new_clause_color = rank_signatures(&clause_sig);
2262        // Recolour variables by the multiset of incident (clause colour, sign).
2263        let mut var_nbrs: Vec<Vec<(usize, bool)>> = vec![Vec::new(); num_vars];
2264        for (ci, c) in clauses.iter().enumerate() {
2265            for l in c {
2266                var_nbrs[l.var() as usize].push((new_clause_color[ci], l.is_positive()));
2267            }
2268        }
2269        for nbrs in var_nbrs.iter_mut() {
2270            nbrs.sort_unstable();
2271        }
2272        let var_sig: Vec<(usize, Vec<(usize, bool)>)> =
2273            (0..num_vars).map(|v| (var_color[v], std::mem::take(&mut var_nbrs[v]))).collect();
2274        let new_var_color = rank_signatures(&var_sig);
2275        if new_var_color == var_color && new_clause_color == clause_color {
2276            break; // the partition is stable (equitable)
2277        }
2278        var_color = new_var_color;
2279        clause_color = new_clause_color;
2280    }
2281    var_color
2282}
2283
2284/// The number of cells in the color-refinement (equitable) partition — a cheap polynomial symmetry
2285/// indicator. At most the number of variable orbits (`cells ≤ orbits`, since each cell is a union of orbits).
2286pub fn color_refinement_cells(num_vars: usize, clauses: &[Vec<Lit>]) -> usize {
2287    color_refinement(num_vars, clauses).iter().copied().max().map_or(0, |m| m + 1)
2288}
2289
2290/// The variables that color refinement places in a **singleton cell** — provably fixed by every (phase-free)
2291/// automorphism, since `orbit(v) ⊆ cell(v)` and `|cell(v)| = 1` forces `|orbit(v)| = 1`. A polynomial
2292/// certificate of asymmetry: these variables can be skipped entirely when detecting or breaking symmetry.
2293pub fn provably_asymmetric_variables(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<usize> {
2294    let cells = color_refinement(num_vars, clauses);
2295    let mut size = vec![0usize; num_vars];
2296    for &c in &cells {
2297        size[c] += 1;
2298    }
2299    (0..num_vars).filter(|&v| size[cells[v]] == 1).collect()
2300}
2301
2302/// The **variable co-occurrence matrix** `A[u][v]` = number of clauses containing both variables `u` and `v`
2303/// (`0` on the diagonal) — the natural weighted graph on the formula's variables.
2304fn cooccurrence_matrix(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Vec<i64>> {
2305    let mut a = vec![vec![0i64; num_vars]; num_vars];
2306    for c in clauses {
2307        let vars: Vec<usize> = c.iter().map(|l| l.var() as usize).collect();
2308        for &u in &vars {
2309            for &v in &vars {
2310                if u != v {
2311                    a[u][v] += 1;
2312                }
2313            }
2314        }
2315    }
2316    a
2317}
2318
2319/// The coarsest **equitable partition** of the variable co-occurrence graph `A`: 1-WL on the weighted graph,
2320/// recolouring each vertex by the sorted multiset of `(neighbour colour, edge weight)` to a fixpoint.
2321fn equitable_partition_of(num_vars: usize, a: &[Vec<i64>]) -> Vec<usize> {
2322    let mut color = vec![0usize; num_vars];
2323    loop {
2324        let sig: Vec<(usize, Vec<(usize, i64)>)> = (0..num_vars)
2325            .map(|u| {
2326                let mut nbrs: Vec<(usize, i64)> =
2327                    (0..num_vars).filter(|&v| a[u][v] != 0).map(|v| (color[v], a[u][v])).collect();
2328                nbrs.sort_unstable();
2329                (color[u], nbrs)
2330            })
2331            .collect();
2332        let next = rank_signatures(&sig);
2333        if next == color {
2334            return color;
2335        }
2336        color = next;
2337    }
2338}
2339
2340/// A **fractional automorphism** of a formula: a *doubly-stochastic* matrix `B` commuting with the variable
2341/// co-occurrence matrix `A` (`BA = AB`) — the LP relaxation of an automorphism (a *permutation* matrix
2342/// commuting with `A`). The canonical one is block-averaging over the coarsest equitable partition
2343/// ([`equitable_partition_of`]): `B[u][v] = 1/|cell(u)|` if `u,v` share a cell, else `0`. By Tinhofer's
2344/// theorem it commutes with `A` iff the partition is equitable, and it is **non-trivial** (not a permutation)
2345/// exactly when the partition is non-discrete — i.e. exactly when the formula has non-trivial 1-WL symmetry.
2346///
2347/// Returns the partition `cell[v]`. Its non-discreteness certifies a fractional automorphism; the commutation
2348/// `BA = AB` is checked exactly (over integers, by clearing the `1/|cell|` denominators) by
2349/// [`is_fractional_automorphism`].
2350pub fn fractional_automorphism(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<usize> {
2351    equitable_partition_of(num_vars, &cooccurrence_matrix(num_vars, clauses))
2352}
2353
2354/// Verify that block-averaging over `partition` gives a doubly-stochastic matrix `B` commuting with the
2355/// co-occurrence matrix `A`. `B` is doubly-stochastic by construction (each row/column sums to 1), so this
2356/// checks `BA = AB`, which holds iff `partition` is equitable — the exact (integer) certificate. The identity
2357/// `(1/|cell(u)|)·Σ_{x∈cell(u)} A[x][w] = (1/|cell(w)|)·Σ_{x∈cell(w)} A[u][x]` is verified cross-multiplied.
2358pub fn is_fractional_automorphism(num_vars: usize, clauses: &[Vec<Lit>], partition: &[usize]) -> bool {
2359    let a = cooccurrence_matrix(num_vars, clauses);
2360    let d = partition.iter().copied().max().map_or(0, |m| m + 1);
2361    let mut cell: Vec<Vec<usize>> = vec![Vec::new(); d];
2362    for (v, &c) in partition.iter().enumerate() {
2363        cell[c].push(v);
2364    }
2365    let sum_over = |rows: &[usize], w: usize, by_row: bool| -> i64 {
2366        rows.iter().map(|&x| if by_row { a[x][w] } else { a[w][x] }).sum()
2367    };
2368    for u in 0..num_vars {
2369        for w in 0..num_vars {
2370            let cu = &cell[partition[u]];
2371            let cw = &cell[partition[w]];
2372            // (1/|cu|)·Σ_{x∈cu} A[x][w] == (1/|cw|)·Σ_{x∈cw} A[u][x]  (cross-multiplied to integers).
2373            let lhs = cw.len() as i64 * sum_over(cu, w, true);
2374            let rhs = cu.len() as i64 * sum_over(cw, u, false);
2375            if lhs != rhs {
2376                return false;
2377            }
2378        }
2379    }
2380    true
2381}
2382
2383/// **2-dimensional Weisfeiler–Leman (2-WL)** of a formula's variables — color refinement lifted from
2384/// vertices to *ordered pairs*. Each pair `(i,j)` is colored: the diagonal by its 1-WL vertex color, an
2385/// off-diagonal pair by `(1-WL(i), 1-WL(j), co-occurrence signature)` where the signature is the sorted
2386/// multiset over clauses containing both of `(clause width, sign of i, sign of j)`. Each round recolors
2387/// `(i,j)` by `(old color, sorted multiset over all k of (color(i,k), color(k,j)))`, to a fixpoint.
2388///
2389/// Strictly stronger than 1-WL (its diagonal refines the 1-WL coloring, and it sees pair structure 1-WL is
2390/// blind to) and a SOUND over-approximation of the **orbitals** (orbits on ordered pairs): every variable
2391/// automorphism preserves the pair coloring, so `orbital(i,j) ⊆ paircell(i,j)`. Returns the `n×n` color
2392/// matrix. `O(rounds · n³)`, so intended for moderate `n`.
2393pub fn two_wl_pair_colors(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Vec<usize>> {
2394    let wl1 = color_refinement(num_vars, clauses);
2395    // Co-occurrence signature per ordered pair: how i and j appear together across clauses.
2396    let mut cooc: Vec<Vec<Vec<(usize, bool, bool)>>> = vec![vec![Vec::new(); num_vars]; num_vars];
2397    for c in clauses {
2398        let lits: Vec<(usize, bool)> = c.iter().map(|l| (l.var() as usize, l.is_positive())).collect();
2399        for &(vi, si) in &lits {
2400            for &(vj, sj) in &lits {
2401                if vi != vj {
2402                    cooc[vi][vj].push((c.len(), si, sj));
2403                }
2404            }
2405        }
2406    }
2407    for row in cooc.iter_mut() {
2408        for s in row.iter_mut() {
2409            s.sort_unstable();
2410        }
2411    }
2412    // Initial pair signature: (diagonal?, 1-WL(i), 1-WL(j), co-occurrence).
2413    let mut flat: Vec<(bool, usize, usize, Vec<(usize, bool, bool)>)> = Vec::with_capacity(num_vars * num_vars);
2414    for i in 0..num_vars {
2415        for j in 0..num_vars {
2416            flat.push((i == j, wl1[i], wl1[j], cooc[i][j].clone()));
2417        }
2418    }
2419    let mut color: Vec<usize> = rank_signatures(&flat); // indexed i*num_vars + j
2420    let at = |i: usize, j: usize| i * num_vars + j;
2421    loop {
2422        let mut sig: Vec<(usize, Vec<(usize, usize)>)> = Vec::with_capacity(num_vars * num_vars);
2423        for i in 0..num_vars {
2424            for j in 0..num_vars {
2425                let mut tri: Vec<(usize, usize)> =
2426                    (0..num_vars).map(|k| (color[at(i, k)], color[at(k, j)])).collect();
2427                tri.sort_unstable();
2428                sig.push((color[at(i, j)], tri));
2429            }
2430        }
2431        let next = rank_signatures(&sig);
2432        if next == color {
2433            break;
2434        }
2435        color = next;
2436    }
2437    (0..num_vars).map(|i| (0..num_vars).map(|j| color[at(i, j)]).collect()).collect()
2438}
2439
2440/// The number of distinct 2-WL pair colors — at most the number of orbitals (`pair-cells ≤ orbitals`).
2441pub fn two_wl_pair_cells(num_vars: usize, clauses: &[Vec<Lit>]) -> usize {
2442    let c = two_wl_pair_colors(num_vars, clauses);
2443    c.iter().flatten().copied().max().map_or(0, |m| m + 1)
2444}
2445
2446/// The label-independent **2-WL fingerprint**: the sorted multiset of pair-color class sizes. Two formulas
2447/// with different fingerprints are provably non-isomorphic (as colored variable structures) — the
2448/// distinguishing power that separates, e.g., a 6-cycle from two triangles where 1-WL cannot.
2449pub fn two_wl_fingerprint(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<usize> {
2450    let c = two_wl_pair_colors(num_vars, clauses);
2451    let k = c.iter().flatten().copied().max().map_or(0, |m| m + 1);
2452    let mut sizes = vec![0usize; k];
2453    for &col in c.iter().flatten() {
2454        sizes[col] += 1;
2455    }
2456    sizes.sort_unstable();
2457    sizes
2458}
2459
2460/// The **coherent configuration** (association scheme) that 2-WL stabilizes into: its `d` basis relations
2461/// (the stable pair-color classes `R_0,…,R_{d-1}`) and their **intersection numbers**
2462/// `p[i][j][k] = |{z : (x,z) ∈ R_i and (z,y) ∈ R_j}|` for any `(x,y) ∈ R_k`. Returns `(d, p)`.
2463///
2464/// The intersection numbers are well-defined *because* the coloring is coherent (independent of which
2465/// `(x,y) ∈ R_k` is chosen) — and that well-definedness is checked over EVERY pair, FAIL-CLOSED: `None` if
2466/// the 2-WL coloring is somehow not coherent (it always is once stabilized, so this certifies it). These are
2467/// the structure constants of the coherent algebra — the combinatorial dual of the class-algebra structure
2468/// constants ([`crate::permgroup::class_multiplication_coefficients`]); for a group's orbital configuration
2469/// they are the orbital intersection numbers.
2470pub fn coherent_configuration_constants(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<(usize, Vec<Vec<Vec<u128>>>)> {
2471    let n = num_vars;
2472    let pc = two_wl_pair_colors(n, clauses);
2473    let d = pc.iter().flatten().copied().max().map_or(0, |m| m + 1);
2474    if d == 0 {
2475        return None;
2476    }
2477    // The d × d count matrix [(x,z)-color][(z,y)-color] for a fixed pair (x,y).
2478    let count_matrix = |x: usize, y: usize| -> Vec<Vec<u128>> {
2479        let mut m = vec![vec![0u128; d]; d];
2480        for z in 0..n {
2481            m[pc[x][z]][pc[z][y]] += 1;
2482        }
2483        m
2484    };
2485    // p[i][j][k] from one representative pair per relation k.
2486    let mut rep: Vec<Option<(usize, usize)>> = vec![None; d];
2487    for i in 0..n {
2488        for j in 0..n {
2489            rep[pc[i][j]].get_or_insert((i, j));
2490        }
2491    }
2492    let mut p = vec![vec![vec![0u128; d]; d]; d];
2493    for k in 0..d {
2494        let (x, y) = rep[k]?;
2495        let m = count_matrix(x, y);
2496        for i in 0..d {
2497            for j in 0..d {
2498                p[i][j][k] = m[i][j];
2499            }
2500        }
2501    }
2502    // COHERENCE (fail-closed): every pair in R_k must yield the same intersection numbers.
2503    for x in 0..n {
2504        for y in 0..n {
2505            let k = pc[x][y];
2506            let m = count_matrix(x, y);
2507            for i in 0..d {
2508                for j in 0..d {
2509                    if m[i][j] != p[i][j][k] {
2510                        return None;
2511                    }
2512                }
2513            }
2514        }
2515    }
2516    Some((d, p))
2517}
2518
2519/// The **rank** of the coherent configuration — the number of basis relations (`d`), the dimension of the
2520/// coherent algebra. Equal to [`two_wl_pair_cells`]; for a group's orbital configuration it is the orbital
2521/// rank. `None` when the configuration is out of range / not coherent.
2522pub fn coherent_rank(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<usize> {
2523    coherent_configuration_constants(num_vars, clauses).map(|(d, _)| d)
2524}
2525
2526/// Simultaneously diagonalize commuting `d×d` matrices over `GF(p)`: refine `GF(p)^d` into common 1-dim
2527/// eigenspaces by splitting on each matrix in turn, and return, per eigenspace, the vector of its eigenvalues
2528/// across the matrices. `None` if they do not fully diagonalize over this `GF(p)` (the caller retries with
2529/// another prime). The same construction the Burnside–Dixon character table uses, applied to the coherent
2530/// algebra's intersection matrices.
2531fn gf_simultaneous_eigenvalues(mmats: &[Vec<Vec<u64>>], d: usize, p: u64) -> Option<Vec<Vec<u64>>> {
2532    use crate::permgroup::{gf_mat_vec, gf_nullspace, mod_inv};
2533    let mut subspaces: Vec<Vec<Vec<u64>>> = vec![(0..d)
2534        .map(|i| {
2535            let mut e = vec![0u64; d];
2536            e[i] = 1;
2537            e
2538        })
2539        .collect()];
2540    for mi in mmats {
2541        if subspaces.iter().all(|s| s.len() == 1) {
2542            break;
2543        }
2544        let mut next: Vec<Vec<Vec<u64>>> = Vec::new();
2545        for s in &subspaces {
2546            if s.len() == 1 {
2547                next.push(s.clone());
2548                continue;
2549            }
2550            let bn = s.len();
2551            let mb: Vec<Vec<u64>> = s.iter().map(|b| gf_mat_vec(mi, b, p)).collect();
2552            let mut pieces: Vec<Vec<Vec<u64>>> = Vec::new();
2553            let mut covered = 0usize;
2554            for lam in 0..p {
2555                let mut rows = vec![vec![0u64; bn]; d];
2556                for k in 0..d {
2557                    for (jj, sj) in s.iter().enumerate() {
2558                        let shift = (lam as u128 * sj[k] as u128) % p as u128;
2559                        rows[k][jj] = ((mb[jj][k] as u128 + p as u128 - shift) % p as u128) as u64;
2560                    }
2561                }
2562                let ns = gf_nullspace(rows, bn, p);
2563                if ns.is_empty() {
2564                    continue;
2565                }
2566                let eig: Vec<Vec<u64>> = ns
2567                    .iter()
2568                    .map(|c| {
2569                        let mut x = vec![0u64; d];
2570                        for (jj, &cj) in c.iter().enumerate() {
2571                            if cj != 0 {
2572                                for k in 0..d {
2573                                    x[k] = ((x[k] as u128 + cj as u128 * s[jj][k] as u128) % p as u128) as u64;
2574                                }
2575                            }
2576                        }
2577                        x
2578                    })
2579                    .collect();
2580                covered += eig.len();
2581                pieces.push(eig);
2582                if covered == bn {
2583                    break;
2584                }
2585            }
2586            if covered == bn {
2587                next.extend(pieces);
2588            } else {
2589                next.push(s.clone());
2590            }
2591        }
2592        subspaces = next;
2593    }
2594    if subspaces.iter().any(|s| s.len() != 1) {
2595        return None; // not fully diagonalizable over this GF(p)
2596    }
2597    Some(
2598        subspaces
2599            .iter()
2600            .map(|s| {
2601                let v = &s[0];
2602                let t = v.iter().position(|&x| x != 0).unwrap();
2603                let inv = mod_inv(v[t], p);
2604                mmats
2605                    .iter()
2606                    .map(|mi| {
2607                        let mv = gf_mat_vec(mi, v, p);
2608                        (mv[t] as u128 * inv as u128 % p as u128) as u64
2609                    })
2610                    .collect()
2611            })
2612            .collect(),
2613    )
2614}
2615
2616/// The **eigenmatrix (P-matrix) of the association scheme** of a formula's variable symmetry — the scheme's
2617/// "character table". The coherent configuration ([`coherent_configuration_constants`]) yields a commutative
2618/// algebra spanned by the relation matrices `A_i` (`A_i A_j = Σ_k a_{ijk} A_k`); its intersection matrices
2619/// `B_i` (`B_i[k][j] = a_{ijk}`) commute and are simultaneously diagonalizable, and `P[m][i]` is the
2620/// eigenvalue of `A_i` on the `m`-th common eigenspace. Computed exactly over a `GF(p)` chosen (by search)
2621/// so the algebra splits — Dixon's method applied to the scheme. Returns `(prime, P)`.
2622///
2623/// The rows of `P` are exactly the 1-dimensional representations of the coherent algebra, so
2624/// `P[m][i]·P[m][j] = Σ_k a_{ijk}·P[m][k]`; one row is the valencies `n_i` (the all-ones eigenvector). `None`
2625/// when the scheme is not commutative, is out of range, or no small prime splits it. Mirrors
2626/// [`crate::permgroup::character_table`] for the group ↔ scheme analogy.
2627pub fn association_scheme_eigenmatrix(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<(u64, Vec<Vec<u64>>)> {
2628    let (d, a) = coherent_configuration_constants(num_vars, clauses)?;
2629    if d == 0 {
2630        return None;
2631    }
2632    // The algebra must be commutative for a P-matrix to exist.
2633    for i in 0..d {
2634        for j in 0..d {
2635            for k in 0..d {
2636                if a[i][j][k] != a[j][i][k] {
2637                    return None;
2638                }
2639            }
2640        }
2641    }
2642    // Valencies n_i = Σ_j a_{ij0} (independent of the third index — the all-ones eigenvalue).
2643    let valency: Vec<u128> = (0..d).map(|i| (0..d).map(|j| a[i][j][0]).sum()).collect();
2644    let mut tried = 0;
2645    let mut p = 2u64;
2646    while tried < 200 && p < 100_000 {
2647        if crate::permgroup::is_prime(p) {
2648            tried += 1;
2649            let mmats: Vec<Vec<Vec<u64>>> = (0..d)
2650                .map(|i| (0..d).map(|k| (0..d).map(|j| (a[i][j][k] % p as u128) as u64).collect()).collect())
2651                .collect();
2652            if let Some(rows) = gf_simultaneous_eigenvalues(&mmats, d, p) {
2653                // FAIL-CLOSED: each row is a 1-dim rep of the algebra, and some row is the valencies.
2654                let hom_ok = rows.iter().all(|row| {
2655                    (0..d).all(|i| {
2656                        (0..d).all(|j| {
2657                            let lhs = row[i] as u128 * row[j] as u128 % p as u128;
2658                            let rhs = (0..d)
2659                                .map(|k| (a[i][j][k] % p as u128) * row[k] as u128 % p as u128)
2660                                .sum::<u128>()
2661                                % p as u128;
2662                            lhs == rhs
2663                        })
2664                    })
2665                });
2666                let has_valency =
2667                    rows.iter().any(|row| (0..d).all(|i| row[i] as u128 == valency[i] % p as u128));
2668                if hom_ok && has_valency {
2669                    return Some((p, rows));
2670                }
2671            }
2672        }
2673        p += 1;
2674    }
2675    None
2676}
2677
2678/// The **multiplicities of the association scheme** — the dimensions `m_j` of the common eigenspaces of the
2679/// relation matrices in the full `|X|`-dimensional space; the "degrees" of the scheme, dual to the valencies.
2680/// From the eigenmatrix `P` (#eigenmatrix) and the scheme orthogonality relation
2681/// `m_j = |X| / Σ_i P[j][i]·P[j][ī]/k_i` (`k_i` = valency, `ī` = transpose relation). Computed exactly over a
2682/// `GF(p)` chosen to split the algebra AND exceed `|X|` (so the small positive integer `m_j ≤ |X|` decodes
2683/// uniquely). Returns the multiplicities sorted ascending.
2684///
2685/// FAIL-CLOSED: `None` unless every `m_j` is a positive integer `≤ |X|`, `Σ_j m_j = |X|` (the eigenspaces
2686/// partition the space), and the trivial eigenspace (the valency row of `P`) has multiplicity 1. For a
2687/// multiplicity-free group action these are exactly the degrees of the constituent irreducibles.
2688pub fn association_scheme_multiplicities(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<u128>> {
2689    let (d, a) = coherent_configuration_constants(num_vars, clauses)?;
2690    if d == 0 {
2691        return None;
2692    }
2693    for i in 0..d {
2694        for j in 0..d {
2695            for k in 0..d {
2696                if a[i][j][k] != a[j][i][k] {
2697                    return None; // commutative only
2698                }
2699            }
2700        }
2701    }
2702    let valency: Vec<u128> = (0..d).map(|i| (0..d).map(|j| a[i][j][0]).sum()).collect();
2703    // The transpose relation ī (the reverse of relation i) from the 2-WL pair coloring.
2704    let pc = two_wl_pair_colors(num_vars, clauses);
2705    let mut transpose = vec![usize::MAX; d];
2706    for x in 0..num_vars {
2707        for y in 0..num_vars {
2708            let i = pc[x][y];
2709            if transpose[i] == usize::MAX {
2710                transpose[i] = pc[y][x];
2711            }
2712        }
2713    }
2714    let n = num_vars as u128;
2715    let mut tried = 0;
2716    let mut p = 2u64;
2717    while tried < 300 && p < 1_000_000 {
2718        // The prime must split the algebra AND exceed |X| so multiplicities decode uniquely.
2719        if crate::permgroup::is_prime(p) && p as u128 > n {
2720            tried += 1;
2721            let mmats: Vec<Vec<Vec<u64>>> = (0..d)
2722                .map(|i| (0..d).map(|k| (0..d).map(|j| (a[i][j][k] % p as u128) as u64).collect()).collect())
2723                .collect();
2724            if let Some(rows) = gf_simultaneous_eigenvalues(&mmats, d, p) {
2725                let mut mult = Vec::with_capacity(d);
2726                let mut ok = true;
2727                for row in &rows {
2728                    let mut denom = 0u64;
2729                    for i in 0..d {
2730                        let ki = (valency[i] % p as u128) as u64;
2731                        let term = (row[i] as u128 * row[transpose[i]] as u128 % p as u128) as u64;
2732                        denom = ((denom as u128 + term as u128 * crate::permgroup::mod_inv(ki, p) as u128)
2733                            % p as u128) as u64;
2734                    }
2735                    if denom == 0 {
2736                        ok = false;
2737                        break;
2738                    }
2739                    let m = (n % p as u128) * crate::permgroup::mod_inv(denom, p) as u128 % p as u128;
2740                    if m == 0 || m > n {
2741                        ok = false;
2742                        break;
2743                    }
2744                    mult.push(m);
2745                }
2746                let trivial_ok = ok
2747                    && rows.iter().zip(&mult).any(|(row, &m)| {
2748                        m == 1 && (0..d).all(|i| row[i] as u128 == valency[i] % p as u128)
2749                    });
2750                if ok && trivial_ok && mult.iter().sum::<u128>() == n {
2751                    mult.sort_unstable();
2752                    return Some(mult);
2753                }
2754            }
2755        }
2756        p += 1;
2757    }
2758    None
2759}
2760
2761/// **3-dimensional Weisfeiler–Leman (3-WL)** of a formula's variables — color refinement on *ordered
2762/// triples*. A triple `(i,j,k)` is initialized by the 2-WL colors of its three pairs
2763/// `(pc[i][j], pc[i][k], pc[j][k])`, then each round recolored by `(old color, sorted multiset over all w of
2764/// (color(w,j,k), color(i,w,k), color(i,j,w)))` to a fixpoint. Returns the `n×n×n` color tensor.
2765///
2766/// Strictly stronger than 2-WL (it distinguishes graphs with identical 2-WL colorings — e.g. the rook's
2767/// graph from the Shrikhande graph) and a SOUND over-approximation of the **3-orbits** (orbits on ordered
2768/// triples): every automorphism preserves the coloring, so `3-orbit(i,j,k) ⊆ triplecell(i,j,k)`.
2769/// `O(rounds · n⁴)`, so for small `n` only.
2770pub fn three_wl_colors(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Vec<Vec<usize>>> {
2771    let n = num_vars;
2772    let pc = two_wl_pair_colors(n, clauses);
2773    let at = |i: usize, j: usize, k: usize| (i * n + j) * n + k;
2774    let init: Vec<(usize, usize, usize)> = (0..n)
2775        .flat_map(|i| (0..n).flat_map(move |j| (0..n).map(move |k| (i, j, k))))
2776        .map(|(i, j, k)| (pc[i][j], pc[i][k], pc[j][k]))
2777        .collect();
2778    let mut color = rank_signatures(&init);
2779    loop {
2780        let mut sig: Vec<(usize, Vec<(usize, usize, usize)>)> = Vec::with_capacity(n * n * n);
2781        for i in 0..n {
2782            for j in 0..n {
2783                for k in 0..n {
2784                    let mut nbr: Vec<(usize, usize, usize)> =
2785                        (0..n).map(|w| (color[at(w, j, k)], color[at(i, w, k)], color[at(i, j, w)])).collect();
2786                    nbr.sort_unstable();
2787                    sig.push((color[at(i, j, k)], nbr));
2788                }
2789            }
2790        }
2791        let next = rank_signatures(&sig);
2792        if next == color {
2793            break;
2794        }
2795        color = next;
2796    }
2797    (0..n)
2798        .map(|i| (0..n).map(|j| (0..n).map(|k| color[at(i, j, k)]).collect()).collect())
2799        .collect()
2800}
2801
2802/// The label-independent **3-WL fingerprint**: the sorted multiset of triple-color class sizes. Separates
2803/// strongly-regular graphs that share every 2-WL invariant (the rook's vs Shrikhande graph).
2804pub fn three_wl_fingerprint(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<usize> {
2805    let c = three_wl_colors(num_vars, clauses);
2806    let max = c.iter().flatten().flatten().copied().max().map_or(0, |m| m + 1);
2807    let mut sizes = vec![0usize; max];
2808    for &col in c.iter().flatten().flatten() {
2809        sizes[col] += 1;
2810    }
2811    sizes.sort_unstable();
2812    sizes
2813}
2814
2815/// The canonical certificate of a formula under a *discrete* variable coloring (a labeling): the clause set
2816/// rewritten with each variable replaced by its dense label, every clause sorted, and the clauses sorted —
2817/// an exact representation of the formula as seen through that labeling.
2818fn formula_certificate(clauses: &[Vec<Lit>], coloring: &[usize]) -> Vec<Vec<(usize, bool)>> {
2819    let label = rank_signatures(coloring); // discrete coloring ⇒ a bijection onto 0..n-1
2820    let mut cls: Vec<Vec<(usize, bool)>> = clauses
2821        .iter()
2822        .map(|c| {
2823            let mut lits: Vec<(usize, bool)> =
2824                c.iter().map(|l| (label[l.var() as usize], l.is_positive())).collect();
2825            lits.sort_unstable();
2826            lits
2827        })
2828        .collect();
2829    cls.sort_unstable();
2830    cls
2831}
2832
2833/// The individualization–refinement search: refine the coloring; if discrete, return its certificate; else
2834/// individualize each vertex of the first non-singleton cell in turn, recurse, and keep the lexicographically
2835/// maximal leaf certificate. `None` if the node budget is exceeded (so the canonical form is not certified).
2836fn ir_canonical(
2837    num_vars: usize,
2838    clauses: &[Vec<Lit>],
2839    colors: &[usize],
2840    nodes: &mut usize,
2841    cap: usize,
2842) -> Option<Vec<Vec<(usize, bool)>>> {
2843    *nodes += 1;
2844    if *nodes > cap {
2845        return None;
2846    }
2847    let refined = equitable_refine(num_vars, clauses, colors);
2848    let d = refined.iter().copied().max().map_or(0, |m| m + 1);
2849    let mut members: Vec<Vec<usize>> = vec![Vec::new(); d];
2850    for (v, &c) in refined.iter().enumerate() {
2851        members[c].push(v);
2852    }
2853    match (0..d).find(|&c| members[c].len() > 1) {
2854        None => Some(formula_certificate(clauses, &refined)), // discrete ⇒ a leaf
2855        Some(c) => {
2856            let mut best: Option<Vec<Vec<(usize, bool)>>> = None;
2857            for &v in &members[c] {
2858                let mut nc = refined.clone();
2859                nc[v] = d; // individualize v: give it a fresh, unique color
2860                let leaf = ir_canonical(num_vars, clauses, &nc, nodes, cap)?;
2861                if best.as_ref().map_or(true, |b| leaf > *b) {
2862                    best = Some(leaf);
2863                }
2864            }
2865            best
2866        }
2867    }
2868}
2869
2870/// The **canonical form** of a formula's variable structure, computed by individualization–refinement (the
2871/// algorithm behind nauty/saucy/bliss). It is an *isomorphism invariant*: two formulas equal up to a
2872/// variable permutation that preserves the clause structure have the **same** canonical form, and — since
2873/// I-R is complete — two that are not get **different** canonical forms (so it decides isomorphism exactly,
2874/// unlike Weisfeiler–Leman). Built on [`color_refinement`] as its refinement step. `None` if the search
2875/// exceeds its node budget (gate to moderate `num_vars`).
2876pub fn canonical_form(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<Vec<(usize, bool)>>> {
2877    let mut nodes = 0;
2878    ir_canonical(num_vars, clauses, &vec![0usize; num_vars], &mut nodes, 200_000)
2879}
2880
2881/// Whether two formulas over the same variables are **isomorphic** — equal up to a variable permutation
2882/// preserving the (signed) clause structure — by comparing their canonical forms. `None` if either canonical
2883/// form exceeds the search budget.
2884pub fn formulas_isomorphic(num_vars: usize, f: &[Vec<Lit>], g: &[Vec<Lit>]) -> Option<bool> {
2885    Some(canonical_form(num_vars, f)? == canonical_form(num_vars, g)?)
2886}
2887
2888/// Is `g` a genuine symmetry of `clauses` that may be trusted for breaking? A well-formed permutation that
2889/// either preserves the clause set (syntactic) or whose image is logically entailed, `F ⊨ g(F)` — which for
2890/// a finite-order permutation means `F ≡ g(F)` (semantic). A malformed or non-symmetric declaration is
2891/// rejected, so a wrong declaration can never corrupt the result.
2892fn is_declared_symmetry(num_vars: usize, clauses: &[Vec<Lit>], g: &[usize]) -> bool {
2893    if g.len() != num_vars {
2894        return false;
2895    }
2896    let mut seen = vec![false; num_vars];
2897    for &x in g {
2898        if x >= num_vars || seen[x] {
2899            return false; // not a permutation
2900        }
2901        seen[x] = true;
2902    }
2903    let clause_set: HashSet<Vec<(u32, bool)>> = clauses.iter().map(|c| canon_clause(c)).collect();
2904    let syntactic = clauses.iter().all(|c| clause_set.contains(&canon_clause(&apply_perm_to_clause(c, g))));
2905    syntactic
2906        || clauses.iter().all(|c| {
2907            let gc = apply_perm_to_clause(c, g);
2908            clause_set.contains(&canon_clause(&gc)) || clause_is_implied(num_vars, clauses, &gc)
2909        })
2910}
2911
2912/// **The full arsenal with caller-DECLARED symmetry.** The modeler often knows symmetries the automatic
2913/// detector cannot afford to find (geometric, semantic, or simply large). This entry point accepts declared
2914/// variable-permutation generators, **verifies** each is a genuine symmetry ([`is_declared_symmetry`] —
2915/// never trusting a declaration blindly, so an incorrect one is silently dropped), unions the survivors with
2916/// the auto-detected symmetry, and breaks the combined group with the lex-leader (complete by enumeration
2917/// when the group is small, partial over the generators otherwise). Sound: only verified, model-set-
2918/// preserving permutations enter the break; a SAT model is re-checked, and on any anomaly it falls back to
2919/// the authoritative [`solve_comprehensive`]. With no declared and no detected symmetry it simply *is*
2920/// [`solve_comprehensive`].
2921pub fn solve_with_declared_symmetry(
2922    num_vars: usize,
2923    clauses: &[Vec<Lit>],
2924    declared: &[crate::permgroup::Perm],
2925) -> Solved {
2926    let mut gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
2927    for g in declared {
2928        if is_declared_symmetry(num_vars, clauses, g) {
2929            gens.push(g.clone());
2930        }
2931    }
2932    if gens.is_empty() {
2933        return solve_comprehensive(num_vars, clauses);
2934    }
2935    let bsgs = crate::permgroup::schreier_sims(num_vars, &gens);
2936    let to_litsym = |p: &[usize]| -> Vec<Lit> { (0..num_vars).map(|v| Lit::pos(p[v] as u32)).collect() };
2937    let group: Vec<Vec<Lit>> = match bsgs.elements(50_000) {
2938        Some(elts) => elts.iter().map(|p| to_litsym(p)).collect(),
2939        None => gens.iter().map(|p| to_litsym(p)).collect(),
2940    };
2941    let (sbp, total) = crate::sym_break::lex_leader_sbp_lit(num_vars, &group);
2942    let mut solver = Solver::new(total);
2943    for c in clauses {
2944        solver.add_clause(c.clone());
2945    }
2946    for c in &sbp {
2947        solver.add_clause(c.clone());
2948    }
2949    match solver.solve() {
2950        SolveResult::Sat(model) => {
2951            let projected: Vec<bool> = model[..num_vars].to_vec();
2952            if clauses.iter().all(|c| c.iter().any(|l| projected[l.var() as usize] == l.is_positive())) {
2953                Solved { answer: Answer::Sat(projected), via: Route::DeclaredSymmetry, proof: Vec::new(), conflicts: solver.conflicts() }
2954            } else {
2955                solve_comprehensive(num_vars, clauses) // re-check failed ⟹ authoritative fallback
2956            }
2957        }
2958        SolveResult::Unsat => {
2959            Solved { answer: Answer::Unsat, via: Route::DeclaredSymmetry, proof: Vec::new(), conflicts: solver.conflicts() }
2960        }
2961    }
2962}
2963
2964/// The solution set described **up to symmetry**: one representative per orbit, plus the exact total.
2965#[derive(Clone, Debug, PartialEq, Eq)]
2966pub struct SymmetricCount {
2967    /// One satisfying assignment per orbit of the model set under the variable-symmetry group — the
2968    /// essentially-distinct solutions.
2969    pub representatives: Vec<Vec<bool>>,
2970    /// The EXACT number of models (over the variables that occur), recovered as the sum of orbit sizes —
2971    /// so a `2^Θ(n)` model count is obtained by enumerating only the orbits.
2972    pub total_models: u128,
2973    /// `false` if the representative cap was reached before the model set was exhausted.
2974    pub exhaustive: bool,
2975}
2976
2977/// The orbit of an assignment under the variable-symmetry generators, as the distinct assignments reached
2978/// by the group (deduplicated on the occurring variables — free variables are immaterial to a model).
2979fn assignment_orbit(num_vars: usize, m: &[bool], gens: &[crate::permgroup::Perm], occurs: &[usize]) -> Vec<Vec<bool>> {
2980    let proj = |a: &[bool]| -> Vec<bool> { occurs.iter().map(|&v| a[v]).collect() };
2981    let mut seen: HashSet<Vec<bool>> = HashSet::from([proj(m)]);
2982    let mut out = vec![m.to_vec()];
2983    let mut i = 0;
2984    while i < out.len() {
2985        let cur = out[i].clone();
2986        i += 1;
2987        for g in gens {
2988            let mut pm = vec![false; num_vars];
2989            for v in 0..num_vars {
2990                pm[g[v]] = cur[v];
2991            }
2992            if seen.insert(proj(&pm)) {
2993                out.push(pm);
2994            }
2995        }
2996    }
2997    out
2998}
2999
3000/// **Enumerate the solution set up to symmetry, and count it exactly.** Find a model (via the full
3001/// arsenal), record it as an orbit representative, then BLOCK its entire symmetry orbit and repeat until
3002/// unsatisfiable. Because every orbit is removed in one step, the representatives are the essentially-
3003/// distinct solutions and the exact model count is the sum of the orbit sizes — so an instance with
3004/// `2^Θ(n)` models is counted by enumerating only its (far fewer) orbits. Sound: each representative is a
3005/// re-checked model, each orbit a genuine set of models (the generators are automorphisms), so the orbits
3006/// partition the model set. The representative count agrees with Burnside
3007/// ([`crate::sym_break::count_models_modulo_symmetry`]) — two independent routes to the orbit count.
3008pub fn models_up_to_symmetry(num_vars: usize, clauses: &[Vec<Lit>], cap: usize) -> SymmetricCount {
3009    let mut appears = vec![false; num_vars];
3010    for c in clauses {
3011        for l in c {
3012            appears[l.var() as usize] = true;
3013        }
3014    }
3015    let occurs: Vec<usize> = (0..num_vars).filter(|&v| appears[v]).collect();
3016    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3017
3018    let mut working = clauses.to_vec();
3019    let mut representatives: Vec<Vec<bool>> = Vec::new();
3020    let mut total_models: u128 = 0;
3021    let mut exhaustive = true;
3022    loop {
3023        if representatives.len() >= cap {
3024            exhaustive = false;
3025            break;
3026        }
3027        match solve_comprehensive(num_vars, &working).answer {
3028            Answer::Unsat => break,
3029            Answer::Sat(m) => {
3030                let orbit = assignment_orbit(num_vars, &m, &gens, &occurs);
3031                total_models = total_models.saturating_add(orbit.len() as u128);
3032                representatives.push(m);
3033                for a in &orbit {
3034                    // Block this assignment on the occurring variables (free variables are immaterial).
3035                    working.push(occurs.iter().map(|&v| Lit::new(v as u32, !a[v])).collect());
3036                }
3037            }
3038        }
3039    }
3040    SymmetricCount { representatives, total_models, exhaustive }
3041}
3042
3043/// The structural profile of a formula's **variable-symmetry group** — `|Aut|`, the orbit/rank/transitivity
3044/// ladder, primitivity, and block structure. The data a surfacing layer (e.g. a Studio panel) needs to
3045/// explain *why* an instance is symmetric.
3046#[derive(Clone, Debug, PartialEq, Eq)]
3047pub struct SymmetryProfile {
3048    /// `|Aut|` of the phase-free variable-symmetry group (`1` when there is no symmetry).
3049    pub order: u128,
3050    /// Number of detected generators.
3051    pub generators: usize,
3052    /// Variable orbits (points moved together).
3053    pub num_orbits: usize,
3054    /// Rank — orbits on ordered pairs (`2` iff 2-transitive); `0` when not computed (group too large).
3055    pub rank: usize,
3056    /// Transitivity degree, capped (`1` = transitive, `2` = 2-transitive, …).
3057    pub transitivity: usize,
3058    /// Whether the (transitive) group is primitive.
3059    pub primitive: bool,
3060    /// The number of minimal blocks when the group is transitive and imprimitive, else `None`.
3061    pub blocks: Option<usize>,
3062    /// Whether the group is abelian.
3063    pub abelian: bool,
3064    /// Whether the group is solvable (its derived series reaches the trivial group); `None` when not
3065    /// computed (group too large for the derived-series walk).
3066    pub solvable: Option<bool>,
3067    /// Whether the group is nilpotent (its lower central series reaches the trivial group); strictly
3068    /// stronger than solvable. `None` when not computed.
3069    pub nilpotent: Option<bool>,
3070    /// Derived length (solvability class) — the depth of the derived series; `None` if unsolvable / not
3071    /// computed.
3072    pub derived_length: Option<usize>,
3073    /// Nilpotency class — the depth of the lower central series; `None` if not nilpotent / not computed.
3074    pub nilpotency_class: Option<usize>,
3075    /// Order of the derived (commutator) subgroup `[G, G]`; `0` when not computed.
3076    pub derived_order: u128,
3077    /// Number of conjugacy classes (= number of irreducible representations); `None` when the group is too
3078    /// large to enumerate.
3079    pub conjugacy_classes: Option<usize>,
3080    /// Order of the centre `Z(G)`; `None` when too large to enumerate.
3081    pub center_order: Option<u128>,
3082    /// The exponent — lcm of element orders (smallest `e` with `gᵉ = id` for all `g`); `None` when too
3083    /// large to enumerate.
3084    pub exponent: Option<u128>,
3085    /// The number of distinct `{0,1}` assignments to the variables up to symmetry (Pólya with 2 colours) —
3086    /// the symmetry-reduced size of the search space; `None` when too large to enumerate.
3087    pub assignment_orbits: Option<u128>,
3088    /// The abelianisation `G/[G,G]` as `(order, exponent)` — the largest abelian quotient; `None` when too
3089    /// large to enumerate.
3090    pub abelianization: Option<(u128, u128)>,
3091    /// The number of subgroups (size of the subgroup lattice); `None` for larger groups (the lattice walk
3092    /// is gated more tightly than the other invariants).
3093    pub subgroups: Option<usize>,
3094    /// Whether the group is simple (non-trivial, no normal subgroup but itself and `{id}`); `None` when too
3095    /// large to enumerate.
3096    pub simple: Option<bool>,
3097    /// The composition factors (Jordan–Hölder) as the sorted multiset of their orders; their product is
3098    /// `|G|`. `None` when the lattice walk is out of range.
3099    pub composition_factors: Option<Vec<u128>>,
3100    /// The Sylow structure as `(p, n_p)` pairs — the number of Sylow `p`-subgroups per prime; `None` when
3101    /// the lattice walk is out of range.
3102    pub sylow: Option<Vec<(u128, usize)>>,
3103    /// The number of real conjugacy classes (= number of real irreducible characters); `None` when too
3104    /// large to enumerate.
3105    pub real_classes: Option<usize>,
3106    /// The number of **rational** conjugacy classes (= number of rational-valued irreducible characters) —
3107    /// the singleton Galois orbits. Strictly refines [`Self::real_classes`] (`rational ≤ real`); they differ
3108    /// exactly when a character is real but irrational. `None` when too large to enumerate.
3109    pub rational_classes: Option<usize>,
3110    /// The irreducible-representation degrees `χ_s(1)` (sorted, `Σ dᵢ² = |G|`) from the Burnside–Dixon
3111    /// character table — the complete representation-theoretic fingerprint of the symmetry; `None` when the
3112    /// character table is out of range.
3113    pub irreducible_degrees: Option<Vec<u128>>,
3114    /// The Frobenius–Schur indicators (one per irreducible, aligned with the character table): `+1` real,
3115    /// `0` complex, `−1` quaternionic. Refines [`Self::real_classes`] and separates groups with identical
3116    /// character tables (`D₄` vs `Q₈`). `None` when the character table is out of range.
3117    pub frobenius_schur: Option<Vec<i8>>,
3118    /// The isotypic decomposition of the variable-permutation representation: the multiplicity `m_s` of each
3119    /// irreducible in `π = Σ_s m_s χ_s` (aligned with the character table). Bridges the action and the linear
3120    /// theory — `m_trivial = num_orbits`, `Σ m_s² = rank`, `Σ m_s·d_s = num_vars`. `None` when out of range.
3121    pub isotypic_multiplicities: Option<Vec<u128>>,
3122    /// The order of `Aut(G)` — the automorphism group of the symmetry group itself. The intrinsic symmetry of
3123    /// `G` as an abstract group. `None` when out of range. Separates groups indistinguishable by character
3124    /// table / Frobenius–Schur / rationality (e.g. `|Aut(D₄)|=8` vs `|Aut(Q₈)|=24`).
3125    pub automorphism_order: Option<u128>,
3126    /// The order of `Out(G) = Aut(G)/Inn(G)` — the outer automorphisms (those not realised by conjugation).
3127    /// `None` when out of range.
3128    pub outer_automorphism_order: Option<u128>,
3129    /// The **coherent (association-scheme) rank** — the number of relations in the coherent configuration
3130    /// that 2-WL stabilizes to (`= two_wl_pair_cells`), the combinatorial analog of the orbital `rank`. It is
3131    /// `≤ rank` (2-WL can only be coarser than the true orbitals), so a value below `rank` witnesses that the
3132    /// polynomial pre-filter cannot resolve the full pair structure. Clause-derived: `Some` only from
3133    /// [`symmetry_structure`] (`None` for the pseudo-Boolean coefficient profile, which has no clauses).
3134    pub coherent_rank: Option<usize>,
3135}
3136
3137/// Compute the [`SymmetryProfile`] of `clauses` — detect the variable-symmetry generators and read off the
3138/// whole structural ladder (order via Schreier–Sims, orbits, orbitals/rank, transitivity, primitivity,
3139/// blocks). The rank and transitivity rungs are gated to moderate sizes (their tuple spaces grow with the
3140/// degree); `0` signals "not computed" there.
3141pub fn symmetry_structure(num_vars: usize, clauses: &[Vec<Lit>]) -> SymmetryProfile {
3142    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3143    let mut profile = profile_of_generators(num_vars, &gens);
3144    // The coherent (association-scheme) rank is clause-derived — wire it in here (the group profile alone
3145    // cannot see it). It refines the orbital rank: coherent_rank ≤ rank.
3146    profile.coherent_rank = Some(coherent_rank(num_vars, clauses).unwrap_or_else(|| two_wl_pair_cells(num_vars, clauses)));
3147    profile
3148}
3149
3150/// The structural profile of the group generated by `gens` over `num_vars` points — the shared core behind
3151/// [`symmetry_structure`] (CNF variable symmetry) and [`pb_symmetry_profile`] (pseudo-Boolean coefficient
3152/// symmetry).
3153fn profile_of_generators(num_vars: usize, gens: &[crate::permgroup::Perm]) -> SymmetryProfile {
3154    if gens.is_empty() {
3155        return SymmetryProfile {
3156            order: 1,
3157            generators: 0,
3158            num_orbits: num_vars,
3159            rank: 0,
3160            transitivity: 0,
3161            primitive: false,
3162            blocks: None,
3163            abelian: true, // the trivial group is abelian
3164            solvable: Some(true),
3165            nilpotent: Some(true),
3166            derived_length: Some(0),
3167            nilpotency_class: Some(0),
3168            derived_order: 1,
3169            conjugacy_classes: Some(1),
3170            center_order: Some(1),
3171            exponent: Some(1),
3172            assignment_orbits: Some(1u128 << num_vars.min(127)),
3173            abelianization: Some((1, 1)),
3174            subgroups: Some(1),
3175            simple: Some(false), // the trivial group is not simple
3176            composition_factors: Some(Vec::new()),
3177            sylow: Some(Vec::new()),
3178            irreducible_degrees: Some(vec![1]), // the trivial group has one trivial irreducible
3179            frobenius_schur: Some(vec![1]),     // its trivial character is real
3180            // The permutation rep of the trivial group is `num_vars` copies of the trivial irreducible.
3181            isotypic_multiplicities: Some(vec![num_vars as u128]),
3182            real_classes: Some(1),
3183            rational_classes: Some(1), // the trivial character is rational
3184            automorphism_order: Some(1), // the trivial group has only the identity automorphism
3185            outer_automorphism_order: Some(1),
3186            coherent_rank: None, // scheme rank is clause-derived; set by symmetry_structure
3187        };
3188    }
3189    let gens = gens.to_vec();
3190    let order = crate::permgroup::schreier_sims(num_vars, &gens).order();
3191    let num_orbits = crate::permgroup::orbits(num_vars, &gens).len();
3192    let rank = if num_vars <= 48 { crate::permgroup::rank(num_vars, &gens) } else { 0 };
3193    let transitivity =
3194        if num_vars <= 24 { crate::permgroup::transitivity_degree(num_vars, &gens, 3) } else { 0 };
3195    let primitive = crate::permgroup::is_primitive(num_vars, &gens);
3196    let blocks = crate::permgroup::minimal_block_system(num_vars, &gens).map(|b| b.len());
3197    let abelian = crate::permgroup::is_abelian(num_vars, &gens);
3198    // The series walks are heavier (repeated Schreier–Sims); gate them to moderate sizes. Compute the
3199    // depths once and read the booleans off them.
3200    let (solvable, nilpotent, derived_length, nilpotency_class, derived_order) = if num_vars <= 24 {
3201        let dl = crate::permgroup::derived_length(num_vars, &gens);
3202        let nc = crate::permgroup::nilpotency_class(num_vars, &gens);
3203        let d = crate::permgroup::derived_subgroup(num_vars, &gens);
3204        (Some(dl.is_some()), Some(nc.is_some()), dl, nc, crate::permgroup::schreier_sims(num_vars, &d).order())
3205    } else {
3206        (None, None, None, None, 0)
3207    };
3208    // Conjugacy classes (⇒ #irreps) and the centre need the group enumerated; cap the order.
3209    const ENUM_CAP: usize = 4096;
3210    let classes = crate::permgroup::conjugacy_classes(num_vars, &gens, ENUM_CAP);
3211    let conjugacy_classes = classes.as_ref().map(|c| c.len());
3212    let center_order = classes.map(|c| c.iter().filter(|cls| cls.len() == 1).count() as u128);
3213    let exponent = crate::permgroup::exponent(num_vars, &gens, ENUM_CAP);
3214    let assignment_orbits = crate::permgroup::polya_count(num_vars, &gens, 2, ENUM_CAP);
3215    let abelianization = crate::permgroup::abelianization(num_vars, &gens, ENUM_CAP);
3216    // The subgroup-lattice walk is heavier (exponential worst case) — gate it to small groups.
3217    let subgroups = crate::permgroup::subgroup_count(num_vars, &gens, 256);
3218    let simple = crate::permgroup::is_simple(num_vars, &gens, ENUM_CAP);
3219    // Composition factors share the subgroup-lattice walk — gate to small groups.
3220    let composition_factors = crate::permgroup::composition_factor_orders(num_vars, &gens, 256);
3221    let sylow = crate::permgroup::sylow_counts(num_vars, &gens, 256);
3222    let real_classes = crate::permgroup::real_class_count(num_vars, &gens, ENUM_CAP);
3223    let rational_classes = crate::permgroup::rational_class_count(num_vars, &gens, ENUM_CAP);
3224    // The Burnside–Dixon character table is computed once and both the degrees and the Frobenius–Schur
3225    // indicators are read off it.
3226    let ctable = crate::permgroup::character_table(num_vars, &gens, ENUM_CAP);
3227    let irreducible_degrees = ctable.as_ref().map(|t| t.degrees.clone());
3228    let frobenius_schur = ctable.as_ref().and_then(crate::permgroup::frobenius_schur_from_table);
3229    let isotypic_multiplicities =
3230        ctable.as_ref().and_then(|t| crate::permgroup::isotypic_from_table(num_vars, &gens, t));
3231    // The automorphism group of the symmetry group itself (gated tighter — the search is heavier).
3232    let automorphism_order = crate::permgroup::automorphism_group_order(num_vars, &gens, 256);
3233    let outer_automorphism_order = match (automorphism_order, center_order) {
3234        (Some(a), Some(c)) if c > 0 => Some(a / (order / c)), // |Out| = |Aut| / |Inn|, |Inn| = |G|/|Z|
3235        _ => None,
3236    };
3237    SymmetryProfile {
3238        order,
3239        generators: gens.len(),
3240        num_orbits,
3241        rank,
3242        transitivity,
3243        primitive,
3244        blocks,
3245        abelian,
3246        solvable,
3247        nilpotent,
3248        derived_length,
3249        nilpotency_class,
3250        derived_order,
3251        conjugacy_classes,
3252        center_order,
3253        exponent,
3254        assignment_orbits,
3255        abelianization,
3256        subgroups,
3257        simple,
3258        composition_factors,
3259        sylow,
3260        real_classes,
3261        rational_classes,
3262        irreducible_degrees,
3263        frobenius_schur,
3264        isotypic_multiplicities,
3265        automorphism_order,
3266        outer_automorphism_order,
3267        coherent_rank: None, // clause-derived; filled in by symmetry_structure
3268    }
3269}
3270
3271/// The [`SymmetryProfile`] of a pseudo-Boolean system's **coefficient-symmetry** group — variables that
3272/// share a coefficient profile across all constraints ([`crate::pseudo_boolean::coeff_symmetry_generators`])
3273/// — read through the same structural ladder as [`symmetry_structure`]. The symmetry of *weighted*
3274/// constraints, surfaced exactly like the clause-structure symmetry.
3275pub fn pb_symmetry_profile(num_vars: usize, constraints: &[crate::pseudo_boolean::PbConstraint]) -> SymmetryProfile {
3276    let gens = crate::pseudo_boolean::coeff_symmetry_generators(num_vars, constraints);
3277    profile_of_generators(num_vars, &gens)
3278}
3279
3280/// The **class-algebra structure constants** of a formula's variable-symmetry group — `a[i][j][k]`, the
3281/// multiplication coefficients of the conjugacy classes (`Cᵢ·Cⱼ = Σₖ a[i][j][k]·Cₖ`), the foundation of the
3282/// character table. `None` when the group is too large to enumerate.
3283pub fn class_algebra_constants(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<Vec<Vec<u128>>>> {
3284    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3285    crate::permgroup::class_multiplication_coefficients(num_vars, &gens, 4096)
3286}
3287
3288/// The **character table** of a formula's variable-symmetry group, computed exactly over a finite field by
3289/// the Burnside–Dixon algorithm (degrees + `GF(p)`-valued irreducible characters). The deepest structural
3290/// view of the symmetry — `None` when the group is too large for the finite-field diagonalisation.
3291pub fn character_table(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<crate::permgroup::CharacterTable> {
3292    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3293    crate::permgroup::character_table(num_vars, &gens, 4096)
3294}
3295
3296/// The **Frobenius–Schur indicators** of a formula's variable-symmetry group — `+1`/`0`/`−1` per
3297/// irreducible (real/complex/quaternionic), the finest representation-theoretic refinement of the symmetry
3298/// (it separates groups even when the character table cannot). `None` when out of range.
3299pub fn frobenius_schur_indicators(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<i8>> {
3300    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3301    crate::permgroup::frobenius_schur_indicators(num_vars, &gens, 4096)
3302}
3303
3304/// The **permutation character** `π(g) = #fixed variables of g` of a formula's variable-symmetry action,
3305/// valued per conjugacy class. The character of the representation the symmetry group carries on the
3306/// variables themselves. `None` when the group is too large to enumerate.
3307pub fn permutation_character(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<u128>> {
3308    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3309    crate::permgroup::permutation_character(num_vars, &gens, 4096)
3310}
3311
3312/// The **isotypic decomposition** of a formula's variable-permutation representation — the multiplicity of
3313/// each irreducible in `π = Σ_s m_s χ_s`. The representation-theoretic spectrum of the symmetry, tying the
3314/// character table back to the orbit/orbital structure of the action. `None` when out of range.
3315pub fn isotypic_multiplicities(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<u128>> {
3316    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3317    crate::permgroup::isotypic_multiplicities(num_vars, &gens, 4096)
3318}
3319
3320/// The **tensor (fusion) decomposition** of a formula's variable-symmetry irreducibles — `N[i][j][k]`, the
3321/// multiplicity of `χ_k` in `χ_i ⊗ χ_j`, the multiplication table of the representation ring `R(G)`. `None`
3322/// when the character table is out of range.
3323pub fn tensor_decomposition(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<Vec<Vec<u128>>>> {
3324    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3325    crate::permgroup::tensor_decomposition(num_vars, &gens, 4096)
3326}
3327
3328/// The **Galois orbits on conjugacy classes** of a formula's variable symmetry — classes fused by the
3329/// `(ℤ/e)*` action `C ↦ C^t` (algebraic conjugacy). The singletons are the rational classes. `None` when
3330/// out of range.
3331pub fn galois_class_orbits(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<Vec<usize>>> {
3332    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3333    crate::permgroup::galois_class_orbits(num_vars, &gens, 4096)
3334}
3335
3336/// The **table of marks** of a formula's variable-symmetry group — the Burnside-ring classification of its
3337/// `G`-sets (the permutation-representation analogue of [`character_table`]). Returns
3338/// `(subgroup_class_orders, marks)`. `None` when the subgroup lattice is out of range.
3339pub fn table_of_marks(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<(Vec<u128>, Vec<Vec<u128>>)> {
3340    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3341    crate::permgroup::table_of_marks(num_vars, &gens, 256)
3342}
3343
3344/// The **Burnside ring** multiplication of a formula's variable-symmetry group — `N[a][b][l]`, the
3345/// decomposition of the product G-set `(G/H_a)×(G/H_b)` into transitive G-sets (the G-set analogue of the
3346/// character table's fusion ring). `None` when the subgroup lattice is out of range.
3347pub fn burnside_ring_product(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<Vec<Vec<i128>>>> {
3348    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3349    crate::permgroup::burnside_ring_product(num_vars, &gens, 256)
3350}
3351
3352/// The **Möbius number** `μ(1, G)` of the subgroup lattice of a formula's variable-symmetry group. `None`
3353/// when the subgroup lattice is out of range.
3354pub fn mobius_number(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<i128> {
3355    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3356    crate::permgroup::mobius_number(num_vars, &gens, 256)
3357}
3358
3359/// The number of ordered `k`-tuples of symmetries that **generate** the whole variable-symmetry group `G`
3360/// (Hall's Eulerian function `e_k(G)`); `e_k(G)/|G|^k` is the probability `k` random symmetries generate it.
3361/// `None` when out of range.
3362pub fn generating_tuple_count(num_vars: usize, clauses: &[Vec<Lit>], k: u32) -> Option<i128> {
3363    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3364    crate::permgroup::generating_tuple_count(num_vars, &gens, 256, k)
3365}
3366
3367/// The **permutation-character decomposition** of a formula's variable-symmetry group — the bridge between
3368/// its table of marks and its character table. `M[i][s]` is the multiplicity of irreducible `χ_s` in the
3369/// permutation representation on the cosets `G/H_i` (Frobenius reciprocity). Returns
3370/// `(subgroup_orders, irreducible_degrees, M)`. `None` when out of range.
3371pub fn permutation_character_decomposition(
3372    num_vars: usize,
3373    clauses: &[Vec<Lit>],
3374) -> Option<(Vec<u128>, Vec<u128>, Vec<Vec<u128>>)> {
3375    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3376    crate::permgroup::permutation_character_decomposition(num_vars, &gens, 256)
3377}
3378
3379/// The order of `Aut(G)` for a formula's variable-symmetry group `G` — the automorphism group of the
3380/// symmetry itself. `None` when out of range.
3381pub fn automorphism_group_order(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<u128> {
3382    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3383    crate::permgroup::automorphism_group_order(num_vars, &gens, 256)
3384}
3385
3386/// The **weight inventory** of a formula's variable symmetry: entry `w` is the number of essentially-
3387/// distinct (modulo symmetry) `{0,1}` assignments to the variables with exactly `w` ones — the weighted
3388/// Pólya refinement of [`SymmetryProfile::assignment_orbits`] (which is the sum). `None` when the group is
3389/// too large to enumerate.
3390pub fn assignment_weight_inventory(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<u128>> {
3391    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3392    crate::permgroup::pattern_inventory(num_vars, &gens, 4096)
3393}
3394
3395/// **Iterated symmetry breaking to a fixpoint** — the automated breaker. Repeatedly detect the formula's
3396/// variable symmetry and break each generator with one sound lex constraint — `x_v ≤ x_{g[v]}` at `g`'s
3397/// least moved point `v` (`v < g[v]`, and the lex-minimum of `g`'s orbit satisfies it, so it keeps ≥ 1
3398/// model per orbit, no auxiliary variables) — then **re-detect on the strengthened formula**, so symmetry
3399/// that earlier breaks expose is broken in its turn. It runs until a round adds no new constraint: the
3400/// symmetry "broken to conclusion." Sound: every clause breaks a genuine symmetry of the *current* formula,
3401/// so each round is equisatisfiability-preserving and the whole is equisatisfiable with the input. Returns
3402/// the original clauses plus the accumulated breaks; the residual variable-symmetry group has shrunk (to
3403/// trivial when single-bit breaks suffice). Solve the result with [`solve_comprehensive`].
3404pub fn break_all_symmetry(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Vec<Lit>> {
3405    let key = |a: Lit, b: Lit| -> (u32, bool, u32, bool) {
3406        let (x, y) = ((a.var(), a.is_positive()), (b.var(), b.is_positive()));
3407        if x <= y { (x.0, x.1, y.0, y.1) } else { (y.0, y.1, x.0, x.1) }
3408    };
3409    let mut combined = clauses.to_vec();
3410    let mut seen: HashSet<(u32, bool, u32, bool)> =
3411        combined.iter().filter(|c| c.len() == 2).map(|c| key(c[0], c[1])).collect();
3412    loop {
3413        let gens =
3414            crate::sym_break::variable_automorphism_generators(num_vars, &combined).unwrap_or_default();
3415        if gens.is_empty() {
3416            break; // fixpoint: no symmetry remains
3417        }
3418        let bsgs = crate::permgroup::schreier_sims(num_vars, &gens);
3419        let mut added = false;
3420        // Phase 1 — COMPLETE break of fully-symmetric, independent orbits via sortedness. An orbit whose
3421        // every pure adjacent transposition lies in the group carries an independent Sₙ action, for which
3422        // `x_{o₀} ≤ x_{o₁} ≤ …` is the complete (one-per-orbit), aux-free, sound symmetry break.
3423        for orbit in crate::permgroup::orbits(num_vars, &gens) {
3424            if orbit.len() < 2 {
3425                continue;
3426            }
3427            let full = orbit.windows(2).all(|w| {
3428                let mut t: Vec<usize> = (0..num_vars).collect();
3429                t.swap(w[0], w[1]);
3430                bsgs.contains(&t)
3431            });
3432            if full {
3433                for w in orbit.windows(2) {
3434                    let (a, b) = (Lit::neg(w[0] as u32), Lit::pos(w[1] as u32)); // x_{o_i} ≤ x_{o_{i+1}}
3435                    if seen.insert(key(a, b)) {
3436                        combined.push(vec![a, b]);
3437                        added = true;
3438                    }
3439                }
3440            }
3441        }
3442        // Phase 2 — fallback single-bit break per generator, only when no full orbit was sorted this round.
3443        if !added {
3444            for g in &gens {
3445                let Some(v) = (0..num_vars).find(|&i| g[i] != i) else { continue };
3446                let (a, b) = (Lit::neg(v as u32), Lit::pos(g[v] as u32)); // x_v ≤ x_{g[v]}
3447                if seen.insert(key(a, b)) {
3448                    combined.push(vec![a, b]);
3449                    added = true;
3450                }
3451            }
3452        }
3453        if !added {
3454            break; // no new ordering ⇒ fixpoint (any residual symmetry is single-bit-irreducible)
3455        }
3456    }
3457    combined
3458}
3459
3460/// **Complete symmetry breaking** — the breaker driven all the way to a single representative per orbit.
3461/// Detect the full variable-symmetry group and add the COMPLETE lex-leader (one `a ≤_lex a∘g` per group
3462/// element, via the standard lex-chain auxiliaries), which admits **exactly one model per symmetry orbit**
3463/// when the group is enumerable. Returns the strengthened clauses and the new variable count (originals +
3464/// auxiliaries). Sound (equisatisfiable with the input); complete for groups up to the enumeration cap, and
3465/// a sound partial break (generators ∪ coset representatives) for larger ones. Unlike [`break_all_symmetry`]
3466/// (aux-free, complete only for fully-symmetric orbits), this leaves *no* residual orbit symmetry.
3467pub fn break_all_symmetry_complete(num_vars: usize, clauses: &[Vec<Lit>]) -> (Vec<Vec<Lit>>, usize) {
3468    let gens = crate::sym_break::variable_automorphism_generators(num_vars, clauses).unwrap_or_default();
3469    if gens.is_empty() {
3470        return (clauses.to_vec(), num_vars);
3471    }
3472    let bsgs = crate::permgroup::schreier_sims(num_vars, &gens);
3473    let to_litsym = |p: &[usize]| -> Vec<Lit> { (0..num_vars).map(|v| Lit::pos(p[v] as u32)).collect() };
3474    let group: Vec<Vec<Lit>> = match bsgs.elements(50_000) {
3475        Some(elts) => elts.iter().map(|p| to_litsym(p)).collect(), // complete: one constraint per element
3476        None => {
3477            // Too large to enumerate — sound partial break over generators ∪ coset representatives.
3478            let mut g: Vec<Vec<Lit>> = gens.iter().map(|p| to_litsym(p)).collect();
3479            g.extend(bsgs.transversal_elements().iter().map(|p| to_litsym(p)));
3480            g
3481        }
3482    };
3483    let (sbp, total) = crate::sym_break::lex_leader_sbp_lit(num_vars, &group);
3484    let mut combined = clauses.to_vec();
3485    combined.extend(sbp);
3486    (combined, total)
3487}
3488
3489/// **Solve by breaking all symmetry first** — the breaker as a front-end. Run the aux-free recursive
3490/// breaker ([`break_all_symmetry`]) to collapse the symmetry to its fixpoint, then decide the reduced
3491/// formula with the full arsenal ([`solve_comprehensive`]). Equisatisfiable, so the verdict is the
3492/// original's, and any returned model satisfies the original clauses (a subset of the broken set) —
3493/// re-checked fail-closed. (Uses the aux-free reducer, not [`break_all_symmetry_complete`]: CDCL handles the
3494/// residual cheaply, and the complete lex-leader of a large group would be far more expensive than the
3495/// search it saves.)
3496pub fn solve_by_symmetry_breaking(num_vars: usize, clauses: &[Vec<Lit>]) -> Solved {
3497    let broken = break_all_symmetry(num_vars, clauses);
3498    let inner = solve_comprehensive(num_vars, &broken);
3499    match inner.answer {
3500        Answer::Sat(model) => {
3501            if clauses.iter().all(|c| c.iter().any(|l| model[l.var() as usize] == l.is_positive())) {
3502                Solved { answer: Answer::Sat(model), via: inner.via, proof: inner.proof, conflicts: inner.conflicts }
3503            } else {
3504                solve_comprehensive(num_vars, clauses) // re-check failed ⇒ authoritative fallback
3505            }
3506        }
3507        Answer::Unsat => Solved { answer: Answer::Unsat, via: inner.via, proof: inner.proof, conflicts: inner.conflicts },
3508    }
3509}
3510
3511/// Decide an instance by lifting it onto `GF(p)`: recover the mod-`p` one-hot system from the clauses
3512/// ([`crate::modp::recover_from_cnf`]) and run the certified Gaussian engine. Returns `None` when the
3513/// formula is not a clean mod-`p` encoding, so the dispatcher falls through to its other routes. UNSAT is
3514/// sound by the equisatisfiable recovery (the linear refutation re-checks); a SAT model is translated
3515/// back to the Boolean variables and accepted only if it satisfies every original clause (fail-closed).
3516fn modp_route(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
3517    use crate::modp::{self, ModpOutcome};
3518    let rec = modp::recover_from_cnf(num_vars, clauses)?;
3519
3520    // Translate a recovered ℤ/modulus assignment (value per one-hot group) back to the Boolean variables,
3521    // accepting it only if it satisfies every original clause (fail-closed — never trust the lift blindly).
3522    let build_model = |assign: &[u64]| -> Option<Vec<bool>> {
3523        let mut model = vec![false; num_vars];
3524        for (g, group) in rec.groups.iter().enumerate() {
3525            if let Some(&bit) = group.get(*assign.get(g).unwrap_or(&0) as usize) {
3526                if (bit as usize) < model.len() {
3527                    model[bit as usize] = true;
3528                }
3529            }
3530        }
3531        clauses
3532            .iter()
3533            .all(|c| c.iter().any(|l| model.get(l.var() as usize).copied().unwrap_or(false) == l.is_positive()))
3534            .then_some(model)
3535    };
3536
3537    if modp::is_prime(rec.modulus) {
3538        // Prime modulus: the proven field engine.
3539        match modp::solve(&rec.equations, rec.num_vars, rec.modulus) {
3540            ModpOutcome::Unsat(combo) => {
3541                debug_assert!(
3542                    modp::is_refutation(&rec.equations, rec.num_vars, rec.modulus, &combo),
3543                    "the recovered GF(p) refutation must re-check"
3544                );
3545                Some(Solved::unsat(Route::ModP))
3546            }
3547            ModpOutcome::Sat(assign) => Some(Solved {
3548                answer: Answer::Sat(build_model(&assign)?),
3549                via: Route::ModP,
3550                proof: Vec::new(),
3551                conflicts: 0,
3552            }),
3553        }
3554    } else {
3555        // Composite modulus: CRT over the prime-power components, the ℤ/m ring engine.
3556        use crate::modm::{self, ModmOutcome};
3557        match modm::solve(&rec.equations, rec.num_vars, rec.modulus)? {
3558            ModmOutcome::Unsat { modulus, combo } => {
3559                debug_assert!(
3560                    modm::is_refutation(&rec.equations, rec.num_vars, modulus, &combo),
3561                    "the recovered ℤ/m refutation must re-check"
3562                );
3563                Some(Solved::unsat(Route::ModM))
3564            }
3565            ModmOutcome::Sat(assign) => Some(Solved {
3566                answer: Answer::Sat(build_model(&assign)?),
3567                via: Route::ModM,
3568                proof: Vec::new(),
3569                conflicts: 0,
3570            }),
3571        }
3572    }
3573}
3574
3575/// **The clause-bundle pass.** Run every structure-mining contributor and union the *implied* clauses
3576/// (no-goods) they discover. Each contributor's soundness contract: every returned clause is implied
3577/// by the formula, so the union preserves the solution set — a sound, never-worse enrichment that lets
3578/// one method's discovered structure accelerate the others (and CDCL). Contributors that find nothing
3579/// are cheap, so this is safe to run before the fallback on any instance.
3580pub fn mine_clauses(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Vec<Lit>> {
3581    let mut seen: HashSet<Vec<i64>> = clauses.iter().map(|c| canon(c)).collect();
3582    let mut pool = Vec::new();
3583    let mut add = |bundle: Vec<Vec<Lit>>, pool: &mut Vec<Vec<Lit>>, seen: &mut HashSet<Vec<i64>>| {
3584        for c in bundle {
3585            if seen.insert(canon(&c)) {
3586                pool.push(c);
3587            }
3588        }
3589    };
3590    add(xor_gaussian_bundle(num_vars, clauses), &mut pool, &mut seen);
3591    add(failed_literal_bundle(num_vars, clauses), &mut pool, &mut seen);
3592    pool
3593}
3594
3595/// Canonical clause key (sorted signed-var ids) for deduping against the formula and each other.
3596fn canon(c: &[Lit]) -> Vec<i64> {
3597    let mut k: Vec<i64> = c
3598        .iter()
3599        .map(|l| if l.is_positive() { l.var() as i64 + 1 } else { -(l.var() as i64 + 1) })
3600        .collect();
3601    k.sort_unstable();
3602    k.dedup();
3603    k
3604}
3605
3606/// Max width of clauses the Gaussian bundle emits (tunable for experiments via `LOGOS_XOR_WIDTH`).
3607fn xor_width() -> usize {
3608    std::env::var("LOGOS_XOR_WIDTH").ok().and_then(|s| s.parse().ok()).unwrap_or(3)
3609}
3610
3611/// Contributor: the short clauses the GF(2) Gaussian reduction implies (units, binaries, …).
3612fn xor_gaussian_bundle(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Vec<Lit>> {
3613    let eqs = extract_xor(num_vars, clauses);
3614    if eqs.len() < 2 {
3615        return Vec::new();
3616    }
3617    IncXor::new(num_vars, &eqs).derived_clauses(xor_width())
3618}
3619
3620/// Contributor: failed-literal probing — `v` whose assertion unit-propagates to a root conflict gives
3621/// the implied unit `¬v`. Catches RUP-implied units the gadget clauses hide. Budgeted: skipped on
3622/// very large instances where the O(vars·clauses) probe sweep would not pay off.
3623fn failed_literal_bundle(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Vec<Lit>> {
3624    if num_vars > 5000 || clauses.len() > 50_000 {
3625        return Vec::new();
3626    }
3627    let mut out = Vec::new();
3628    for v in 0..num_vars {
3629        let neg = Lit::new(v as u32, false);
3630        let pos = Lit::new(v as u32, true);
3631        if crate::rup::is_rup(num_vars, clauses, std::slice::from_ref(&neg)) {
3632            out.push(vec![neg]);
3633        } else if crate::rup::is_rup(num_vars, clauses, std::slice::from_ref(&pos)) {
3634            out.push(vec![pos]);
3635        }
3636    }
3637    out
3638}
3639
3640/// View the CNF as 2-SAT — `Some` iff every clause has 1 or 2 literals (a unit `l` becomes `l ∨ l`).
3641fn as_two_sat(clauses: &[Vec<Lit>]) -> Option<Vec<(crate::twosat::Lit, crate::twosat::Lit)>> {
3642    let cvt = |l: &Lit| {
3643        if l.is_positive() {
3644            crate::twosat::Lit::pos(l.var() as usize)
3645        } else {
3646            crate::twosat::Lit::neg(l.var() as usize)
3647        }
3648    };
3649    let mut out = Vec::with_capacity(clauses.len());
3650    for c in clauses {
3651        match c.as_slice() {
3652            [a] => out.push((cvt(a), cvt(a))),
3653            [a, b] => out.push((cvt(a), cvt(b))),
3654            _ => return None,
3655        }
3656    }
3657    Some(out)
3658}
3659
3660/// View the CNF as Horn — `Some` iff every clause has at most one positive literal.
3661fn as_horn(clauses: &[Vec<Lit>]) -> Option<Vec<crate::hornsat::HornClause>> {
3662    let mut out = Vec::with_capacity(clauses.len());
3663    for c in clauses {
3664        if c.is_empty() {
3665            return None;
3666        }
3667        let pos: Vec<usize> = c.iter().filter(|l| l.is_positive()).map(|l| l.var() as usize).collect();
3668        let neg: Vec<usize> = c.iter().filter(|l| !l.is_positive()).map(|l| l.var() as usize).collect();
3669        match pos.len() {
3670            0 => out.push(crate::hornsat::HornClause::goal(neg)),
3671            1 if neg.is_empty() => out.push(crate::hornsat::HornClause::fact(pos[0])),
3672            1 => out.push(crate::hornsat::HornClause::rule(neg, pos[0])),
3673            _ => return None,
3674        }
3675    }
3676    Some(out)
3677}
3678
3679/// Build the conjunction-of-disjunctions [`ProofExpr`] the pigeonhole/cutting-plane/parity detectors
3680/// read (atoms named `v{index}`). `None` if any clause is empty (handled by the CDCL fallback).
3681fn cnf_to_expr(clauses: &[Vec<Lit>]) -> Option<ProofExpr> {
3682    let atom = |l: &Lit| {
3683        let a = ProofExpr::Atom(format!("v{}", l.var()));
3684        if l.is_positive() { a } else { ProofExpr::Not(Box::new(a)) }
3685    };
3686    let mut clause_exprs = Vec::with_capacity(clauses.len());
3687    for c in clauses {
3688        if c.is_empty() {
3689            return None;
3690        }
3691        let atoms: Vec<ProofExpr> = c.iter().map(&atom).collect();
3692        clause_exprs.push(balanced(atoms, &|a, b| ProofExpr::Or(Box::new(a), Box::new(b)))?);
3693    }
3694    // Balanced trees keep the And/Or spine depth ~log(n), so the recursive detector traversals do
3695    // not overflow the stack on competition-scale formulas (tens of thousands of clauses).
3696    balanced(clause_exprs, &|a, b| ProofExpr::And(Box::new(a), Box::new(b)))
3697}
3698
3699/// Fold `items` into one expression with a balanced (log-depth) tree under `combine`.
3700fn balanced(mut items: Vec<ProofExpr>, combine: &dyn Fn(ProofExpr, ProofExpr) -> ProofExpr) -> Option<ProofExpr> {
3701    if items.is_empty() {
3702        return None;
3703    }
3704    while items.len() > 1 {
3705        let mut next = Vec::with_capacity(items.len().div_ceil(2));
3706        let mut it = items.into_iter();
3707        while let Some(a) = it.next() {
3708            match it.next() {
3709                Some(b) => next.push(combine(a, b)),
3710                None => next.push(a),
3711            }
3712        }
3713        items = next;
3714    }
3715    items.into_iter().next()
3716}
3717
3718/// Hybrid XOR-SAT: mine the GF(2) linear system for structure, hand it to CDCL.
3719///
3720/// Three contributions, all sound (the recovered equations are logical consequences of the formula):
3721/// (1) decide the linear system — an inconsistent subsystem refutes the whole formula; (2) inject the
3722/// short clauses Gaussian *implies* ([`IncXor::derived_clauses`] — units, binaries, …) that resolution
3723/// would not find, so CDCL inherits the strategy's discovered no-goods; (3) seed CDCL's phases with a
3724/// GF(2) witness so search starts on the solution manifold. CDCL remains the authoritative decider, so
3725/// adding implied clauses + a phase hint can only help. `None` when not XOR-heavy.
3726///
3727/// (The full incremental DPLL(XOR) engine — live Gaussian during search, [`crate::xor_engine::IncXor`]
3728/// — is built and proven correct against a brute-force + differential oracle, and is the decisive
3729/// lever for *UNSAT* parity. On *satisfiable* parity-learning it needs a watch-based bit-packed matrix
3730/// to beat this clause-mining path; that is the remaining perf work for par32-scale.)
3731fn hybrid_xor(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
3732    if num_vars == 0 {
3733        return None;
3734    }
3735    let eqs = extract_xor(num_vars, clauses);
3736    let mut xor_vars = HashSet::new();
3737    for e in &eqs {
3738        xor_vars.extend(e.vars.iter().copied());
3739    }
3740    // Gate: a real linear system (≥2 equations) covering at least half the variables.
3741    if eqs.len() < 2 || xor_vars.len() * 2 < num_vars {
3742        return None;
3743    }
3744    let engine = IncXor::new(num_vars, &eqs);
3745    if !engine.is_active() {
3746        return None;
3747    }
3748    // Decide the linear system once: an inconsistent subsystem refutes the whole formula.
3749    let witness = match crate::xorsat::solve(&eqs, num_vars) {
3750        XorOutcome::Unsat(_) => return Some(Solved::unsat(Route::HybridXor)),
3751        XorOutcome::Sat(w) => w,
3752    };
3753    // Collect the strategy's discovered structure: the short clauses Gaussian implies (units,
3754    // binaries, …) that resolution misses. Inject them so CDCL inherits the ruled-out no-goods, and
3755    // seed phases with the GF(2) witness so search starts on the solution manifold. Both only help.
3756    let derived = engine.derived_clauses(xor_width());
3757    let mut solver = Solver::new(num_vars);
3758    for c in clauses {
3759        solver.add_clause(c.clone());
3760    }
3761    for c in derived {
3762        solver.add_clause(c);
3763    }
3764    if xor_seed() {
3765        solver.set_initial_phase(&witness);
3766    }
3767    let result = if xor_live() {
3768        // DPLL(XOR): run the live incremental Gaussian engine as a theory. Optionally restrict CDCL's
3769        // decisions to the kernel — the free variables the linear system leaves undetermined — so the
3770        // pivots are forced by Gaussian propagation rather than branched on (the search then ranges
3771        // only over the true degrees of freedom).
3772        let live = IncXor::new(num_vars, &eqs);
3773        if xor_kernel() {
3774            let decisions = live.decision_vars();
3775            solver.set_decision_vars(&decisions);
3776        }
3777        let mut theories: Vec<Box<dyn crate::cdcl::Theory>> = vec![Box::new(live)];
3778        solver.solve_with(&mut theories)
3779    } else {
3780        solver.solve()
3781    };
3782    match result {
3783        SolveResult::Sat(model) => Some(Solved {
3784            answer: Answer::Sat(model),
3785            via: Route::HybridXor,
3786            proof: Vec::new(),
3787            conflicts: solver.conflicts(),
3788        }),
3789        SolveResult::Unsat => {
3790            let proof = solver.learned().iter().map(|lc| ProofStep::Rup(lc.lits.clone())).collect();
3791            Some(Solved { answer: Answer::Unsat, via: Route::HybridXor, proof, conflicts: solver.conflicts() })
3792        }
3793    }
3794}
3795
3796/// **Fused parity + cardinality.** When a formula carries BOTH a recovered GF(2) parity substructure
3797/// ([`extract_xor`]) AND an at-most-one cardinality substructure ([`crate::lyapunov::recover_at_most_one`]),
3798/// decide it with the two live theories reasoning TOGETHER on one trail under a single
3799/// [`Solver::solve_with`] — Gaussian elimination for the parity, cardinality propagation for the counting,
3800/// the structural attack on minimal-disagreement parity that neither alone cracks. `None` unless both
3801/// substructures are genuinely present, so pure-parity ([`hybrid_xor`]) and pure-cardinality (the covering
3802/// cut) instances keep their own routes. Sound: the original clauses are solved (Boolean-complete), both
3803/// theories return only formula-entailed clauses, and a SAT model is re-checked (fail-closed). Uses the
3804/// stateless [`crate::xor_engine::XorEngine`], whose trail-sync is always correct.
3805fn fused_modular_solve(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Solved> {
3806    if num_vars == 0 {
3807        return None;
3808    }
3809    let eqs = extract_xor(num_vars, clauses);
3810    let amo = crate::lyapunov::recover_cardinality_substructure(num_vars, clauses);
3811    if eqs.is_empty() || amo.is_empty() {
3812        return None;
3813    }
3814    // Break the FULL affine × cardinality symmetry — the wreath-tower permutation break (class + family) AND
3815    // the affine parity shears. Sound (equisatisfiable); the aux variables extend the var count (they appear
3816    // in no equation, so the theories ignore them), and the answer projects back onto the original variables.
3817    // Break the ENTIRE symmetry group — permutations AND affine maps AND cross-compositions — COMPLETELY and
3818    // DYNAMICALLY via the aux-free SymmetryTheory, fused on the shared trail with parity + cardinality. No
3819    // static clauses, no aux variables.
3820    let mut solver = Solver::new(num_vars);
3821    for c in clauses {
3822        solver.add_clause(c.clone());
3823    }
3824    let mut theories: Vec<Box<dyn crate::cdcl::Theory>> = vec![
3825        Box::new(crate::xor_engine::XorEngine::new(num_vars, &eqs)),
3826        Box::new(crate::pseudo_boolean::CardinalityTheory::new(num_vars, &amo)),
3827        Box::new(crate::lyapunov::SymmetryTheory::new(num_vars, crate::lyapunov::fused_symmetry_group(num_vars, clauses))),
3828    ];
3829    match solver.solve_with(&mut theories) {
3830        SolveResult::Sat(model) => {
3831            clauses
3832                .iter()
3833                .all(|c| c.iter().any(|l| model[l.var() as usize] == l.is_positive()))
3834                .then_some(Solved {
3835                    answer: Answer::Sat(model),
3836                    via: Route::HybridXor,
3837                    proof: Vec::new(),
3838                    conflicts: solver.conflicts(),
3839                })
3840        }
3841        SolveResult::Unsat => {
3842            Some(Solved { answer: Answer::Unsat, via: Route::HybridXor, proof: Vec::new(), conflicts: solver.conflicts() })
3843        }
3844    }
3845}
3846
3847/// Whether to run the live incremental DPLL(XOR) engine during search (vs. clause-mining + phase
3848/// seeding only). Gated by `LOGOS_XOR_LIVE` while we A/B it against the mining path at par-scale.
3849fn xor_live() -> bool {
3850    std::env::var("LOGOS_XOR_LIVE").map(|s| s != "0" && !s.is_empty()).unwrap_or(false)
3851}
3852
3853/// Whether to restrict CDCL's decisions to the kernel (non-pivot) variables under the live engine.
3854/// Default on; `LOGOS_XOR_KERNEL=0` lets VSIDS branch on every variable (Gaussian as pure propagator).
3855fn xor_kernel() -> bool {
3856    std::env::var("LOGOS_XOR_KERNEL").map(|s| s != "0" && !s.is_empty()).unwrap_or(true)
3857}
3858
3859/// Whether to seed CDCL's phases with the GF(2) witness. Default on; `LOGOS_XOR_SEED=0` disables it.
3860fn xor_seed() -> bool {
3861    std::env::var("LOGOS_XOR_SEED").map(|s| s != "0" && !s.is_empty()).unwrap_or(true)
3862}
3863
3864#[cfg(test)]
3865mod tests {
3866    use super::*;
3867    use crate::families::{clique_coloring, php, tseitin_expander};
3868    use crate::rup::check_refutation;
3869
3870    #[test]
3871    fn pigeonhole_is_crushed_by_a_specialist_not_cdcl() {
3872        // PHP(20): 20 pigeons into 19 holes — exponential for resolution, but a polynomial matching
3873        // refutation. It must be decided by a structural route, never the CDCL fallback.
3874        let (cnf, _) = php(20);
3875        let solved = solve_structured(cnf.num_vars, &cnf.clauses);
3876        assert!(matches!(solved.answer, Answer::Unsat));
3877        assert_ne!(solved.via, Route::Cdcl, "PHP must be crushed structurally, got CDCL");
3878    }
3879
3880    #[test]
3881    fn massive_pigeonhole_does_not_fall_to_search() {
3882        // The headline: a large PHP a CDCL solver cannot touch is decided structurally and fast
3883        // (the test simply completing proves it did not enter exponential search).
3884        let (cnf, _) = php(50);
3885        let solved = solve_structured(cnf.num_vars, &cnf.clauses);
3886        assert!(matches!(solved.answer, Answer::Unsat));
3887        assert_ne!(solved.via, Route::Cdcl);
3888    }
3889
3890    #[test]
3891    fn clique_colouring_is_crushed_structurally() {
3892        let (cnf, _) = clique_coloring(8, 7);
3893        let solved = solve_structured(cnf.num_vars, &cnf.clauses);
3894        assert!(matches!(solved.answer, Answer::Unsat));
3895        assert_ne!(solved.via, Route::Cdcl, "clique-colouring must be crushed structurally");
3896    }
3897
3898    #[test]
3899    fn sparse_sat_is_certified_by_lll_not_search() {
3900        // Four width-4 clauses over DISJOINT variable sets: dependency degree 0, so the Lovász Local
3901        // Lemma guarantees a model. The dispatcher must certify SAT via the LLL / Moser–Tardos route
3902        // with a genuine, re-checked model — the SAT-side specialist, not the CDCL fallback.
3903        let cl = |vs: [u32; 4]| vs.iter().map(|&v| Lit::pos(v)).collect::<Vec<_>>();
3904        let clauses = vec![cl([0, 1, 2, 3]), cl([4, 5, 6, 7]), cl([8, 9, 10, 11]), cl([12, 13, 14, 15])];
3905        let solved = solve_structured(16, &clauses);
3906        assert_eq!(solved.via, Route::Lll, "a locally-sparse SAT formula must route to LLL");
3907        match &solved.answer {
3908            Answer::Sat(model) => assert!(
3909                clauses.iter().all(|c| c.iter().any(|l| model[l.var() as usize] == l.is_positive())),
3910                "the LLL/Moser–Tardos witness must satisfy every clause"
3911            ),
3912            Answer::Unsat => panic!("a satisfiable sparse formula must not be reported UNSAT"),
3913        }
3914    }
3915
3916    #[test]
3917    fn tseitin_parity_is_crushed_structurally() {
3918        let (_, cnf, _) = tseitin_expander(40, 7);
3919        let solved = solve_structured(cnf.num_vars, &cnf.clauses);
3920        assert!(matches!(solved.answer, Answer::Unsat));
3921        assert!(
3922            matches!(solved.via, Route::Parity | Route::Collapse),
3923            "Tseitin parity must go through the GF(2) route, got {:?}",
3924            solved.via
3925        );
3926    }
3927
3928    #[test]
3929    fn two_sat_is_decided_with_a_model() {
3930        // (x0 ∨ x1) ∧ (¬x0 ∨ x1) ∧ (x0 ∨ ¬x1) — 2-SAT, satisfiable (x0=x1=true).
3931        let clauses = vec![
3932            vec![Lit::new(0, true), Lit::new(1, true)],
3933            vec![Lit::new(0, false), Lit::new(1, true)],
3934            vec![Lit::new(0, true), Lit::new(1, false)],
3935        ];
3936        let solved = solve_structured(2, &clauses);
3937        assert_eq!(solved.via, Route::TwoSat);
3938        match solved.answer {
3939            Answer::Sat(m) => {
3940                for c in &clauses {
3941                    assert!(c.iter().any(|l| m[l.var() as usize] == l.is_positive()));
3942                }
3943            }
3944            Answer::Unsat => panic!("instance is SAT"),
3945        }
3946    }
3947
3948    #[test]
3949    fn horn_is_decided_with_its_least_model() {
3950        // facts a, b; and a ∧ b → c (a 3-literal Horn clause, so it is NOT 2-SAT and reaches the
3951        // Horn route). Satisfiable, least model {a,b,c}.
3952        let clauses = vec![
3953            vec![Lit::new(0, true)],
3954            vec![Lit::new(1, true)],
3955            vec![Lit::new(0, false), Lit::new(1, false), Lit::new(2, true)],
3956        ];
3957        let solved = solve_structured(3, &clauses);
3958        assert_eq!(solved.via, Route::Horn);
3959        match solved.answer {
3960            Answer::Sat(m) => assert!(m[0] && m[1] && m[2]),
3961            Answer::Unsat => panic!("instance is SAT"),
3962        }
3963    }
3964
3965    #[test]
3966    fn unstructured_sat_returns_a_model_via_cdcl() {
3967        // A 3-literal clause keeps it out of the 2-SAT / Horn classes; it is satisfiable.
3968        let clauses = vec![
3969            vec![Lit::new(0, true), Lit::new(1, true), Lit::new(2, true)],
3970            vec![Lit::new(0, false), Lit::new(1, true), Lit::new(2, false)],
3971        ];
3972        let solved = solve_structured(3, &clauses);
3973        match solved.answer {
3974            Answer::Sat(m) => {
3975                for c in &clauses {
3976                    assert!(c.iter().any(|l| m[l.var() as usize] == l.is_positive()));
3977                }
3978            }
3979            Answer::Unsat => panic!("instance is SAT"),
3980        }
3981    }
3982
3983    #[test]
3984    fn cdcl_route_carries_a_valid_rup_certificate() {
3985        // All 8 clauses over 3 vars → UNSAT. Whichever route fires, the CDCL fallback's proof must
3986        // be a valid RUP refutation; a polynomial specialist certifies internally.
3987        let clauses = vec![
3988            vec![Lit::new(0, true), Lit::new(1, true), Lit::new(2, true)],
3989            vec![Lit::new(0, true), Lit::new(1, true), Lit::new(2, false)],
3990            vec![Lit::new(0, false), Lit::new(1, false), Lit::new(2, true)],
3991            vec![Lit::new(0, false), Lit::new(1, false), Lit::new(2, false)],
3992            vec![Lit::new(0, true), Lit::new(1, false), Lit::new(2, true)],
3993            vec![Lit::new(0, false), Lit::new(1, true), Lit::new(2, false)],
3994            vec![Lit::new(0, true), Lit::new(1, false), Lit::new(2, false)],
3995            vec![Lit::new(0, false), Lit::new(1, true), Lit::new(2, true)],
3996        ];
3997        let solved = solve_structured(3, &clauses);
3998        assert!(matches!(solved.answer, Answer::Unsat));
3999        if solved.via == Route::Cdcl {
4000            let learned: Vec<Vec<Lit>> = solved.proof.iter().map(|s| s.clause().to_vec()).collect();
4001            assert!(check_refutation(3, &clauses, &learned));
4002        }
4003    }
4004
4005    /// The 2^(k-1) gadget clauses encoding XOR(vars)=rhs — each forbids one wrong-parity row.
4006    fn xor_gadget(vars: &[u32], rhs: bool) -> Vec<Vec<Lit>> {
4007        let k = vars.len();
4008        let mut clauses = Vec::new();
4009        for mask in 0u32..(1 << k) {
4010            if ((mask.count_ones() % 2) == 1) != rhs {
4011                clauses.push((0..k).map(|i| Lit::new(vars[i], (mask >> i) & 1 == 0)).collect());
4012            }
4013        }
4014        clauses
4015    }
4016
4017    #[test]
4018    fn hybrid_xor_solves_an_xor_heavy_sat_instance_with_a_valid_model() {
4019        // XOR gadgets x0⊕x1⊕x2=0, x2⊕x3=1, unit x0; plus a genuine residual clause (x1∨x3) that is
4020        // NOT a complete gadget — so CDCL must repair the seeded GF(2) witness to satisfy it.
4021        let mut clauses = xor_gadget(&[0, 1, 2], false);
4022        clauses.extend(xor_gadget(&[2, 3], true));
4023        clauses.push(vec![Lit::new(0, true)]);
4024        clauses.push(vec![Lit::new(1, true), Lit::new(3, true)]);
4025        let solved = solve_structured(4, &clauses);
4026        assert_eq!(solved.via, Route::HybridXor, "XOR-heavy SAT must take the hybrid route");
4027        match solved.answer {
4028            Answer::Sat(m) => {
4029                for c in &clauses {
4030                    assert!(c.iter().any(|l| m[l.var() as usize] == l.is_positive()), "model fails {c:?}");
4031                }
4032            }
4033            Answer::Unsat => panic!("instance is SAT"),
4034        }
4035    }
4036
4037    #[test]
4038    fn fused_route_decides_a_mixed_parity_cardinality_instance() {
4039        // Exactly-one of {0,1,2} linked by equalities to {3,4,5} under even parity: exactly-one forces an
4040        // ODD count, the parity forces EVEN — UNSAT, a MIX neither the parity route nor the covering cut
4041        // refutes alone. The fused rung (recovered XOR + recovered at-most-one, reasoned together) decides
4042        // it; verdict must be UNSAT and the fused route must actually fire (not fall through to plain CDCL).
4043        let mut clauses: Vec<Vec<Lit>> = vec![
4044            vec![Lit::new(0, true), Lit::new(1, true), Lit::new(2, true)],
4045            vec![Lit::new(0, false), Lit::new(1, false)],
4046            vec![Lit::new(0, false), Lit::new(2, false)],
4047            vec![Lit::new(1, false), Lit::new(2, false)],
4048        ];
4049        for i in 0..3u32 {
4050            clauses.extend(xor_gadget(&[i, i + 3], false)); // x_i = x_{i+3}
4051        }
4052        clauses.extend(xor_gadget(&[3, 4, 5], false)); // x3 ⊕ x4 ⊕ x5 = 0
4053        let solved = solve_comprehensive(6, &clauses);
4054        assert!(matches!(solved.answer, Answer::Unsat), "the mixed instance is UNSAT (via {:?})", solved.via);
4055        assert_eq!(solved.via, Route::HybridXor, "the fused parity+cardinality route must fire");
4056    }
4057
4058    #[test]
4059    fn xor_inconsistent_system_is_refuted_structurally() {
4060        // x0⊕x1=0 ∧ x1⊕x2=0 ∧ x0⊕x2=1 sums to 0=1 — UNSAT by Gaussian alone, never CDCL search.
4061        let mut clauses = xor_gadget(&[0, 1], false);
4062        clauses.extend(xor_gadget(&[1, 2], false));
4063        clauses.extend(xor_gadget(&[0, 2], true));
4064        let solved = solve_structured(3, &clauses);
4065        assert!(matches!(solved.answer, Answer::Unsat));
4066        assert_ne!(solved.via, Route::Cdcl, "a contradictory linear system must collapse structurally");
4067    }
4068
4069    #[test]
4070    fn mod_p_tseitin_cnf_is_lifted_to_gf_p_not_left_to_cdcl() {
4071        // The mod-3 Tseitin obstruction encoded as opaque Boolean CNF. It is invisible to the GF(2)
4072        // parity cut (the whole point of the family), so the dispatcher must RECOVER the one-hot GF(p)
4073        // system from the raw clauses and crush it by Gaussian elimination over the right field — never
4074        // fall to CDCL, which (like Z3 and Kissat) needs exponential resolution here.
4075        for &p in &[3u64, 5] {
4076            let (_, cnf, _) = crate::families::mod_p_tseitin_expander(6, p, 0xC0FFEE);
4077            let solved = solve_structured(cnf.num_vars, &cnf.clauses);
4078            assert!(matches!(solved.answer, Answer::Unsat), "mod-{p} Tseitin is UNSAT");
4079            assert_eq!(solved.via, Route::ModP, "mod-{p} CNF must be lifted to the GF(p) route");
4080            assert_eq!(solved.conflicts, 0, "the GF(p) collapse spends no search");
4081        }
4082    }
4083
4084    #[test]
4085    fn the_gf_p_route_returns_a_verified_model_on_a_satisfiable_mod_p_cnf() {
4086        // A *consistent* mod-p divergence system (total charge 0) is satisfiable; the recovered GF(p)
4087        // route must report SAT with a Boolean model that genuinely satisfies every original clause.
4088        let p = 3u64;
4089        let (_, cnf, _) = crate::families::mod_p_consistent_onehot(6, p, 0xABCD);
4090        let solved = solve_structured(cnf.num_vars, &cnf.clauses);
4091        assert_eq!(solved.via, Route::ModP, "a consistent mod-p one-hot CNF must take the GF(p) route");
4092        match &solved.answer {
4093            Answer::Sat(m) => assert!(
4094                cnf.clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
4095                "the GF(p) model must satisfy every clause"
4096            ),
4097            Answer::Unsat => panic!("a consistent mod-p system must be SAT"),
4098        }
4099    }
4100
4101    #[test]
4102    fn the_gf_p_route_never_misfires_on_unstructured_or_non_linear_cnf() {
4103        // Soundness of the lift: it must decline (recover → None) on inputs that are NOT a clean mod-p
4104        // one-hot encoding, leaving them to the honest routes. Random 3-SAT and plain PHP must not be
4105        // misrouted to ModP.
4106        let rnd = crate::families::random_3sat(30, 120, 0x5EED);
4107        let rnd_via = solve_structured(rnd.num_vars, &rnd.clauses).via;
4108        assert_ne!(rnd_via, Route::ModP);
4109        assert_ne!(rnd_via, Route::ModM, "random must not be misrouted to the composite lift either");
4110        let (php_cnf, _) = php(6);
4111        let php_via = solve_structured(php_cnf.num_vars, &php_cnf.clauses).via;
4112        assert_ne!(php_via, Route::ModP);
4113        assert_ne!(php_via, Route::ModM);
4114    }
4115
4116    #[test]
4117    fn the_gf_p_route_agrees_with_boolean_brute_force_on_tiny_instances() {
4118        // The ultimate oracle: on instances small enough to enumerate every Boolean assignment, the GF(p)
4119        // lift's verdict matches exhaustive search — on both the UNSAT (Tseitin) and SAT (consistent)
4120        // forms. K₄ at p=3 is 6 edges × 3 bits = 18 variables, a 2¹⁸ sweep.
4121        for (cnf, want_sat) in [
4122            (crate::families::mod_p_tseitin_expander(4, 3, 1).1, false),
4123            (crate::families::mod_p_consistent_onehot(4, 3, 1).1, true),
4124        ] {
4125            let solved = solve_structured(cnf.num_vars, &cnf.clauses);
4126            assert_eq!(solved.via, Route::ModP, "a tiny mod-3 instance must take the GF(p) route");
4127            let brute = (0u64..(1u64 << cnf.num_vars)).any(|code| {
4128                let asg: Vec<bool> = (0..cnf.num_vars).map(|i| (code >> i) & 1 == 1).collect();
4129                cnf.clauses.iter().all(|c| c.iter().any(|l| asg[l.var() as usize] == l.is_positive()))
4130            });
4131            assert_eq!(matches!(solved.answer, Answer::Sat(_)), brute, "GF(p) verdict must match brute force");
4132            assert_eq!(brute, want_sat, "family verdict sanity");
4133        }
4134    }
4135
4136    #[test]
4137    fn composite_modulus_onehot_cnf_is_lifted_to_zmod_m_not_left_to_cdcl() {
4138        // A mod-6 one-hot Tseitin system: COMPOSITE modulus. It is invisible to GF(2) and the obstruction
4139        // lives in the GF(3) factor. The dispatcher must recover the ℤ/6 system from the raw clauses and
4140        // decide it by CRT over the prime-power components — the composite lift — never fall to CDCL.
4141        let (_, cnf, _) = crate::families::mod_p_tseitin_expander(6, 6, 0xC0FFEE);
4142        let solved = solve_structured(cnf.num_vars, &cnf.clauses);
4143        assert!(matches!(solved.answer, Answer::Unsat), "mod-6 Tseitin is UNSAT");
4144        assert_eq!(solved.via, Route::ModM, "a composite mod-6 CNF must be lifted to the ℤ/m route");
4145        assert_eq!(solved.conflicts, 0, "the ℤ/m collapse spends no search");
4146    }
4147
4148    #[test]
4149    fn the_zmod_m_route_returns_a_verified_model_on_a_satisfiable_composite_cnf() {
4150        let (_, cnf, _) = crate::families::mod_p_consistent_onehot(6, 6, 0xABCD);
4151        let solved = solve_structured(cnf.num_vars, &cnf.clauses);
4152        assert_eq!(solved.via, Route::ModM, "a consistent composite one-hot CNF must take the ℤ/m route");
4153        match &solved.answer {
4154            Answer::Sat(m) => assert!(
4155                cnf.clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
4156                "the ℤ/m model must satisfy every clause"
4157            ),
4158            Answer::Unsat => panic!("a consistent composite system must be SAT"),
4159        }
4160    }
4161
4162    #[test]
4163    fn the_sos_route_is_sound_in_the_dispatcher() {
4164        // SoS is wired as the last specialist before CDCL (`Route::Sos`). This pins its CONTRACT in the
4165        // dispatcher: whatever it routes is genuinely UNSAT, and a satisfiable instance is never routed
4166        // to it. (Empirically it rarely *fires* — small instances are decided faster by CDCL and its
4167        // degree-2 niche is caught by the cheaper specialists — but the wiring is always sound.)
4168        fn sm(s: &mut u64) -> u64 {
4169            *s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
4170            let mut z = *s;
4171            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
4172            z ^ (z >> 31)
4173        }
4174        fn brute_sat(nv: usize, cl: &[Vec<Lit>]) -> bool {
4175            (0u64..(1u64 << nv))
4176                .any(|x| cl.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 != 0) == l.is_positive())))
4177        }
4178        let mut state = 0x5005_7777u64;
4179        for _ in 0..120 {
4180            let nv = 4usize;
4181            let m = 6 + (sm(&mut state) % 8) as usize;
4182            let mut cl: Vec<Vec<Lit>> = Vec::new();
4183            for _ in 0..m {
4184                let mut vs: Vec<u32> = Vec::new();
4185                while vs.len() < 3 {
4186                    let v = (sm(&mut state) % nv as u64) as u32;
4187                    if !vs.contains(&v) {
4188                        vs.push(v);
4189                    }
4190                }
4191                cl.push(vs.iter().map(|&v| Lit::new(v, sm(&mut state) % 2 == 0)).collect());
4192            }
4193            let solved = solve_structured(nv, &cl);
4194            let sat = brute_sat(nv, &cl);
4195            if solved.via == Route::Sos {
4196                assert!(matches!(solved.answer, Answer::Unsat), "the SoS route only ever refutes");
4197                assert!(!sat, "the SoS route must never fire on a satisfiable instance: {cl:?}");
4198            }
4199            if sat {
4200                assert_ne!(solved.via, Route::Sos, "a satisfiable instance must not be routed to SoS");
4201            }
4202        }
4203    }
4204
4205    #[test]
4206    fn solve_comprehensive_matches_brute_force() {
4207        // The full-arsenal power solver must decide correctly across a fuzz (the CDCL fallback is
4208        // complete; the heavy Nullstellensatz / symmetry-breaking routes must never change the verdict),
4209        // and every reported model must satisfy the formula.
4210        fn sm(s: &mut u64) -> u64 {
4211            *s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
4212            let mut z = *s;
4213            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
4214            z ^ (z >> 31)
4215        }
4216        fn brute_sat(nv: usize, cl: &[Vec<Lit>]) -> bool {
4217            (0u64..(1u64 << nv))
4218                .any(|x| cl.iter().all(|c| c.iter().any(|l| ((x >> l.var()) & 1 != 0) == l.is_positive())))
4219        }
4220        let mut state = 0xC0DE_5A1Du64;
4221        for _ in 0..80 {
4222            let nv = 3 + (sm(&mut state) % 3) as usize; // 3..5
4223            let m = 2 + (sm(&mut state) % 10) as usize;
4224            let mut cl: Vec<Vec<Lit>> = Vec::new();
4225            for _ in 0..m {
4226                let mut c = Vec::new();
4227                for v in 0..nv {
4228                    if sm(&mut state) % 2 == 0 {
4229                        c.push(Lit::new(v as u32, sm(&mut state) % 2 == 0));
4230                    }
4231                }
4232                if !c.is_empty() {
4233                    cl.push(c);
4234                }
4235            }
4236            if cl.is_empty() {
4237                continue;
4238            }
4239            let solved = solve_comprehensive(nv, &cl);
4240            assert_eq!(
4241                matches!(solved.answer, Answer::Sat(_)),
4242                brute_sat(nv, &cl),
4243                "solve_comprehensive verdict must match brute force via {:?}: {cl:?}",
4244                solved.via
4245            );
4246            if let Answer::Sat(m) = &solved.answer {
4247                assert!(
4248                    cl.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
4249                    "a reported model must satisfy every clause: {cl:?}"
4250                );
4251            }
4252        }
4253    }
4254
4255    #[test]
4256    fn the_symmetry_break_route_solves_a_symmetric_instance() {
4257        // clique_coloring(3,3) is SAT and richly symmetric (S₃ vertices × S₃ colours); it slips past the
4258        // cheap specialists, so the comprehensive solver's complete-symmetry-breaking route decides it
4259        // with a re-checked model.
4260        let (cnf, _) = crate::families::clique_coloring(3, 3);
4261        let s = symmetry_break_solve(cnf.num_vars, &cnf.clauses)
4262            .expect("clique colouring has a usable, phase-free symmetry group");
4263        assert_eq!(s.via, Route::SymmetryBreak);
4264        match s.answer {
4265            Answer::Sat(m) => assert!(
4266                cnf.clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
4267                "the symmetry-break route returns a valid model"
4268            ),
4269            Answer::Unsat => panic!("clique_coloring(3,3) is SAT"),
4270        }
4271    }
4272
4273    #[test]
4274    fn dynamic_sel_refutes_a_symmetric_instance_in_search() {
4275        // Dynamic in-CDCL symmetry breaking (SEL): refute PHP by amplifying learned clauses with the
4276        // symmetry group during the budgeted search — the dynamic complement to the static lex-leader,
4277        // with a proof checked internally before it is returned. (In the full dispatcher PHP is caught
4278        // earlier by the pigeonhole specialist; this exercises the SEL route directly.)
4279        let (cnf, _) = crate::families::php(5);
4280        let solved =
4281            dynamic_sel(cnf.num_vars, &cnf.clauses).expect("PHP is symmetric — dynamic SEL engages");
4282        assert_eq!(solved.via, Route::Sel);
4283        assert!(matches!(solved.answer, Answer::Unsat), "PHP(5) is UNSAT");
4284        assert!(!solved.proof.is_empty(), "SEL returns a refutation proof");
4285    }
4286
4287    #[test]
4288    fn orbital_branch_collapses_symmetric_branches_and_is_correct() {
4289        // SAT, richly symmetric: clique_coloring(3,3). The whole variable grid is one orbit under
4290        // S₃(vertices)×S₃(colours), so orbital branching collapses every "some-cell-true" branch into the
4291        // single representative branch and decides it with a re-checked model.
4292        let (sat_cnf, _) = crate::families::clique_coloring(3, 3);
4293        let s = orbital_branch_solve(sat_cnf.num_vars, &sat_cnf.clauses)
4294            .expect("a large variable orbit drives orbital branching");
4295        assert_eq!(s.via, Route::OrbitalBranch);
4296        match &s.answer {
4297            Answer::Sat(m) => assert!(
4298                sat_cnf.clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
4299                "orbital branching returns a valid model"
4300            ),
4301            Answer::Unsat => panic!("clique_coloring(3,3) is SAT"),
4302        }
4303
4304        // UNSAT, richly symmetric: clique_coloring(4,3) — K₄ cannot be 3-coloured. Orbital concludes UNSAT
4305        // only because BOTH branches (rep-true and all-orbit-false) are genuinely UNSAT.
4306        let (unsat_cnf, _) = crate::families::clique_coloring(4, 3);
4307        let u = orbital_branch_solve(unsat_cnf.num_vars, &unsat_cnf.clauses)
4308            .expect("a large variable orbit drives orbital branching");
4309        assert_eq!(u.via, Route::OrbitalBranch);
4310        assert!(matches!(u.answer, Answer::Unsat), "clique_coloring(4,3) is UNSAT");
4311
4312        // The RECURSION: clique_coloring(4,4) (16 vars) is SAT and far too symmetric to dispatch in one
4313        // split — the representative branch is itself symmetric, so orbital branching descends the
4314        // residual group level by level and still returns a re-checked model.
4315        let (big_cnf, _) = crate::families::clique_coloring(4, 4);
4316        let big = orbital_branch_solve(big_cnf.num_vars, &big_cnf.clauses)
4317            .expect("recursive orbital branching engages on the larger grid");
4318        assert_eq!(big.via, Route::OrbitalBranch);
4319        match &big.answer {
4320            Answer::Sat(m) => assert!(
4321                big_cnf.clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
4322                "the recursive orbital model is valid"
4323            ),
4324            Answer::Unsat => panic!("clique_coloring(4,4) is SAT"),
4325        }
4326
4327        // Soundness: the orbital verdict matches an independent brute-force decision across all instances.
4328        for (cnf, _) in [
4329            crate::families::clique_coloring(3, 3),
4330            crate::families::clique_coloring(4, 3),
4331            crate::families::clique_coloring(4, 4),
4332        ] {
4333            let nv = cnf.num_vars;
4334            let brute = (0u64..(1u64 << nv)).any(|x| {
4335                let a: Vec<bool> = (0..nv).map(|i| (x >> i) & 1 == 1).collect();
4336                cnf.clauses.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
4337            });
4338            let got = orbital_branch_solve(nv, &cnf.clauses).expect("orbital fires");
4339            assert_eq!(
4340                matches!(got.answer, Answer::Sat(_)),
4341                brute,
4342                "orbital-branch verdict matches brute force (nv={nv})"
4343            );
4344        }
4345    }
4346
4347    #[test]
4348    fn solve_by_symmetry_breaking_matches_brute_force() {
4349        let instances = [
4350            crate::families::clique_coloring(3, 3), // SAT
4351            crate::families::clique_coloring(4, 3), // UNSAT (K₄ not 3-colourable)
4352            crate::families::clique_coloring(4, 4), // SAT
4353        ];
4354        for (cnf, _) in instances {
4355            let nv = cnf.num_vars;
4356            let brute = (0u64..(1u64 << nv)).any(|x| {
4357                let a: Vec<bool> = (0..nv).map(|i| (x >> i) & 1 == 1).collect();
4358                cnf.clauses.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
4359            });
4360            let solved = solve_by_symmetry_breaking(nv, &cnf.clauses);
4361            assert_eq!(matches!(solved.answer, Answer::Sat(_)), brute, "break-then-solve matches brute (nv={nv})");
4362            if let Answer::Sat(m) = &solved.answer {
4363                assert!(
4364                    cnf.clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
4365                    "the projected model satisfies the original formula"
4366                );
4367            }
4368        }
4369    }
4370
4371    #[test]
4372    fn break_all_symmetry_complete_leaves_one_model_per_orbit() {
4373        // Count the distinct ORIGINAL-variable models of a (possibly aux-laden) formula by blocking each
4374        // original projection in turn.
4375        let count_original = |total: usize, cl: &[Vec<Lit>], nv: usize| -> usize {
4376            let mut working = cl.to_vec();
4377            let mut count = 0;
4378            loop {
4379                match solve_comprehensive(total, &working).answer {
4380                    Answer::Unsat => break,
4381                    Answer::Sat(m) => {
4382                        count += 1;
4383                        working.push((0..nv).map(|v| Lit::new(v as u32, !m[v])).collect());
4384                    }
4385                }
4386            }
4387            count
4388        };
4389        let n = |v| Lit::new(v, false);
4390
4391        // at-most-1-of-3 (S₃): models {000,100,010,001} → 2 orbits. Complete breaking ⇒ exactly 2 survive.
4392        let amo = vec![vec![n(0), n(1)], vec![n(0), n(2)], vec![n(1), n(2)]];
4393        let orbits_amo = models_up_to_symmetry(3, &amo, 1000).representatives.len();
4394        let (b1, t1) = break_all_symmetry_complete(3, &amo);
4395        assert_eq!(count_original(t1, &b1, 3), orbits_amo, "one model per orbit (at-most-1-of-3)");
4396
4397        // clique_coloring(3,3) (S₃×S₃): the 6 proper colourings form 1 orbit ⇒ exactly 1 model survives.
4398        let (cnf, _) = crate::families::clique_coloring(3, 3);
4399        let orbits_clq = models_up_to_symmetry(cnf.num_vars, &cnf.clauses, 1000).representatives.len();
4400        let (b2, t2) = break_all_symmetry_complete(cnf.num_vars, &cnf.clauses);
4401        assert_eq!(orbits_clq, 1, "clique(3,3)'s colourings form a single orbit");
4402        assert_eq!(count_original(t2, &b2, cnf.num_vars), 1, "complete breaking leaves exactly one model (clique)");
4403
4404        // No symmetry ⇒ returned unchanged.
4405        let asym = vec![vec![Lit::new(0, true)], vec![n(1), Lit::new(2, true)]];
4406        let (b3, t3) = break_all_symmetry_complete(3, &asym);
4407        assert_eq!((b3.len(), t3), (asym.len(), 3), "no symmetry ⇒ no breaks");
4408    }
4409
4410    #[test]
4411    fn break_all_symmetry_runs_to_a_fixpoint_soundly() {
4412        // clique_coloring(3,3): rich S₃×S₃ symmetry. The automated breaker drives it down, stays
4413        // equisatisfiable, and reaches a fixpoint.
4414        let (cnf, _) = crate::families::clique_coloring(3, 3);
4415        let before = symmetry_structure(cnf.num_vars, &cnf.clauses).order;
4416        let broken = break_all_symmetry(cnf.num_vars, &cnf.clauses);
4417        let after = symmetry_structure(cnf.num_vars, &broken).order;
4418        assert!(after < before, "the breaker reduced the symmetry group: {before} → {after}");
4419
4420        // Equisatisfiable: same verdict as the original, and the broken model satisfies the ORIGINAL.
4421        let orig = solve_comprehensive(cnf.num_vars, &cnf.clauses).answer;
4422        let brk = solve_comprehensive(cnf.num_vars, &broken).answer;
4423        assert_eq!(matches!(orig, Answer::Sat(_)), matches!(brk, Answer::Sat(_)), "breaking preserves the verdict");
4424        if let Answer::Sat(m) = &brk {
4425            assert!(
4426                cnf.clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
4427                "the broken-formula model is a genuine model of the original"
4428            );
4429        }
4430
4431        // Fixpoint: breaking the already-broken formula adds nothing.
4432        let twice = break_all_symmetry(cnf.num_vars, &broken);
4433        assert_eq!(twice.len(), broken.len(), "already at the fixpoint — re-breaking is a no-op");
4434
4435        // An asymmetric formula is returned unchanged (nothing to break).
4436        let asym = vec![vec![Lit::new(0, true)], vec![Lit::new(1, false), Lit::new(2, true)]];
4437        assert_eq!(break_all_symmetry(3, &asym).len(), asym.len(), "no symmetry ⇒ no breaks");
4438    }
4439
4440    #[test]
4441    fn class_algebra_constants_wrapper_has_the_right_shape() {
4442        let (cnf, _) = crate::families::clique_coloring(3, 3);
4443        let k = symmetry_structure(cnf.num_vars, &cnf.clauses).conjugacy_classes.unwrap(); // 9
4444        let a = class_algebra_constants(cnf.num_vars, &cnf.clauses).expect("enumerable group");
4445        assert_eq!(a.len(), k, "the class-algebra tensor is k × k × k");
4446        assert!(a.iter().all(|m| m.len() == k && m.iter().all(|r| r.len() == k)));
4447    }
4448
4449    #[test]
4450    fn character_table_wrapper_is_the_grid_groups_table() {
4451        // The variable symmetry of clique_coloring(3,3) is S₃×S₃; its character table has one row per
4452        // conjugacy class with the product degrees, satisfies Σ dᵢ² = |G|, and re-checks orthogonality.
4453        let (cnf, _) = crate::families::clique_coloring(3, 3);
4454        let t = character_table(cnf.num_vars, &cnf.clauses).expect("enumerable group");
4455        assert_eq!(t.degrees, vec![1, 1, 1, 1, 2, 2, 2, 2, 4], "S₃×S₃ irreducible degrees");
4456        assert_eq!(t.degrees.iter().map(|d| d * d).sum::<u128>(), 36, "Σ dᵢ² = |S₃×S₃|");
4457        assert_eq!(t.values.len(), t.degrees.len(), "one character per irreducible");
4458        assert!(t.values.iter().all(|row| row.len() == t.class_sizes.len()), "a value per conjugacy class");
4459        assert!(t.values.iter().any(|row| row.iter().all(|&x| x == 1)), "the trivial character is present");
4460    }
4461
4462    #[test]
4463    fn frobenius_schur_wrapper_reports_a_real_grid_group() {
4464        // S₃×S₃ is totally real, so every indicator is +1, one per irreducible.
4465        let (cnf, _) = crate::families::clique_coloring(3, 3);
4466        let fs = frobenius_schur_indicators(cnf.num_vars, &cnf.clauses).expect("enumerable group");
4467        assert_eq!(fs, vec![1; 9], "S₃×S₃: nine real irreducibles");
4468    }
4469
4470    #[test]
4471    fn isotypic_decomposition_wrapper_bridges_action_and_representation() {
4472        // clique_coloring(3,3): S₃×S₃ on 9 cells. The permutation character decomposes with the trivial
4473        // multiplicity equal to the orbit count (1, transitive) and Σ m·d = 9.
4474        let (cnf, _) = crate::families::clique_coloring(3, 3);
4475        let iso = isotypic_multiplicities(cnf.num_vars, &cnf.clauses).expect("enumerable group");
4476        let degs = symmetry_structure(cnf.num_vars, &cnf.clauses).irreducible_degrees.unwrap();
4477        assert_eq!(iso.iter().zip(&degs).map(|(m, d)| m * d).sum::<u128>(), 9, "Σ m·d = 9 cells");
4478        // The permutation character: the identity fixes all 9 cells.
4479        let pi = permutation_character(cnf.num_vars, &cnf.clauses).expect("enumerable group");
4480        assert!(pi.contains(&9), "the identity class fixes all 9 variables");
4481    }
4482
4483    #[test]
4484    fn automorphism_group_wrapper_measures_the_symmetry_of_the_symmetry() {
4485        // clique_coloring(3,3): the variable symmetry is S₃×S₃, whose automorphism group has order 72.
4486        let (cnf, _) = crate::families::clique_coloring(3, 3);
4487        assert_eq!(
4488            automorphism_group_order(cnf.num_vars, &cnf.clauses),
4489            Some(72),
4490            "|Aut(S₃×S₃)| = 72"
4491        );
4492    }
4493
4494    #[test]
4495    fn table_of_marks_wrapper_classifies_a_grid_groups_g_sets() {
4496        // clique_coloring(3,3): the variable symmetry is S₃×S₃; its table of marks is triangular with the
4497        // trivial-subgroup row giving the coset indices [G:H] and the full-group column all ones.
4498        let (cnf, _) = crate::families::clique_coloring(3, 3);
4499        let (orders, marks) = table_of_marks(cnf.num_vars, &cnf.clauses).expect("subgroup lattice in range");
4500        let k = orders.len();
4501        assert_eq!(*orders.last().unwrap(), 36, "the whole group S₃×S₃ has order 36");
4502        assert_eq!(marks[0][0], 36, "m(1, 1) = [G:1] = |G| = 36 (the regular action)");
4503        for j in 0..k {
4504            assert_eq!(marks[0][j], 36 / orders[j], "m(1, H_j) = [G : H_j]");
4505            assert_eq!(marks[j][k - 1], 1, "every subgroup fixes the one coset of G");
4506        }
4507    }
4508
4509    #[test]
4510    fn galois_class_orbits_wrapper_partitions_a_rational_grid_group() {
4511        // S₃×S₃ is rational, so every Galois orbit on its 9 classes is a singleton.
4512        let (cnf, _) = crate::families::clique_coloring(3, 3);
4513        let orbits = galois_class_orbits(cnf.num_vars, &cnf.clauses).expect("enumerable group");
4514        assert_eq!(orbits.len(), 9, "S₃×S₃ has 9 conjugacy classes");
4515        assert!(orbits.iter().all(|o| o.len() == 1), "all rational ⇒ every orbit is a singleton");
4516        let prof = symmetry_structure(cnf.num_vars, &cnf.clauses);
4517        assert_eq!(prof.rational_classes, Some(9));
4518    }
4519
4520    #[test]
4521    fn tensor_decomposition_wrapper_is_a_valid_fusion_ring() {
4522        // clique_coloring(3,3): S₃×S₃, 9 irreducibles. The fusion coefficients form a valid representation
4523        // ring — every product has the right dimension and the trivial character is the unit.
4524        let (cnf, _) = crate::families::clique_coloring(3, 3);
4525        let degs = symmetry_structure(cnf.num_vars, &cnf.clauses).irreducible_degrees.unwrap();
4526        let n = tensor_decomposition(cnf.num_vars, &cnf.clauses).expect("enumerable group");
4527        let k = degs.len();
4528        assert_eq!(n.len(), k, "a k×k×k fusion tensor");
4529        for i in 0..k {
4530            for j in 0..k {
4531                assert_eq!(
4532                    (0..k).map(|c| n[i][j][c] * degs[c]).sum::<u128>(),
4533                    degs[i] * degs[j],
4534                    "dim(χ_i ⊗ χ_j) = d_i·d_j"
4535                );
4536            }
4537        }
4538    }
4539
4540    #[test]
4541    fn equivalence_symmetry_matches_brute_force_and_reduces() {
4542        let p = |v: u32| Lit::new(v, true);
4543        let nl = |v: u32| Lit::new(v, false);
4544        let sat = |m: &[bool], cls: &[Vec<Lit>]| {
4545            cls.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive()))
4546        };
4547        let brute_equiv = |nv: usize, f: &[Vec<Lit>], s: &[Vec<Lit>]| -> bool {
4548            (0u32..(1u32 << nv)).all(|x| {
4549                let a: Vec<bool> = (0..nv).map(|i| (x >> i) & 1 == 1).collect();
4550                sat(&a, f) == sat(&a, s)
4551            })
4552        };
4553
4554        // 1. THE REDUCTION FIRES: at-most-one-of-4 is fully S₄-symmetric, so its 6 pair clauses fuse into
4555        //    ONE orbit — equivalence to itself is decided with far fewer entailment checks than the naive 12.
4556        let amo: Vec<Vec<Lit>> =
4557            (0..4u32).flat_map(|i| ((i + 1)..4).map(move |j| vec![nl(i), nl(j)])).collect();
4558        assert_eq!(amo.len(), 6);
4559        let gens = common_automorphism_generators(4, &amo, &amo);
4560        assert!(!gens.is_empty(), "at-most-one-of-4 has a nontrivial common symmetry group");
4561        let (reps, naive) = equivalence_check_counts(4, &amo, &amo);
4562        assert!(reps < naive, "the common symmetry must reduce the work: {reps} < {naive}");
4563        assert_eq!(equivalent_modulo_symmetry(4, &amo, &amo), EquivVerdict::Equivalent);
4564
4565        // 2. EQUIVALENT but syntactically different: add a clause F already entails (resolvent x1). Still ≡.
4566        let f2 = vec![vec![p(0), p(1)], vec![nl(0), p(1)]];
4567        let mut s2 = f2.clone();
4568        s2.push(vec![p(1)]);
4569        assert!(brute_equiv(2, &f2, &s2), "sanity: the resolvent makes them equivalent");
4570        assert_eq!(equivalent_modulo_symmetry(2, &f2, &s2), EquivVerdict::Equivalent);
4571
4572        // 3. INEQUIVALENT: a dropped constraint changes the function; the witness must truly distinguish.
4573        let f3 = vec![vec![p(0), p(1)], vec![nl(0), nl(1)]];
4574        let s3 = vec![vec![p(0), p(1)]];
4575        match equivalent_modulo_symmetry(2, &f3, &s3) {
4576            EquivVerdict::Differ(m) => assert_ne!(sat(&m, &f3), sat(&m, &s3), "witness must distinguish"),
4577            EquivVerdict::Equivalent => panic!("f3 and s3 are NOT equivalent"),
4578        }
4579
4580        // 4. ASYMMETRIC pair: no usable common symmetry ⇒ no orbit fusion (and the verdict is still right).
4581        let fa = vec![vec![p(0), p(1)]];
4582        let sa = vec![vec![p(0), p(1)], vec![p(1), p(2)]];
4583        let (r, t) = equivalence_check_counts(3, &fa, &sa);
4584        assert_eq!(r, t, "no common symmetry ⇒ one check per clause");
4585
4586        // 5. FUZZ: the symmetry-reduced verdict ALWAYS equals brute force, and every witness distinguishes.
4587        fn xs(s: &mut u64) -> u64 {
4588            *s ^= *s << 13;
4589            *s ^= *s >> 7;
4590            *s ^= *s << 17;
4591            *s
4592        }
4593        let nv = 4usize;
4594        let mk = |s: &mut u64| -> Vec<Vec<Lit>> {
4595            let m = (xs(s) % 5) as usize; // 0..=4 clauses
4596            (0..m)
4597                .map(|_| {
4598                    let w = 1 + (xs(s) % 2) as usize; // width 1 or 2
4599                    (0..w).map(|_| Lit::new((xs(s) % nv as u64) as u32, xs(s) & 1 == 0)).collect()
4600                })
4601                .collect()
4602        };
4603        let mut seed = 0x1234_5678_9abc_def0u64;
4604        for _ in 0..400 {
4605            let f = mk(&mut seed);
4606            let s = mk(&mut seed);
4607            let want = brute_equiv(nv, &f, &s);
4608            match equivalent_modulo_symmetry(nv, &f, &s) {
4609                EquivVerdict::Equivalent => {
4610                    assert!(want, "claimed equivalent but brute says differ: F={f:?} S={s:?}")
4611                }
4612                EquivVerdict::Differ(m) => {
4613                    assert!(!want, "claimed differ but brute says equivalent: F={f:?} S={s:?}");
4614                    assert_ne!(sat(&m, &f), sat(&m, &s), "witness must distinguish F and S");
4615                }
4616            }
4617        }
4618    }
4619
4620    #[test]
4621    fn optimization_symmetry_matches_brute_force_and_reduces() {
4622        let p = |v: u32| Lit::new(v, true);
4623        let sat = |m: &[bool], cls: &[Vec<Lit>]| {
4624            cls.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive()))
4625        };
4626        let brute_min = |nv: usize, f: &[Vec<Lit>], w: &[i64]| -> Option<i64> {
4627            (0u32..(1u32 << nv))
4628                .filter_map(|x| {
4629                    let a: Vec<bool> = (0..nv).map(|i| (x >> i) & 1 == 1).collect();
4630                    sat(&a, f).then(|| (0..nv).filter(|&i| a[i]).map(|i| w[i]).sum::<i64>())
4631                })
4632                .min()
4633        };
4634
4635        // 1. THE REDUCTION FIRES: "at least one of 4", minimise the count (all weights 1). Fully S₄-symmetric
4636        //    (clauses AND weights), so the optimal "exactly one true" models form one orbit — far fewer
4637        //    candidate models to enumerate than the naive 15.
4638        let amo1 = vec![vec![p(0), p(1), p(2), p(3)]];
4639        let w1 = vec![1i64; 4];
4640        let (opt, m) = optimize_modulo_symmetry(4, &amo1, &w1).expect("F is satisfiable");
4641        assert_eq!(opt, 1, "minimum is one variable true");
4642        assert!(sat(&m, &amo1), "witness satisfies F");
4643        assert_eq!(m.iter().filter(|&&b| b).count(), 1, "witness has weight 1");
4644        let (with, without) = optimize_enumeration_counts(4, &amo1, &w1);
4645        assert!(with < without, "symmetry must shrink the enumeration: {with} < {without}");
4646
4647        // 2. UNSAT hard clauses ⇒ no optimum.
4648        assert_eq!(optimize_modulo_symmetry(2, &[vec![p(0)], vec![Lit::new(0, false)]], &[1, 1]), None);
4649
4650        // 3. ASYMMETRIC weights kill the symmetry ⇒ no reduction (distinct weights ⇒ no objective-preserving
4651        //    permutation), and the optimum is still correct.
4652        let w_distinct = vec![1i64, 2, 3, 4];
4653        assert!(optimization_symmetry_generators(4, &amo1, &w_distinct).is_empty(), "distinct weights ⇒ no sym");
4654        let (wa, wo) = optimize_enumeration_counts(4, &amo1, &w_distinct);
4655        assert_eq!(wa, wo, "no usable symmetry ⇒ no reduction");
4656
4657        // 4. FUZZ: the symmetry-reduced optimum ALWAYS equals brute force, and the witness is a real model of
4658        //    F achieving exactly that weight.
4659        fn xs(s: &mut u64) -> u64 {
4660            *s ^= *s << 13;
4661            *s ^= *s >> 7;
4662            *s ^= *s << 17;
4663            *s
4664        }
4665        let nv = 4usize;
4666        let mut seed = 0xC0FFEE_1234_5678u64;
4667        for _ in 0..400 {
4668            let m = (xs(&mut seed) % 5) as usize;
4669            let f: Vec<Vec<Lit>> = (0..m)
4670                .map(|_| {
4671                    let wclause = 1 + (xs(&mut seed) % 2) as usize;
4672                    (0..wclause).map(|_| Lit::new((xs(&mut seed) % nv as u64) as u32, xs(&mut seed) & 1 == 0)).collect()
4673                })
4674                .collect();
4675            // Weights in -2..=2 (symmetric instances arise when repeats let an automorphism preserve them).
4676            let weights: Vec<i64> = (0..nv).map(|_| (xs(&mut seed) % 5) as i64 - 2).collect();
4677            let want = brute_min(nv, &f, &weights);
4678            match optimize_modulo_symmetry(nv, &f, &weights) {
4679                None => assert!(want.is_none(), "claimed UNSAT but a model exists: F={f:?}"),
4680                Some((opt, wit)) => {
4681                    assert_eq!(Some(opt), want, "optimum must match brute force: F={f:?} w={weights:?}");
4682                    assert!(sat(&wit, &f), "witness must satisfy F");
4683                    let ww: i64 = (0..nv).filter(|&i| wit[i]).map(|i| weights[i]).sum();
4684                    assert_eq!(ww, opt, "witness must achieve the optimum");
4685                }
4686            }
4687        }
4688    }
4689
4690    #[test]
4691    fn fractional_automorphism_is_a_doubly_stochastic_commuting_relaxation() {
4692        let edge = |a: u32, b: u32| vec![Lit::new(a, true), Lit::new(b, true)];
4693
4694        // The canonical (block-averaging over the coarsest equitable partition) matrix is ALWAYS a valid
4695        // fractional automorphism — a doubly-stochastic matrix commuting with the co-occurrence graph.
4696        let (php, _) = crate::families::php(4);
4697        let (clq, _) = crate::families::clique_coloring(3, 3);
4698        let c6: Vec<Vec<Lit>> = (0..6).map(|i| edge(i, (i + 1) % 6)).collect();
4699        for (nv, cl) in [(php.num_vars, php.clauses.clone()), (clq.num_vars, clq.clauses.clone()), (6, c6.clone())] {
4700            let part = fractional_automorphism(nv, &cl);
4701            assert!(is_fractional_automorphism(nv, &cl, &part), "the equitable partition commutes with A");
4702            // Non-trivial: symmetric formulas admit a fractional automorphism that is NOT a permutation.
4703            let cells = part.iter().copied().max().map_or(0, |m| m + 1);
4704            assert!(cells < nv, "a non-discrete partition ⇒ a genuine (non-permutation) fractional automorphism");
4705            // Fractional symmetry contains integer symmetry: every automorphism orbit lies within one cell.
4706            let gens = crate::sym_break::variable_automorphism_generators(nv, &cl).unwrap_or_default();
4707            for orbit in crate::permgroup::orbits(nv, &gens) {
4708                assert!(orbit.iter().all(|&v| part[v] == part[orbit[0]]), "orbit ⊆ fractional-automorphism cell");
4709            }
4710        }
4711
4712        // The identity partition (each variable its own cell) is the trivial fractional automorphism (B = I).
4713        let (nv, _) = (clq.num_vars, ());
4714        assert!(is_fractional_automorphism(nv, &clq.clauses, &(0..nv).collect::<Vec<_>>()), "identity is a fractional automorphism");
4715
4716        // REJECTION: on the path 0–1–2–3, {0,1} | {2,3} is NOT equitable (vertex 0 and vertex 1 see different
4717        // cell-distributions), so it is not a fractional automorphism — but the reflection {0,3} | {1,2} is.
4718        let p4 = vec![edge(0, 1), edge(1, 2), edge(2, 3)];
4719        assert!(!is_fractional_automorphism(4, &p4, &[0, 0, 1, 1]), "a non-equitable partition is rejected");
4720        assert!(is_fractional_automorphism(4, &p4, &[0, 1, 1, 0]), "the reflection partition is equitable");
4721        assert_eq!(fractional_automorphism(4, &p4), vec![0, 1, 1, 0], "P₄'s coarsest equitable partition is the reflection");
4722
4723        // FUZZ: the canonical partition is always a valid fractional automorphism.
4724        fn xs(s: &mut u64) -> u64 {
4725            *s ^= *s << 13;
4726            *s ^= *s >> 7;
4727            *s ^= *s << 17;
4728            *s
4729        }
4730        let n = 5usize;
4731        let mut seed = 0x0FAC_0000_1234_9999u64;
4732        for _ in 0..200 {
4733            let m = (xs(&mut seed) % 6) as usize;
4734            let cl: Vec<Vec<Lit>> = (0..m)
4735                .map(|_| {
4736                    let w = 1 + (xs(&mut seed) % 3) as usize;
4737                    (0..w).map(|_| Lit::new((xs(&mut seed) % n as u64) as u32, xs(&mut seed) & 1 == 0)).collect()
4738                })
4739                .collect();
4740            let part = fractional_automorphism(n, &cl);
4741            assert!(is_fractional_automorphism(n, &cl, &part), "canonical partition must always commute: {cl:?}");
4742        }
4743    }
4744
4745    #[test]
4746    fn color_refinement_over_approximates_the_orbit_partition() {
4747        let p = |v: u32| Lit::new(v, true);
4748
4749        // 1. Vertex-transitive symmetric families collapse to ONE cell (one orbit ⇒ one colour).
4750        let (php, _) = crate::families::php(4);
4751        assert_eq!(color_refinement_cells(php.num_vars, &php.clauses), 1, "PHP(4) variables are all alike");
4752        let (clq, _) = crate::families::clique_coloring(3, 3);
4753        assert_eq!(color_refinement_cells(clq.num_vars, &clq.clauses), 1, "clique(3,3) variables are all alike");
4754
4755        // 2. A structurally-distinguished variable gets its own cell — a polynomial asymmetry certificate.
4756        let f = vec![vec![p(0)], vec![p(1), p(2)]]; // x0 in a unit, x1/x2 symmetric in a binary
4757        let cells = color_refinement(3, &f);
4758        assert_ne!(cells[0], cells[1], "the unit variable is distinguished from the binary pair");
4759        assert_eq!(cells[1], cells[2], "x1 and x2 are interchangeable");
4760        assert_eq!(provably_asymmetric_variables(3, &f), vec![0], "x0 is provably fixed by every automorphism");
4761        // …and the detected symmetry indeed fixes the singleton-cell variable.
4762        for g in crate::sym_break::variable_automorphism_generators(3, &f).unwrap_or_default() {
4763            assert_eq!(g[0], 0, "an automorphism must fix the provably-asymmetric variable");
4764        }
4765
4766        // 3. THE THEOREM, on every instance incl. a fuzz: orbit(v) ⊆ cell(v) (each orbit is monochromatic),
4767        //    and the equitable partition is never finer than the orbit partition (cells ≤ orbits).
4768        let check = |nv: usize, cl: &[Vec<Lit>]| {
4769            let gens = crate::sym_break::variable_automorphism_generators(nv, cl).unwrap_or_default();
4770            let orbits = crate::permgroup::orbits(nv, &gens);
4771            let cells = color_refinement(nv, cl);
4772            for orbit in &orbits {
4773                let c0 = cells[orbit[0]];
4774                assert!(orbit.iter().all(|&v| cells[v] == c0), "orbit {orbit:?} must be monochromatic: {cells:?}");
4775            }
4776            assert!(
4777                color_refinement_cells(nv, cl) <= orbits.len(),
4778                "the equitable partition is coarser than the orbit partition"
4779            );
4780        };
4781        check(php.num_vars, &php.clauses);
4782        check(clq.num_vars, &clq.clauses);
4783        check(3, &f);
4784
4785        fn xs(s: &mut u64) -> u64 {
4786            *s ^= *s << 13;
4787            *s ^= *s >> 7;
4788            *s ^= *s << 17;
4789            *s
4790        }
4791        let nv = 5usize;
4792        let mut seed = 0xA5A5_1234_DEAD_BEEFu64;
4793        for _ in 0..200 {
4794            let m = (xs(&mut seed) % 6) as usize;
4795            let cl: Vec<Vec<Lit>> = (0..m)
4796                .map(|_| {
4797                    let w = 1 + (xs(&mut seed) % 3) as usize;
4798                    (0..w).map(|_| Lit::new((xs(&mut seed) % nv as u64) as u32, xs(&mut seed) & 1 == 0)).collect()
4799                })
4800                .collect();
4801            check(nv, &cl);
4802        }
4803    }
4804
4805    #[test]
4806    fn two_wl_over_approximates_orbitals_and_beats_one_wl() {
4807        let edge = |a: u32, b: u32| vec![Lit::new(a, true), Lit::new(b, true)]; // an edge as a 2-clause
4808
4809        // THE HEADLINE: a 6-cycle vs two triangles. Both are 2-regular, so 1-WL sees a single cell for each
4810        // and CANNOT tell them apart — but 2-WL distinguishes them by their pair structure.
4811        let c6 = vec![edge(0, 1), edge(1, 2), edge(2, 3), edge(3, 4), edge(4, 5), edge(5, 0)];
4812        let two_tri = vec![edge(0, 1), edge(1, 2), edge(0, 2), edge(3, 4), edge(4, 5), edge(3, 5)];
4813        assert_eq!(color_refinement_cells(6, &c6), 1, "1-WL: C₆ is one cell");
4814        assert_eq!(color_refinement_cells(6, &two_tri), 1, "1-WL: 2·C₃ is one cell — same as C₆");
4815        assert_ne!(
4816            two_wl_fingerprint(6, &c6),
4817            two_wl_fingerprint(6, &two_tri),
4818            "2-WL SEPARATES C₆ from 2·C₃ where 1-WL cannot"
4819        );
4820
4821        // 2-WL refines 1-WL: the diagonal pair-coloring is at least as fine as the 1-WL vertex coloring.
4822        let diag_refines_1wl = |nv: usize, cl: &[Vec<Lit>]| {
4823            let wl1 = color_refinement(nv, cl);
4824            let pc = two_wl_pair_colors(nv, cl);
4825            for a in 0..nv {
4826                for b in 0..nv {
4827                    if pc[a][a] == pc[b][b] {
4828                        assert_eq!(wl1[a], wl1[b], "2-WL diagonal must refine 1-WL");
4829                    }
4830                }
4831            }
4832        };
4833        diag_refines_1wl(6, &c6);
4834        diag_refines_1wl(6, &two_tri);
4835
4836        // THE THEOREM (the 2-orbit analogue of #42): orbital(i,j) ⊆ paircell(i,j) — every orbital is
4837        // monochromatic under 2-WL — and pair-cells are never finer than orbitals. Checked incl. a fuzz.
4838        let check = |nv: usize, cl: &[Vec<Lit>]| {
4839            let gens = crate::sym_break::variable_automorphism_generators(nv, cl).unwrap_or_default();
4840            let orbitals = crate::permgroup::orbitals(nv, &gens);
4841            let pc = two_wl_pair_colors(nv, cl);
4842            for orbital in &orbitals {
4843                let (i0, j0) = orbital[0];
4844                let c0 = pc[i0][j0];
4845                assert!(orbital.iter().all(|&(i, j)| pc[i][j] == c0), "orbital must be monochromatic");
4846            }
4847            assert!(two_wl_pair_cells(nv, cl) <= orbitals.len(), "pair-cells ≤ orbitals");
4848        };
4849        let (php, _) = crate::families::php(4);
4850        let (clq, _) = crate::families::clique_coloring(3, 3);
4851        check(php.num_vars, &php.clauses);
4852        check(clq.num_vars, &clq.clauses);
4853        check(6, &c6);
4854        check(6, &two_tri);
4855        // clique(3,3): 1-WL is one cell, but 2-WL recovers the rank-4 orbital structure 1-WL is blind to.
4856        assert_eq!(color_refinement_cells(clq.num_vars, &clq.clauses), 1);
4857        assert!(two_wl_pair_cells(clq.num_vars, &clq.clauses) >= 4, "2-WL sees the 4 orbitals of the grid");
4858
4859        fn xs(s: &mut u64) -> u64 {
4860            *s ^= *s << 13;
4861            *s ^= *s >> 7;
4862            *s ^= *s << 17;
4863            *s
4864        }
4865        let nv = 5usize;
4866        let mut seed = 0x2B0C_1A7E_55AA_F00Du64;
4867        for _ in 0..120 {
4868            let m = (xs(&mut seed) % 6) as usize;
4869            let cl: Vec<Vec<Lit>> = (0..m)
4870                .map(|_| {
4871                    let w = 1 + (xs(&mut seed) % 3) as usize;
4872                    (0..w).map(|_| Lit::new((xs(&mut seed) % nv as u64) as u32, xs(&mut seed) & 1 == 0)).collect()
4873                })
4874                .collect();
4875            check(nv, &cl);
4876        }
4877    }
4878
4879    #[test]
4880    fn association_scheme_multiplicities_are_the_eigenspace_dimensions() {
4881        let edge = |a: u32, b: u32| vec![Lit::new(a, true), Lit::new(b, true)];
4882        let (clq, _) = crate::families::clique_coloring(3, 3);
4883        let c6: Vec<Vec<Lit>> = (0..6).map(|i| edge(i, (i + 1) % 6)).collect();
4884
4885        // clique(3,3) is the multiplicity-free S₃×S₃ action on 9 cells: its scheme multiplicities are the
4886        // degrees of the four constituent irreducibles — triv⊗triv=1, triv⊗std=2, std⊗triv=2, std⊗std=4.
4887        assert_eq!(
4888            association_scheme_multiplicities(clq.num_vars, &clq.clauses),
4889            Some(vec![1, 2, 2, 4]),
4890            "clique(3,3): eigenspace dimensions = S₃×S₃ constituent degrees"
4891        );
4892
4893        // In general the multiplicities partition the space and include the trivial eigenspace (dim 1).
4894        for (nv, cl) in [(clq.num_vars, clq.clauses.clone()), (6, c6.clone())] {
4895            let m = association_scheme_multiplicities(nv, &cl).expect("commutative scheme has multiplicities");
4896            assert_eq!(m.iter().sum::<u128>(), nv as u128, "Σ multiplicities = number of vertices");
4897            assert!(m.iter().all(|&x| x >= 1), "each eigenspace is non-empty");
4898            assert_eq!(m[0], 1, "the smallest (trivial) eigenspace has dimension 1");
4899            // #multiplicities = #eigenspaces = scheme rank.
4900            assert_eq!(m.len(), coherent_rank(nv, &cl).unwrap(), "one multiplicity per eigenspace");
4901        }
4902    }
4903
4904    #[test]
4905    fn association_scheme_eigenmatrix_is_the_scheme_character_table() {
4906        let edge = |a: u32, b: u32| vec![Lit::new(a, true), Lit::new(b, true)];
4907        let (clq, _) = crate::families::clique_coloring(3, 3);
4908        let c6: Vec<Vec<Lit>> = (0..6).map(|i| edge(i, (i + 1) % 6)).collect();
4909
4910        // clique(3,3) is the rook's-graph scheme (rank 4); C₆ is the cyclic scheme (rank 4). Both are
4911        // commutative, so each has an eigenmatrix P — the scheme's character table.
4912        assert_eq!(coherent_rank(clq.num_vars, &clq.clauses), Some(4), "clique(3,3): 4 relations");
4913
4914        for (nv, cl) in [(clq.num_vars, clq.clauses.clone()), (6, c6.clone())] {
4915            let d = coherent_rank(nv, &cl).unwrap();
4916            let (p, pm) = association_scheme_eigenmatrix(nv, &cl).expect("a commutative scheme has an eigenmatrix");
4917            assert_eq!(pm.len(), d, "P has one row per common eigenspace (d × d)");
4918            assert!(pm.iter().all(|r| r.len() == d));
4919
4920            // Each row is a 1-dimensional representation of the coherent algebra:
4921            // P[m][i]·P[m][j] = Σ_k a_{ijk}·P[m][k]  (re-verified against the intersection numbers).
4922            let (_, a) = coherent_configuration_constants(nv, &cl).unwrap();
4923            for row in &pm {
4924                for i in 0..d {
4925                    for j in 0..d {
4926                        let lhs = row[i] as u128 * row[j] as u128 % p as u128;
4927                        let rhs = (0..d)
4928                            .map(|k| (a[i][j][k] % p as u128) * row[k] as u128 % p as u128)
4929                            .sum::<u128>()
4930                            % p as u128;
4931                        assert_eq!(lhs, rhs, "row must be an algebra homomorphism");
4932                    }
4933                }
4934            }
4935
4936            // The valencies (all-ones eigenvalue) partition the vertices and include the diagonal (valency 1),
4937            // and that valency vector is one of P's rows.
4938            let valency: Vec<u128> = (0..d).map(|i| (0..d).map(|j| a[i][j][0]).sum()).collect();
4939            assert_eq!(valency.iter().sum::<u128>(), nv as u128, "Σ valencies = number of vertices");
4940            assert!(valency.contains(&1), "the diagonal relation has valency 1");
4941            assert!(
4942                pm.iter().any(|row| (0..d).all(|i| row[i] as u128 == valency[i] % p as u128)),
4943                "the valency vector is a row of P (the trivial eigenspace)"
4944            );
4945        }
4946    }
4947
4948    #[test]
4949    fn coherent_configuration_is_a_genuine_association_scheme() {
4950        let edge = |a: u32, b: u32| vec![Lit::new(a, true), Lit::new(b, true)];
4951
4952        // The stabilized 2-WL coloring is a coherent configuration: its intersection numbers are
4953        // well-defined (the function returns Some only after checking EVERY pair), and they satisfy the
4954        // basic algebra identities. Verified on several structures + a fuzz.
4955        let (php, _) = crate::families::php(4);
4956        let (clq, _) = crate::families::clique_coloring(3, 3);
4957        let c6 = vec![edge(0, 1), edge(1, 2), edge(2, 3), edge(3, 4), edge(4, 5), edge(5, 0)];
4958
4959        let check = |nv: usize, cl: &[Vec<Lit>]| {
4960            let (d, p) = coherent_configuration_constants(nv, cl).expect("2-WL is coherent");
4961            assert_eq!(d, two_wl_pair_cells(nv, cl), "rank = number of basis relations");
4962            // Σ_{i,j} p[i][j][k] = n for every relation k (every intermediate z lands in exactly one cell).
4963            for k in 0..d {
4964                let total: u128 = (0..d).flat_map(|i| (0..d).map(move |j| (i, j))).map(|(i, j)| p[i][j][k]).sum();
4965                assert_eq!(total, nv as u128, "Σ_ij p[i][j][k] counts all n intermediate points");
4966            }
4967            // Transpose closure: the reverse of a basis relation is again a single basis relation.
4968            let pc = two_wl_pair_colors(nv, cl);
4969            let mut transpose = vec![usize::MAX; d];
4970            for i in 0..nv {
4971                for j in 0..nv {
4972                    let (r, rt) = (pc[i][j], pc[j][i]);
4973                    if transpose[r] == usize::MAX {
4974                        transpose[r] = rt;
4975                    } else {
4976                        assert_eq!(transpose[r], rt, "R_r^T must be a single relation");
4977                    }
4978                }
4979            }
4980            d
4981        };
4982        check(php.num_vars, &php.clauses);
4983        let dclq = check(clq.num_vars, &clq.clauses);
4984        check(6, &c6);
4985
4986        // clique(3,3) is the 3×3 rook's graph: S₃×S₃ is transitive with rank 4, so the coherent
4987        // configuration has exactly 4 relations (diagonal, same-row, same-column, different-both) — and it
4988        // matches the group's orbital count (Schurian).
4989        assert_eq!(dclq, 4, "the rook's graph scheme has 4 relations");
4990        let clq_gens = crate::sym_break::variable_automorphism_generators(clq.num_vars, &clq.clauses).unwrap();
4991        assert_eq!(dclq, crate::permgroup::orbitals(clq.num_vars, &clq_gens).len(), "Schurian: relations = orbitals");
4992
4993        // A concrete intersection number on C₆ (vertices 0..5 in a cycle): the adjacency relation is the
4994        // colour of an edge pair (0,1) and the distance-2 relation that of (0,2). Two vertices at distance 2
4995        // share exactly ONE common neighbour, so p[adjacency][adjacency][distance-2] = 1.
4996        let pc6 = two_wl_pair_colors(6, &c6);
4997        let (_d6, p6) = coherent_configuration_constants(6, &c6).unwrap();
4998        let adj = pc6[0][1];
4999        let dist2 = pc6[0][2];
5000        assert_ne!(adj, dist2, "2-WL separates adjacency from distance-2 in C₆");
5001        assert_eq!(p6[adj][adj][dist2], 1, "C₆: vertices at distance 2 share exactly one common neighbour");
5002
5003        fn xs(s: &mut u64) -> u64 {
5004            *s ^= *s << 13;
5005            *s ^= *s >> 7;
5006            *s ^= *s << 17;
5007            *s
5008        }
5009        let nv = 5usize;
5010        let mut seed = 0x0C0E_4E17_C0FF_EE00u64;
5011        for _ in 0..100 {
5012            let m = (xs(&mut seed) % 6) as usize;
5013            let cl: Vec<Vec<Lit>> = (0..m)
5014                .map(|_| {
5015                    let w = 1 + (xs(&mut seed) % 3) as usize;
5016                    (0..w).map(|_| Lit::new((xs(&mut seed) % nv as u64) as u32, xs(&mut seed) & 1 == 0)).collect()
5017                })
5018                .collect();
5019            // The key invariant: 2-WL always yields a coherent configuration (Some, not None).
5020            assert!(coherent_configuration_constants(nv, &cl).is_some(), "2-WL must always be coherent");
5021        }
5022    }
5023
5024    #[test]
5025    fn three_wl_over_approximates_3_orbits_and_beats_two_wl() {
5026        let edge = |a: usize, b: usize| vec![Lit::new(a as u32, true), Lit::new(b as u32, true)];
5027
5028        // Build a 6-regular Cayley graph on ℤ₄×ℤ₄ (16 vertices v = 4*r + c) from a connection set.
5029        let cayley = |conn: &[(i32, i32)]| -> Vec<Vec<Lit>> {
5030            let idx = |r: i32, c: i32| (((r.rem_euclid(4)) * 4 + c.rem_euclid(4)) as usize);
5031            let mut edges = std::collections::BTreeSet::new();
5032            for r in 0..4 {
5033                for c in 0..4 {
5034                    for &(dr, dc) in conn {
5035                        let (a, b) = (idx(r, c), idx(r + dr, c + dc));
5036                        if a < b {
5037                            edges.insert((a, b));
5038                        }
5039                    }
5040                }
5041            }
5042            edges.into_iter().map(|(a, b)| edge(a, b)).collect()
5043        };
5044        // The 4×4 rook's graph (= K₄□K₄): same row or same column. SRG(16,6,2,2).
5045        let rook = cayley(&[(1, 0), (2, 0), (3, 0), (0, 1), (0, 2), (0, 3)]);
5046        // The Shrikhande graph: connection set ±(1,0), ±(0,1), ±(1,1). Also SRG(16,6,2,2).
5047        let shrikhande = cayley(&[(1, 0), (3, 0), (0, 1), (0, 3), (1, 1), (3, 3)]);
5048
5049        // THE HEADLINE: both are strongly regular with the SAME parameters, so 2-WL produces identical
5050        // colorings (same fingerprint) and CANNOT tell them apart — but 3-WL distinguishes them.
5051        assert_eq!(
5052            two_wl_fingerprint(16, &rook),
5053            two_wl_fingerprint(16, &shrikhande),
5054            "2-WL cannot separate two SRG(16,6,2,2) graphs"
5055        );
5056        assert_ne!(
5057            three_wl_fingerprint(16, &rook),
5058            three_wl_fingerprint(16, &shrikhande),
5059            "3-WL SEPARATES the rook's graph from the Shrikhande graph"
5060        );
5061
5062        // THE THEOREM (the 3-orbit analogue of #42/#43): every 3-orbit is monochromatic under 3-WL.
5063        let check = |nv: usize, cl: &[Vec<Lit>]| {
5064            let gens = crate::sym_break::variable_automorphism_generators(nv, cl).unwrap_or_default();
5065            let tw = three_wl_colors(nv, cl);
5066            for orbit in crate::permgroup::orbits_on_tuples(nv, &gens, 3) {
5067                let t0 = &orbit[0];
5068                let c0 = tw[t0[0]][t0[1]][t0[2]];
5069                assert!(orbit.iter().all(|t| tw[t[0]][t[1]][t[2]] == c0), "3-orbit must be monochromatic");
5070            }
5071        };
5072        let (clq, _) = crate::families::clique_coloring(3, 3);
5073        let c6: Vec<Vec<Lit>> = (0..6).map(|i| edge(i, (i + 1) % 6)).collect();
5074        check(clq.num_vars, &clq.clauses);
5075        check(6, &c6);
5076
5077        fn xs(s: &mut u64) -> u64 {
5078            *s ^= *s << 13;
5079            *s ^= *s >> 7;
5080            *s ^= *s << 17;
5081            *s
5082        }
5083        let nv = 5usize;
5084        let mut seed = 0x33C0_FFEE_0033_0033u64;
5085        for _ in 0..60 {
5086            let m = (xs(&mut seed) % 6) as usize;
5087            let cl: Vec<Vec<Lit>> = (0..m)
5088                .map(|_| {
5089                    let w = 1 + (xs(&mut seed) % 3) as usize;
5090                    (0..w).map(|_| Lit::new((xs(&mut seed) % nv as u64) as u32, xs(&mut seed) & 1 == 0)).collect()
5091                })
5092                .collect();
5093            check(nv, &cl);
5094        }
5095    }
5096
5097    #[test]
5098    fn canonical_form_decides_formula_isomorphism() {
5099        let edge = |a: usize, b: usize| vec![Lit::new(a as u32, true), Lit::new(b as u32, true)];
5100        let relabel = |cl: &[Vec<Lit>], perm: &[usize]| -> Vec<Vec<Lit>> {
5101            cl.iter()
5102                .map(|c| c.iter().map(|l| Lit::new(perm[l.var() as usize] as u32, l.is_positive())).collect())
5103                .collect()
5104        };
5105
5106        // THE DEFINING PROPERTY: canonical form is an isomorphism invariant. Relabelling the variables by any
5107        // permutation leaves the canonical form unchanged.
5108        let c6: Vec<Vec<Lit>> = (0..6).map(|i| edge(i, (i + 1) % 6)).collect();
5109        let perm = [3usize, 5, 0, 2, 4, 1];
5110        assert_eq!(
5111            canonical_form(6, &c6),
5112            canonical_form(6, &relabel(&c6, &perm)),
5113            "isomorphic formulas share a canonical form"
5114        );
5115        assert_eq!(formulas_isomorphic(6, &c6, &relabel(&c6, &perm)), Some(true));
5116
5117        // COMPLETENESS: canonical form separates C₆ from two triangles — where 1-WL is blind (one cell each).
5118        let two_tri = vec![edge(0, 1), edge(1, 2), edge(0, 2), edge(3, 4), edge(4, 5), edge(3, 5)];
5119        assert_eq!(color_refinement_cells(6, &c6), color_refinement_cells(6, &two_tri), "1-WL cannot tell them apart");
5120        assert_eq!(formulas_isomorphic(6, &c6, &two_tri), Some(false), "but canonical form CAN");
5121        assert_ne!(canonical_form(6, &c6), canonical_form(6, &two_tri));
5122
5123        // A path and a star on 4 vertices are non-isomorphic trees; canonical form distinguishes them.
5124        let path4 = vec![edge(0, 1), edge(1, 2), edge(2, 3)];
5125        let star4 = vec![edge(0, 1), edge(0, 2), edge(0, 3)];
5126        assert_eq!(formulas_isomorphic(4, &path4, &star4), Some(false));
5127
5128        // FUZZ: for random formulas and random permutations, F and π(F) ALWAYS share a canonical form, and a
5129        // structural change (dropping a clause) is detected as non-isomorphic when it truly changes the form.
5130        fn xs(s: &mut u64) -> u64 {
5131            *s ^= *s << 13;
5132            *s ^= *s >> 7;
5133            *s ^= *s << 17;
5134            *s
5135        }
5136        let nv = 6usize;
5137        let mut seed = 0xCA90_F00D_1234_5678u64;
5138        for _ in 0..120 {
5139            let m = (xs(&mut seed) % 7) as usize;
5140            let f: Vec<Vec<Lit>> = (0..m)
5141                .map(|_| {
5142                    let w = 1 + (xs(&mut seed) % 2) as usize;
5143                    (0..w).map(|_| Lit::new((xs(&mut seed) % nv as u64) as u32, xs(&mut seed) & 1 == 0)).collect()
5144                })
5145                .collect();
5146            // A random permutation via Fisher–Yates.
5147            let mut perm: Vec<usize> = (0..nv).collect();
5148            for i in (1..nv).rev() {
5149                let j = (xs(&mut seed) % (i as u64 + 1)) as usize;
5150                perm.swap(i, j);
5151            }
5152            let fp = relabel(&f, &perm);
5153            assert_eq!(canonical_form(nv, &f), canonical_form(nv, &fp), "F ≅ π(F): F={f:?} perm={perm:?}");
5154            assert_eq!(formulas_isomorphic(nv, &f, &fp), Some(true));
5155        }
5156    }
5157
5158    #[test]
5159    fn weighted_model_count_is_exact_and_symmetry_accelerated() {
5160        let p = |v: u32| Lit::new(v, true);
5161        let nl = |v: u32| Lit::new(v, false);
5162        let sat = |m: &[bool], cls: &[Vec<Lit>]| {
5163            cls.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive()))
5164        };
5165        let brute = |nv: usize, f: &[Vec<Lit>], w: &[(i64, i64)]| -> i128 {
5166            (0u32..(1u32 << nv))
5167                .filter_map(|x| {
5168                    let a: Vec<bool> = (0..nv).map(|i| (x >> i) & 1 == 1).collect();
5169                    sat(&a, f).then(|| (0..nv).map(|i| if a[i] { w[i].1 as i128 } else { w[i].0 as i128 }).product::<i128>())
5170                })
5171                .sum()
5172        };
5173
5174        // Unit weights ⇒ the weighted count is just the model count.
5175        let amo = vec![vec![p(0), p(1), p(2)]]; // at-least-one-of-3: 7 models
5176        let ones = vec![(1i64, 1i64); 3];
5177        assert_eq!(weighted_model_count(3, &amo, &ones), 7, "unit weights ⇒ #models");
5178        assert_eq!(weighted_model_count(3, &amo, &ones), brute(3, &amo, &ones));
5179
5180        // THE ACCELERATION: a fully S₃-symmetric instance fuses its models into few orbits, so the count
5181        // takes far fewer solves than there are models.
5182        let (solves, models) = weighted_model_count_solve_counts(3, &amo);
5183        assert_eq!(models, 7, "7 models of at-least-one-of-3");
5184        assert!(solves < models, "symmetry must reduce the solves: {solves} < {models}");
5185
5186        // Weighted, symmetric weights: Z must match brute force exactly.
5187        let w_sym = vec![(2i64, 3i64); 3];
5188        assert_eq!(weighted_model_count(3, &amo, &w_sym), brute(3, &amo, &w_sym));
5189
5190        // UNSAT formula ⇒ empty product sum = 0.
5191        assert_eq!(weighted_model_count(2, &[vec![p(0)], vec![nl(0)]], &[(1, 1), (1, 1)]), 0);
5192
5193        // FUZZ: arbitrary formulas and arbitrary (asymmetric) weights — the symmetry-accelerated count is
5194        // ALWAYS the exact weighted model count (every model's true weight is summed; symmetry only groups
5195        // the search).
5196        fn xs(s: &mut u64) -> u64 {
5197            *s ^= *s << 13;
5198            *s ^= *s >> 7;
5199            *s ^= *s << 17;
5200            *s
5201        }
5202        let nv = 4usize;
5203        let mut seed = 0x5EED_4321_FACE_0001u64;
5204        for _ in 0..200 {
5205            let m = (xs(&mut seed) % 5) as usize;
5206            let f: Vec<Vec<Lit>> = (0..m)
5207                .map(|_| {
5208                    let wd = 1 + (xs(&mut seed) % 2) as usize;
5209                    (0..wd).map(|_| Lit::new((xs(&mut seed) % nv as u64) as u32, xs(&mut seed) & 1 == 0)).collect()
5210                })
5211                .collect();
5212            let w: Vec<(i64, i64)> = (0..nv).map(|_| ((xs(&mut seed) % 4) as i64, (xs(&mut seed) % 4) as i64)).collect();
5213            assert_eq!(weighted_model_count(nv, &f, &w), brute(nv, &f, &w), "F={f:?} w={w:?}");
5214        }
5215    }
5216
5217    #[test]
5218    fn assignment_weight_inventory_splits_orbits_by_weight() {
5219        let (cnf, _) = crate::families::clique_coloring(3, 3);
5220        let inv = assignment_weight_inventory(cnf.num_vars, &cnf.clauses).expect("enumerable group");
5221        assert_eq!(inv.len(), 10, "weights 0..=9");
5222        assert_eq!(inv.iter().sum::<u128>(), 36, "sums to the assignment-orbit count (36 binary 3×3 matrices)");
5223        assert_eq!(inv[0], 1, "one all-false assignment");
5224        assert_eq!(inv[1], 1, "all single-one matrices are row/column equivalent");
5225        assert_eq!(inv[9], 1, "one all-true assignment");
5226        // The sum agrees with the profile's scalar assignment_orbits.
5227        assert_eq!(
5228            Some(inv.iter().sum::<u128>()),
5229            symmetry_structure(cnf.num_vars, &cnf.clauses).assignment_orbits,
5230            "the inventory sums to the profile's assignment_orbits"
5231        );
5232
5233        // No symmetry ⇒ the binomial distribution (every assignment is its own orbit).
5234        let asym = vec![vec![Lit::new(0, true)], vec![Lit::new(1, false), Lit::new(2, true)]];
5235        assert_eq!(assignment_weight_inventory(3, &asym), Some(vec![1, 3, 3, 1]), "no symmetry ⇒ C(3,w)");
5236    }
5237
5238    #[test]
5239    fn pb_coefficient_symmetry_profiles_through_the_same_ladder() {
5240        use crate::pseudo_boolean::PbConstraint;
5241        // 3·x0 + 3·x1 + 3·x2 + 5·x3 ≥ 6: {x0,x1,x2} share coefficient 3 (interchangeable, S₃), x3 (coeff 5)
5242        // is alone. The coefficient-symmetry group is S₃ on {0,1,2}.
5243        let c = PbConstraint::new_weighted(&[(0, 3, true), (1, 3, true), (2, 3, true), (3, 5, true)], 6);
5244        let prof = pb_symmetry_profile(4, &[c]);
5245        assert_eq!(prof.order, 6, "S₃ on the three weight-3 variables");
5246        assert_eq!(prof.num_orbits, 2, "two orbits: {{x0,x1,x2}} and the fixed point x3");
5247        assert!(!prof.abelian, "S₃ is non-abelian");
5248        assert_eq!(prof.solvable, Some(true), "S₃ is solvable");
5249        assert_eq!(prof.coherent_rank, None, "the coefficient profile has no clauses ⇒ no scheme rank");
5250
5251        // All-distinct coefficients ⇒ no coefficient symmetry ⇒ the trivial profile.
5252        let distinct = PbConstraint::new_weighted(&[(0, 1, true), (1, 2, true), (2, 3, true)], 3);
5253        assert_eq!(pb_symmetry_profile(3, &[distinct]).order, 1, "distinct weights ⇒ trivial group");
5254    }
5255
5256    #[test]
5257    fn symmetry_structure_profiles_the_variable_group() {
5258        let p = |v| Lit::new(v, true);
5259        let n = |v| Lit::new(v, false);
5260
5261        // exactly-one-of-3: the variable group is S₃ on 3 points — sharply 3-transitive, primitive, rank 2.
5262        let exactly1 = vec![
5263            vec![p(0), p(1), p(2)],
5264            vec![n(0), n(1)], vec![n(0), n(2)], vec![n(1), n(2)],
5265        ];
5266        let prof = symmetry_structure(3, &exactly1);
5267        assert_eq!(prof.order, 6, "|S₃| = 6");
5268        assert_eq!(prof.num_orbits, 1, "transitive on the 3 cells");
5269        assert_eq!(prof.rank, 2, "S₃ is 2-transitive ⇒ rank 2");
5270        assert_eq!(prof.coherent_rank, Some(2), "coherent (scheme) rank matches the orbital rank here");
5271        assert!(prof.coherent_rank.unwrap() <= prof.rank, "coherent rank ≤ orbital rank");
5272        assert_eq!(prof.transitivity, 3, "S₃ is 3-transitive on 3 points");
5273        assert!(prof.primitive, "S₃ on 3 points is primitive");
5274        assert_eq!(prof.blocks, None, "a primitive group has no block system");
5275        assert!(!prof.abelian, "S₃ is non-abelian");
5276        assert_eq!(prof.solvable, Some(true), "S₃ is solvable");
5277        assert_eq!(prof.nilpotent, Some(false), "S₃ is solvable but NOT nilpotent");
5278        assert_eq!(prof.derived_length, Some(2), "S₃ has derived length 2");
5279        assert_eq!(prof.nilpotency_class, None, "S₃ is not nilpotent ⇒ no nilpotency class");
5280        assert_eq!(prof.derived_order, 3, "[S₃,S₃] = A₃ has order 3");
5281        assert_eq!(prof.conjugacy_classes, Some(3), "S₃ has 3 conjugacy classes (= 3 irreps)");
5282        assert_eq!(prof.center_order, Some(1), "S₃ has a trivial centre");
5283        assert_eq!(prof.exponent, Some(6), "S₃ has exponent 6 (lcm of orders 1,2,3)");
5284        assert_eq!(prof.subgroups, Some(6), "S₃ has 6 subgroups");
5285        assert_eq!(prof.simple, Some(false), "S₃ is not simple (A₃ is normal)");
5286        assert_eq!(prof.composition_factors, Some(vec![2, 3]), "S₃ = C₂, C₃");
5287        assert_eq!(prof.sylow, Some(vec![(2, 3), (3, 1)]), "S₃: 3 Sylow-2, 1 Sylow-3");
5288        assert_eq!(prof.real_classes, Some(3), "S₃: all 3 classes (= irreps) are real");
5289        assert_eq!(prof.rational_classes, Some(3), "S₃ is rational: all 3 classes rational");
5290        assert_eq!(prof.automorphism_order, Some(6), "|Aut(S₃)| = 6 (S₃ is complete)");
5291        assert_eq!(prof.outer_automorphism_order, Some(1), "Out(S₃) = 1");
5292        assert_eq!(prof.irreducible_degrees, Some(vec![1, 1, 2]), "S₃ irreps: trivial, sign, 2-dim standard");
5293        assert_eq!(prof.frobenius_schur, Some(vec![1, 1, 1]), "S₃ is totally real ⇒ all indicators +1");
5294        {
5295            // The permutation rep of S₃ on its 3 variables = trivial ⊕ standard: Σ m·d = 3, Σ m² = rank,
5296            // and the trivial multiplicity is the orbit count (Burnside).
5297            let iso = prof.isotypic_multiplicities.as_ref().expect("S₃ isotypic decomposition");
5298            let degs = prof.irreducible_degrees.as_ref().unwrap();
5299            assert_eq!(iso.iter().zip(degs).map(|(m, d)| m * d).sum::<u128>(), 3, "Σ m·d = 3 variables");
5300            assert_eq!(iso.iter().map(|m| m * m).sum::<u128>(), prof.rank as u128, "Σ m² = rank");
5301        }
5302
5303        // clique_coloring(3,3): the variable group is S₃(vertices) × S₃(colours) on the 3×3 grid — order 36,
5304        // transitive but only 1-transitive, IMPRIMITIVE (rows/cols are blocks), rank 4 (diagonal + same-row
5305        // + same-col + different-both).
5306        let (clique, _) = crate::families::clique_coloring(3, 3);
5307        let cp = symmetry_structure(clique.num_vars, &clique.clauses);
5308        assert_eq!(cp.order, 36, "|S₃ × S₃| = 36");
5309        assert_eq!(cp.num_orbits, 1, "transitive on the 9 cells");
5310        assert_eq!(cp.rank, 4, "the grid action has rank 4");
5311        assert_eq!(cp.coherent_rank, Some(4), "clique(3,3): the coherent scheme has 4 relations");
5312        assert_eq!(cp.transitivity, 1, "transitive but not 2-transitive");
5313        assert!(!cp.primitive, "the grid action is imprimitive");
5314        assert!(cp.blocks.is_some(), "rows/columns form a block system");
5315        assert!(!cp.abelian, "S₃ × S₃ is non-abelian");
5316        assert_eq!(cp.solvable, Some(true), "S₃ × S₃ is solvable");
5317        assert_eq!(cp.nilpotent, Some(false), "S₃ × S₃ is not nilpotent (S₃ isn't)");
5318        assert_eq!(cp.derived_order, 9, "[S₃×S₃, S₃×S₃] = A₃ × A₃ has order 9");
5319        assert_eq!(cp.conjugacy_classes, Some(9), "S₃×S₃ has 3·3 = 9 conjugacy classes");
5320        assert_eq!(cp.center_order, Some(1), "S₃×S₃ has a trivial centre");
5321        assert_eq!(cp.rational_classes, Some(9), "S₃×S₃ is rational: all 9 classes rational");
5322        // Aut(S₃×S₃) = Aut(S₃) ≀ C₂ = (S₃×S₃)⋊C₂, order 72; Inn = 36 (trivial centre) ⇒ Out = C₂.
5323        assert_eq!(cp.automorphism_order, Some(72), "|Aut(S₃×S₃)| = 72");
5324        assert_eq!(cp.outer_automorphism_order, Some(2), "Out(S₃×S₃) = C₂ (factor swap)");
5325        assert_eq!(cp.assignment_orbits, Some(36), "2⁹ assignments up to S₃×S₃ = 36 binary 3×3 matrices");
5326        assert_eq!(cp.abelianization, Some((4, 2)), "(S₃×S₃)ᵃᵇ = C₂×C₂ (order 4, exponent 2)");
5327        assert!(cp.subgroups.is_some(), "the S₃×S₃ subgroup lattice is computed");
5328        assert_eq!(cp.simple, Some(false), "S₃×S₃ is not simple");
5329        assert_eq!(cp.composition_factors, Some(vec![2, 2, 3, 3]), "S₃×S₃ = C₂², C₃² (product 36)");
5330        assert_eq!(cp.sylow, Some(vec![(2, 9), (3, 1)]), "S₃×S₃: 3² = 9 Sylow-2, 1 Sylow-3");
5331        // Irreducibles of a direct product are the pairwise products of the factors' irreducibles:
5332        // {1,1,2} ⊗ {1,1,2} = {1,1,1,1,2,2,2,2,4}, and Σ dᵢ² = 4·1 + 4·4 + 16 = 36 = |G|.
5333        assert_eq!(
5334            cp.irreducible_degrees,
5335            Some(vec![1, 1, 1, 1, 2, 2, 2, 2, 4]),
5336            "S₃×S₃ irreps = products of the S₃ irreps"
5337        );
5338        assert_eq!(
5339            cp.frobenius_schur,
5340            Some(vec![1, 1, 1, 1, 1, 1, 1, 1, 1]),
5341            "S₃×S₃ is totally real ⇒ all nine indicators +1"
5342        );
5343        {
5344            // The permutation rep on the 9 grid cells decomposes with Σ m·d = 9 and ⟨π,π⟩ = rank = 4.
5345            let iso = cp.isotypic_multiplicities.as_ref().expect("S₃×S₃ isotypic decomposition");
5346            let degs = cp.irreducible_degrees.as_ref().unwrap();
5347            assert_eq!(iso.iter().zip(degs).map(|(m, d)| m * d).sum::<u128>(), 9, "Σ m·d = 9 cells");
5348            assert_eq!(iso.iter().map(|m| m * m).sum::<u128>(), cp.rank as u128, "⟨π,π⟩ = rank = 4");
5349        }
5350
5351        // No symmetry → the trivial profile.
5352        let asym = vec![vec![p(0)], vec![n(1), p(2)]];
5353        let ap = symmetry_structure(3, &asym);
5354        assert_eq!(ap.order, 1, "no symmetry ⇒ trivial group");
5355        assert!(ap.coherent_rank.is_some(), "the scheme rank is always computed from the clauses");
5356        assert_eq!(ap.irreducible_degrees, Some(vec![1]), "trivial group: one trivial irreducible");
5357        assert_eq!(ap.frobenius_schur, Some(vec![1]), "trivial group: its character is real");
5358        assert_eq!(
5359            ap.isotypic_multiplicities,
5360            Some(vec![3]),
5361            "trivial group on 3 vars: the perm rep is 3 copies of the trivial irreducible"
5362        );
5363        assert_eq!(ap.rational_classes, Some(1), "trivial group: its one class is rational");
5364        assert_eq!(ap.automorphism_order, Some(1), "trivial group: Aut is trivial");
5365        assert_eq!(ap.outer_automorphism_order, Some(1), "trivial group: Out is trivial");
5366    }
5367
5368    #[test]
5369    fn models_up_to_symmetry_enumerates_orbits_and_counts_exactly() {
5370        let p = |v| Lit::new(v, true);
5371        let n = |v| Lit::new(v, false);
5372
5373        // Independent oracle: (#models, #orbits) under the variable-symmetry generators, by brute force.
5374        let oracle = |nv: usize, cl: &[Vec<Lit>]| -> (u128, usize) {
5375            let models: Vec<Vec<bool>> = (0u64..(1u64 << nv))
5376                .filter_map(|x| {
5377                    let a: Vec<bool> = (0..nv).map(|i| (x >> i) & 1 == 1).collect();
5378                    cl.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive())).then_some(a)
5379                })
5380                .collect();
5381            let gens = crate::sym_break::variable_automorphism_generators(nv, cl).unwrap_or_default();
5382            let model_set: std::collections::HashSet<Vec<bool>> = models.iter().cloned().collect();
5383            let mut seen = std::collections::HashSet::new();
5384            let mut orbits = 0;
5385            for m in &models {
5386                if seen.contains(m) {
5387                    continue;
5388                }
5389                orbits += 1;
5390                let mut stack = vec![m.clone()];
5391                while let Some(cur) = stack.pop() {
5392                    if !seen.insert(cur.clone()) {
5393                        continue;
5394                    }
5395                    for g in &gens {
5396                        let mut pm = vec![false; nv];
5397                        for v in 0..nv {
5398                            pm[g[v]] = cur[v];
5399                        }
5400                        if model_set.contains(&pm) && !seen.contains(&pm) {
5401                            stack.push(pm);
5402                        }
5403                    }
5404                }
5405            }
5406            (models.len() as u128, orbits)
5407        };
5408
5409        // exactly-one-of-3 (one-hot): 3 models, all one orbit under S₃.
5410        let exactly1 = vec![
5411            vec![p(0), p(1), p(2)],
5412            vec![n(0), n(1)], vec![n(0), n(2)], vec![n(1), n(2)],
5413        ];
5414        // at-most-one-of-3: 4 models {000, 100, 010, 001} → 2 orbits ({000}, the three weight-1).
5415        let atmost1 = vec![vec![n(0), n(1)], vec![n(0), n(2)], vec![n(1), n(2)]];
5416
5417        for cl in [&exactly1, &atmost1] {
5418            let (m_exact, m_orbits) = oracle(3, cl);
5419            let sc = models_up_to_symmetry(3, cl, 1000);
5420            assert!(sc.exhaustive, "the small instance is enumerated to exhaustion");
5421            assert_eq!(sc.total_models, m_exact, "exact model count = sum of orbit sizes");
5422            assert_eq!(sc.representatives.len(), m_orbits, "one representative per orbit");
5423            // Cross-check the orbit count against Burnside — two independent routes must agree.
5424            if let Some(burnside) = crate::sym_break::count_models_modulo_symmetry(3, cl) {
5425                assert_eq!(sc.representatives.len(), burnside, "enumeration agrees with Burnside");
5426            }
5427            // Every representative is a genuine model, and they lie in distinct orbits (all distinct).
5428            for r in &sc.representatives {
5429                assert!(cl.iter().all(|c| c.iter().any(|l| r[l.var() as usize] == l.is_positive())), "valid model");
5430            }
5431            let distinct: std::collections::HashSet<&Vec<bool>> = sc.representatives.iter().collect();
5432            assert_eq!(distinct.len(), sc.representatives.len(), "representatives are distinct");
5433        }
5434
5435        // Specific counts, to pin the numbers.
5436        assert_eq!(models_up_to_symmetry(3, &exactly1, 1000).total_models, 3);
5437        assert_eq!(models_up_to_symmetry(3, &exactly1, 1000).representatives.len(), 1);
5438        assert_eq!(models_up_to_symmetry(3, &atmost1, 1000).total_models, 4);
5439        assert_eq!(models_up_to_symmetry(3, &atmost1, 1000).representatives.len(), 2);
5440    }
5441
5442    #[test]
5443    fn declared_symmetry_is_verified_then_broken() {
5444        let p = |v| Lit::new(v, true);
5445        let brute = |cl: &[Vec<Lit>], nv: usize| {
5446            (0u64..(1u64 << nv)).any(|x| {
5447                let a: Vec<bool> = (0..nv).map(|i| (x >> i) & 1 == 1).collect();
5448                cl.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
5449            })
5450        };
5451
5452        // clique_coloring(3,3), x[v][c] = 3v+c. A declared vertex swap 0↔1 is a genuine symmetry — verified
5453        // and used.
5454        let (cnf, _) = crate::families::clique_coloring(3, 3);
5455        let nv = cnf.num_vars;
5456        let mut vswap: Vec<usize> = (0..nv).collect();
5457        for c in 0..3 {
5458            vswap.swap(c, 3 + c);
5459        }
5460        assert!(is_declared_symmetry(nv, &cnf.clauses, &vswap), "a vertex swap is a genuine clique symmetry");
5461        let s = solve_with_declared_symmetry(nv, &cnf.clauses, &[vswap]);
5462        assert_eq!(s.via, Route::DeclaredSymmetry);
5463        match &s.answer {
5464            Answer::Sat(m) => assert!(cnf.clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive()))),
5465            Answer::Unsat => panic!("clique_coloring(3,3) is SAT"),
5466        }
5467
5468        // A BOGUS declared generator (a valid permutation but NOT a symmetry) is rejected — the verdict is
5469        // unaffected (a wrong declaration cannot corrupt the result).
5470        let mut bogus: Vec<usize> = (0..nv).collect();
5471        bogus.swap(0, 5); // x[0][0] ↔ x[1][2] is not a system symmetry
5472        assert!(!is_declared_symmetry(nv, &cnf.clauses, &bogus), "a non-symmetry must be rejected");
5473        let wb = solve_with_declared_symmetry(nv, &cnf.clauses, &[bogus]);
5474        assert!(matches!(wb.answer, Answer::Sat(_)), "a bogus declaration must not corrupt the SAT verdict");
5475        if let Answer::Sat(m) = &wb.answer {
5476            assert!(cnf.clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())));
5477        }
5478
5479        // A SEMANTIC declared symmetry (not syntactic): a↔b in (a∨x)(b∨x)(a∨x∨y). Accepted via implication.
5480        let f = vec![vec![p(0), p(2)], vec![p(1), p(2)], vec![p(0), p(2), p(3)]];
5481        let mut ab: Vec<usize> = (0..4).collect();
5482        ab.swap(0, 1);
5483        assert!(is_declared_symmetry(4, &f, &ab), "a↔b is a semantic symmetry, verified by implication");
5484        let sem = solve_with_declared_symmetry(4, &f, &[ab]);
5485        assert_eq!(sem.via, Route::DeclaredSymmetry);
5486        assert_eq!(matches!(sem.answer, Answer::Sat(_)), brute(&f, 4), "declared-symmetry verdict matches brute force");
5487
5488        // A malformed generator (wrong length) is rejected without panicking; the verdict is still correct.
5489        assert!(!is_declared_symmetry(4, &f, &[0usize, 1]), "a wrong-length permutation is rejected");
5490        let mf = solve_with_declared_symmetry(4, &f, &[vec![0usize, 1]]);
5491        assert_eq!(
5492            matches!(mf.answer, Answer::Sat(_)),
5493            matches!(solve_comprehensive(4, &f).answer, Answer::Sat(_)),
5494            "a malformed declaration is dropped, verdict unchanged"
5495        );
5496    }
5497
5498    #[test]
5499    fn almost_symmetry_breaks_a_near_miss_conditionally() {
5500        let p = |v| Lit::new(v, true);
5501        let brute = |cl: &[Vec<Lit>], nv: usize| {
5502            (0u64..(1u64 << nv)).any(|x| {
5503                let a: Vec<bool> = (0..nv).map(|i| (x >> i) & 1 == 1).collect();
5504                cl.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
5505            })
5506        };
5507        // a=0,b=1,x=2,y=3. (a∨x)(b∨x) is symmetric in a,b; the extra (a∨y) breaks it, and its swap (b∨y)
5508        // is NOT implied — so a↔b is an ALMOST-symmetry (one broken clause), not a semantic one.
5509        let f = vec![vec![p(0), p(2)], vec![p(1), p(2)], vec![p(0), p(3)]];
5510
5511        // It is NOT a semantic symmetry (the broken image is not implied).
5512        let (sem, _) = semantic_symmetry_pairs(4, &f);
5513        assert!(!sem.contains(&(0, 1)), "a,b is not a SEMANTIC symmetry here: {sem:?}");
5514
5515        // It IS an almost-symmetry: a↔b breaks exactly one clause.
5516        let almost = almost_symmetry_pairs(4, &f, 2);
5517        assert!(
5518            almost.iter().any(|(a, b, imgs)| *a == 0 && *b == 1 && imgs.len() == 1),
5519            "a↔b breaks exactly one clause: {almost:?}"
5520        );
5521
5522        // Solved correctly — the conditional (guarded) break is sound.
5523        let s = almost_symmetry_solve(4, &f).expect("an almost-symmetry is detected and conditionally broken");
5524        assert_eq!(s.via, Route::AlmostSymmetry);
5525        assert_eq!(matches!(s.answer, Answer::Sat(_)), brute(&f, 4), "almost-symmetry verdict matches brute force");
5526        if let Answer::Sat(m) = &s.answer {
5527            assert!(f.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())), "valid model");
5528        }
5529
5530        // UNSAT path: force every variable off — (a∨x) cannot hold. The conditional break preserves UNSAT.
5531        let mut un = f.clone();
5532        un.extend([
5533            vec![Lit::new(0, false)],
5534            vec![Lit::new(1, false)],
5535            vec![Lit::new(2, false)],
5536            vec![Lit::new(3, false)],
5537        ]);
5538        let u = almost_symmetry_solve(4, &un).expect("almost-symmetry still detected");
5539        assert_eq!(u.via, Route::AlmostSymmetry);
5540        assert!(matches!(u.answer, Answer::Unsat), "all variables off makes (a∨x) UNSAT");
5541        assert_eq!(matches!(u.answer, Answer::Sat(_)), brute(&un, 4), "UNSAT verdict matches brute force");
5542    }
5543
5544    #[test]
5545    fn semantic_symmetry_breaks_a_non_syntactic_interchange() {
5546        let p = |v| Lit::new(v, true);
5547        // a=0, b=1, x=2, y=3. (a∨x) ∧ (b∨x) ∧ (a∨x∨y): the third clause is IMPLIED by (a∨x), so it is
5548        // redundant — F's models are symmetric in a,b. But its swap (b∨x∨y) is not a clause, so swapping
5549        // a,b does NOT preserve the clause set: a,b are SEMANTICALLY but not SYNTACTICALLY interchangeable.
5550        let f = vec![vec![p(0), p(2)], vec![p(1), p(2)], vec![p(0), p(2), p(3)]];
5551
5552        // The semantic detector finds (a,b), flagged non-syntactic.
5553        let (pairs, non_syntactic) = semantic_symmetry_pairs(4, &f);
5554        assert!(pairs.contains(&(0, 1)) && non_syntactic, "a,b are a semantic, non-syntactic symmetry: {pairs:?}");
5555
5556        // Confirm it really is non-syntactic: swapping a,b changes the clause set.
5557        let canon_set = |swap: bool| -> std::collections::HashSet<Vec<(u32, bool)>> {
5558            f.iter()
5559                .map(|c| {
5560                    let mut k: Vec<(u32, bool)> = c
5561                        .iter()
5562                        .map(|l| {
5563                            let v = l.var() as usize;
5564                            let nv = if swap && v == 0 { 1 } else if swap && v == 1 { 0 } else { v };
5565                            (nv as u32, l.is_positive())
5566                        })
5567                        .collect();
5568                    k.sort_unstable();
5569                    k
5570                })
5571                .collect()
5572        };
5573        assert_ne!(canon_set(true), canon_set(false), "the a↔b swap changes the clause set — not syntactic");
5574
5575        // It is broken and solved correctly.
5576        let s = semantic_symmetry_solve(4, &f).expect("a semantic symmetry is detected and broken");
5577        assert_eq!(s.via, Route::SemanticSymmetry);
5578        let brute = (0u64..16).any(|x| {
5579            let a: Vec<bool> = (0..4).map(|i| (x >> i) & 1 == 1).collect();
5580            f.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
5581        });
5582        assert_eq!(matches!(s.answer, Answer::Sat(_)), brute, "semantic-symmetry verdict matches brute force");
5583        if let Answer::Sat(m) = &s.answer {
5584            assert!(f.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())), "valid model");
5585        }
5586
5587        // Declines when the only symmetry is already syntactic: (a∨x)∧(b∨x), a↔b preserves the clause set,
5588        // so symmetry_break_solve already covers it and the semantic route bows out.
5589        let syntactic = vec![vec![p(0), p(2)], vec![p(1), p(2)]];
5590        assert!(
5591            semantic_symmetry_solve(3, &syntactic).is_none(),
5592            "a purely syntactic symmetry is left to the syntactic route"
5593        );
5594    }
5595
5596    #[test]
5597    fn nested_block_tower_breaks_multidimensional_symmetry() {
5598        let p = |v| Lit::new(v, true);
5599        let brute = |nv: usize, cl: &[Vec<Lit>]| -> bool {
5600            (0u64..(1u64 << nv)).any(|x| {
5601                let a: Vec<bool> = (0..nv).map(|i| (x >> i) & 1 == 1).collect();
5602                cl.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
5603            })
5604        };
5605
5606        // One block level (2-D), SAT: clique_coloring(3,3) — vertex × colour, phase-free.
5607        let (sat, _) = crate::families::clique_coloring(3, 3);
5608        let s = nested_symmetry_solve(sat.num_vars, &sat.clauses).expect("a grid symmetry has a block system");
5609        assert_eq!(s.via, Route::NestedSymmetry);
5610        match &s.answer {
5611            Answer::Sat(m) => assert!(sat.clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive()))),
5612            Answer::Unsat => panic!("clique_coloring(3,3) is SAT"),
5613        }
5614        assert_eq!(matches!(s.answer, Answer::Sat(_)), brute(sat.num_vars, &sat.clauses), "verdict matches brute");
5615
5616        // One block level, UNSAT: clique_coloring(4,3) — K₄ is not 3-colourable.
5617        let (unsat, _) = crate::families::clique_coloring(4, 3);
5618        let u = nested_symmetry_solve(unsat.num_vars, &unsat.clauses).expect("a grid symmetry has a block system");
5619        assert_eq!(u.via, Route::NestedSymmetry);
5620        assert!(matches!(u.answer, Answer::Unsat), "clique_coloring(4,3) is UNSAT");
5621        assert_eq!(matches!(u.answer, Answer::Sat(_)), brute(unsat.num_vars, &unsat.clauses), "verdict matches brute");
5622
5623        // MULTI-LEVEL TOWER: the cube graph Q₃ (8 vertices, "cover every edge" = xᵤ ∨ xᵥ per edge). It is
5624        // vertex-transitive, phase-free, and imprimitive with NESTED blocks (antipodal pairs ⊂ … ⊂ whole),
5625        // so the tower ascends past the single block system a 2-D break would stop at. SAT, broken soundly.
5626        let mut cube: Vec<Vec<Lit>> = Vec::new();
5627        for v in 0u32..8 {
5628            for b in 0..3 {
5629                let w = v ^ (1 << b);
5630                if v < w {
5631                    cube.push(vec![p(v), p(w)]);
5632                }
5633            }
5634        }
5635        let c = nested_symmetry_solve(8, &cube).expect("the cube's nested block tower engages");
5636        assert_eq!(c.via, Route::NestedSymmetry);
5637        match &c.answer {
5638            Answer::Sat(m) => assert!(cube.iter().all(|cl| cl.iter().any(|l| m[l.var() as usize] == l.is_positive()))),
5639            Answer::Unsat => panic!("covering every cube edge is SAT (e.g. all true)"),
5640        }
5641        assert_eq!(matches!(c.answer, Answer::Sat(_)), brute(8, &cube), "the nested-tower verdict matches brute force");
5642    }
5643
5644    #[test]
5645    fn symmetry_revealed_by_simplification_is_unlocked_and_broken() {
5646        let p = |v| Lit::new(v, true);
5647        let n = |v| Lit::new(v, false);
5648        // Raw F lacks the x2↔x3 symmetry (the ¬x0 tag sits only on x2's clause); propagating x0=T strips
5649        // the tag, revealing (x1∨x2)∧(x1∨x3) — symmetric in x2,x3. The route detects the unlocked
5650        // symmetry, solves the residual with the arsenal, and re-applies x0=T.
5651        let unlock = vec![vec![p(0)], vec![p(1), p(2), n(0)], vec![p(1), p(3)]];
5652        let s = symmetry_via_simplification_solve(4, &unlock).expect("simplification unlocks symmetry");
5653        assert_eq!(s.via, Route::SymmetrySimplify);
5654        match &s.answer {
5655            Answer::Sat(m) => {
5656                assert!(unlock.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())));
5657                assert!(m[0], "the forced literal x0 is re-applied to the model");
5658            }
5659            Answer::Unsat => panic!("the instance is SAT"),
5660        }
5661        let brute = (0u64..16).any(|x| {
5662            let a: Vec<bool> = (0..4).map(|i| (x >> i) & 1 == 1).collect();
5663            unlock.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
5664        });
5665        assert_eq!(matches!(s.answer, Answer::Sat(_)), brute, "verdict matches brute force");
5666
5667        // Declines when the symmetry is already visible on the raw formula (no unit hides it).
5668        let already = vec![vec![p(0)], vec![p(1), p(2)], vec![p(1), p(3)]];
5669        assert!(
5670            symmetry_via_simplification_solve(4, &already).is_none(),
5671            "symmetry already present on the raw formula ⟹ nothing unlocked"
5672        );
5673
5674        // A unit-propagation conflict is reported as UNSAT.
5675        let conflict = vec![vec![p(0)], vec![p(1)], vec![n(0), n(1)]];
5676        let u = symmetry_via_simplification_solve(2, &conflict).expect("BCP reaches a conflict");
5677        assert_eq!(u.via, Route::SymmetrySimplify);
5678        assert!(matches!(u.answer, Answer::Unsat), "x0 ∧ x1 ∧ ¬(x0∧x1) is UNSAT");
5679    }
5680
5681    #[test]
5682    fn symmetric_binary_inference_learns_implication_orbits() {
5683        let p = |v| Lit::new(v, true);
5684        let n = |v| Lit::new(v, false);
5685        // Chain xᵢ → a → y with x0,x1,x2 interchangeable (S₃; a=var3, y=var4). BCP under x0 derives the
5686        // TRANSITIVE x0→y (not a clause), and symmetry adds x1→y and x2→y from that single probe.
5687        let chain = vec![
5688            vec![n(0), p(3)], vec![n(1), p(3)], vec![n(2), p(3)], // xᵢ → a
5689            vec![n(3), p(4)],                                     // a → y
5690        ];
5691        let s = symmetric_binary_inference_solve(5, &chain)
5692            .expect("a derived implication orbit is learned");
5693        assert_eq!(s.via, Route::SymmetricBinary);
5694        match &s.answer {
5695            Answer::Sat(m) => assert!(
5696                chain.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
5697                "the strengthened formula yields a valid model"
5698            ),
5699            Answer::Unsat => panic!("the chain is SAT"),
5700        }
5701        // Soundness vs brute force.
5702        let brute = (0u64..32).any(|x| {
5703            let a: Vec<bool> = (0..5).map(|i| (x >> i) & 1 == 1).collect();
5704            chain.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
5705        });
5706        assert_eq!(matches!(s.answer, Answer::Sat(_)), brute, "binary-inference verdict matches brute force");
5707
5708        // Declines when BCP yields no NEW binary: here xᵢ→y are already clauses, so there is nothing to
5709        // learn and the route bows out (the cheap routes / CDCL decide).
5710        let direct = vec![vec![n(0), p(2)], vec![n(1), p(2)]]; // x0→y, x1→y directly (y=var2), S₂
5711        assert!(
5712            symmetric_binary_inference_solve(3, &direct).is_none(),
5713            "no new implication to learn ⟹ declines"
5714        );
5715    }
5716
5717    #[test]
5718    fn symmetric_component_decomposition_solves_copies_once() {
5719        let p = |v| Lit::new(v, true);
5720        let n = |v| Lit::new(v, false);
5721        // Two interchangeable copies of (x∨y): components {0,1} and {2,3}, swapped by (0↔2, 1↔3). SAT —
5722        // the representative is solved once and its model is replicated through the symmetry.
5723        let sat = vec![vec![p(0), p(1)], vec![p(2), p(3)]];
5724        let s = symmetric_component_solve(4, &sat).expect("two symmetric components decompose");
5725        assert_eq!(s.via, Route::SymmetricComponent);
5726        match &s.answer {
5727            Answer::Sat(m) => assert!(
5728                sat.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
5729                "the assembled model is valid"
5730            ),
5731            Answer::Unsat => panic!("two copies of (x∨y) is SAT"),
5732        }
5733
5734        // Two interchangeable copies of an UNSAT gadget x∧y∧¬(x∧y). Each copy is UNSAT, and the mixed
5735        // clause widths give it no phase symmetry, so the variable group (with the copy swap) is seen — so
5736        // F is UNSAT, detected by solving just ONE copy.
5737        let unsat = vec![
5738            vec![p(0)], vec![p(1)], vec![n(0), n(1)],
5739            vec![p(2)], vec![p(3)], vec![n(2), n(3)],
5740        ];
5741        let u = symmetric_component_solve(4, &unsat).expect("two symmetric components decompose");
5742        assert_eq!(u.via, Route::SymmetricComponent);
5743        assert!(matches!(u.answer, Answer::Unsat), "an UNSAT component makes F UNSAT");
5744
5745        // Soundness vs brute force on both.
5746        for cl in [&sat, &unsat] {
5747            let brute = (0u64..16).any(|x| {
5748                let a: Vec<bool> = (0..4).map(|i| (x >> i) & 1 == 1).collect();
5749                cl.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
5750            });
5751            let got = symmetric_component_solve(4, cl).expect("decomposition fires");
5752            assert_eq!(
5753                matches!(got.answer, Answer::Sat(_)),
5754                brute,
5755                "component-decomposition verdict matches brute force"
5756            );
5757        }
5758
5759        // A single connected component (clique_coloring(3,3)) has nothing to decompose — it declines.
5760        let (clique, _) = crate::families::clique_coloring(3, 3);
5761        assert!(
5762            symmetric_component_solve(clique.num_vars, &clique.clauses).is_none(),
5763            "a single component is not decomposable"
5764        );
5765    }
5766
5767    #[test]
5768    fn plain_component_decomposition_solves_asymmetric_independent_parts() {
5769        let p = |v| Lit::new(v, true);
5770        let n = |v| Lit::new(v, false);
5771        // Heterogeneous independent components: A = (x0∨x1) on {0,1}, B = (x2 ≠ x3) on {2,3}. Their different
5772        // structure means no automorphism maps one onto the other, so the SYMMETRIC route declines — this is
5773        // exactly the case plain component decomposition exists to cover.
5774        let sat = vec![vec![p(0), p(1)], vec![p(2), p(3)], vec![n(2), n(3)]];
5775        assert!(symmetric_component_solve(4, &sat).is_none(), "asymmetric components: the symmetric route declines");
5776        let s = solve_by_components(4, &sat);
5777        assert_eq!(s.via, Route::Component);
5778        match &s.answer {
5779            Answer::Sat(m) => assert!(
5780                sat.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
5781                "the assembled model satisfies every clause"
5782            ),
5783            Answer::Unsat => panic!("both components are satisfiable, so F is SAT"),
5784        }
5785
5786        // One component UNSAT (x2 ∧ ¬x2) ⟹ F is UNSAT, found without ever touching the other component.
5787        let unsat = vec![vec![p(0), p(1)], vec![p(2)], vec![n(2)]];
5788        let u = solve_by_components(3, &unsat);
5789        assert_eq!(u.via, Route::Component);
5790        assert!(matches!(u.answer, Answer::Unsat), "an UNSAT component makes F UNSAT");
5791
5792        // A single connected component has nothing to split — component_solve declines to the fallback.
5793        let single = vec![vec![p(0), p(1)], vec![n(0), p(1)]];
5794        assert_ne!(solve_by_components(2, &single).via, Route::Component, "one component is not decomposable");
5795
5796        // Soundness vs brute force on both decomposable instances.
5797        for (nv, cl) in [(4usize, &sat), (3, &unsat)] {
5798            let brute = (0u64..(1 << nv)).any(|x| {
5799                let a: Vec<bool> = (0..nv).map(|i| (x >> i) & 1 == 1).collect();
5800                cl.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
5801            });
5802            assert_eq!(matches!(solve_by_components(nv, cl).answer, Answer::Sat(_)), brute, "matches brute force");
5803        }
5804    }
5805
5806    #[test]
5807    fn symmetry_propagation_breaks_during_search_and_is_correct() {
5808        // clique_coloring(3,3): SAT and variable-symmetric. The lex-leader propagator prunes non-canonical
5809        // assignments DURING the search (no static clauses, no aux vars) and returns a re-checked model.
5810        let (sat, _) = crate::families::clique_coloring(3, 3);
5811        let s = symmetry_propagate_solve(sat.num_vars, &sat.clauses)
5812            .expect("a phase-free variable symmetry drives the propagator");
5813        assert_eq!(s.via, Route::SymmetryPropagate);
5814        match &s.answer {
5815            Answer::Sat(m) => assert!(
5816                sat.clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
5817                "the propagator returns a valid model"
5818            ),
5819            Answer::Unsat => panic!("clique_coloring(3,3) is SAT"),
5820        }
5821
5822        // clique_coloring(4,3): UNSAT (K₄ needs 4 colours). The propagator preserves satisfiability, so the
5823        // dynamic break decides UNSAT.
5824        let (unsat, _) = crate::families::clique_coloring(4, 3);
5825        let u = symmetry_propagate_solve(unsat.num_vars, &unsat.clauses).expect("variable symmetry");
5826        assert_eq!(u.via, Route::SymmetryPropagate);
5827        assert!(matches!(u.answer, Answer::Unsat), "clique_coloring(4,3) is UNSAT");
5828
5829        // Soundness: the dynamic verdict matches an independent brute-force decision on both.
5830        for (cnf, _) in [crate::families::clique_coloring(3, 3), crate::families::clique_coloring(4, 3)] {
5831            let nv = cnf.num_vars;
5832            let brute = (0u64..(1u64 << nv)).any(|x| {
5833                let a: Vec<bool> = (0..nv).map(|i| (x >> i) & 1 == 1).collect();
5834                cnf.clauses.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
5835            });
5836            let got = symmetry_propagate_solve(nv, &cnf.clauses).expect("propagator fires");
5837            assert_eq!(
5838                matches!(got.answer, Answer::Sat(_)),
5839                brute,
5840                "symmetry-propagation verdict matches brute force (nv={nv})"
5841            );
5842        }
5843    }
5844
5845    #[test]
5846    fn orbit_weight_quotient_collapses_a_full_symmetric_instance() {
5847        let p = |v| Lit::new(v, true);
5848        // at-least-2 of {x0,x1,x2}: fully interchangeable (G = S₃, no phase symmetry), SAT at weight 2.
5849        let at_least_2 = vec![vec![p(0), p(1)], vec![p(0), p(2)], vec![p(1), p(2)]];
5850        let s = orbit_weight_quotient_solve(3, &at_least_2)
5851            .expect("a full symmetric group collapses to weight classes");
5852        assert_eq!(s.via, Route::OrbitWeightQuotient);
5853        match &s.answer {
5854            Answer::Sat(m) => {
5855                assert!(at_least_2.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())));
5856                assert!(m.iter().filter(|&&b| b).count() >= 2, "the witness has weight ≥ 2");
5857            }
5858            Answer::Unsat => panic!("at-least-2 of 3 is SAT"),
5859        }
5860
5861        // at-least-3 ∧ at-most-1 over {x0..x3}: S₄-symmetric, and UNSAT (≥3 and ≤1 true cannot both hold).
5862        // The clause widths differ (ternary at-least-3, binary at-most-1), so global complement does NOT
5863        // preserve the set — no phase symmetry — and the quotient sees the full S₄ variable group. It
5864        // checks weights 0..4 and finds none satisfying — an exact refutation.
5865        let subsets3 = [[0u32, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]];
5866        let pairs = [[0u32, 1], [0, 2], [0, 3], [1, 2], [1, 3], [2, 3]];
5867        let mut card: Vec<Vec<Lit>> = Vec::new();
5868        for sub in subsets3 {
5869            card.push(sub.iter().map(|&v| Lit::new(v, true)).collect()); // at-least-3
5870        }
5871        for pr in pairs {
5872            card.push(pr.iter().map(|&v| Lit::new(v, false)).collect()); // at-most-1
5873        }
5874        let u = orbit_weight_quotient_solve(4, &card).expect("S₄ is the full symmetric group on the orbit");
5875        assert_eq!(u.via, Route::OrbitWeightQuotient);
5876        assert!(matches!(u.answer, Answer::Unsat), "≥3 and ≤1 true is unsatisfiable");
5877
5878        // Soundness vs brute force on both.
5879        for (nv, cl) in [(3usize, &at_least_2), (4, &card)] {
5880            let brute = (0u64..(1u64 << nv)).any(|x| {
5881                let a: Vec<bool> = (0..nv).map(|i| (x >> i) & 1 == 1).collect();
5882                cl.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
5883            });
5884            let got = orbit_weight_quotient_solve(nv, cl).expect("quotient fires");
5885            assert_eq!(
5886                matches!(got.answer, Answer::Sat(_)),
5887                brute,
5888                "orbit-weight-quotient verdict matches brute force (nv={nv})"
5889            );
5890        }
5891
5892        // The gate DECLINES when the group is not the FULL product: clique_coloring(3,3)'s symmetry is
5893        // S₃(vertices)×S₃(colours) on the 3×3 grid — not the full S₉ on the cells — so a pure cell-swap is
5894        // not in the group and the quotient correctly bows out (the breaking routes handle it instead).
5895        let (clique, _) = crate::families::clique_coloring(3, 3);
5896        assert!(
5897            orbit_weight_quotient_solve(clique.num_vars, &clique.clauses).is_none(),
5898            "a product symmetry that is not the full symmetric group is not a weight quotient"
5899        );
5900    }
5901
5902    #[test]
5903    fn symmetric_probe_infers_a_whole_orbit_from_one_failed_literal() {
5904        let y = |b| Lit::new(3, b);
5905        let nx = |v| Lit::new(v, false);
5906        // x0,x1,x2 are interchangeable (S₃) and each is a FAILED literal: xᵢ=true forces y ∧ ¬y.
5907        let base: Vec<Vec<Lit>> =
5908            (0u32..3).flat_map(|v| [vec![nx(v), y(true)], vec![nx(v), y(false)]]).collect();
5909
5910        // SAT — all xᵢ false (y free). A single probe of x0 fails, and symmetry forces ¬x0,¬x1,¬x2 from
5911        // that one probe (the inference, not a search over the three).
5912        let s = symmetric_probe_solve(4, &base).expect("a symmetric failed literal engages the probe");
5913        assert_eq!(s.via, Route::SymmetricProbe);
5914        match &s.answer {
5915            Answer::Sat(m) => {
5916                assert!(base.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())));
5917                assert!(!m[0] && !m[1] && !m[2], "the whole orbit was inferred false from one probe");
5918            }
5919            Answer::Unsat => panic!("the base instance is SAT"),
5920        }
5921
5922        // UNSAT — add x0∨x1∨x2: once the orbit is forced false, the cardinality clause cannot hold.
5923        let mut unsat = base.clone();
5924        unsat.push(vec![Lit::new(0, true), Lit::new(1, true), Lit::new(2, true)]);
5925        let u = symmetric_probe_solve(4, &unsat).expect("the probe engages");
5926        assert_eq!(u.via, Route::SymmetricProbe);
5927        assert!(matches!(u.answer, Answer::Unsat), "forcing the orbit false refutes the cardinality clause");
5928
5929        // Soundness: the verdict matches an independent brute-force decision on both.
5930        for cl in [&base, &unsat] {
5931            let brute = (0u64..16).any(|x| {
5932                let a: Vec<bool> = (0..4).map(|i| (x >> i) & 1 == 1).collect();
5933                cl.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
5934            });
5935            let got = symmetric_probe_solve(4, cl).expect("probe fires");
5936            assert_eq!(
5937                matches!(got.answer, Answer::Sat(_)),
5938                brute,
5939                "symmetric-probe verdict matches brute force"
5940            );
5941        }
5942    }
5943
5944    #[test]
5945    fn local_symmetry_solve_exploits_branch_symmetry_and_is_correct() {
5946        // Globally asymmetric (the clause x0∨x1 singles out x1), but branching on x0 reveals an x1↔x2
5947        // symmetry in the residual. The local-symmetry route splits on x0, breaks each residual's
5948        // symmetry, and decides it correctly with a re-checked model.
5949        let f = vec![
5950            vec![Lit::new(0, false), Lit::new(1, true), Lit::new(2, true)], // ¬x0 ∨ x1 ∨ x2
5951            vec![Lit::new(0, false), Lit::new(1, false), Lit::new(2, false)], // ¬x0 ∨ ¬x1 ∨ ¬x2
5952            vec![Lit::new(0, true), Lit::new(1, true)], // x0 ∨ x1
5953        ];
5954        let brute = (0u64..8).any(|x| {
5955            let a: Vec<bool> = (0..3).map(|i| (x >> i) & 1 == 1).collect();
5956            f.iter().all(|c| c.iter().any(|l| a[l.var() as usize] == l.is_positive()))
5957        });
5958        let solved = local_symmetry_solve(3, &f).expect("a branch reveals local symmetry");
5959        assert_eq!(solved.via, Route::LocalSymmetry);
5960        assert_eq!(matches!(solved.answer, Answer::Sat(_)), brute, "local-symmetry verdict matches brute force");
5961        if let Answer::Sat(m) = &solved.answer {
5962            assert!(
5963                f.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
5964                "the model from a local-symmetry branch satisfies F"
5965            );
5966        }
5967    }
5968
5969    #[test]
5970    fn symmetry_breaking_scales_to_a_large_group_via_partial_breaking() {
5971        // clique_coloring(6,6): |Aut| = 6!·6! = 518400 — too large to enumerate (> the 50k cap), so the
5972        // symmetry route falls back to sound per-generator PARTIAL breaking and still solves it with a
5973        // re-checked model. (The point: symmetry breaking runs even where the group cannot be enumerated.)
5974        let (cnf, _) = crate::families::clique_coloring(6, 6);
5975        let s = symmetry_break_solve(cnf.num_vars, &cnf.clauses)
5976            .expect("a large phase-free symmetry group still drives partial breaking");
5977        assert_eq!(s.via, Route::SymmetryBreak);
5978        match s.answer {
5979            Answer::Sat(m) => assert!(
5980                cnf.clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
5981                "partial breaking returns a valid model"
5982            ),
5983            Answer::Unsat => panic!("clique_coloring(6,6) is SAT"),
5984        }
5985    }
5986
5987    #[test]
5988    fn the_composite_lift_verdict_matches_the_supplied_equations() {
5989        // A composite one-hot CNF has m bits per group, so a Boolean brute force (2^(edges·m)) is
5990        // intractable — the right oracle is the supplied ℤ/m system the family also returns: the lifted
5991        // CNF verdict must agree with running modm::solve directly on those equations, and with the
5992        // family's declared expectation. (The equation-level engine is itself brute-force-verified in
5993        // modm's own tests.)
5994        use crate::modm::{solve as modm_solve, ModmOutcome};
5995        for (eqs, cnf, want) in [
5996            crate::families::mod_p_tseitin_expander(6, 6, 7),
5997            crate::families::mod_p_consistent_onehot(6, 6, 7),
5998        ] {
5999            let solved = solve_structured(cnf.num_vars, &cnf.clauses);
6000            assert_eq!(solved.via, Route::ModM, "a composite instance must take the ℤ/m route");
6001            let num_gf_vars = cnf.num_vars / 6; // one ℤ/6 variable (edge) per 6 one-hot bits
6002            let supplied_unsat = matches!(modm_solve(&eqs, num_gf_vars, 6), Some(ModmOutcome::Unsat { .. }));
6003            assert_eq!(
6004                matches!(solved.answer, Answer::Unsat),
6005                supplied_unsat,
6006                "the lifted-CNF verdict must match modm::solve on the supplied equations"
6007            );
6008            assert_eq!(
6009                matches!(solved.answer, Answer::Unsat),
6010                matches!(want, crate::families::ExpectedVerdict::Unsat),
6011                "and the family's declared expectation"
6012            );
6013        }
6014    }
6015
6016    #[test]
6017    fn mine_clauses_derives_an_implied_unit_via_probing() {
6018        // {a} ∧ {¬a ∨ b} ⇒ b is implied (a forces b); failed-literal probing must mine the unit b.
6019        let clauses = vec![vec![Lit::new(0, true)], vec![Lit::new(0, false), Lit::new(1, true)]];
6020        let mined = mine_clauses(2, &clauses);
6021        assert!(
6022            mined.iter().any(|c| c.len() == 1 && c[0].var() == 1 && c[0].is_positive()),
6023            "expected mined implied unit b; got {mined:?}"
6024        );
6025    }
6026
6027    #[test]
6028    fn every_mined_clause_is_implied() {
6029        // Soundness: each mined clause must hold in EVERY model of the formula (brute force).
6030        let clauses = vec![
6031            xor_gadget(&[0, 1, 2], false),
6032            vec![vec![Lit::new(0, true)]],
6033            vec![vec![Lit::new(1, true)]],
6034        ]
6035        .concat();
6036        let n = 3;
6037        let mined = mine_clauses(n, &clauses);
6038        assert!(!mined.is_empty(), "this instance has implied structure to mine");
6039        for mask in 0u32..(1 << n) {
6040            let asg: Vec<bool> = (0..n).map(|v| (mask >> v) & 1 == 1).collect();
6041            let is_model = clauses.iter().all(|c| c.iter().any(|l| asg[l.var() as usize] == l.is_positive()));
6042            if is_model {
6043                for mc in &mined {
6044                    assert!(
6045                        mc.iter().any(|l| asg[l.var() as usize] == l.is_positive()),
6046                        "mined clause {mc:?} is not implied (fails model {asg:?})"
6047                    );
6048                }
6049            }
6050        }
6051    }
6052
6053    /// **The exact-cover lift crushes modular counting and the chessboard — one harvest, every
6054    /// modulus, certified.** Exactly-one groups (an all-positive clause whose variable pairs all
6055    /// carry at-most-one clauses) yield the equations `Σ_{v∈g} x_v = 1`, valid consequences over
6056    /// EVERY modulus; Gaussian elimination over `GF(2)`, `GF(3)`, `GF(5)` then finds whatever
6057    /// counting obstruction exists — the parity sum for `Count_2`, the black-minus-white
6058    /// combination for the mutilated chessboard over `GF(3)`, the mod-3/5 sums for `Count_{3,5}` —
6059    /// each refutation re-checked fail-closed before the route reports. This turns the satbench
6060    /// CONTROL rows (`Count_2` odd-matching fell through to CDCL at ~40–60 ms) into µs crushes with
6061    /// zero search. Soundness: the route only ever reports UNSAT (with a re-checked certificate);
6062    /// satisfiable coverings decline through to the rest of the chain, and a SAT control is pinned.
6063    #[test]
6064    fn exact_cover_lift_crushes_modular_counting_and_the_chessboard() {
6065        // Count_2 on odd n — previously a CONTROL row (CDCL); now the GF(2) point-sum, no search.
6066        for n in [7usize, 9, 11] {
6067            let (cnf, _) = crate::families::mod_counting(n, 2);
6068            let s = solve_structured(cnf.num_vars, &cnf.clauses);
6069            assert!(matches!(s.answer, Answer::Unsat), "Count_2({n}) is UNSAT");
6070            assert_eq!(s.via, Route::ExactCover, "Count_2({n}): the exact-cover lift fires");
6071            assert_eq!(s.conflicts, 0, "Count_2({n}): zero search");
6072        }
6073        // Count_3 and Count_5 — the same harvest, higher rungs of the modulus ladder.
6074        for (n, q) in [(7usize, 3usize), (8, 3), (6, 5), (7, 5)] {
6075            let (cnf, _) = crate::families::mod_counting(n, q);
6076            let s = solve_structured(cnf.num_vars, &cnf.clauses);
6077            assert!(matches!(s.answer, Answer::Unsat), "Count_{q}({n}) is UNSAT");
6078            assert_eq!(s.via, Route::ExactCover, "Count_{q}({n}): the exact-cover lift fires");
6079        }
6080        // The mutilated chessboard: the ±1 (black/white) combination lives in GF(3) — Gaussian
6081        // finds it from the raw covering equations, no bipartite reasoning supplied.
6082        let (cb, _) = crate::families::mutilated_chessboard(4);
6083        let s = solve_structured(cb.num_vars, &cb.clauses);
6084        assert!(matches!(s.answer, Answer::Unsat), "chessboard(4) is UNSAT");
6085        assert_eq!(s.conflicts, 0, "chessboard(4): a specialist decides — zero search");
6086        assert_ne!(s.via, Route::Cdcl, "chessboard(4): never the fallback");
6087        // SAT control: a satisfiable covering never yields a false UNSAT from the lift.
6088        let (sat6, _) = crate::families::mod_counting(6, 3);
6089        let s = solve_structured(sat6.num_vars, &sat6.clauses);
6090        match s.answer {
6091            Answer::Sat(model) => {
6092                assert!(
6093                    sat6.clauses.iter().all(|c| c.iter().any(|l| model[l.var() as usize] == l.is_positive())),
6094                    "Count_3(6): the SAT model re-checks"
6095                );
6096            }
6097            Answer::Unsat => panic!("Count_3(6) is satisfiable — the lift must decline"),
6098        }
6099    }
6100
6101    /// **Measurement: the symmetry arsenal against Ramsey.** Ramsey formulas carry the huge `Sₙ`
6102    /// vertex symmetry induced on edge variables — clique geometry, no counting or parity to lift,
6103    /// so the exact-cover and algebraic routes rightly decline and the question is whether the
6104    /// symmetry routes (detection + orbital branching / SEL / lex-leader, `solve_comprehensive`)
6105    /// beat the raw CDCL fallback the fast chain uses. Wall time, route, and conflicts per engine,
6106    /// on `ramsey(3,3;6)` and `ramsey(3,4;9)` — the measured groundwork for the crushable frontier
6107    /// (`ramsey(3,5;14)`, where plain CDCL walls). Verdict correctness asserted; the comparison is
6108    /// reported, not presumed.
6109    #[test]
6110    #[ignore = "scale measurement — symmetry-arsenal wall time on Ramsey; run explicitly"]
6111    fn ramsey_symmetric_attack_is_measured() {
6112        for (s, t, n) in [(3usize, 3usize, 6usize), (3, 4, 9)] {
6113            let (cnf, _) = crate::families::ramsey(s, t, n);
6114            let t0 = std::time::Instant::now();
6115            let fast = solve_structured(cnf.num_vars, &cnf.clauses);
6116            let fast_ms = t0.elapsed().as_millis();
6117            assert!(matches!(fast.answer, Answer::Unsat), "ramsey({s},{t};{n}) is UNSAT");
6118            let t1 = std::time::Instant::now();
6119            let full = solve_comprehensive(cnf.num_vars, &cnf.clauses);
6120            let full_ms = t1.elapsed().as_millis();
6121            assert!(matches!(full.answer, Answer::Unsat), "ramsey({s},{t};{n}) is UNSAT (arsenal)");
6122            eprintln!(
6123                "RAMSEY | ({s},{t};{n}) [{} vars]: fast={:?} {}ms {} conflicts | arsenal={:?} {}ms {} conflicts",
6124                cnf.num_vars, fast.via, fast_ms, fast.conflicts, full.via, full_ms, full.conflicts
6125            );
6126        }
6127    }
6128}