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}