Skip to main content

logicaffeine_proof/
affine.rs

1//! **The affine group `AGL(n,2)` — the symmetry a permutation break cannot see.**
2//!
3//! The clause-level symmetry breakers (`symmetry_detect`, `sym_certify`) quotient by the hyperoctahedral
4//! group `Bₙ` of signed variable permutations. `Bₙ` is exactly the subgroup of `AGL(n,2)` (affine
5//! bijections `x ↦ A x ⊕ b` of the cube `GF(2)ⁿ`) whose linear part `A` is a permutation matrix. The
6//! census proved the clause breakers are *complete* for `Bₙ` — yet `AGL(n,2) ⊋ Bₙ`, and the gap is the
7//! **shears** `xᵢ ↦ xᵢ ⊕ xⱼ`. A shear maps an axis-aligned subcube (a clause) to a *non*-subcube, so no
8//! amount of clause permutation can reach it. That gap is precisely why parity / Tseitin formulas — affine
9//! invariant, `Bₙ`-rigid — defeat clause symmetry breaking and need a linear-algebra engine instead.
10//!
11//! This module is that engine, at the symmetry level: the `AGL(n,2)` group itself, an exhaustive
12//! ground-truth detector of a formula's affine symmetries (so `AGL ⊋ Bₙ` is *measured*, not asserted), and
13//! the affine **break** — recover the formula's `GF(2)`-linear substructure, Gauss-eliminate it, and either
14//! refute an inconsistent linear core or inject the forced units / equivalences (the linear consequences no
15//! single clause states) that collapse the residual search.
16
17use std::collections::HashSet;
18
19use crate::cdcl::Lit;
20use crate::gf2;
21
22/// An affine map of the Boolean cube `GF(2)ⁿ`: `x ↦ A x ⊕ b`. `matrix[i]` is row `i` of `A` as a
23/// coefficient bitmask over the low `n` bits; `translation` is `b`. It is a bijection iff `A ∈ GL(n,2)`.
24#[derive(Clone, PartialEq, Eq, Debug)]
25pub struct Affine {
26    pub n: usize,
27    pub matrix: Vec<u64>,
28    pub translation: u64,
29}
30
31impl Affine {
32    /// The identity `x ↦ x` (`A = I`, `b = 0`).
33    pub fn identity(n: usize) -> Self {
34        Affine { n, matrix: (0..n).map(|i| 1u64 << i).collect(), translation: 0 }
35    }
36
37    /// Apply the map to a point `x` (low `n` bits): output bit `i` is `parity(row_i · x) ⊕ b_i`.
38    pub fn apply(&self, x: u64) -> u64 {
39        let mut y = 0u64;
40        for i in 0..self.n {
41            let dot = (self.matrix[i] & x).count_ones() & 1;
42            let bit = dot ^ ((self.translation >> i) & 1) as u32;
43            y |= (bit as u64) << i;
44        }
45        y
46    }
47
48    /// Composition `self ∘ other`: `(self ∘ other)(x) = self(other(x))`. The linear parts multiply over
49    /// `GF(2)` and the translations combine as `A_self · b_other ⊕ b_self`.
50    pub fn compose(&self, other: &Affine) -> Affine {
51        debug_assert_eq!(self.n, other.n);
52        // Row i of A_self·A_other = XOR of A_other's rows selected by the set bits of A_self's row i.
53        let mut matrix = vec![0u64; self.n];
54        for i in 0..self.n {
55            let mut row = 0u64;
56            let mut sel = self.matrix[i];
57            while sel != 0 {
58                let k = sel.trailing_zeros() as usize;
59                row ^= other.matrix[k];
60                sel &= sel - 1;
61            }
62            matrix[i] = row;
63        }
64        // A_self applied to b_other, then ⊕ b_self.
65        let mut translation = self.translation;
66        for i in 0..self.n {
67            let dot = (self.matrix[i] & other.translation).count_ones() & 1;
68            translation ^= (dot as u64) << i;
69        }
70        Affine { n: self.n, matrix, translation }
71    }
72
73    /// Whether the linear part is invertible (so the map is a bijection of the cube).
74    pub fn is_bijection(&self) -> bool {
75        gf2::is_invertible_gf2(self.n as u32, &self.matrix)
76    }
77}
78
79/// `|AGL(n,2)| = 2ⁿ · |GL(n,2)|` — the `2ⁿ` translations times the invertible linear parts.
80pub fn agl_order(n: u32) -> u128 {
81    (1u128 << n) * gf2::gl_order(n)
82}
83
84/// **The affine automorphism group order of a `k`-dimensional affine subspace of `GF(2)ⁿ`** — i.e. the number
85/// of affine maps `x ↦ Ax ⊕ b` fixing the model set of an XOR-defined family whose solution space has
86/// dimension `k`. In *closed form*, computable at every `n` without enumerating `AGL(n,2)`:
87/// `|GL(k,2)| · |GL(n−k,2)| · 2^{k(n−k)} · 2^k` — the block-upper-triangular invertible linear parts (`GL(k)`
88/// on the subspace, `GL(n−k)` on the quotient, `2^{k(n−k)}` shears between them) times the `2^k` in-subspace
89/// translations. The scalable affine-symmetry detector for the whole class of linear families: `Bₙ` sees a
90/// vanishing fraction of it (see the tests).
91pub fn affine_subspace_agl_order(n: u32, k: u32) -> u128 {
92    debug_assert!(k <= n);
93    gf2::gl_order(k) * gf2::gl_order(n - k) * (1u128 << (k * (n - k))) * (1u128 << k)
94}
95
96/// Every affine bijection of `GF(2)ⁿ` (each invertible matrix × each translation). Exhaustive — for
97/// ground-truth symmetry computation only — so it is capped at `n ≤ 4` (`|AGL(4,2)| = 322 560`).
98pub fn all_affine_bijections(n: usize) -> Vec<Affine> {
99    assert!(n <= 4, "exhaustive AGL enumeration is for ground-truth tests only (n ≤ 4)");
100    let mut out = Vec::new();
101    let total_matrices = 1u64 << (n * n);
102    for code in 0..total_matrices {
103        let matrix: Vec<u64> = (0..n).map(|i| (code >> (i * n)) & ((1 << n) - 1)).collect();
104        if !gf2::is_invertible_gf2(n as u32, &matrix) {
105            continue;
106        }
107        for translation in 0..(1u64 << n) {
108            out.push(Affine { n, matrix: matrix.clone(), translation });
109        }
110    }
111    out
112}
113
114/// The satisfying assignments of a CNF, each packed into the low `num_vars` bits of a `u64`. Brute force
115/// over `2^{num_vars}` — small `n` only (the census / symmetry-measurement regime).
116pub fn models_of(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<u64> {
117    assert!(num_vars <= 24, "model enumeration is brute force — small n only");
118    (0u64..(1u64 << num_vars))
119        .filter(|&x| {
120            clauses.iter().all(|c| {
121                c.iter().any(|l| ((x >> l.var()) & 1 == 1) == l.is_positive())
122            })
123        })
124        .collect()
125}
126
127/// The **affine symmetry group of a formula**, computed exhaustively: every `φ ∈ AGL(n,2)` that maps the
128/// model set onto itself (`φ` a bijection, so `φ(models) = models` iff `φ` maps each model to a model).
129/// This is the `AGL(n,2)` analogue of `symmetry_detect::find_generators` (which finds only the `Bₙ`
130/// part), and it is exact for `n ≤ 4` — the instrument that *measures* `AGL ⊋ Bₙ`.
131pub fn affine_symmetries(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<Affine> {
132    let models: HashSet<u64> = models_of(num_vars, clauses).into_iter().collect();
133    all_affine_bijections(num_vars)
134        .into_iter()
135        .filter(|phi| models.iter().all(|&m| models.contains(&phi.apply(m))))
136        .collect()
137}
138
139/// Pack XOR equations into `(coefficient-mask, rhs)` rows over the low bits, for [`gf2::solve_gf2`].
140fn eqs_to_rows(eqs: &[crate::xorsat::XorEquation]) -> (Vec<u64>, Vec<bool>) {
141    let mut rows = Vec::with_capacity(eqs.len());
142    let mut rhs = Vec::with_capacity(eqs.len());
143    for eq in eqs {
144        let mut mask = 0u64;
145        for &v in &eq.vars {
146            mask |= 1u64 << v;
147        }
148        rows.push(mask);
149        rhs.push(eq.rhs);
150    }
151    (rows, rhs)
152}
153
154/// Recover the `GF(2)`-linear substructure of a CNF: the XOR equations its clauses encode (units,
155/// binary equivalences, and complete wrong-parity bundles), as `(coefficient-mask, rhs)` rows over the
156/// low `num_vars` bits. `None` when there is no linear structure or `num_vars` exceeds the `u64` mask.
157pub fn recover_linear_system(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<(Vec<u64>, Vec<bool>)> {
158    if num_vars > 63 {
159        return None;
160    }
161    let eqs = crate::lyapunov::extract_xor(num_vars, clauses);
162    if eqs.is_empty() {
163        return None;
164    }
165    Some(eqs_to_rows(&eqs))
166}
167
168/// The clausal DRAT **certificate** for an affine refutation, or `None` if the formula's linear core is
169/// not inconsistent (or `num_vars` exceeds the `u64` mask, or the resolution expansion overruns its
170/// budget). Recovers the XOR system, finds the GF(2) linear dependency that sums to `0 = 1`
171/// ([`crate::xorsat::solve`]), and compiles it to RUP resolvent lemmas through the [`crate::xor_drat`]
172/// bridge — the same path `Route::Parity` uses, so it is `drat-trim`-checkable against the original CNF.
173pub fn affine_refutation_drat(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<Vec<Lit>>> {
174    if num_vars > 63 {
175        return None;
176    }
177    let eqs = crate::lyapunov::extract_xor(num_vars, clauses);
178    match crate::xorsat::solve(&eqs, num_vars) {
179        crate::xorsat::XorOutcome::Unsat(refutation) => crate::xor_drat::emit_xor_drat(&eqs, &refutation),
180        crate::xorsat::XorOutcome::Sat(_) => None,
181    }
182}
183
184/// The outcome of the affine break.
185#[derive(Clone, Debug, PartialEq, Eq)]
186pub enum AffineOutcome {
187    /// The recovered linear system is inconsistent (`0 = 1`) ⇒ the formula is UNSAT, by an obstruction no
188    /// clause permutation can express. The payload is the **certificate**: the GF(2) linear-dependency
189    /// refutation compiled to clausal DRAT resolvents through the [`crate::xor_drat`] bridge (a sequence
190    /// of RUP lemmas that `drat-trim` / [`crate::rup::check_refutation`] verifies against the original
191    /// CNF), or `None` when the resolution expansion exceeds its budget — the verdict still stands on the
192    /// native algebraic certificate, exactly as the `Route::Parity` cut does.
193    Refuted(Option<Vec<Vec<Lit>>>),
194    /// Consistent, but Gauss elimination forced linear consequences — units and/or equivalences not stated
195    /// by any single clause. The carried clauses are sound to conjoin (implied by the formula) and are
196    /// genuinely new (none already present), so adding them strictly strengthens it for search.
197    Forced(Vec<Vec<Lit>>),
198    /// No linear structure, or nothing new derivable.
199    Unchanged,
200}
201
202/// Sorted `(var, polarity)` key for clause-set membership.
203fn canon(clause: &[Lit]) -> Vec<(u32, bool)> {
204    let mut k: Vec<(u32, bool)> = clause.iter().map(|l| (l.var(), l.is_positive())).collect();
205    k.sort_unstable();
206    k.dedup();
207    k
208}
209
210/// The **affine `GF(2)` break.** Recover the formula's linear substructure and Gauss-eliminate it
211/// ([`gf2::solve_gf2`]): an inconsistent core [`AffineOutcome::Refuted`]s outright; otherwise the
212/// elimination's [`gf2::SolutionSpace`] exposes which variables are *forced* (the kernel never moves them
213/// ⇒ a unit) and which are *locked together* (the kernel moves them in lockstep ⇒ an equivalence), and we
214/// return those consequences as new clauses ([`AffineOutcome::Forced`]) — the linear inferences the
215/// permutation breaker structurally cannot make.
216pub fn affine_reduce(num_vars: usize, clauses: &[Vec<Lit>]) -> AffineOutcome {
217    if num_vars > 63 {
218        return AffineOutcome::Unchanged;
219    }
220    let eqs = crate::lyapunov::extract_xor(num_vars, clauses);
221    if eqs.is_empty() {
222        return AffineOutcome::Unchanged;
223    }
224    // Inconsistent linear core ⇒ UNSAT, certified through the xor_drat bridge: the GF(2) linear-dependency
225    // witness compiles to clausal DRAT resolvents that re-check against the original CNF.
226    if let crate::xorsat::XorOutcome::Unsat(refutation) = crate::xorsat::solve(&eqs, num_vars) {
227        return AffineOutcome::Refuted(crate::xor_drat::emit_xor_drat(&eqs, &refutation));
228    }
229    // Consistent: derive forced consequences from the RREF solution space.
230    let (rows, rhs) = eqs_to_rows(&eqs);
231    let Some(ss) = gf2::solve_gf2(num_vars, &rows, &rhs) else {
232        return AffineOutcome::Unchanged; // unreachable: xorsat consistent ⇒ solve_gf2 consistent
233    };
234
235    let existing: HashSet<Vec<(u32, bool)>> = clauses.iter().map(|c| canon(c)).collect();
236    let mut forced: Vec<Vec<Lit>> = Vec::new();
237    let push_new = |clause: Vec<Lit>, forced: &mut Vec<Vec<Lit>>| {
238        if !existing.contains(&canon(&clause)) {
239            forced.push(clause);
240        }
241    };
242
243    // A variable the kernel never moves is pinned to its value in every solution — a forced unit.
244    let mut is_forced = vec![false; num_vars];
245    for v in 0..num_vars {
246        if ss.kernel_basis.iter().all(|k| !k[v]) {
247            is_forced[v] = true;
248            push_new(vec![Lit::new(v as u32, ss.particular[v])], &mut forced);
249        }
250    }
251    // A pair the kernel always moves together is locked into `x_u ⊕ x_v = c` in every solution — a forced
252    // equivalence. (Skip pairs with a forced endpoint; the units already pin them.)
253    for u in 0..num_vars {
254        if is_forced[u] {
255            continue;
256        }
257        for v in (u + 1)..num_vars {
258            if is_forced[v] {
259                continue;
260            }
261            if ss.kernel_basis.iter().all(|k| k[u] == k[v]) {
262                let (lu, lv) = (u as u32, v as u32);
263                if ss.particular[u] ^ ss.particular[v] {
264                    // x_u ≠ x_v: (x_u ∨ x_v) ∧ (¬x_u ∨ ¬x_v)
265                    push_new(vec![Lit::pos(lu), Lit::pos(lv)], &mut forced);
266                    push_new(vec![Lit::neg(lu), Lit::neg(lv)], &mut forced);
267                } else {
268                    // x_u = x_v: (¬x_u ∨ x_v) ∧ (x_u ∨ ¬x_v)
269                    push_new(vec![Lit::neg(lu), Lit::pos(lv)], &mut forced);
270                    push_new(vec![Lit::pos(lu), Lit::neg(lv)], &mut forced);
271                }
272            }
273        }
274    }
275
276    if forced.is_empty() {
277        AffineOutcome::Unchanged
278    } else {
279        AffineOutcome::Forced(forced)
280    }
281}
282
283/// How an eliminated variable is recovered from the reduced model — its place in the affine quotient.
284#[derive(Clone, Debug, PartialEq, Eq)]
285enum VarSub {
286    /// Forced to a constant by the linear core (the kernel never moves it).
287    Const(bool),
288    /// Survives as reduced variable `new_index` (a free generator or a class representative).
289    Survive(u32),
290    /// Equal to reduced variable `new_index`, XORed with `flip` — a member of an equivalence class.
291    Alias(u32, bool),
292}
293
294/// The result of the canonical affine reduction: an equisatisfiable formula over the **free generators**
295/// (the linearly-determined variables eliminated), plus the map to lift a reduced model back to the full
296/// space. This is the affine analogue of breaking a permutation orbit down to one representative — here
297/// the RREF canonicalizes the affine structure and the determined coordinates fall away.
298#[derive(Clone, Debug)]
299pub struct AffineCanonical {
300    pub num_vars: usize,
301    pub clauses: Vec<Vec<Lit>>,
302    sub: Vec<VarSub>,
303}
304
305impl AffineCanonical {
306    /// Lift a model of the reduced formula back to a model over the original variables, reconstructing
307    /// each eliminated coordinate from its canonical definition (constant, or alias of a survivor).
308    pub fn lift(&self, reduced_model: &[bool]) -> Vec<bool> {
309        self.sub
310            .iter()
311            .map(|s| match *s {
312                VarSub::Const(c) => c,
313                VarSub::Survive(ni) => reduced_model[ni as usize],
314                VarSub::Alias(ni, flip) => reduced_model[ni as usize] ^ flip,
315            })
316            .collect()
317    }
318}
319
320/// The outcome of the **canonical RREF break**.
321pub enum AffineCanon {
322    /// The linear core is inconsistent — UNSAT, with the xor_drat certificate (as [`AffineOutcome::Refuted`]).
323    Refuted(Option<Vec<Vec<Lit>>>),
324    /// Reduced to the free generators, with the lifting map.
325    Canonical(AffineCanonical),
326    /// No linear structure, or nothing determined to eliminate.
327    Unchanged,
328}
329
330/// **The affine SBP — the canonical RREF break.** Recover the formula's GF(2)-linear substructure and
331/// take its reduced row-echelon form (via [`gf2::solve_gf2`], whose kernel basis is the affine
332/// translation symmetry). The RREF partitions variables into *free generators* and *determined*
333/// coordinates: every variable the kernel never moves is **forced** to a constant, and every set the
334/// kernel moves in lockstep is an **equivalence class** collapsing to one representative. Substituting
335/// those determined coordinates out yields an equisatisfiable formula over the free generators alone — the
336/// affine symmetry quotiented to a canonical representative, exactly as a permutation lex-leader collapses
337/// an orbit. An inconsistent core instead [`AffineCanon::Refuted`]s (certified). Sound: the eliminated
338/// relations are GF(2)-entailed by the formula, so [`AffineCanonical::lift`] turns any reduced model into
339/// a full one.
340pub fn affine_canonicalize(num_vars: usize, clauses: &[Vec<Lit>]) -> AffineCanon {
341    if num_vars > 63 {
342        return AffineCanon::Unchanged;
343    }
344    let eqs = crate::lyapunov::extract_xor(num_vars, clauses);
345    if eqs.is_empty() {
346        return AffineCanon::Unchanged;
347    }
348    if let crate::xorsat::XorOutcome::Unsat(refutation) = crate::xorsat::solve(&eqs, num_vars) {
349        return AffineCanon::Refuted(crate::xor_drat::emit_xor_drat(&eqs, &refutation));
350    }
351    let (rows, rhs) = eqs_to_rows(&eqs);
352    let Some(ss) = gf2::solve_gf2(num_vars, &rows, &rhs) else {
353        return AffineCanon::Unchanged; // unreachable: xorsat consistent ⇒ solve_gf2 consistent
354    };
355
356    // A variable's "kernel column" — which kernel-basis vectors move it. Forced variables have an
357    // all-zero column; variables sharing a column move in lockstep (an equivalence class).
358    let kdim = ss.kernel_basis.len();
359    let column = |v: usize| -> Vec<bool> { (0..kdim).map(|i| ss.kernel_basis[i][v]).collect() };
360    let zero = vec![false; kdim];
361
362    let mut sub = vec![VarSub::Const(false); num_vars];
363    let mut groups: std::collections::HashMap<Vec<bool>, Vec<usize>> = std::collections::HashMap::new();
364    for v in 0..num_vars {
365        let col = column(v);
366        if col == zero {
367            sub[v] = VarSub::Const(ss.particular[v]); // forced
368        } else {
369            groups.entry(col).or_default().push(v);
370        }
371    }
372    // Each class collapses to its lowest-index representative; assign reduced indices in representative order.
373    let mut classes: Vec<Vec<usize>> = groups
374        .into_values()
375        .map(|mut g| {
376            g.sort_unstable();
377            g
378        })
379        .collect();
380    classes.sort_unstable_by_key(|g| g[0]);
381    for (new_index, members) in classes.iter().enumerate() {
382        let rep = members[0];
383        let rep_par = ss.particular[rep];
384        for &v in members {
385            sub[v] = if v == rep {
386                VarSub::Survive(new_index as u32)
387            } else {
388                VarSub::Alias(new_index as u32, ss.particular[v] ^ rep_par) // x_v = x_rep ⊕ flip
389            };
390        }
391    }
392    let reduced_nv = classes.len();
393    if !sub.iter().any(|s| matches!(s, VarSub::Const(_) | VarSub::Alias(_, _))) {
394        return AffineCanon::Unchanged; // nothing determined to eliminate
395    }
396
397    // Apply the substitution to every clause (linear and clausal alike), staying in CNF.
398    let mut out: Vec<Vec<Lit>> = Vec::new();
399    'clause: for c in clauses {
400        let mut seen: std::collections::HashMap<u32, bool> = std::collections::HashMap::new();
401        let mut lits: Vec<Lit> = Vec::new();
402        for l in c {
403            let (ni, pol) = match sub[l.var() as usize] {
404                VarSub::Const(cst) => {
405                    if cst == l.is_positive() {
406                        continue 'clause; // literal true ⇒ clause satisfied, drop it
407                    }
408                    continue; // literal false ⇒ drop the literal
409                }
410                VarSub::Survive(ni) => (ni, l.is_positive()),
411                VarSub::Alias(ni, flip) => (ni, l.is_positive() ^ flip),
412            };
413            match seen.get(&ni) {
414                Some(&prev) if prev != pol => continue 'clause, // x ∨ ¬x ⇒ tautology, drop the clause
415                Some(_) => continue,                           // duplicate literal
416                None => {
417                    seen.insert(ni, pol);
418                    lits.push(Lit::new(ni, pol));
419                }
420            }
421        }
422        out.push(lits); // a now-empty clause is a sound UNSAT marker the solver will catch
423    }
424
425    AffineCanon::Canonical(AffineCanonical { num_vars: reduced_nv, clauses: out, sub })
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    /// `|AGL(n,2)| = 2ⁿ·|GL(n,2)|`, and the exhaustive enumeration produces exactly that many bijections.
433    #[test]
434    fn agl_order_matches_the_enumeration() {
435        for n in 1..=4u32 {
436            assert_eq!(
437                all_affine_bijections(n as usize).len() as u128,
438                agl_order(n),
439                "n={n}: enumerated affine bijections must equal |AGL(n,2)| = 2ⁿ·|GL(n,2)|"
440            );
441        }
442        // Pinned small values: |AGL(1,2)|=2, |AGL(2,2)|=24, |AGL(3,2)|=1344.
443        assert_eq!(agl_order(1), 2);
444        assert_eq!(agl_order(2), 24);
445        assert_eq!(agl_order(3), 1344);
446    }
447
448    /// The group laws hold: composition is associative-ish (closure + identity) and every bijection's
449    /// linear part is invertible. Spot-checked against direct point evaluation.
450    #[test]
451    fn affine_maps_compose_and_act_correctly() {
452        let id = Affine::identity(3);
453        for phi in all_affine_bijections(3) {
454            assert!(phi.is_bijection());
455            // identity is a left/right unit
456            assert_eq!(phi.compose(&id), phi);
457            assert_eq!(id.compose(&phi), phi);
458            // composition matches pointwise application: (φ∘φ)(x) = φ(φ(x))
459            let sq = phi.compose(&phi);
460            for x in 0..8u64 {
461                assert_eq!(sq.apply(x), phi.apply(phi.apply(x)), "composition must match double application");
462            }
463        }
464    }
465
466    /// **THE CENSUS GAP, MEASURED: `AGL ⊋ Bₙ`.** The single even-parity constraint `x₀⊕x₁⊕x₂ = 0` is
467    /// rigid under the clause breakers but drowning in affine symmetry. Exactly `24·4 = 96` affine maps
468    /// fix its model set (24 linear maps preserve the parity hyperplane, 4 translations stay inside it) —
469    /// strictly more than the *entire* hyperoctahedral group `|B₃| = 2³·3! = 48` that bounds any
470    /// permutation-automorphism the clause breakers could ever find. The witness is a concrete **shear**
471    /// `x₁↦x₀⊕x₁, x₂↦x₀⊕x₂` (every column odd-weight, so parity-preserving): an automorphism of the model
472    /// set that is *not* a signed permutation — exactly the kind no clause-level break can reach.
473    #[test]
474    fn affine_symmetry_strictly_exceeds_permutation_symmetry_on_parity() {
475        let p = |v: u32| Lit::pos(v);
476        let q = |v: u32| Lit::neg(v);
477        // Wrong-parity (odd) assignments of (x0,x1,x2): 100,010,001,111 — one clause forbidding each.
478        let clauses = vec![
479            vec![q(0), p(1), p(2)], // not (1,0,0)
480            vec![p(0), q(1), p(2)], // not (0,1,0)
481            vec![p(0), p(1), q(2)], // not (0,0,1)
482            vec![q(0), q(1), q(2)], // not (1,1,1)
483        ];
484
485        let affine = affine_symmetries(3, &clauses);
486        // 24 linear maps preserve the hyperplane × 4 in-plane translations.
487        assert_eq!(affine.len(), 96, "the even-parity plane has exactly 96 affine symmetries");
488        // … which strictly exceeds all of B₃ (48), hence any permutation symmetry the clause breakers find.
489        const B3: usize = 8 * 6;
490        assert!(affine.len() > B3, "AGL symmetry ({}) must exceed |B₃| = {B3}", affine.len());
491
492        // The witness shear (rows x0, x0⊕x1, x0⊕x2 — every column odd-weight ⇒ parity-preserving) IS an
493        // affine symmetry, and it mixes variables, so no signed permutation can express it.
494        let shear = Affine { n: 3, matrix: vec![0b001, 0b011, 0b101], translation: 0 };
495        assert!(shear.is_bijection());
496        assert!(affine.contains(&shear), "the shear x₁↦x₀⊕x₁, x₂↦x₀⊕x₂ must be an affine symmetry of the plane");
497        assert!(
498            shear.matrix.iter().any(|r| r.count_ones() >= 2),
499            "the shear mixes variables — outside Bₙ, invisible to every clause-level break"
500        );
501    }
502
503    /// **The AGL lens-narrowness, quantified for every `n` (a theorem, not a measurement).** The even-parity
504    /// family over `n` variables has closed-form symmetry-group orders under the two lenses:
505    ///   - `|Bₙ-symmetry| = n! · 2^{n−1}` (all variable permutations, plus even sign-flips), and
506    ///   - `|AGL-symmetry| = |GL(n,2)| / (2ⁿ−1) · 2^{n−1}` (linear maps fixing the parity hyperplane, plus
507    ///     in-plane translations).
508    /// Their ratio `|GL(n,2)| / ((2ⁿ−1)·n!)` grows *super-exponentially* (`|GL(n,2)| = 2^{Θ(n²)}`), so the
509    /// clause-symmetry lens `Bₙ` is provably blind to almost all the symmetry — which is exactly why parity /
510    /// affine families defeat clause-based symmetry breaking and need linear algebra. We pin the closed forms
511    /// against the exhaustive count at `n = 3, 4`, then exhibit the unbounded growth via the formula.
512    #[test]
513    fn affine_symmetry_dwarfs_permutation_symmetry_on_parity_at_every_scale() {
514        fn factorial(n: u128) -> u128 {
515            (1..=n).product()
516        }
517        let bn_stab = |n: u32| -> u128 { factorial(n as u128) * (1u128 << (n - 1)) };
518        let agl_stab = |n: u32| -> u128 { gf2::gl_order(n) / ((1u128 << n) - 1) * (1u128 << (n - 1)) };
519
520        // Pin BOTH closed forms against exhaustive counts (n ≤ 4). The even-parity constraint is encoded by
521        // forbidding every ODD-parity assignment with a full-width clause; its automorphisms are the
522        // hyperplane-preserving affine maps, and its `Bₙ` sub-automorphisms are those with permutation-matrix
523        // linear part.
524        for n in 3..=4u32 {
525            let clauses: Vec<Vec<Lit>> = (0u64..(1u64 << n))
526                .filter(|a| a.count_ones() % 2 == 1)
527                .map(|a| (0..n).map(|v| Lit::new(v, (a >> v) & 1 == 0)).collect())
528                .collect();
529            let syms = affine_symmetries(n as usize, &clauses);
530            // A signed permutation = an affine map whose linear part is a permutation matrix.
531            let is_perm = |a: &Affine| -> bool {
532                let mut cols = 0u64;
533                for &row in a.matrix.iter().take(n as usize) {
534                    if row.count_ones() != 1 || cols & row != 0 {
535                        return false;
536                    }
537                    cols |= row;
538                }
539                cols == (1u64 << n) - 1
540            };
541            let bn_count = syms.iter().filter(|a| is_perm(a)).count() as u128;
542            assert_eq!(syms.len() as u128, agl_stab(n), "n={n}: AGL parity-stabilizer = |GL(n,2)|/(2ⁿ−1)·2^{{n−1}}");
543            assert_eq!(bn_count, bn_stab(n), "n={n}: Bₙ parity-stabilizer = n!·2^{{n−1}}");
544            assert!(agl_stab(n) > bn_stab(n), "n={n}: AGL symmetry exceeds Bₙ symmetry");
545        }
546
547        // The ratio grows without bound — the standard lens misses a super-exponential factor of the symmetry.
548        let ratios: Vec<u128> = (3..=8u32).map(|n| agl_stab(n) / bn_stab(n)).collect();
549        eprintln!("AGL/Bₙ parity-symmetry ratio, n = 3..8: {ratios:?}");
550        assert!(ratios.windows(2).all(|w| w[1] > w[0]), "the AGL/Bₙ ratio grows with n: {ratios:?}");
551        assert!(*ratios.last().unwrap() > 1_000_000, "by n=8 the lens misses a >10⁶ factor");
552    }
553
554    /// **The scalable affine-symmetry detector for XOR families: a closed form, verified `∀n`.** Enumerating
555    /// `AGL(n,2)` is stuck at `n ≤ 4`, but the affine automorphism group of any XOR-defined family (model set a
556    /// `k`-dim affine subspace) has the *closed form* [`affine_subspace_agl_order`], evaluable at any `n`. We
557    /// pin it against the exhaustive count at `n ≤ 4` for every `1 ≤ k ≤ n−1`, then push the consequence to
558    /// `n = 10`: for any nontrivial affine family the affine symmetry is `2^{Θ(n²)}`, which **dwarfs the entire
559    /// hyperoctahedral group** `|Bₙ| = 2ⁿ·n! = 2^{O(n log n)}`. So clause-based symmetry breaking captures a
560    /// vanishing `2^{−Θ(n²)}` fraction of the symmetry of a linear family — the linear structure is invisible
561    /// to it, ∀n, and the finder scales without enumerating the group.
562    #[test]
563    fn affine_family_symmetry_closed_form_scales_to_all_n() {
564        // Pin the closed form against exhaustive counts (n ≤ 4), every dimension. The model set of the units
565        // `{¬x_j : k ≤ j < n}` is the k-dim coordinate subspace.
566        for n in 2..=4u32 {
567            for k in 1..n {
568                let clauses: Vec<Vec<Lit>> = (k..n).map(|j| vec![Lit::neg(j)]).collect();
569                assert_eq!(
570                    affine_symmetries(n as usize, &clauses).len() as u128,
571                    affine_subspace_agl_order(n, k),
572                    "n={n} k={k}: closed-form affine-subspace order must match the exhaustive count"
573                );
574            }
575        }
576        // Push to n = 12: the affine symmetry of a densest (k = n/2) linear family dwarfs all of Bₙ, and the
577        // ratio grows super-exponentially (2^{Θ(n²)} / 2^{O(n log n)}).
578        let bn = |n: u128| (1u128 << n) * (1..=n).product::<u128>();
579        let ratios: Vec<u128> = (6..=12u32).map(|n| affine_subspace_agl_order(n, n / 2) / bn(n as u128)).collect();
580        eprintln!("affine-family AGL / |Bₙ| ratio, n = 6..12: {ratios:?}");
581        assert!(ratios.iter().all(|&r| r > 1), "the affine symmetry exceeds |Bₙ| at every n");
582        assert!(ratios.windows(2).all(|w| w[1] > w[0]), "the gap grows with n: {ratios:?}");
583        assert!(*ratios.last().unwrap() > 1_000_000_000, "by n=12 the affine symmetry is a >10⁹ factor beyond |Bₙ|");
584    }
585
586    /// The affine break **refutes** an inconsistent linear core — a transitive XOR contradiction
587    /// (`x₀=x₁, x₁=x₂, x₀≠x₂`) that is parity-inconsistent but has no two clauses in direct conflict.
588    #[test]
589    fn affine_reduce_refutes_an_inconsistent_linear_core() {
590        let p = |v: u32| Lit::pos(v);
591        let q = |v: u32| Lit::neg(v);
592        let clauses = vec![
593            vec![q(0), p(1)], vec![p(0), q(1)], // x0 = x1
594            vec![q(1), p(2)], vec![p(1), q(2)], // x1 = x2
595            vec![p(0), p(2)], vec![q(0), q(2)], // x0 ≠ x2  ⇒ 0 = 1
596        ];
597        // Refuted, and the payload is a DRAT certificate that RUP-refutes the original CNF.
598        match affine_reduce(3, &clauses) {
599            AffineOutcome::Refuted(Some(drat)) => assert!(
600                crate::rup::check_refutation(3, &clauses, &drat),
601                "the affine refutation's xor_drat certificate must RUP-refute the original CNF"
602            ),
603            other => panic!("expected a certified refutation, got {other:?}"),
604        }
605        // Ground truth: it really is UNSAT.
606        assert!(models_of(3, &clauses).is_empty(), "the linear core is genuinely unsatisfiable");
607    }
608
609    /// **The affine refutation is certified through the `xor_drat` bridge.** On a genuine parity core
610    /// (an odd-charge expander Tseitin), [`affine_refutation_drat`] compiles the GF(2) linear dependency to
611    /// RUP resolvent lemmas that our independent checker accepts against the original CNF — the same
612    /// `drat-trim`-checkable path `Route::Parity` uses — and the dispatcher now carries that proof on its
613    /// verdict rather than reporting UNSAT bare.
614    #[test]
615    fn affine_refutation_is_certified_via_xor_drat_bridge() {
616        use crate::solve::{solve_structured, Answer};
617        let (_, cnf, _) = crate::families::tseitin_expander(6, 1);
618        let nv = cnf.num_vars;
619        let drat = affine_refutation_drat(nv, &cnf.clauses).expect("an inconsistent XOR core has a certificate");
620        assert!(!drat.is_empty(), "the certificate must carry resolvent lemmas");
621        assert!(
622            crate::rup::check_refutation(nv, &cnf.clauses, &drat),
623            "the xor_drat certificate must RUP-refute the original CNF"
624        );
625        // Wired: the dispatcher decides UNSAT (and where the affine rung fires, ships this very proof).
626        assert!(matches!(solve_structured(nv, &cnf.clauses).answer, Answer::Unsat), "the parity core is UNSAT");
627    }
628
629    /// The affine break **derives a forced unit** no clause states: `x0⊕x1=1 ∧ x0⊕x1⊕x2=0 ⇒ x2=1`. Gauss
630    /// elimination finds it; unit propagation on the clauses alone does not.
631    #[test]
632    fn affine_reduce_derives_a_nonsyntactic_forced_unit() {
633        let p = |v: u32| Lit::pos(v);
634        let q = |v: u32| Lit::neg(v);
635        // x0⊕x1=1: (x0∨x1)∧(¬x0∨¬x1).  x0⊕x1⊕x2=0: the four even-parity clauses over {0,1,2}.
636        let clauses = vec![
637            vec![p(0), p(1)], vec![q(0), q(1)],
638            vec![q(0), p(1), p(2)], vec![p(0), q(1), p(2)], vec![p(0), p(1), q(2)], vec![q(0), q(1), q(2)],
639        ];
640        match affine_reduce(3, &clauses) {
641            AffineOutcome::Forced(extra) => {
642                assert!(extra.contains(&vec![Lit::pos(2)]), "must derive the forced unit x2 = 1; got {extra:?}");
643            }
644            other => panic!("expected forced consequences, got {other:?}"),
645        }
646        // The derived unit is sound: x2 is 1 in every model.
647        for m in models_of(3, &clauses) {
648            assert_eq!((m >> 2) & 1, 1, "x2 must be 1 in every model");
649        }
650    }
651
652    /// Wired: the dispatcher decides affine-reducible formulas correctly — it refutes an inconsistent
653    /// linear core and returns a satisfying model for a forced-consequence formula (the model re-checked
654    /// against the original clauses).
655    #[test]
656    fn the_dispatcher_decides_affine_reducible_formulas() {
657        use crate::solve::{solve_structured, Answer};
658        let p = |v: u32| Lit::pos(v);
659        let q = |v: u32| Lit::neg(v);
660        // Transitive XOR contradiction (x0=x1, x1=x2, x0≠x2) — UNSAT, no two clauses in direct conflict.
661        let unsat = vec![
662            vec![q(0), p(1)], vec![p(0), q(1)], vec![q(1), p(2)], vec![p(1), q(2)], vec![p(0), p(2)], vec![q(0), q(2)],
663        ];
664        assert!(matches!(solve_structured(3, &unsat).answer, Answer::Unsat), "dispatcher must refute the linear core");
665        // Forced-consequence SAT case: x0⊕x1=1 ∧ x0⊕x1⊕x2=0 forces x2=1, still satisfiable.
666        let sat = vec![
667            vec![p(0), p(1)], vec![q(0), q(1)],
668            vec![q(0), p(1), p(2)], vec![p(0), q(1), p(2)], vec![p(0), p(1), q(2)], vec![q(0), q(1), q(2)],
669        ];
670        match solve_structured(3, &sat).answer {
671            Answer::Sat(m) => assert!(
672                sat.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())),
673                "the returned model must satisfy the original formula"
674            ),
675            Answer::Unsat => panic!("the forced-consequence formula is satisfiable"),
676        }
677    }
678
679    /// Fail-closed: a formula with no inconsistent or constraining linear structure is left `Unchanged`,
680    /// and a satisfiable parity plane is never falsely `Refuted`.
681    #[test]
682    fn affine_reduce_is_sound_and_quiet_when_there_is_nothing_to_do() {
683        // Pure 2-CNF with no recoverable XOR bundles.
684        let p = |v: u32| Lit::pos(v);
685        let plain = vec![vec![p(0), p(1), p(2)]];
686        assert_eq!(affine_reduce(3, &plain), AffineOutcome::Unchanged);
687        // A satisfiable parity constraint: consistent, so never Refuted (Forced or Unchanged only).
688        let q = |v: u32| Lit::neg(v);
689        let sat_parity = vec![
690            vec![q(0), p(1), p(2)], vec![p(0), q(1), p(2)], vec![p(0), p(1), q(2)], vec![q(0), q(1), q(2)],
691        ];
692        assert!(!matches!(affine_reduce(3, &sat_parity), AffineOutcome::Refuted(_)), "a satisfiable plane must not be refuted");
693    }
694
695    /// **The canonical RREF break collapses an equivalence chain.** `x0=x1=x2=x3` plus a forced unit
696    /// `x4=0` and a clause `x0∨x4`: the four-variable equivalence class folds to one representative and
697    /// `x4` is eliminated, so the formula reduces from 5 variables to 1 — and the reduced model lifts back
698    /// to a genuine model of the original.
699    #[test]
700    fn affine_canonicalize_collapses_an_equivalence_chain() {
701        let p = |v: u32| Lit::pos(v);
702        let q = |v: u32| Lit::neg(v);
703        let clauses = vec![
704            vec![q(0), p(1)], vec![p(0), q(1)], // x0 = x1
705            vec![q(1), p(2)], vec![p(1), q(2)], // x1 = x2
706            vec![q(2), p(3)], vec![p(2), q(3)], // x2 = x3
707            vec![p(0), p(4)],                   // x0 ∨ x4
708            vec![q(4)],                         // x4 = 0
709        ];
710        match affine_canonicalize(5, &clauses) {
711            AffineCanon::Canonical(canon) => {
712                assert!(canon.num_vars < 5, "the chain + unit must shrink 5 vars (got {})", canon.num_vars);
713                let orig = models_of(5, &clauses);
714                let red = models_of(canon.num_vars, &canon.clauses);
715                assert_eq!(red.is_empty(), orig.is_empty(), "reduction must preserve satisfiability");
716                for &rm_bits in &red {
717                    let rm: Vec<bool> = (0..canon.num_vars).map(|i| (rm_bits >> i) & 1 == 1).collect();
718                    let lifted = canon.lift(&rm);
719                    assert!(
720                        clauses.iter().all(|c| c.iter().any(|l| lifted[l.var() as usize] == l.is_positive())),
721                        "the lifted reduced model must satisfy the original formula"
722                    );
723                }
724            }
725            AffineCanon::Refuted(_) => panic!("the chain is satisfiable, not refuted"),
726            AffineCanon::Unchanged => panic!("the chain + unit must reduce"),
727        }
728    }
729
730    /// **Soundness to the point of absurdity.** Hundreds of random formulas with injected linear structure
731    /// (equivalences, units, and clausal noise): the canonical break must preserve satisfiability EXACTLY
732    /// against brute force, refute only genuinely-UNSAT instances, and lift every reduced model to a real
733    /// model of the original.
734    #[test]
735    fn affine_canonicalize_is_sound_against_brute_force() {
736        let mut state = 0xA5F1_C0DE_1234_5678u64;
737        let mut rng = || {
738            state ^= state << 13;
739            state ^= state >> 7;
740            state ^= state << 17;
741            state
742        };
743        for _ in 0..400 {
744            let n = 4 + (rng() % 8) as usize; // 4..=11 variables
745            let mut clauses: Vec<Vec<Lit>> = Vec::new();
746            for _ in 0..(rng() % 4) {
747                let k = 2 + (rng() % 2) as usize;
748                let c: Vec<Lit> = (0..k).map(|_| Lit::new((rng() % n as u64) as u32, rng() & 1 == 0)).collect();
749                clauses.push(c);
750            }
751            for _ in 0..(1 + rng() % 3) {
752                let a = (rng() % n as u64) as u32;
753                let b = (rng() % n as u64) as u32;
754                if rng() & 1 == 0 {
755                    if a == b {
756                        continue;
757                    }
758                    if rng() & 1 == 0 {
759                        clauses.push(vec![Lit::neg(a), Lit::pos(b)]); // x_a = x_b
760                        clauses.push(vec![Lit::pos(a), Lit::neg(b)]);
761                    } else {
762                        clauses.push(vec![Lit::pos(a), Lit::pos(b)]); // x_a ≠ x_b
763                        clauses.push(vec![Lit::neg(a), Lit::neg(b)]);
764                    }
765                } else {
766                    clauses.push(vec![Lit::new(a, rng() & 1 == 0)]); // a unit
767                }
768            }
769            let orig = models_of(n, &clauses);
770            match affine_canonicalize(n, &clauses) {
771                AffineCanon::Refuted(_) => assert!(orig.is_empty(), "Refuted ⇒ the original must be UNSAT"),
772                AffineCanon::Canonical(canon) => {
773                    let red = models_of(canon.num_vars, &canon.clauses);
774                    assert_eq!(red.is_empty(), orig.is_empty(), "canonicalization must preserve satisfiability");
775                    for &rm_bits in &red {
776                        let rm: Vec<bool> = (0..canon.num_vars).map(|i| (rm_bits >> i) & 1 == 1).collect();
777                        let lifted = canon.lift(&rm);
778                        assert!(
779                            clauses.iter().all(|c| c.iter().any(|l| lifted[l.var() as usize] == l.is_positive())),
780                            "every lifted reduced model must satisfy the original"
781                        );
782                    }
783                }
784                AffineCanon::Unchanged => {}
785            }
786        }
787    }
788}