Skip to main content

logicaffeine_proof/
affine_gfp.rs

1//! **The affine group `AGL(n,p)` over `GF(p)` — the mod-`p` generalization of the affine break.**
2//!
3//! [`crate::affine`] handles the `GF(2)` cube: the shears `xᵢ↦xᵢ⊕xⱼ` no permutation can see, and the
4//! linear obstruction (parity) that decides them. Over a prime `p` the same picture lifts: the cube
5//! becomes `GF(p)ⁿ`, signed permutations become the **monomial** group (one nonzero scalar per row/column —
6//! the `Bₙ` analog over `GF(p)`), and `AGL(n,p) = { x ↦ A x + b : A ∈ GL(n,p), b ∈ GF(p)ⁿ }` is strictly
7//! larger — its `GF(p)` shears are what mod-`p` counting (the `Count_p` / mod-`p` Tseitin families) is
8//! invariant under and the monomial breakers are blind to. This module is the `GF(p)` affine layer:
9//! the group itself, an exhaustive ground-truth detector of a `GF(p)`-system's affine symmetries (so
10//! `AGL(n,p) ⊋ monomial` is *measured*), and the certified `GF(p)` affine refutation via the mod-`p`
11//! Gaussian engine and the `emit_modp_drat` bridge. It reuses [`crate::modp`]'s `GF(p)` arithmetic
12//! throughout (`gl_order_p`, `is_invertible_modp`, `recover_from_cnf`, `solve`).
13
14use std::collections::HashSet;
15
16use crate::cdcl::Lit;
17use crate::modp::{self, ModpEquation};
18
19/// An affine map of `GF(p)ⁿ`: `x ↦ A x + b`. `matrix[i][j]` is `A`'s entry, `translation` is `b`; both
20/// reduced mod `p`. A bijection iff `A ∈ GL(n,p)`.
21#[derive(Clone, PartialEq, Eq, Debug)]
22pub struct AffineP {
23    pub p: u64,
24    pub n: usize,
25    pub matrix: Vec<Vec<u64>>,
26    pub translation: Vec<u64>,
27}
28
29impl AffineP {
30    /// The identity `x ↦ x`.
31    pub fn identity(n: usize, p: u64) -> Self {
32        let matrix = (0..n).map(|i| (0..n).map(|j| u64::from(i == j)).collect()).collect();
33        AffineP { p, n, matrix, translation: vec![0; n] }
34    }
35
36    /// Apply: output `i` is `(Σⱼ A[i][j]·x[j] + b[i]) mod p`.
37    pub fn apply(&self, x: &[u64]) -> Vec<u64> {
38        (0..self.n)
39            .map(|i| {
40                let s = (0..self.n).fold(0u64, |a, j| (a + self.matrix[i][j] * x[j]) % self.p);
41                (s + self.translation[i]) % self.p
42            })
43            .collect()
44    }
45
46    /// Composition `self ∘ other` over `GF(p)`: linear parts multiply, `b ↦ A_self·b_other + b_self`.
47    pub fn compose(&self, other: &AffineP) -> AffineP {
48        let (p, n) = (self.p, self.n);
49        let mut matrix = vec![vec![0u64; n]; n];
50        for (i, row) in matrix.iter_mut().enumerate() {
51            for (j, cell) in row.iter_mut().enumerate() {
52                *cell = (0..n).fold(0u64, |a, k| (a + self.matrix[i][k] * other.matrix[k][j]) % p);
53            }
54        }
55        let translation = (0..n)
56            .map(|i| {
57                let av = (0..n).fold(0u64, |a, k| (a + self.matrix[i][k] * other.translation[k]) % p);
58                (av + self.translation[i]) % p
59            })
60            .collect();
61        AffineP { p, n, matrix, translation }
62    }
63
64    /// Whether the linear part is invertible over `GF(p)` (so the map is a bijection of the cube).
65    pub fn is_bijection(&self) -> bool {
66        modp::is_invertible_modp(self.n, self.p, &self.matrix)
67    }
68
69    /// Whether the linear part is **monomial** — one nonzero entry per row and per column (a scaled
70    /// permutation). These are exactly the `Bₙ`-analog over `GF(p)`; everything else is a genuine shear.
71    pub fn is_monomial(&self) -> bool {
72        let n = self.n;
73        let rows = self.matrix.iter().all(|r| r.iter().filter(|&&x| x != 0).count() == 1);
74        let cols = (0..n).all(|j| (0..n).filter(|&i| self.matrix[i][j] != 0).count() == 1);
75        rows && cols
76    }
77}
78
79/// `|AGL(n,p)| = pⁿ · |GL(n,p)|`.
80pub fn agl_p_order(n: u32, p: u64) -> u128 {
81    (p as u128).pow(n) * modp::gl_order_p(n, p)
82}
83
84/// Every affine bijection of `GF(p)ⁿ` (each invertible matrix × each translation). Exhaustive — for
85/// ground-truth symmetry computation only — bounded so `p^{n²} ≤ 200_000`.
86pub fn all_affine_p_bijections(n: usize, p: u64) -> Vec<AffineP> {
87    assert!((p as u128).pow((n * n) as u32) <= 200_000, "exhaustive AGL(n,p) enumeration is bounded (p^{{n²}} ≤ 200k)");
88    let decode = |mut code: u64, len: usize| -> Vec<u64> {
89        (0..len)
90            .map(|_| {
91                let d = code % p;
92                code /= p;
93                d
94            })
95            .collect()
96    };
97    let mut out = Vec::new();
98    let matrices = (p).pow((n * n) as u32);
99    let translations = (p).pow(n as u32);
100    for code in 0..matrices {
101        let flat = decode(code, n * n);
102        let matrix: Vec<Vec<u64>> = (0..n).map(|i| flat[i * n..(i + 1) * n].to_vec()).collect();
103        if !modp::is_invertible_modp(n, p, &matrix) {
104            continue;
105        }
106        for tcode in 0..translations {
107            out.push(AffineP { p, n, matrix: matrix.clone(), translation: decode(tcode, n) });
108        }
109    }
110    out
111}
112
113/// The `GF(p)`-valued solutions of a mod-`p` linear system (`Σ coeffs·x ≡ rhs (mod p)` for each
114/// equation), brute force over `pⁿ` — small `n` only.
115pub fn models_p(n: usize, p: u64, equations: &[ModpEquation]) -> Vec<Vec<u64>> {
116    assert!((p as u128).pow(n as u32) <= 60_000, "model enumeration is brute force — small n");
117    let total = p.pow(n as u32);
118    (0..total)
119        .filter_map(|code| {
120            let mut c = code;
121            let x: Vec<u64> = (0..n)
122                .map(|_| {
123                    let d = c % p;
124                    c /= p;
125                    d
126                })
127                .collect();
128            equations
129                .iter()
130                .all(|eq| eq.coeffs.iter().fold(0u64, |a, &(v, co)| (a + co * x[v]) % p) % p == eq.rhs % p)
131                .then_some(x)
132        })
133        .collect()
134}
135
136/// The **affine symmetry group of a `GF(p)` model set**, computed exhaustively: every `φ ∈ AGL(n,p)` that
137/// maps the model set onto itself. The `AGL(n,p)` analogue of [`crate::affine::affine_symmetries`] — the
138/// instrument that *measures* `AGL(n,p) ⊋ monomial`.
139pub fn affine_p_symmetries(n: usize, p: u64, models: &[Vec<u64>]) -> Vec<AffineP> {
140    let set: HashSet<Vec<u64>> = models.iter().cloned().collect();
141    all_affine_p_bijections(n, p)
142        .into_iter()
143        .filter(|phi| set.iter().all(|m| set.contains(&phi.apply(m))))
144        .collect()
145}
146
147/// The clausal DRAT **certificate** for a `GF(p)` affine refutation, or `None` if the formula's mod-`p`
148/// core is not inconsistent (or the resolution expansion overruns its budget). Recovers the one-hot
149/// `GF(p)` system, finds the `Σ multiplierᵢ·equationᵢ` dependency whose left side cancels while the right
150/// does not ([`crate::modp::solve`]), and compiles it to RUP resolvent lemmas over the Boolean one-hot
151/// encoding through the [`crate::xor_drat`] bridge — `drat-trim`-checkable against the original CNF, the
152/// mod-`p` generalization of [`crate::affine::affine_refutation_drat`].
153pub fn affine_p_refutation_drat(num_bool_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<Vec<Lit>>> {
154    crate::xor_drat::emit_modp_drat(num_bool_vars, clauses)
155}
156
157/// The outcome of the **`GF(p)` SAT-side break**.
158pub enum AffinePForced {
159    /// The mod-`p` core is inconsistent — UNSAT, with the mod-`p` DRAT certificate.
160    Refuted(Option<Vec<Vec<Lit>>>),
161    /// Forced Boolean one-hot units — implied by the mod-`p` structure, none already present.
162    Forced(Vec<Vec<Lit>>),
163    /// No recoverable mod-`p` structure, or nothing determined.
164    Unchanged,
165}
166
167/// `GF(p)` multiplication, subtraction, and (Fermat) inverse — `modp`'s are private, and these are small.
168fn gfp_mul(a: u64, b: u64, p: u64) -> u64 {
169    (a % p) * (b % p) % p
170}
171fn gfp_sub(a: u64, b: u64, p: u64) -> u64 {
172    (a % p + p - b % p) % p
173}
174fn gfp_inv(a: u64, p: u64) -> u64 {
175    let (mut result, mut base, mut exp) = (1u64, a % p, p - 2);
176    while exp > 0 {
177        if exp & 1 == 1 {
178            result = gfp_mul(result, base, p);
179        }
180        base = gfp_mul(base, base, p);
181        exp >>= 1;
182    }
183    result
184}
185
186/// **The `GF(p)` SAT-side break.** Recover the one-hot mod-`p` system ([`crate::modp::recover_from_cnf`])
187/// and solve its solution space ([`crate::modp::solve_space`]), whose kernel is the affine translation
188/// symmetry. Two structures fall out, the mod-`p` analogues of [`crate::affine::affine_reduce`]'s forced
189/// units and equivalence classes:
190///
191/// * **Forced** — a `GF(p)` variable the kernel never moves is pinned to a single value `v`; lift that
192///   through the one-hot encoding (the group's bit for `v` true, the rest false) to Boolean units.
193/// * **Linked** — variables whose kernel columns are *scalar-proportional* satisfy `x_g = c·x_rep + d` in
194///   every solution. That lifts to **value-permuted bit-equivalences** `b(g,v) ↔ b(rep, (v−d)·c⁻¹)` — the
195///   genuinely `GF(p)` shear-links no monomial break can see, the value permutation a clause equivalence
196///   cannot express on its own.
197///
198/// Every emitted clause is `GF(p)`-entailed by the formula (sound to conjoin) and new. An inconsistent
199/// core instead [`AffinePForced::Refuted`]s (certified).
200pub fn affine_p_forced(num_bool_vars: usize, clauses: &[Vec<Lit>]) -> AffinePForced {
201    let Some(rec) = modp::recover_from_cnf(num_bool_vars, clauses) else {
202        return AffinePForced::Unchanged;
203    };
204    if !modp::is_prime(rec.modulus) {
205        return AffinePForced::Unchanged; // a composite modulus is the affine_m_forced path — solve_space needs a field
206    }
207    let Some(ss) = modp::solve_space(&rec.equations, rec.num_vars, rec.modulus) else {
208        return AffinePForced::Refuted(crate::xor_drat::emit_modp_drat(num_bool_vars, clauses));
209    };
210    let p = rec.modulus;
211    let key = |c: &[Lit]| -> Vec<(u32, bool)> {
212        let mut k: Vec<(u32, bool)> = c.iter().map(|l| (l.var(), l.is_positive())).collect();
213        k.sort_unstable();
214        k
215    };
216    let existing: HashSet<Vec<(u32, bool)>> = clauses.iter().map(|c| key(c)).collect();
217    let mut out: Vec<Vec<Lit>> = Vec::new();
218    let mut push_new = |c: Vec<Lit>| {
219        if !existing.contains(&key(&c)) {
220            out.push(c);
221        }
222    };
223    // The kernel "column" of a variable — which kernel directions move it.
224    let col = |g: usize| -> Vec<u64> { ss.kernel_basis.iter().map(|kv| kv[g]).collect() };
225
226    // Forced variables (zero column) → one-hot units; everything else is grouped by its column up to a
227    // GF(p) scalar (projective equivalence) — that is exactly the linked relation.
228    let mut classes: std::collections::HashMap<Vec<u64>, Vec<usize>> = std::collections::HashMap::new();
229    for g in 0..rec.num_vars {
230        let c = col(g);
231        if c.iter().all(|&x| x == 0) {
232            let val = ss.particular[g];
233            for (v, &bvar) in rec.groups[g].iter().enumerate() {
234                push_new(vec![if v as u64 == val { Lit::pos(bvar) } else { Lit::neg(bvar) }]);
235            }
236        } else {
237            let scale = gfp_inv(*c.iter().find(|&&x| x != 0).unwrap(), p); // normalize: first nonzero ↦ 1
238            classes.entry(c.iter().map(|&x| gfp_mul(x, scale, p)).collect()).or_default().push(g);
239        }
240    }
241    let mut class_list: Vec<Vec<usize>> = classes
242        .into_values()
243        .map(|mut v| {
244            v.sort_unstable();
245            v
246        })
247        .collect();
248    class_list.sort_unstable_by_key(|c| c[0]);
249    for members in &class_list {
250        if members.len() < 2 {
251            continue; // a lone variable carries no link
252        }
253        let rep = members[0];
254        let s_rep = *col(rep).iter().find(|&&x| x != 0).unwrap();
255        for &g in &members[1..] {
256            let s_g = *col(g).iter().find(|&&x| x != 0).unwrap();
257            let c_g = gfp_mul(s_g, gfp_inv(s_rep, p), p); // x_g = c_g·x_rep + d_g
258            let d_g = gfp_sub(ss.particular[g], gfp_mul(c_g, ss.particular[rep], p), p);
259            let inv_c = gfp_inv(c_g, p);
260            for v in 0..p {
261                let sv = gfp_mul(gfp_sub(v, d_g, p), inv_c, p); // b(g,v) ↔ b(rep, (v−d)·c⁻¹)
262                let (bg, br) = (rec.groups[g][v as usize], rec.groups[rep][sv as usize]);
263                push_new(vec![Lit::neg(bg), Lit::pos(br)]);
264                push_new(vec![Lit::pos(bg), Lit::neg(br)]);
265            }
266        }
267    }
268
269    if out.is_empty() {
270        AffinePForced::Unchanged
271    } else {
272        AffinePForced::Forced(out)
273    }
274}
275
276/// `|AGL(n, ℤ/m)|` for **squarefree** `m`: by CRT (`ℤ/m ≅ ∏ GF(pᵢ)`) the affine group factors as
277/// `∏ AGL(n, pᵢ)`, so the order is the product of the prime orders. `None` if `m` is not squarefree.
278pub fn agl_m_order(n: u32, m: u64) -> Option<u128> {
279    Some(crate::modm::squarefree_primes(m)?.iter().map(|&p| agl_p_order(n, p)).product())
280}
281
282/// Combine residues with pairwise-coprime moduli into `(value, ∏ moduli)` by incremental CRT.
283fn crt_combine(residues: &[(u64, u64)]) -> (u64, u64) {
284    let (mut acc_r, mut acc_m): (i128, i128) = (0, 1);
285    for &(r, modu) in residues {
286        let modu = modu as i128;
287        let diff = (r as i128 - acc_r).rem_euclid(modu);
288        let t = (diff * mod_inverse(acc_m.rem_euclid(modu) as u64, modu as u64) as i128).rem_euclid(modu);
289        acc_r += acc_m * t;
290        acc_m *= modu;
291        acc_r = acc_r.rem_euclid(acc_m);
292    }
293    (acc_r as u64, acc_m as u64)
294}
295
296/// The inverse of `a` mod `m` (extended Euclid) — `a` must be coprime to `m`.
297fn mod_inverse(a: u64, m: u64) -> u64 {
298    let (mut old_r, mut r): (i128, i128) = (a as i128, m as i128);
299    let (mut old_s, mut s): (i128, i128) = (1, 0);
300    while r != 0 {
301        let q = old_r / r;
302        (old_r, r) = (r, old_r - q * r);
303        (old_s, s) = (s, old_s - q * s);
304    }
305    old_s.rem_euclid(m as i128) as u64
306}
307
308/// **The composite `ℤ/m` SAT-side break** (squarefree `m`), the full mod-`m` analogue of
309/// [`affine_p_forced`] via CRT (`ℤ/m ≅ ∏ GF(pᵢ)`). Solve the system over each prime field, then for each
310/// variable combine the per-prime structure:
311///
312/// * **Forced / partially forced** — where the kernel pins `x_g` mod a set `S` of primes, `x_g` is
313///   congruent to one residue mod `∏S`; forbid every one-hot value off that residue (a single allowed
314///   value when `S` is *all* primes — the fully-forced units).
315/// * **Linked** — variables free mod *every* prime whose kernel columns are scalar-proportional mod each
316///   prime are linked over `ℤ/m` by `x_g = c·x_rep + d` with `c = CRT(cᵢ)`, `d = CRT(dᵢ)`; lift to
317///   value-permuted bit-equivalences `b(g,v) ↔ b(rep, (v−d)·c⁻¹ mod m)`.
318///
319/// Every emitted clause is `ℤ/m`-entailed and new; an inconsistent prime field [`AffinePForced::Refuted`]s.
320/// A prime modulus defers to [`affine_p_forced`]; a non-squarefree `m` (prime-power Smith case) is untouched.
321pub fn affine_m_forced(num_bool_vars: usize, clauses: &[Vec<Lit>]) -> AffinePForced {
322    let Some(rec) = modp::recover_from_cnf(num_bool_vars, clauses) else {
323        return AffinePForced::Unchanged;
324    };
325    let m = rec.modulus;
326    if modp::is_prime(m) {
327        return AffinePForced::Unchanged; // the prime path is affine_p_forced's
328    }
329    let Some(primes) = crate::modm::squarefree_primes(m) else {
330        return prime_power_forced(num_bool_vars, &rec, clauses); // non-squarefree (prime-power) branch
331    };
332    let mut spaces: Vec<crate::modp::SolutionSpaceP> = Vec::with_capacity(primes.len());
333    for &p in &primes {
334        let Some(ss) = modp::solve_space(&rec.equations, rec.num_vars, p) else {
335            return AffinePForced::Refuted(crate::xor_drat::emit_modp_drat(num_bool_vars, clauses));
336        };
337        spaces.push(ss);
338    }
339
340    let key = |c: &[Lit]| -> Vec<(u32, bool)> {
341        let mut k: Vec<(u32, bool)> = c.iter().map(|l| (l.var(), l.is_positive())).collect();
342        k.sort_unstable();
343        k
344    };
345    let existing: HashSet<Vec<(u32, bool)>> = clauses.iter().map(|c| key(c)).collect();
346    let mut out: Vec<Vec<Lit>> = Vec::new();
347    let push_new = |c: Vec<Lit>, out: &mut Vec<Vec<Lit>>| {
348        if !existing.contains(&key(&c)) {
349            out.push(c);
350        }
351    };
352    let col = |i: usize, g: usize| -> Vec<u64> { spaces[i].kernel_basis.iter().map(|kk| kk[g]).collect() };
353    let forced_res = |i: usize, g: usize| -> Option<u64> {
354        col(i, g).iter().all(|&x| x == 0).then(|| spaces[i].particular[g])
355    };
356
357    // PART 1 — forced / partially forced: `x_g ≡ res (mod q)` over the primes that pin it.
358    for g in 0..rec.num_vars {
359        let constraints: Vec<(u64, u64)> =
360            primes.iter().enumerate().filter_map(|(i, &p)| forced_res(i, g).map(|v| (v, p))).collect();
361        if constraints.is_empty() {
362            continue;
363        }
364        let (res, q) = crt_combine(&constraints);
365        let fully = constraints.len() == primes.len();
366        for (v, &bvar) in rec.groups[g].iter().enumerate() {
367            if (v as u64) % q != res {
368                push_new(vec![Lit::neg(bvar)], &mut out); // x_g ≠ v (off the forced residue)
369            } else if fully {
370                push_new(vec![Lit::pos(bvar)], &mut out); // the single allowed value
371            }
372        }
373    }
374
375    // PART 2 — composite links: variables free mod EVERY prime, grouped by per-prime projective signature.
376    let mut classes: std::collections::HashMap<Vec<Vec<u64>>, Vec<usize>> = std::collections::HashMap::new();
377    for g in 0..rec.num_vars {
378        if (0..primes.len()).any(|i| forced_res(i, g).is_some()) {
379            continue;
380        }
381        let sig: Vec<Vec<u64>> = (0..primes.len())
382            .map(|i| {
383                let (p, c) = (primes[i], col(i, g));
384                let s = gfp_inv(*c.iter().find(|&&x| x != 0).unwrap(), p);
385                c.iter().map(|&x| gfp_mul(x, s, p)).collect()
386            })
387            .collect();
388        classes.entry(sig).or_default().push(g);
389    }
390    let mut class_list: Vec<Vec<usize>> = classes
391        .into_values()
392        .map(|mut v| {
393            v.sort_unstable();
394            v
395        })
396        .collect();
397    class_list.sort_unstable_by_key(|c| c[0]);
398    for members in &class_list {
399        if members.len() < 2 {
400            continue;
401        }
402        let rep = members[0];
403        for &g in &members[1..] {
404            let (mut cs, mut ds): (Vec<(u64, u64)>, Vec<(u64, u64)>) = (Vec::new(), Vec::new());
405            for (i, &p) in primes.iter().enumerate() {
406                let s_rep = *col(i, rep).iter().find(|&&x| x != 0).unwrap();
407                let s_g = *col(i, g).iter().find(|&&x| x != 0).unwrap();
408                let c_i = gfp_mul(s_g, gfp_inv(s_rep, p), p);
409                let d_i = gfp_sub(spaces[i].particular[g], gfp_mul(c_i, spaces[i].particular[rep], p), p);
410                cs.push((c_i, p));
411                ds.push((d_i, p));
412            }
413            let (c, _) = crt_combine(&cs);
414            let (d, _) = crt_combine(&ds);
415            let inv_c = mod_inverse(c, m);
416            for v in 0..m {
417                let sv = ((v + m - d) % m) * inv_c % m; // (v − d)·c⁻¹ mod m
418                let (bg, br) = (rec.groups[g][v as usize], rec.groups[rep][sv as usize]);
419                push_new(vec![Lit::neg(bg), Lit::pos(br)], &mut out);
420                push_new(vec![Lit::pos(bg), Lit::neg(br)], &mut out);
421            }
422        }
423    }
424
425    if out.is_empty() {
426        AffinePForced::Unchanged
427    } else {
428        AffinePForced::Forced(out)
429    }
430}
431
432/// Greatest common divisor over `u64`.
433fn gcd_u64(mut a: u64, mut b: u64) -> u64 {
434    while b != 0 {
435        (a, b) = (b, a % b);
436    }
437    a
438}
439
440/// `a·b mod q` (helper for the ring-link arithmetic).
441fn cg_mul(a: u64, b: u64, q: u64) -> u64 {
442    a % q * (b % q) % q
443}
444
445/// The kernel "column" of variable `g` in a component's solution space.
446fn kcol(ss: &crate::modm::SolutionSpaceM, g: usize) -> Vec<u64> {
447    ss.kernel_basis.iter().map(|kk| kk[g]).collect()
448}
449
450/// The composite break structure shared by the inject ([`prime_power_forced`]) and eliminate
451/// ([`affine_m_canonicalize`]) paths over a non-prime modulus: per prime-power component its Smith solution
452/// space, and per variable its tightest congruence `(res, modu)` plus (when free everywhere) its ring link
453/// `(rep, c, d)`.
454struct BreakStructure {
455    residue: Vec<(u64, u64)>,
456    link: Vec<Option<(usize, u64, u64)>>,
457}
458
459enum BreakOutcome {
460    Inconsistent,
461    Structure(BreakStructure),
462}
463
464/// Compute the [`BreakStructure`] by Smith normal form per prime-power component + CRT. `None` on overflow.
465fn composite_break_structure(eqs: &[ModpEquation], num_vars: usize, m: u64) -> Option<BreakOutcome> {
466    use crate::modm::PrimePowerSpace;
467    let factors = crate::modm::prime_power_factorize(m)?;
468    let mut spaces: Vec<(u64, u64, crate::modm::SolutionSpaceM)> = Vec::with_capacity(factors.len());
469    for (p, k) in factors {
470        match crate::modm::solve_space_prime_power(eqs, num_vars, p, k) {
471            None => return None,
472            Some(PrimePowerSpace::Inconsistent) => return Some(BreakOutcome::Inconsistent),
473            Some(PrimePowerSpace::Space(ss)) => spaces.push((p, p.pow(k), ss)),
474        }
475    }
476
477    // Each variable's tightest congruence: the CRT of its per-component coset (modulus gcd(pᵏ, kernel)).
478    let residue: Vec<(u64, u64)> = (0..num_vars)
479        .map(|g| {
480            let pairs: Vec<(u64, u64)> = spaces
481                .iter()
482                .filter_map(|(_, q, ss)| {
483                    let d = kcol(ss, g).iter().fold(*q, |acc, &x| gcd_u64(acc, x));
484                    (d > 1).then(|| (ss.particular[g] % d, d))
485                })
486                .collect();
487            if pairs.is_empty() {
488                (0, 1)
489            } else {
490                (crt_combine(&pairs).0, pairs.iter().map(|&(_, d)| d).product())
491            }
492        })
493        .collect();
494
495    // Ring links: free-everywhere variables grouped by per-component canonical column (first-unit-normalized).
496    let mut classes: std::collections::HashMap<Vec<Vec<u64>>, Vec<usize>> = std::collections::HashMap::new();
497    'g: for g in 0..num_vars {
498        let mut sig: Vec<Vec<u64>> = Vec::with_capacity(spaces.len());
499        for (p, q, ss) in &spaces {
500            let c = kcol(ss, g);
501            let Some(piv) = c.iter().position(|&x| x % p != 0) else {
502                continue 'g;
503            };
504            let inv = mod_inverse(c[piv], *q);
505            sig.push(c.iter().map(|&x| x * inv % q).collect());
506        }
507        classes.entry(sig).or_default().push(g);
508    }
509    let mut link = vec![None; num_vars];
510    let mut class_list: Vec<Vec<usize>> = classes.into_values().map(|mut v| { v.sort_unstable(); v }).collect();
511    class_list.sort_unstable_by_key(|c| c[0]);
512    for members in &class_list {
513        if members.len() < 2 {
514            continue;
515        }
516        let rep = members[0];
517        for &g in &members[1..] {
518            let (mut cs, mut ds): (Vec<(u64, u64)>, Vec<(u64, u64)>) = (Vec::new(), Vec::new());
519            for (p, q, ss) in &spaces {
520                let (cg, cr) = (kcol(ss, g), kcol(ss, rep));
521                let piv = cr.iter().position(|&x| x % p != 0).unwrap();
522                let c_c = cg[piv] * mod_inverse(cr[piv], *q) % q;
523                let d_c = (ss.particular[g] + q - cg_mul(c_c, ss.particular[rep], *q)) % q;
524                cs.push((c_c, *q));
525                ds.push((d_c, *q));
526            }
527            link[g] = Some((rep, crt_combine(&cs).0, crt_combine(&ds).0));
528        }
529    }
530
531    Some(BreakOutcome::Structure(BreakStructure { residue, link }))
532}
533
534/// The **prime-power / mixed** (non-squarefree) branch of [`affine_m_forced`] — the full INJECT break over a
535/// ring. Computes the [`composite_break_structure`] and emits its consequences: forbid every off-residue
536/// one-hot value (forced ⇒ a single value, partial ⇒ a value-subset), and the ring links as value-permuted
537/// bit-equivalences `b(g,v) ↔ b(rep, (v−d)·c⁻¹)`. An inconsistent component refutes.
538fn prime_power_forced(num_bool_vars: usize, rec: &modp::ModpRecovery, clauses: &[Vec<Lit>]) -> AffinePForced {
539    let m = rec.modulus;
540    let st = match composite_break_structure(&rec.equations, rec.num_vars, m) {
541        None => return AffinePForced::Unchanged,
542        Some(BreakOutcome::Inconsistent) => {
543            return AffinePForced::Refuted(crate::xor_drat::emit_modp_drat(num_bool_vars, clauses));
544        }
545        Some(BreakOutcome::Structure(s)) => s,
546    };
547    let key = |c: &[Lit]| -> Vec<(u32, bool)> {
548        let mut k: Vec<(u32, bool)> = c.iter().map(|l| (l.var(), l.is_positive())).collect();
549        k.sort_unstable();
550        k
551    };
552    let existing: HashSet<Vec<(u32, bool)>> = clauses.iter().map(|c| key(c)).collect();
553    let mut out: Vec<Vec<Lit>> = Vec::new();
554    let push_new = |c: Vec<Lit>, out: &mut Vec<Vec<Lit>>| {
555        if !existing.contains(&key(&c)) {
556            out.push(c);
557        }
558    };
559    for (g, &(res, modu)) in st.residue.iter().enumerate() {
560        if modu <= 1 {
561            continue;
562        }
563        for (v, &bvar) in rec.groups[g].iter().enumerate() {
564            if (v as u64) % modu != res {
565                push_new(vec![Lit::neg(bvar)], &mut out);
566            } else if modu == m {
567                push_new(vec![Lit::pos(bvar)], &mut out);
568            }
569        }
570    }
571    for g in 0..rec.num_vars {
572        if let Some((rep, c, d)) = st.link[g] {
573            let inv_c = mod_inverse(c, m);
574            for v in 0..m {
575                let sv = (v + m - d) % m * inv_c % m;
576                let (bg, br) = (rec.groups[g][v as usize], rec.groups[rep][sv as usize]);
577                push_new(vec![Lit::neg(bg), Lit::pos(br)], &mut out);
578                push_new(vec![Lit::pos(bg), Lit::neg(br)], &mut out);
579            }
580        }
581    }
582    if out.is_empty() {
583        AffinePForced::Unchanged
584    } else {
585        AffinePForced::Forced(out)
586    }
587}
588
589/// How an eliminated one-hot bit is recovered from the reduced model.
590#[derive(Clone, Debug)]
591enum BoolSub {
592    /// Pinned to a constant (a forced group's bit).
593    Const(bool),
594    /// Survives as reduced variable `new_index` (a free/representative group's bit).
595    Survive(u32),
596    /// Equal to reduced variable `new_index` — a linked group's bit aliased to its representative's,
597    /// value-permuted (`b(g,v) = b(rep, σ(v))`).
598    Alias(u32),
599}
600
601/// The reduced one-hot formula over the surviving groups, plus the lift map.
602#[derive(Clone, Debug)]
603pub struct AffinePCanonical {
604    pub num_vars: usize,
605    pub clauses: Vec<Vec<Lit>>,
606    sub: Vec<BoolSub>,
607}
608
609impl AffinePCanonical {
610    /// Lift a model of the reduced formula back over the original one-hot bits.
611    pub fn lift(&self, reduced_model: &[bool]) -> Vec<bool> {
612        self.sub
613            .iter()
614            .map(|s| match *s {
615                BoolSub::Const(c) => c,
616                BoolSub::Survive(ni) | BoolSub::Alias(ni) => reduced_model[ni as usize],
617            })
618            .collect()
619    }
620}
621
622/// The outcome of the `GF(p)` canonical elimination.
623pub enum AffinePCanon {
624    Refuted(Option<Vec<Vec<Lit>>>),
625    Canonical(AffinePCanonical),
626    Unchanged,
627}
628
629/// **The `GF(p)` canonical RREF break (elimination).** The one-hot analogue of
630/// [`crate::affine::affine_canonicalize`]: recover the mod-`p` system and solve its space, then physically
631/// *eliminate* the determined one-hot groups rather than merely injecting their consequences. A **forced**
632/// group's bits become constants; a **linked** group's bits alias to its representative's, value-permuted
633/// (`b(g,v) = b(rep, σ(v))` with `σ(v) = (v−d)·c⁻¹`). The result is an equisatisfiable formula over the
634/// surviving (free/representative) groups, with [`AffinePCanonical::lift`] to recover the eliminated bits.
635/// An inconsistent core [`AffinePCanon::Refuted`]s; a composite modulus is left to [`affine_m_forced`].
636pub fn affine_p_canonicalize(num_bool_vars: usize, clauses: &[Vec<Lit>]) -> AffinePCanon {
637    let Some(rec) = modp::recover_from_cnf(num_bool_vars, clauses) else {
638        return AffinePCanon::Unchanged;
639    };
640    let p = rec.modulus;
641    if !modp::is_prime(p) {
642        return AffinePCanon::Unchanged;
643    }
644    let Some(ss) = modp::solve_space(&rec.equations, rec.num_vars, p) else {
645        return AffinePCanon::Refuted(crate::xor_drat::emit_modp_drat(num_bool_vars, clauses));
646    };
647
648    // Classify each GF(p) variable: forced to a constant, or a member of a projective (linked) class.
649    enum Role {
650        Forced(u64),
651        Survive,
652        Linked { rep: usize, c: u64, d: u64 },
653    }
654    let col = |g: usize| -> Vec<u64> { ss.kernel_basis.iter().map(|k| k[g]).collect() };
655    let mut role: Vec<Role> = Vec::with_capacity(rec.num_vars);
656    let mut classes: std::collections::HashMap<Vec<u64>, Vec<usize>> = std::collections::HashMap::new();
657    for g in 0..rec.num_vars {
658        let c = col(g);
659        if c.iter().all(|&x| x == 0) {
660            role.push(Role::Forced(ss.particular[g]));
661        } else {
662            let s = gfp_inv(*c.iter().find(|&&x| x != 0).unwrap(), p);
663            classes.entry(c.iter().map(|&x| gfp_mul(x, s, p)).collect()).or_default().push(g);
664            role.push(Role::Survive); // provisional; linked members are rewritten below
665        }
666    }
667    for members in classes.values() {
668        let mut ms = members.clone();
669        ms.sort_unstable();
670        let rep = ms[0];
671        let s_rep = *col(rep).iter().find(|&&x| x != 0).unwrap();
672        for &g in &ms[1..] {
673            let s_g = *col(g).iter().find(|&&x| x != 0).unwrap();
674            let c = gfp_mul(s_g, gfp_inv(s_rep, p), p);
675            let d = gfp_sub(ss.particular[g], gfp_mul(c, ss.particular[rep], p), p);
676            role[g] = Role::Linked { rep, c, d };
677        }
678    }
679
680    // Assign reduced indices to the surviving bits (free/representative groups, then any non-group bits).
681    let mut in_group = vec![false; num_bool_vars];
682    for grp in &rec.groups {
683        for &b in grp {
684            in_group[b as usize] = true;
685        }
686    }
687    let mut new_idx: Vec<Option<u32>> = vec![None; num_bool_vars];
688    let mut next = 0u32;
689    for g in 0..rec.num_vars {
690        if matches!(role[g], Role::Survive) {
691            for &b in &rec.groups[g] {
692                new_idx[b as usize] = Some(next);
693                next += 1;
694            }
695        }
696    }
697    for (b, slot) in new_idx.iter_mut().enumerate() {
698        if !in_group[b] {
699            *slot = Some(next);
700            next += 1;
701        }
702    }
703    let reduced_nv = next as usize;
704
705    // Build the per-bit substitution.
706    let mut sub = vec![BoolSub::Const(false); num_bool_vars];
707    for (b, slot) in new_idx.iter().enumerate() {
708        if let Some(ni) = slot {
709            sub[b] = BoolSub::Survive(*ni); // surviving + non-group bits
710        }
711    }
712    for g in 0..rec.num_vars {
713        match role[g] {
714            Role::Forced(val) => {
715                for (v, &b) in rec.groups[g].iter().enumerate() {
716                    sub[b as usize] = BoolSub::Const(v as u64 == val);
717                }
718            }
719            Role::Survive => {} // already Survive
720            Role::Linked { rep, c, d } => {
721                let inv_c = gfp_inv(c, p);
722                for (v, &b) in rec.groups[g].iter().enumerate() {
723                    let sv = gfp_mul(gfp_sub(v as u64, d, p), inv_c, p); // σ(v) = (v − d)·c⁻¹
724                    let rep_b = rec.groups[rep][sv as usize];
725                    sub[b as usize] = BoolSub::Alias(new_idx[rep_b as usize].unwrap());
726                }
727            }
728        }
729    }
730    if !sub.iter().any(|s| matches!(s, BoolSub::Const(_) | BoolSub::Alias(_))) {
731        return AffinePCanon::Unchanged; // nothing eliminated
732    }
733
734    // Apply the substitution to every clause, staying in CNF.
735    let mut out: Vec<Vec<Lit>> = Vec::new();
736    'clause: for c in clauses {
737        let mut seen: std::collections::HashMap<u32, bool> = std::collections::HashMap::new();
738        let mut lits: Vec<Lit> = Vec::new();
739        for l in c {
740            let (ni, pol) = match sub[l.var() as usize] {
741                BoolSub::Const(cst) => {
742                    if cst == l.is_positive() {
743                        continue 'clause; // literal true ⇒ clause satisfied
744                    }
745                    continue; // literal false ⇒ drop it
746                }
747                BoolSub::Survive(ni) | BoolSub::Alias(ni) => (ni, l.is_positive()),
748            };
749            match seen.get(&ni) {
750                Some(&prev) if prev != pol => continue 'clause, // tautology
751                Some(_) => continue,                            // duplicate
752                None => {
753                    seen.insert(ni, pol);
754                    lits.push(Lit::new(ni, pol));
755                }
756            }
757        }
758        out.push(lits);
759    }
760    AffinePCanon::Canonical(AffinePCanonical { num_vars: reduced_nv, clauses: out, sub })
761}
762
763/// **The composite-ring canonical elimination.** The `ℤ/m` analogue of [`affine_p_canonicalize`] and the
764/// eliminate twin of [`prime_power_forced`]: over a non-prime modulus, recover the mod-`m` system, take its
765/// [`composite_break_structure`], and physically *eliminate* the determined one-hot structure rather than
766/// merely injecting it. A **forced** group (`x_g` pinned to a single value) becomes constants; a **partially
767/// forced** group (`x_g` confined to a coset `v ≡ res (mod modu)`, `1 < modu < m`) keeps only its on-coset
768/// bits, the rest pinned false; a **linked** group's bits alias its representative's, value-permuted
769/// (`b(g,v) = b(rep, σ(v))`, `σ(v) = (v−d)·c⁻¹ mod m`). The result is equisatisfiable over the surviving
770/// bits, with [`AffinePCanonical::lift`] to recover the rest. An inconsistent component
771/// [`AffinePCanon::Refuted`]s; a prime modulus is left to [`affine_p_canonicalize`].
772pub fn affine_m_canonicalize(num_bool_vars: usize, clauses: &[Vec<Lit>]) -> AffinePCanon {
773    let Some(rec) = modp::recover_from_cnf(num_bool_vars, clauses) else {
774        return AffinePCanon::Unchanged;
775    };
776    let m = rec.modulus;
777    if modp::is_prime(m) {
778        return AffinePCanon::Unchanged; // prime ⇒ the field path
779    }
780    let st = match composite_break_structure(&rec.equations, rec.num_vars, m) {
781        None => return AffinePCanon::Unchanged,
782        Some(BreakOutcome::Inconsistent) => {
783            return AffinePCanon::Refuted(crate::xor_drat::emit_modp_drat(num_bool_vars, clauses));
784        }
785        Some(BreakOutcome::Structure(s)) => s,
786    };
787
788    enum Role {
789        Forced(u64),
790        Partial { res: u64, modu: u64 },
791        Survive,
792        Linked { rep: usize, c: u64, d: u64 },
793    }
794    let role: Vec<Role> = (0..rec.num_vars)
795        .map(|g| {
796            let (res, modu) = st.residue[g];
797            if modu == m {
798                Role::Forced(res)
799            } else if modu > 1 {
800                Role::Partial { res, modu }
801            } else if let Some((rep, c, d)) = st.link[g] {
802                Role::Linked { rep, c, d }
803            } else {
804                Role::Survive
805            }
806        })
807        .collect();
808
809    // Assign reduced indices to the surviving bits: free groups' bits, partial groups' on-coset bits, then
810    // any non-group bits.
811    let mut in_group = vec![false; num_bool_vars];
812    for grp in &rec.groups {
813        for &b in grp {
814            in_group[b as usize] = true;
815        }
816    }
817    let mut new_idx: Vec<Option<u32>> = vec![None; num_bool_vars];
818    let mut next = 0u32;
819    for g in 0..rec.num_vars {
820        match role[g] {
821            Role::Survive => {
822                for &b in &rec.groups[g] {
823                    new_idx[b as usize] = Some(next);
824                    next += 1;
825                }
826            }
827            Role::Partial { res, modu } => {
828                for (v, &b) in rec.groups[g].iter().enumerate() {
829                    if (v as u64) % modu == res {
830                        new_idx[b as usize] = Some(next);
831                        next += 1;
832                    }
833                }
834            }
835            _ => {}
836        }
837    }
838    for (b, slot) in new_idx.iter_mut().enumerate() {
839        if !in_group[b] {
840            *slot = Some(next);
841            next += 1;
842        }
843    }
844    let reduced_nv = next as usize;
845
846    // Build the per-bit substitution. Surviving (free, on-coset, non-group) bits are set above; forced bits
847    // become constants, off-coset partial bits false, linked bits value-permuted aliases.
848    let mut sub = vec![BoolSub::Const(false); num_bool_vars];
849    for (b, slot) in new_idx.iter().enumerate() {
850        if let Some(ni) = slot {
851            sub[b] = BoolSub::Survive(*ni);
852        }
853    }
854    for g in 0..rec.num_vars {
855        match role[g] {
856            Role::Forced(val) => {
857                for (v, &b) in rec.groups[g].iter().enumerate() {
858                    sub[b as usize] = BoolSub::Const(v as u64 == val);
859                }
860            }
861            Role::Partial { .. } | Role::Survive => {} // already set by the surviving-bit pass
862            Role::Linked { rep, c, d } => {
863                let inv_c = mod_inverse(c, m);
864                for (v, &b) in rec.groups[g].iter().enumerate() {
865                    let sv = (v as u64 + m - d) % m * inv_c % m; // σ(v) = (v − d)·c⁻¹
866                    let rep_b = rec.groups[rep][sv as usize];
867                    sub[b as usize] = BoolSub::Alias(new_idx[rep_b as usize].unwrap());
868                }
869            }
870        }
871    }
872    if !sub.iter().any(|s| matches!(s, BoolSub::Const(_) | BoolSub::Alias(_))) {
873        return AffinePCanon::Unchanged; // nothing eliminated
874    }
875
876    // Apply the substitution to every clause, staying in CNF.
877    let mut out: Vec<Vec<Lit>> = Vec::new();
878    'clause: for c in clauses {
879        let mut seen: std::collections::HashMap<u32, bool> = std::collections::HashMap::new();
880        let mut lits: Vec<Lit> = Vec::new();
881        for l in c {
882            let (ni, pol) = match sub[l.var() as usize] {
883                BoolSub::Const(cst) => {
884                    if cst == l.is_positive() {
885                        continue 'clause; // literal true ⇒ clause satisfied
886                    }
887                    continue; // literal false ⇒ drop it
888                }
889                BoolSub::Survive(ni) | BoolSub::Alias(ni) => (ni, l.is_positive()),
890            };
891            match seen.get(&ni) {
892                Some(&prev) if prev != pol => continue 'clause, // tautology
893                Some(_) => continue,                            // duplicate
894                None => {
895                    seen.insert(ni, pol);
896                    lits.push(Lit::new(ni, pol));
897                }
898            }
899        }
900        out.push(lits);
901    }
902    AffinePCanon::Canonical(AffinePCanonical { num_vars: reduced_nv, clauses: out, sub })
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908    use crate::families;
909
910    /// `|AGL(n,p)| = pⁿ·|GL(n,p)|`, and the exhaustive enumeration produces exactly that many bijections.
911    #[test]
912    fn agl_p_order_matches_the_enumeration() {
913        for (n, p) in [(1usize, 2u64), (1, 3), (2, 2), (2, 3), (2, 5), (3, 2), (3, 3)] {
914            assert_eq!(
915                all_affine_p_bijections(n, p).len() as u128,
916                agl_p_order(n as u32, p),
917                "n={n}, p={p}: enumerated affine bijections must equal |AGL(n,p)| = pⁿ·|GL(n,p)|"
918            );
919        }
920        // Pinned: |AGL(2,3)| = 9·48 = 432, |AGL(3,2)| = 8·168 = 1344.
921        assert_eq!(agl_p_order(2, 3), 432);
922        assert_eq!(agl_p_order(3, 2), 1344);
923    }
924
925    /// Group laws over `GF(p)`: identity is a unit, and composition matches pointwise application.
926    #[test]
927    fn affine_p_maps_compose_and_act_correctly() {
928        let p = 3u64;
929        let id = AffineP::identity(2, p);
930        for phi in all_affine_p_bijections(2, p) {
931            assert!(phi.is_bijection());
932            assert_eq!(phi.compose(&id), phi);
933            assert_eq!(id.compose(&phi), phi);
934            let sq = phi.compose(&phi);
935            for a in 0..p {
936                for b in 0..p {
937                    let x = vec![a, b];
938                    assert_eq!(sq.apply(&x), phi.apply(&phi.apply(&x)), "composition must match double application");
939                }
940            }
941        }
942    }
943
944    /// **THE CENSUS GAP OVER `GF(p)`: `AGL(3,3) ⊋ monomial`.** The mod-3 parity plane `x₀+x₁+x₂ ≡ 0 (mod 3)`
945    /// is rigid under the monomial (scaled-permutation) breakers but carries `864·9 = 7776` affine
946    /// symmetries (864 linear maps fix the hyperplane × 9 in-plane translations) — far more than the
947    /// monomial subgroup. The witness is a genuine `GF(3)` **shear**: a symmetry whose matrix is not
948    /// monomial, the mod-`p` analog of the parity shear no clause break can see.
949    #[test]
950    fn gfp_affine_symmetry_strictly_exceeds_monomial_on_mod_p_parity() {
951        let eq = ModpEquation::new(vec![(0usize, 1u64), (1, 1), (2, 1)], 0); // x0+x1+x2 ≡ 0 (mod 3)
952        let models = models_p(3, 3, std::slice::from_ref(&eq));
953        assert_eq!(models.len(), 9, "the mod-3 hyperplane has 3² = 9 points");
954
955        let sym = affine_p_symmetries(3, 3, &models);
956        assert_eq!(sym.len(), 7776, "AGL(3,3) stabilizer of the mod-3 hyperplane = 864 linear × 9 translations");
957
958        let monomial = sym.iter().filter(|a| a.is_monomial()).count();
959        assert!(sym.len() > monomial, "affine symmetry ({}) must strictly exceed the monomial part ({monomial})", sym.len());
960        assert!(
961            sym.iter().any(|a| !a.is_monomial()),
962            "a non-monomial GF(3) affine symmetry (a shear) must exist — invisible to the monomial breakers"
963        );
964    }
965
966    /// **The `GF(p)` affine refutation is certified through the mod-`p` DRAT bridge.** On a mod-`p` Tseitin
967    /// counting core (UNSAT over `GF(p)`, exponential for resolution and Z3), [`affine_p_refutation_drat`]
968    /// compiles the `GF(p)` dependency to RUP resolvent lemmas over the one-hot encoding that our
969    /// independent checker accepts against the original CNF.
970    #[test]
971    fn gfp_affine_refutation_is_certified_via_modp_drat_bridge() {
972        for (n, p) in [(4usize, 3u64), (6, 3), (4, 5)] {
973            let (_, cnf, _) = families::mod_p_tseitin_expander(n, p, 1);
974            let Some(proof) = affine_p_refutation_drat(cnf.num_vars, &cnf.clauses) else {
975                eprintln!("[modp n={n} p={p}] resolution route over budget — skipped");
976                continue;
977            };
978            assert!(proof.last().is_some_and(|c| c.is_empty()), "proof ends in the empty clause (n={n}, p={p})");
979            assert!(
980                crate::rup::check_refutation(cnf.num_vars, &cnf.clauses, &proof),
981                "the GF({p}) affine refutation must RUP-refute the original CNF (n={n})"
982            );
983        }
984    }
985
986    /// **The GF(p) solution-space solver is exact.** Against brute force over hundreds of random `GF(p)`
987    /// systems: it reports inconsistency iff there are no solutions, its `count` equals the solution
988    /// count, and its `particular + kernel` enumerates *exactly* the solution set.
989    #[test]
990    fn solve_space_matches_brute_force() {
991        use crate::modp::solve_space;
992        let mut state = 0xC0FFEE_77u64;
993        let mut rng = || {
994            state ^= state << 13;
995            state ^= state >> 7;
996            state ^= state << 17;
997            state
998        };
999        for &p in &[3u64, 5] {
1000            for _ in 0..200 {
1001                let n = 2 + (rng() % 3) as usize; // 2..=4 variables
1002                let m = 1 + (rng() % 4) as usize;
1003                let eqs: Vec<ModpEquation> = (0..m)
1004                    .map(|_| {
1005                        let k = 1 + (rng() % n as u64) as usize;
1006                        let mut vars: Vec<usize> = Vec::new();
1007                        while vars.len() < k {
1008                            let v = (rng() % n as u64) as usize;
1009                            if !vars.contains(&v) {
1010                                vars.push(v);
1011                            }
1012                        }
1013                        let coeffs: Vec<(usize, u64)> = vars.iter().map(|&v| (v, 1 + rng() % (p - 1))).collect();
1014                        ModpEquation::new(coeffs, rng() % p)
1015                    })
1016                    .collect();
1017                let brute: HashSet<Vec<u64>> = models_p(n, p, &eqs).into_iter().collect();
1018                match solve_space(&eqs, n, p) {
1019                    None => assert!(brute.is_empty(), "None ⇒ no GF({p}) solutions"),
1020                    Some(ss) => {
1021                        assert_eq!(ss.count() as usize, brute.len(), "count must equal the brute-force solution count");
1022                        let got: HashSet<Vec<u64>> = ss.enumerate().into_iter().collect();
1023                        assert_eq!(got, brute, "particular + kernel must enumerate exactly the GF({p}) solutions");
1024                    }
1025                }
1026            }
1027        }
1028    }
1029
1030    /// Encode a mod-`p` system in one-hot Boolean form exactly as the families do: `b(e,val) = e·p+val`,
1031    /// at-least-one + at-most-one per edge, and per-equation "forbid every wrong-sum assignment" clauses.
1032    fn onehot_cnf(p: u64, num_edges: usize, equations: &[ModpEquation]) -> (usize, Vec<Vec<Lit>>) {
1033        let bvar = |e: usize, val: u64| (e * p as usize + val as usize) as u32;
1034        let mut clauses: Vec<Vec<Lit>> = Vec::new();
1035        for e in 0..num_edges {
1036            clauses.push((0..p).map(|v| Lit::pos(bvar(e, v))).collect());
1037            for v1 in 0..p {
1038                for v2 in (v1 + 1)..p {
1039                    clauses.push(vec![Lit::neg(bvar(e, v1)), Lit::neg(bvar(e, v2))]);
1040                }
1041            }
1042        }
1043        for eq in equations {
1044            let d = eq.coeffs.len();
1045            for idx in 0..p.pow(d as u32) {
1046                let mut x = idx;
1047                let combo: Vec<u64> = (0..d)
1048                    .map(|_| {
1049                        let v = x % p;
1050                        x /= p;
1051                        v
1052                    })
1053                    .collect();
1054                let sum = eq.coeffs.iter().zip(&combo).fold(0u64, |a, (&(_, co), &val)| (a + co * val) % p);
1055                if sum % p != eq.rhs % p {
1056                    clauses.push(eq.coeffs.iter().zip(&combo).map(|(&(v, _), &val)| Lit::neg(bvar(v, val))).collect());
1057                }
1058            }
1059        }
1060        (num_edges * p as usize, clauses)
1061    }
1062
1063    /// **The GF(p) SAT-side break pins a determined edge — soundly.** A 3-edge `GF(3)` system whose
1064    /// equations force every edge (`x0=1, x1=2, x2=1`): the break recovers it, finds all three forced, and
1065    /// emits the one-hot units — each of which is verified to hold in *every* Boolean model of the CNF.
1066    #[test]
1067    fn affine_p_forced_pins_determined_edges_and_is_sound() {
1068        // x0+x1≡0, x1+x2≡0, x0+x2≡2 (mod 3) ⇒ x0=1, x1=2, x2=1, all forced.
1069        let eqs = vec![
1070            ModpEquation::new(vec![(0usize, 1u64), (1, 1)], 0),
1071            ModpEquation::new(vec![(1usize, 1u64), (2, 1)], 0),
1072            ModpEquation::new(vec![(0usize, 1u64), (2, 1)], 2),
1073        ];
1074        let (nbv, clauses) = onehot_cnf(3, 3, &eqs);
1075        match affine_p_forced(nbv, &clauses) {
1076            AffinePForced::Forced(units) => {
1077                // b(0,1)=1, b(1,2)=5, b(2,1)=7 must be pinned true.
1078                for &(e, v) in &[(0usize, 1u64), (1, 2), (2, 1)] {
1079                    let bvar = (e * 3 + v as usize) as u32;
1080                    assert!(units.contains(&vec![Lit::pos(bvar)]), "must pin b(edge{e}={v}) = var {bvar}; got {units:?}");
1081                }
1082                // Soundness: every forced unit holds in every Boolean model of the one-hot CNF.
1083                let models = crate::affine::models_of(nbv, &clauses);
1084                assert!(!models.is_empty(), "the crafted determined system is satisfiable");
1085                for u in &units {
1086                    let l = u[0];
1087                    assert!(
1088                        models.iter().all(|&m| ((m >> l.var()) & 1 == 1) == l.is_positive()),
1089                        "forced unit on var {} must hold in every Boolean model",
1090                        l.var()
1091                    );
1092                }
1093            }
1094            _ => panic!("the determined edges must produce forced one-hot units"),
1095        }
1096    }
1097
1098    /// **A GF(p) link lifts to value-permuted bit-equivalences.** Over `GF(3)`, `x0 + x1 ≡ 0` links the two
1099    /// edges by the scalar `c = 2` (`x0 = 2·x1`) with neither forced — the solution space exposes the
1100    /// proportional kernel columns, and the SAT-side break lifts the link to one-hot bit-equivalences that
1101    /// genuinely *permute values* (e.g. `b(x=1) ↔ b(=2)`), the mod-`p` shear no clause equivalence expresses.
1102    #[test]
1103    fn gfp_link_lifts_to_value_permuted_bit_equivalences() {
1104        let p = 3u64;
1105        let eqs = vec![ModpEquation::new(vec![(0usize, 1u64), (1, 1)], 0)];
1106
1107        // Abstract: one free direction, and x0's kernel entry is exactly 2× x1's — the scalar link c = 2.
1108        let ss = crate::modp::solve_space(&eqs, 2, p).expect("consistent system");
1109        assert_eq!(ss.kernel_basis.len(), 1, "one free variable ⇒ a single kernel direction");
1110        let kv = &ss.kernel_basis[0];
1111        assert!(kv[0] != 0 && kv[1] != 0, "neither variable is forced");
1112        assert_eq!(kv[0], (2 * kv[1]) % p, "x0's kernel entry is 2× x1's — the GF(3) scalar link");
1113
1114        // Boolean: the break fires with sound, value-permuted bit-equivalences.
1115        let (nbv, clauses) = onehot_cnf(p, 2, &eqs);
1116        let AffinePForced::Forced(extra) = affine_p_forced(nbv, &clauses) else {
1117            panic!("the link must fire as forced consequences");
1118        };
1119        assert!(extra.iter().any(|c| c.len() == 2), "must emit bit-equivalences (2-literal clauses)");
1120        let models = crate::affine::models_of(nbv, &clauses);
1121        for c in &extra {
1122            assert!(
1123                models.iter().all(|&m| c.iter().any(|l| ((m >> l.var()) & 1 == 1) == l.is_positive())),
1124                "every emitted equivalence must hold in every Boolean model"
1125            );
1126        }
1127        // Non-trivial: at least one equivalence relates DIFFERENT value-positions across the two groups.
1128        let rec = crate::modp::recover_from_cnf(nbv, &clauses).expect("recovers");
1129        let pos_of = |bv: u32| {
1130            rec.groups.iter().enumerate().find_map(|(g, grp)| grp.iter().position(|&b| b == bv).map(|v| (g, v)))
1131        };
1132        let value_permuted = extra.iter().filter(|c| c.len() == 2).any(|c| {
1133            matches!((pos_of(c[0].var()), pos_of(c[1].var())), (Some((g0, v0)), Some((g1, v1))) if g0 != g1 && v0 != v1)
1134        });
1135        assert!(value_permuted, "a genuine GF(3) link must produce a value-permuted bit-equivalence");
1136    }
1137
1138    /// **Soundness to the point of absurdity.** Random `GF(3)`/`GF(5)` systems over two one-hot edges, the
1139    /// whole SAT-side break (forced units AND linked bit-equivalences) brute-force-checked: every emitted
1140    /// clause holds in *every* Boolean model, and `Refuted` only ever names a genuinely-UNSAT formula.
1141    #[test]
1142    fn affine_p_break_is_sound_against_brute_force() {
1143        let mut state = 0x5A7_AFF1_9E37u64;
1144        let mut rng = || {
1145            state ^= state << 13;
1146            state ^= state >> 7;
1147            state ^= state << 17;
1148            state
1149        };
1150        for &p in &[3u64, 5] {
1151            for _ in 0..200 {
1152                let m = 1 + (rng() % 3) as usize;
1153                let eqs: Vec<ModpEquation> = (0..m)
1154                    .map(|_| {
1155                        // 2-variable equations over the two edges (what recover_from_cnf reconstructs).
1156                        ModpEquation::new(vec![(0usize, 1 + rng() % (p - 1)), (1, 1 + rng() % (p - 1))], rng() % p)
1157                    })
1158                    .collect();
1159                let (nbv, clauses) = onehot_cnf(p, 2, &eqs);
1160                let models = crate::affine::models_of(nbv, &clauses);
1161                match affine_p_forced(nbv, &clauses) {
1162                    AffinePForced::Refuted(_) => assert!(models.is_empty(), "Refuted ⇒ the Boolean CNF must be UNSAT"),
1163                    AffinePForced::Forced(extra) => {
1164                        for c in &extra {
1165                            assert!(
1166                                models.iter().all(|&m| c.iter().any(|l| ((m >> l.var()) & 1 == 1) == l.is_positive())),
1167                                "every forced/linked consequence must hold in every Boolean model"
1168                            );
1169                        }
1170                    }
1171                    AffinePForced::Unchanged => {}
1172                }
1173            }
1174        }
1175    }
1176
1177    /// `|AGL(n, ℤ/m)|` factors by CRT: `|AGL(2, ℤ/6)| = |AGL(2,2)|·|AGL(2,3)| = 24·432 = 10368`, and a
1178    /// non-squarefree modulus has no such product form.
1179    #[test]
1180    fn agl_m_order_factors_by_crt() {
1181        assert_eq!(agl_m_order(2, 6), Some(24 * 432));
1182        assert_eq!(agl_m_order(2, 30), Some(24 * 432 * 12000)); // 2·3·5
1183        assert_eq!(agl_m_order(2, 4), None, "4 is not squarefree");
1184        assert_eq!(agl_m_order(2, 12), None, "12 = 2²·3 is not squarefree");
1185    }
1186
1187    /// Brute-force the GF(m)-valued solutions of a `ℤ/m` system (`Σ coeffs·x ≡ rhs mod m`).
1188    fn models_m(n: usize, m: u64, equations: &[ModpEquation]) -> Vec<Vec<u64>> {
1189        (0..m.pow(n as u32))
1190            .filter_map(|code| {
1191                let mut c = code;
1192                let x: Vec<u64> = (0..n)
1193                    .map(|_| {
1194                        let v = c % m;
1195                        c /= m;
1196                        v
1197                    })
1198                    .collect();
1199                equations
1200                    .iter()
1201                    .all(|eq| eq.coeffs.iter().fold(0u64, |a, &(v, co)| (a + co * x[v]) % m) % m == eq.rhs % m)
1202                    .then_some(x)
1203            })
1204            .collect()
1205    }
1206
1207    /// **The composite forced-value detector is exact.** Against brute force over random squarefree-`m`
1208    /// systems: a variable is reported forced iff it takes one value across every `ℤ/m` solution, with that
1209    /// value; `Inconsistent` iff there are no solutions.
1210    #[test]
1211    fn forced_values_squarefree_matches_brute_force() {
1212        use crate::modm::{forced_values_squarefree, ForcedM};
1213        let mut state = 0xC0DE_B0D5u64;
1214        let mut rng = || {
1215            state ^= state << 13;
1216            state ^= state >> 7;
1217            state ^= state << 17;
1218            state
1219        };
1220        for &m in &[6u64, 10, 15] {
1221            for _ in 0..150 {
1222                let eqs: Vec<ModpEquation> = (0..(1 + rng() % 3))
1223                    .map(|_| ModpEquation::new(vec![(0usize, rng() % m), (1, rng() % m)], rng() % m))
1224                    .collect();
1225                let models = models_m(2, m, &eqs);
1226                let brute: Vec<Option<u64>> = (0..2)
1227                    .map(|g| {
1228                        let vals: HashSet<u64> = models.iter().map(|x| x[g]).collect();
1229                        if vals.len() == 1 { vals.into_iter().next() } else { None }
1230                    })
1231                    .collect();
1232                match forced_values_squarefree(&eqs, 2, m) {
1233                    None => panic!("m={m} is squarefree"),
1234                    Some(ForcedM::Inconsistent) => assert!(models.is_empty(), "Inconsistent ⇒ no ℤ/{m} solutions"),
1235                    Some(ForcedM::Forced(f)) => assert_eq!(f, brute, "forced values must match brute force (m={m})"),
1236                }
1237            }
1238        }
1239    }
1240
1241    /// **The composite ℤ/m SAT-side break pins forced edges — soundly.** `x0 ≡ 5`, `x1 ≡ 2` over `ℤ/6`
1242    /// (one congruence per edge, as the recovery requires): the break recovers it, CRT-combines the
1243    /// `GF(2)×GF(3)` forced values, and emits one-hot units each verified against every Boolean model.
1244    #[test]
1245    fn affine_m_forced_pins_composite_modulus_and_is_sound() {
1246        let m = 6u64;
1247        let eqs = vec![
1248            ModpEquation::new(vec![(0usize, 1u64)], 5),
1249            ModpEquation::new(vec![(1usize, 1u64)], 2),
1250        ];
1251        let (nbv, clauses) = onehot_cnf(m, 2, &eqs);
1252        match affine_m_forced(nbv, &clauses) {
1253            AffinePForced::Forced(units) => {
1254                assert!(units.contains(&vec![Lit::pos(5)]), "must pin b(edge0=5); got {units:?}"); // 0*6+5
1255                assert!(units.contains(&vec![Lit::pos(8)]), "must pin b(edge1=2)"); // 1*6+2
1256                let models = crate::affine::models_of(nbv, &clauses);
1257                assert!(!models.is_empty(), "the crafted ℤ/6 system is satisfiable");
1258                for u in &units {
1259                    let l = u[0];
1260                    assert!(
1261                        models.iter().all(|&mm| ((mm >> l.var()) & 1 == 1) == l.is_positive()),
1262                        "forced unit on var {} must hold in every Boolean model",
1263                        l.var()
1264                    );
1265                }
1266            }
1267            _ => panic!("the determined ℤ/6 edges must produce forced one-hot units"),
1268        }
1269    }
1270
1271    /// **The composite break refutes a zero-divisor obstruction.** Over `ℤ/6` the 3-cycle forcing
1272    /// `2·x ≡ 3` is inconsistent (2 is a zero-divisor, so `2·x ≡ 3` is unsolvable) — the break reports
1273    /// `Refuted` on the genuinely-UNSAT one-hot encoding, a composite obstruction no prime field sees alone.
1274    #[test]
1275    fn affine_m_forced_refutes_a_composite_zero_divisor_obstruction() {
1276        let m = 6u64;
1277        let eqs = vec![
1278            ModpEquation::new(vec![(0usize, 1u64), (1, 5)], 0), // x0 − x1 ≡ 0
1279            ModpEquation::new(vec![(1usize, 1u64), (2, 5)], 0), // x1 − x2 ≡ 0
1280            ModpEquation::new(vec![(0usize, 1u64), (2, 1)], 3), // x0 + x2 ≡ 3  ⇒  2·x0 ≡ 3, unsolvable
1281        ];
1282        assert!(models_m(3, m, &eqs).is_empty(), "the ℤ/6 system is genuinely inconsistent");
1283        let (nbv, clauses) = onehot_cnf(m, 3, &eqs);
1284        assert!(matches!(affine_m_forced(nbv, &clauses), AffinePForced::Refuted(_)), "must refute the ℤ/6 obstruction");
1285    }
1286
1287    /// **A composite ℤ/m link lifts to value-permuted bit-equivalences via CRT.** `x0 + x1 ≡ 0` over `ℤ/6`
1288    /// links `x0 = 5·x1` — the unit `c = 5 = CRT(1 mod 2, 2 mod 3)`, neither variable forced. The break
1289    /// emits value-permuted one-hot equivalences `b(x0=v) ↔ b(x1=…)`, each sound against every Boolean
1290    /// model, the composite shear no monomial break sees.
1291    #[test]
1292    fn affine_m_link_lifts_to_composite_value_permuted_equivalences() {
1293        let m = 6u64;
1294        let eqs = vec![ModpEquation::new(vec![(0usize, 1u64), (1, 1)], 0)];
1295        let (nbv, clauses) = onehot_cnf(m, 2, &eqs);
1296        let AffinePForced::Forced(extra) = affine_m_forced(nbv, &clauses) else {
1297            panic!("the ℤ/6 link must fire");
1298        };
1299        assert!(extra.iter().any(|c| c.len() == 2), "must emit value-permuted bit-equivalences (2-literal clauses)");
1300        let models = crate::affine::models_of(nbv, &clauses);
1301        for c in &extra {
1302            assert!(
1303                models.iter().all(|&mm| c.iter().any(|l| ((mm >> l.var()) & 1 == 1) == l.is_positive())),
1304                "every emitted equivalence must hold in every Boolean model"
1305            );
1306        }
1307        let rec = crate::modp::recover_from_cnf(nbv, &clauses).expect("recovers");
1308        let pos_of = |bv: u32| {
1309            rec.groups.iter().enumerate().find_map(|(g, grp)| grp.iter().position(|&b| b == bv).map(|v| (g, v)))
1310        };
1311        assert!(
1312            extra.iter().filter(|c| c.len() == 2).any(|c| matches!(
1313                (pos_of(c[0].var()), pos_of(c[1].var())),
1314                (Some((g0, v0)), Some((g1, v1))) if g0 != g1 && v0 != v1
1315            )),
1316            "a genuine ℤ/6 link must produce a value-permuted equivalence"
1317        );
1318    }
1319
1320    /// **Soundness to the point of absurdity over composite moduli.** Random `ℤ/6`, `ℤ/10`, `ℤ/15` systems
1321    /// (distinct-subset congruences the recovery accepts), the whole composite break — forced, partially
1322    /// forced, AND linked consequences — brute-force-checked: every emitted clause holds in *every* Boolean
1323    /// model, and `Refuted` only ever names a genuinely-UNSAT formula.
1324    #[test]
1325    fn affine_m_break_is_sound_against_brute_force() {
1326        let mut state = 0x0BAD_F00D_77u64;
1327        let mut rng = || {
1328            state ^= state << 13;
1329            state ^= state >> 7;
1330            state ^= state << 17;
1331            state
1332        };
1333        // ℤ/6 keeps the Boolean encoding small (2 edges = 12 bits, under models_of's brute-force cap)
1334        // while exercising forced + partial + linked consequences; larger composites are covered by the
1335        // direct forced_values_squarefree fuzz.
1336        for &m in &[6u64] {
1337            for _ in 0..300 {
1338                let mut eqs: Vec<ModpEquation> = Vec::new();
1339                if rng() & 1 == 0 {
1340                    eqs.push(ModpEquation::new(vec![(0usize, 1 + rng() % (m - 1))], rng() % m));
1341                }
1342                if rng() & 1 == 0 {
1343                    eqs.push(ModpEquation::new(vec![(1usize, 1 + rng() % (m - 1))], rng() % m));
1344                }
1345                if rng() & 1 == 0 {
1346                    eqs.push(ModpEquation::new(vec![(0usize, 1 + rng() % (m - 1)), (1, 1 + rng() % (m - 1))], rng() % m));
1347                }
1348                if eqs.is_empty() {
1349                    continue;
1350                }
1351                let (nbv, clauses) = onehot_cnf(m, 2, &eqs);
1352                let models = crate::affine::models_of(nbv, &clauses);
1353                match affine_m_forced(nbv, &clauses) {
1354                    AffinePForced::Refuted(_) => assert!(models.is_empty(), "Refuted ⇒ the Boolean CNF must be UNSAT (m={m})"),
1355                    AffinePForced::Forced(extra) => {
1356                        for c in &extra {
1357                            assert!(
1358                                models.iter().all(|&mm| c.iter().any(|l| ((mm >> l.var()) & 1 == 1) == l.is_positive())),
1359                                "every composite consequence must hold in every Boolean model (m={m})"
1360                            );
1361                        }
1362                    }
1363                    AffinePForced::Unchanged => {}
1364                }
1365            }
1366        }
1367    }
1368
1369    /// **The GF(p) elimination shrinks and lifts soundly.** Over `GF(3)`, `x0+x1 ≡ 0` (links `x0 = 2·x1`)
1370    /// and `x2 ≡ 1` (forced): the `x2` group collapses to constants and the `x0` group aliases
1371    /// value-permuted to `x1`, so only `x1`'s group survives — and every reduced model lifts to a genuine
1372    /// model of the original.
1373    #[test]
1374    fn affine_p_canonicalize_eliminates_and_lifts_soundly() {
1375        let eqs = vec![
1376            ModpEquation::new(vec![(0usize, 1u64), (1, 1)], 0),
1377            ModpEquation::new(vec![(2usize, 1u64)], 1),
1378        ];
1379        let (nbv, clauses) = onehot_cnf(3, 3, &eqs);
1380        match affine_p_canonicalize(nbv, &clauses) {
1381            AffinePCanon::Canonical(canon) => {
1382                assert!(canon.num_vars < nbv, "elimination must shrink the bit count ({} < {nbv})", canon.num_vars);
1383                let orig = crate::affine::models_of(nbv, &clauses);
1384                let red = crate::affine::models_of(canon.num_vars, &canon.clauses);
1385                assert_eq!(red.is_empty(), orig.is_empty(), "reduction must preserve satisfiability");
1386                for &rm_bits in &red {
1387                    let rm: Vec<bool> = (0..canon.num_vars).map(|i| (rm_bits >> i) & 1 == 1).collect();
1388                    let lifted = canon.lift(&rm);
1389                    assert!(
1390                        clauses.iter().all(|c| c.iter().any(|l| lifted[l.var() as usize] == l.is_positive())),
1391                        "every lifted reduced model must satisfy the original formula"
1392                    );
1393                }
1394            }
1395            _ => panic!("forced + linked groups must canonicalize"),
1396        }
1397    }
1398
1399    /// **Soundness to the point of absurdity.** Random `GF(3)`/`GF(5)` one-hot systems: the elimination
1400    /// must preserve satisfiability exactly against brute force, refute only genuinely-UNSAT instances, and
1401    /// lift every reduced model to a real model of the original.
1402    #[test]
1403    fn affine_p_canonicalize_is_sound_against_brute_force() {
1404        let mut state = 0xE11_3110u64;
1405        let mut rng = || {
1406            state ^= state << 13;
1407            state ^= state >> 7;
1408            state ^= state << 17;
1409            state
1410        };
1411        for &p in &[3u64, 5] {
1412            for _ in 0..120 {
1413                let mut eqs: Vec<ModpEquation> = Vec::new();
1414                if rng() & 1 == 0 {
1415                    eqs.push(ModpEquation::new(vec![(0usize, 1 + rng() % (p - 1))], rng() % p));
1416                }
1417                if rng() & 1 == 0 {
1418                    eqs.push(ModpEquation::new(vec![(1usize, 1 + rng() % (p - 1))], rng() % p));
1419                }
1420                if rng() & 1 == 0 {
1421                    eqs.push(ModpEquation::new(vec![(0usize, 1 + rng() % (p - 1)), (1, 1 + rng() % (p - 1))], rng() % p));
1422                }
1423                if eqs.is_empty() {
1424                    continue;
1425                }
1426                let (nbv, clauses) = onehot_cnf(p, 2, &eqs);
1427                let orig = crate::affine::models_of(nbv, &clauses);
1428                match affine_p_canonicalize(nbv, &clauses) {
1429                    AffinePCanon::Refuted(_) => assert!(orig.is_empty(), "Refuted ⇒ original UNSAT (p={p})"),
1430                    AffinePCanon::Canonical(canon) => {
1431                        let red = crate::affine::models_of(canon.num_vars, &canon.clauses);
1432                        assert_eq!(red.is_empty(), orig.is_empty(), "elimination preserves satisfiability (p={p})");
1433                        for &rm_bits in &red {
1434                            let rm: Vec<bool> = (0..canon.num_vars).map(|i| (rm_bits >> i) & 1 == 1).collect();
1435                            let lifted = canon.lift(&rm);
1436                            assert!(
1437                                clauses.iter().all(|c| c.iter().any(|l| lifted[l.var() as usize] == l.is_positive())),
1438                                "every lifted reduced model must satisfy the original (p={p})"
1439                            );
1440                        }
1441                    }
1442                    AffinePCanon::Unchanged => {}
1443                }
1444            }
1445        }
1446    }
1447
1448    /// **The prime-power detector is exact.** Over `ℤ/8` the zero-divisor `2·x0 ≡ 4` leaves `x0 ∈ {2,6}`
1449    /// (NOT forced) while `x1 ≡ 5` is; over `ℤ/9` the zero-divisor `3·x0 ≡ 1` is unsolvable.
1450    #[test]
1451    fn forced_values_prime_power_is_exact() {
1452        use crate::modm::{forced_values_prime_power, ForcedM};
1453        let eqs = vec![
1454            ModpEquation::new(vec![(0usize, 2u64)], 4), // 2·x0 ≡ 4 (mod 8) ⇒ x0 ∈ {2, 6}
1455            ModpEquation::new(vec![(1usize, 1u64)], 5), // x1 ≡ 5
1456        ];
1457        match forced_values_prime_power(&eqs, 2, 8) {
1458            Some(ForcedM::Forced(f)) => {
1459                assert_eq!(f[0], None, "x0 is not forced — 2·x0 ≡ 4 has two solutions mod 8");
1460                assert_eq!(f[1], Some(5), "x1 is forced to 5");
1461            }
1462            other => panic!("expected a forced structure, got something else: {}", matches!(other, Some(ForcedM::Inconsistent))),
1463        }
1464        let unsolvable = vec![ModpEquation::new(vec![(0usize, 3u64)], 1)]; // 3·x0 ≡ 1 (mod 9): no solution
1465        assert!(
1466            matches!(forced_values_prime_power(&unsolvable, 1, 9), Some(ForcedM::Inconsistent)),
1467            "3·x0 ≡ 1 (mod 9) is unsolvable"
1468        );
1469    }
1470
1471    /// **The composite break handles a prime-power modulus.** Over `ℤ/4 = 2²` (a local ring, not
1472    /// squarefree, so the CRT path declines) the break takes the prime-power route: `x0 ≡ 3` is forced and
1473    /// lifted to one-hot units, each sound against every Boolean model.
1474    #[test]
1475    fn affine_m_forced_handles_prime_power_modulus() {
1476        let m = 4u64;
1477        let eqs = vec![ModpEquation::new(vec![(0usize, 1u64)], 3)];
1478        let (nbv, clauses) = onehot_cnf(m, 1, &eqs);
1479        match affine_m_forced(nbv, &clauses) {
1480            AffinePForced::Forced(units) => {
1481                assert!(units.contains(&vec![Lit::pos(3)]), "must pin b(x0=3) over ℤ/4; got {units:?}");
1482                let models = crate::affine::models_of(nbv, &clauses);
1483                assert!(!models.is_empty(), "the ℤ/4 system is satisfiable");
1484                for u in &units {
1485                    let l = u[0];
1486                    assert!(
1487                        models.iter().all(|&mm| ((mm >> l.var()) & 1 == 1) == l.is_positive()),
1488                        "forced unit on var {} must hold in every Boolean model",
1489                        l.var()
1490                    );
1491                }
1492            }
1493            _ => panic!("the ℤ/4 forced variable must produce one-hot units"),
1494        }
1495    }
1496
1497    /// **The Smith-form solution space is exact.** Over small `ℤ/pᵏ` rings (ℤ/4, ℤ/8, ℤ/9), against brute
1498    /// enumeration of hundreds of random systems: the particular solution satisfies, `Inconsistent` iff no
1499    /// solution exists, and a variable's kernel-never-moves-it test matches "takes one value in every
1500    /// solution" — exactly (with the forced value agreeing).
1501    #[test]
1502    fn solve_space_prime_power_matches_brute() {
1503        use crate::modm::{solve_space_prime_power, PrimePowerSpace};
1504        let mut state = 0x511700D5u64;
1505        let mut rng = || {
1506            state ^= state << 13;
1507            state ^= state >> 7;
1508            state ^= state << 17;
1509            state
1510        };
1511        for &(p, k) in &[(2u64, 2u32), (2, 3), (3, 2)] {
1512            let q = p.pow(k);
1513            for _ in 0..200 {
1514                let nv = 1 + (rng() % 3) as usize;
1515                let eqs: Vec<ModpEquation> = (0..(1 + rng() % 3))
1516                    .map(|_| {
1517                        let kk = 1 + (rng() % nv as u64) as usize;
1518                        let mut vars: Vec<usize> = Vec::new();
1519                        while vars.len() < kk {
1520                            let vv = (rng() % nv as u64) as usize;
1521                            if !vars.contains(&vv) {
1522                                vars.push(vv);
1523                            }
1524                        }
1525                        ModpEquation::new(vars.iter().map(|&vv| (vv, rng() % q)).collect::<Vec<_>>(), rng() % q)
1526                    })
1527                    .collect();
1528                let total = (q as u128).pow(nv as u32) as u64;
1529                let mut sols: Vec<Vec<u64>> = Vec::new();
1530                for code in 0..total {
1531                    let mut c = code;
1532                    let x: Vec<u64> = (0..nv)
1533                        .map(|_| {
1534                            let vv = c % q;
1535                            c /= q;
1536                            vv
1537                        })
1538                        .collect();
1539                    if eqs.iter().all(|eq| eq.coeffs.iter().fold(0u64, |a, &(vv, co)| (a + co * x[vv]) % q) % q == eq.rhs % q) {
1540                        sols.push(x);
1541                    }
1542                }
1543                match solve_space_prime_power(&eqs, nv, p, k).expect("no Smith overflow at this size") {
1544                    PrimePowerSpace::Inconsistent => assert!(sols.is_empty(), "Inconsistent ⇒ no ℤ/{q} solution"),
1545                    PrimePowerSpace::Space(ss) => {
1546                        assert!(!sols.is_empty(), "a Space ⇒ at least one solution (ℤ/{q})");
1547                        assert!(
1548                            eqs.iter().all(|eq| eq.coeffs.iter().fold(0u64, |a, &(vv, co)| (a + co * ss.particular[vv]) % q) % q == eq.rhs % q),
1549                            "the particular solution must satisfy (ℤ/{q})"
1550                        );
1551                        for g in 0..nv {
1552                            let smith = ss.kernel_basis.iter().all(|kk| kk[g] == 0);
1553                            let vals: HashSet<u64> = sols.iter().map(|s| s[g]).collect();
1554                            assert_eq!(smith, vals.len() == 1, "forced(var {g}): Smith vs brute (ℤ/{q})");
1555                            if smith {
1556                                assert_eq!(ss.particular[g], *vals.iter().next().unwrap(), "forced value matches brute (ℤ/{q})");
1557                            }
1558                        }
1559                    }
1560                }
1561            }
1562        }
1563    }
1564
1565    /// **The Smith path scales past the brute cap.** `ℤ/16` with 6 variables has `16⁶ = 2²⁴` value tuples —
1566    /// over the old `2²⁰` brute bound, so this is only decidable via the Smith solution space. All six are
1567    /// forced (unit congruences), and the scalable detector pins each.
1568    #[test]
1569    fn forced_values_prime_power_scales_past_the_brute_cap() {
1570        use crate::modm::{forced_values_prime_power, ForcedM};
1571        let m = 16u64;
1572        let eqs: Vec<ModpEquation> = (0..6usize).map(|i| ModpEquation::new(vec![(i, 1u64)], i as u64 + 1)).collect();
1573        match forced_values_prime_power(&eqs, 6, m) {
1574            Some(ForcedM::Forced(f)) => {
1575                for i in 0..6 {
1576                    assert_eq!(f[i], Some(i as u64 + 1), "x{i} forced to {} over ℤ/16 (2²⁴ tuples, Smith only)", i + 1);
1577                }
1578            }
1579            _ => panic!("ℤ/16 forcing over 6 vars must succeed via the scalable Smith path"),
1580        }
1581    }
1582
1583    /// **The allowed-residue congruence is exact.** Over small composite moduli (ℤ/4, ℤ/8, ℤ/9, ℤ/6,
1584    /// ℤ/12 — prime-power AND mixed), against brute force: each variable's `(res, modu)` describes *exactly*
1585    /// the set of values it takes across all solutions (`v` allowed ⟺ `v ≡ res mod modu`).
1586    #[test]
1587    fn allowed_residues_matches_brute() {
1588        use crate::modm::{allowed_residues, AllowedOutcome};
1589        let mut state = 0xA110_0ED5u64;
1590        let mut rng = || {
1591            state ^= state << 13;
1592            state ^= state >> 7;
1593            state ^= state << 17;
1594            state
1595        };
1596        for &m in &[4u64, 8, 9, 6, 12] {
1597            for _ in 0..150 {
1598                let nv = 1 + (rng() % 2) as usize;
1599                let eqs: Vec<ModpEquation> = (0..(1 + rng() % 2))
1600                    .map(|_| {
1601                        let kk = 1 + (rng() % nv as u64) as usize;
1602                        let mut vars: Vec<usize> = Vec::new();
1603                        while vars.len() < kk {
1604                            let vv = (rng() % nv as u64) as usize;
1605                            if !vars.contains(&vv) {
1606                                vars.push(vv);
1607                            }
1608                        }
1609                        ModpEquation::new(vars.iter().map(|&vv| (vv, rng() % m)).collect::<Vec<_>>(), rng() % m)
1610                    })
1611                    .collect();
1612                let total = (m as u128).pow(nv as u32) as u64;
1613                let mut sols: Vec<Vec<u64>> = Vec::new();
1614                for code in 0..total {
1615                    let mut c = code;
1616                    let x: Vec<u64> = (0..nv)
1617                        .map(|_| {
1618                            let vv = c % m;
1619                            c /= m;
1620                            vv
1621                        })
1622                        .collect();
1623                    if eqs.iter().all(|eq| eq.coeffs.iter().fold(0u64, |a, &(vv, co)| (a + co * x[vv]) % m) % m == eq.rhs % m) {
1624                        sols.push(x);
1625                    }
1626                }
1627                match allowed_residues(&eqs, nv, m).expect("no Smith overflow") {
1628                    AllowedOutcome::Inconsistent => assert!(sols.is_empty(), "Inconsistent ⇒ no ℤ/{m} solution"),
1629                    AllowedOutcome::Allowed(residues) => {
1630                        assert!(!sols.is_empty());
1631                        for g in 0..nv {
1632                            let (res, modu) = residues[g];
1633                            let vals: HashSet<u64> = sols.iter().map(|s| s[g]).collect();
1634                            for v in 0..m {
1635                                assert_eq!(vals.contains(&v), v % modu == res, "var {g} value {v} allowed? (m={m}, res={res} mod {modu})");
1636                            }
1637                        }
1638                    }
1639                }
1640            }
1641        }
1642    }
1643
1644    /// **The composite break PARTIALLY forces a DERIVED prime-power value-subset.** Over `ℤ/8`, `2·x1 ≡ 0`
1645    /// pins `x1` to `{0,4}`, and `x0 + x1 ≡ 0` then *derives* `x0 ∈ {0,4}` (`≡ 0 mod 4`) — a partial
1646    /// constraint no single clause states (`x0`'s subset is not in the encoding). The break emits the new
1647    /// forbidding units on `x0`, each sound against every Boolean model — the value-subset the prime-power
1648    /// path used to miss.
1649    #[test]
1650    fn affine_m_forced_partially_forces_a_prime_power_value_subset() {
1651        let m = 8u64;
1652        let eqs = vec![
1653            ModpEquation::new(vec![(0usize, 1u64), (1, 1)], 0), // x0 + x1 ≡ 0
1654            ModpEquation::new(vec![(1usize, 2u64)], 0),         // 2·x1 ≡ 0  ⇒  x1 ∈ {0,4}
1655        ];
1656        let (nbv, clauses) = onehot_cnf(m, 2, &eqs);
1657        match affine_m_forced(nbv, &clauses) {
1658            AffinePForced::Forced(units) => {
1659                // b(x0=v) = var v; x0 ≡ 0 mod 4 ⇒ forbid v ∈ {1,2,3,5,6,7} (derived, not in the encoding).
1660                for v in [1u32, 2, 3, 5, 6, 7] {
1661                    assert!(units.contains(&vec![Lit::neg(v)]), "must derive-forbid b(x0={v}) (off ≡0 mod 4)");
1662                }
1663                let models = crate::affine::models_of(nbv, &clauses);
1664                assert!(!models.is_empty(), "the ℤ/8 system is satisfiable");
1665                for u in &units {
1666                    assert!(
1667                        models.iter().all(|&mm| u.iter().any(|l| ((mm >> l.var()) & 1 == 1) == l.is_positive())),
1668                        "emitted clause {u:?} must hold in every Boolean model"
1669                    );
1670                }
1671            }
1672            _ => panic!("ℤ/8 derived partial forcing must fire"),
1673        }
1674    }
1675
1676    /// **The composite break now LINKS over a prime-power ring.** Over `ℤ/8` (a local ring), `x0 + 5·x1 ≡ 0`
1677    /// gives `x0 = 3·x1` — `c = 3` is a unit, neither variable forced. The ring link lifts to value-permuted
1678    /// bit-equivalences `b(x1=v) ↔ b(x0=3v)`, each sound against every Boolean model, with a genuine value
1679    /// permutation (the link the prime-power path used to miss entirely).
1680    #[test]
1681    fn affine_m_forced_links_over_a_prime_power_ring() {
1682        let m = 8u64;
1683        let eqs = vec![ModpEquation::new(vec![(0usize, 1u64), (1, 5)], 0)];
1684        let (nbv, clauses) = onehot_cnf(m, 2, &eqs);
1685        let AffinePForced::Forced(extra) = affine_m_forced(nbv, &clauses) else {
1686            panic!("the ℤ/8 ring link must fire");
1687        };
1688        assert!(extra.iter().any(|c| c.len() == 2), "must emit value-permuted bit-equivalences");
1689        let models = crate::affine::models_of(nbv, &clauses);
1690        for c in &extra {
1691            assert!(
1692                models.iter().all(|&mm| c.iter().any(|l| ((mm >> l.var()) & 1 == 1) == l.is_positive())),
1693                "every emitted equivalence must hold in every Boolean model"
1694            );
1695        }
1696        let rec = crate::modp::recover_from_cnf(nbv, &clauses).expect("recovers");
1697        let pos_of = |bv: u32| {
1698            rec.groups.iter().enumerate().find_map(|(g, grp)| grp.iter().position(|&b| b == bv).map(|v| (g, v)))
1699        };
1700        assert!(
1701            extra.iter().filter(|c| c.len() == 2).any(|c| matches!(
1702                (pos_of(c[0].var()), pos_of(c[1].var())),
1703                (Some((g0, v0)), Some((g1, v1))) if g0 != g1 && v0 != v1
1704            )),
1705            "a genuine ℤ/8 ring link must produce a value-permuted equivalence"
1706        );
1707    }
1708
1709    /// **Soundness to the point of absurdity over a prime-power ring.** Random `ℤ/8` systems (distinct-subset
1710    /// congruences), the WHOLE break — forced, partial, AND ring links — brute-force-checked: every emitted
1711    /// clause holds in every Boolean model, and `Refuted` only names a genuinely-UNSAT formula.
1712    #[test]
1713    fn affine_m_break_is_sound_over_a_prime_power_ring() {
1714        let m = 8u64;
1715        let mut state = 0x21B6_0FF5u64;
1716        let mut rng = || {
1717            state ^= state << 13;
1718            state ^= state >> 7;
1719            state ^= state << 17;
1720            state
1721        };
1722        for _ in 0..120 {
1723            let mut eqs: Vec<ModpEquation> = Vec::new();
1724            if rng() & 1 == 0 {
1725                eqs.push(ModpEquation::new(vec![(0usize, 1 + rng() % (m - 1))], rng() % m));
1726            }
1727            if rng() & 1 == 0 {
1728                eqs.push(ModpEquation::new(vec![(1usize, 1 + rng() % (m - 1))], rng() % m));
1729            }
1730            if rng() & 1 == 0 {
1731                eqs.push(ModpEquation::new(vec![(0usize, 1 + rng() % (m - 1)), (1, 1 + rng() % (m - 1))], rng() % m));
1732            }
1733            if eqs.is_empty() {
1734                continue;
1735            }
1736            let (nbv, clauses) = onehot_cnf(m, 2, &eqs);
1737            let models = crate::affine::models_of(nbv, &clauses);
1738            match affine_m_forced(nbv, &clauses) {
1739                AffinePForced::Refuted(_) => assert!(models.is_empty(), "Refuted ⇒ the ℤ/8 CNF must be UNSAT"),
1740                AffinePForced::Forced(extra) => {
1741                    for c in &extra {
1742                        assert!(
1743                            models.iter().all(|&mm| c.iter().any(|l| ((mm >> l.var()) & 1 == 1) == l.is_positive())),
1744                            "every ℤ/8 consequence must hold in every Boolean model"
1745                        );
1746                    }
1747                }
1748                AffinePForced::Unchanged => {}
1749            }
1750        }
1751    }
1752
1753    /// **The composite eliminate path physically reduces and lifts.** Over `ℤ/4`, `x0 ≡ 3` is forced and
1754    /// `x1 + x2 ≡ 0` links `x2 = −x1`. Both the forced and linked groups are eliminated — only `x1`'s one-hot
1755    /// survives — and every model of the reduced formula lifts to a real model of the original.
1756    #[test]
1757    fn affine_m_canonicalize_eliminates_and_lifts_soundly() {
1758        let m = 4u64;
1759        let eqs = vec![
1760            ModpEquation::new(vec![(0usize, 1u64)], 3),
1761            ModpEquation::new(vec![(1usize, 1u64), (2, 1)], 0),
1762        ];
1763        let (nbv, clauses) = onehot_cnf(m, 3, &eqs);
1764        match affine_m_canonicalize(nbv, &clauses) {
1765            AffinePCanon::Canonical(canon) => {
1766                assert!(canon.num_vars < nbv, "elimination must shrink the bit count ({} < {nbv})", canon.num_vars);
1767                let orig = crate::affine::models_of(nbv, &clauses);
1768                let red = crate::affine::models_of(canon.num_vars, &canon.clauses);
1769                assert!(!orig.is_empty(), "the ℤ/4 system is satisfiable");
1770                assert_eq!(red.is_empty(), orig.is_empty(), "reduction must preserve satisfiability");
1771                for &rm_bits in &red {
1772                    let rm: Vec<bool> = (0..canon.num_vars).map(|i| (rm_bits >> i) & 1 == 1).collect();
1773                    let lifted = canon.lift(&rm);
1774                    assert!(
1775                        clauses.iter().all(|c| c.iter().any(|l| lifted[l.var() as usize] == l.is_positive())),
1776                        "every lifted reduced model must satisfy the original formula"
1777                    );
1778                }
1779            }
1780            _ => panic!("the forced + linked ℤ/4 groups must canonicalize"),
1781        }
1782    }
1783
1784    /// **Composite eliminate, sound to the point of absurdity.** Random one-hot systems over `ℤ/4`, `ℤ/6`,
1785    /// `ℤ/8` (zero-divisor coefficients exercise the *partial*-coset survivor pruning): the elimination must
1786    /// preserve satisfiability exactly against brute force, refute only genuinely-UNSAT instances, and lift
1787    /// every reduced model to a real model of the original.
1788    #[test]
1789    fn affine_m_canonicalize_is_sound_against_brute_force() {
1790        let mut state = 0x5EED_3110u64;
1791        let mut rng = || {
1792            state ^= state << 13;
1793            state ^= state >> 7;
1794            state ^= state << 17;
1795            state
1796        };
1797        for &m in &[4u64, 6, 8] {
1798            for _ in 0..150 {
1799                let mut eqs: Vec<ModpEquation> = Vec::new();
1800                if rng() % 3 != 0 {
1801                    eqs.push(ModpEquation::new(vec![(0usize, 1 + rng() % (m - 1))], rng() % m));
1802                }
1803                if rng() % 3 != 0 {
1804                    eqs.push(ModpEquation::new(vec![(1usize, 1 + rng() % (m - 1))], rng() % m));
1805                }
1806                if rng() % 3 != 0 {
1807                    eqs.push(ModpEquation::new(vec![(0usize, 1 + rng() % (m - 1)), (1, 1 + rng() % (m - 1))], rng() % m));
1808                }
1809                if eqs.is_empty() {
1810                    continue;
1811                }
1812                let (nbv, clauses) = onehot_cnf(m, 2, &eqs);
1813                let orig = crate::affine::models_of(nbv, &clauses);
1814                match affine_m_canonicalize(nbv, &clauses) {
1815                    AffinePCanon::Refuted(_) => assert!(orig.is_empty(), "Refuted ⇒ original UNSAT (m={m})"),
1816                    AffinePCanon::Canonical(canon) => {
1817                        let red = crate::affine::models_of(canon.num_vars, &canon.clauses);
1818                        assert_eq!(red.is_empty(), orig.is_empty(), "elimination preserves satisfiability (m={m})");
1819                        for &rm_bits in &red {
1820                            let rm: Vec<bool> = (0..canon.num_vars).map(|i| (rm_bits >> i) & 1 == 1).collect();
1821                            let lifted = canon.lift(&rm);
1822                            assert!(
1823                                clauses.iter().all(|c| c.iter().any(|l| lifted[l.var() as usize] == l.is_positive())),
1824                                "every lifted reduced model must satisfy the original (m={m})"
1825                            );
1826                        }
1827                    }
1828                    AffinePCanon::Unchanged => {}
1829                }
1830            }
1831        }
1832    }
1833}