Skip to main content

logicaffeine_proof/
modp.rs

1//! Linear algebra over `GF(p)` — the mod-`p` generalization of the GF(2) parity cut ([`crate::xorsat`]).
2//!
3//! A system of congruences `Σ aᵢ·xᵢ ≡ c (mod p)` is decided in **polynomial time** by Gaussian
4//! elimination with modular inverses, and it is **certified**: an inconsistent system yields a
5//! re-checkable linear-dependency refutation — a combination of the original equations whose left side
6//! cancels to `0` while the right side is some nonzero residue, i.e. `0 ≡ r ≢ 0 (mod p)`. A consistent
7//! system yields a satisfying assignment over `GF(p)`.
8//!
9//! This matters because the parity cut only speaks GF(2). The mod-`p` *counting principles* (`Count_p`:
10//! "partition a set whose size is not a multiple of `p` into `p`-blocks") are resolution-hard, and a
11//! polynomial-calculus proof over the *wrong* characteristic cannot refute them either — but Gaussian
12//! elimination over the *right* `GF(p)` decides them instantly. A genuinely new invariant, the parity
13//! crush carried to every prime.
14
15/// A congruence `Σ (a·x) ≡ rhs (mod p)`. Coefficients and `rhs` are reduced mod `p`. `p` must be prime
16/// (so every nonzero element is invertible, via Fermat).
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct ModpEquation {
19    pub coeffs: Vec<(usize, u64)>,
20    pub rhs: u64,
21}
22
23impl ModpEquation {
24    pub fn new(coeffs: impl Into<Vec<(usize, u64)>>, rhs: u64) -> Self {
25        ModpEquation { coeffs: coeffs.into(), rhs }
26    }
27}
28
29/// The outcome of solving a mod-`p` linear system.
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub enum ModpOutcome {
32    /// Satisfiable, with an assignment over `0..p` for each of `0..num_vars` (re-checkable via
33    /// [`satisfies`]).
34    Sat(Vec<u64>),
35    /// Unsatisfiable, witnessed by a combination `Σ (multiplier · equationᵢ)` whose left side cancels
36    /// while the right side is nonzero — re-checkable via [`is_refutation`].
37    Unsat(Vec<(usize, u64)>),
38}
39
40#[inline]
41fn add(a: u64, b: u64, p: u64) -> u64 {
42    (a + b) % p
43}
44#[inline]
45fn sub(a: u64, b: u64, p: u64) -> u64 {
46    (a + p - b % p) % p
47}
48#[inline]
49fn mul(a: u64, b: u64, p: u64) -> u64 {
50    (a % p) * (b % p) % p
51}
52#[inline]
53fn powm(mut a: u64, mut e: u64, p: u64) -> u64 {
54    let mut r = 1u64 % p;
55    a %= p;
56    while e > 0 {
57        if e & 1 == 1 {
58            r = mul(r, a, p);
59        }
60        a = mul(a, a, p);
61        e >>= 1;
62    }
63    r
64}
65/// Modular inverse of a nonzero `a` over a prime field (Fermat: `a^{p-2}`).
66#[inline]
67fn inv(a: u64, p: u64) -> u64 {
68    powm(a, p - 2, p)
69}
70
71/// Decide a mod-`p` linear system by Gaussian elimination over `GF(p)`. Returns a satisfying
72/// assignment, or an inconsistency-witnessing combination of the original equations. `p` must be prime.
73pub fn solve(equations: &[ModpEquation], num_vars: usize, p: u64) -> ModpOutcome {
74    let m = equations.len();
75    // Each row carries its variable coefficients, its rhs, and its provenance (how it is built from the
76    // original equations — a coefficient per original equation). All arithmetic is mod p.
77    let mut coeff: Vec<Vec<u64>> = Vec::with_capacity(m);
78    let mut rhs: Vec<u64> = Vec::with_capacity(m);
79    let mut prov: Vec<Vec<u64>> = Vec::with_capacity(m);
80    for (i, eq) in equations.iter().enumerate() {
81        let mut c = vec![0u64; num_vars];
82        for &(v, a) in &eq.coeffs {
83            if v < num_vars {
84                c[v] = add(c[v], a, p);
85            }
86        }
87        coeff.push(c);
88        rhs.push(eq.rhs % p);
89        let mut pr = vec![0u64; m];
90        pr[i] = 1 % p;
91        prov.push(pr);
92    }
93
94    let mut pivot_col_of_row: Vec<usize> = Vec::new();
95    let mut row = 0usize;
96    for col in 0..num_vars {
97        let Some(sel) = (row..coeff.len()).find(|&r| coeff[r][col] != 0) else {
98            continue;
99        };
100        coeff.swap(row, sel);
101        rhs.swap(row, sel);
102        prov.swap(row, sel);
103        // Normalize the pivot row so its pivot coefficient is 1.
104        let factor = inv(coeff[row][col], p);
105        for v in 0..num_vars {
106            coeff[row][v] = mul(coeff[row][v], factor, p);
107        }
108        rhs[row] = mul(rhs[row], factor, p);
109        for k in 0..m {
110            prov[row][k] = mul(prov[row][k], factor, p);
111        }
112        // Eliminate this column from every other row.
113        for r in 0..coeff.len() {
114            if r != row && coeff[r][col] != 0 {
115                let f = coeff[r][col];
116                for v in 0..num_vars {
117                    coeff[r][v] = sub(coeff[r][v], mul(f, coeff[row][v], p), p);
118                }
119                rhs[r] = sub(rhs[r], mul(f, rhs[row], p), p);
120                for k in 0..m {
121                    prov[r][k] = sub(prov[r][k], mul(f, prov[row][k], p), p);
122                }
123            }
124        }
125        pivot_col_of_row.push(col);
126        row += 1;
127        if row == coeff.len() {
128            break;
129        }
130    }
131
132    // An all-zero row with a nonzero rhs is `0 ≡ nonzero` — a refutation; its provenance is the combo.
133    for r in 0..coeff.len() {
134        if coeff[r].iter().all(|&x| x == 0) && rhs[r] != 0 {
135            let combo: Vec<(usize, u64)> =
136                prov[r].iter().enumerate().filter(|&(_, &m)| m != 0).map(|(i, &m)| (i, m)).collect();
137            return ModpOutcome::Unsat(combo);
138        }
139    }
140
141    // Consistent: free variables take 0, each pivot variable takes its (reduced) rhs.
142    let mut assignment = vec![0u64; num_vars];
143    for (r, &col) in pivot_col_of_row.iter().enumerate() {
144        assignment[col] = rhs[r];
145    }
146    ModpOutcome::Sat(assignment)
147}
148
149/// The **complete solution space** of a `GF(p)` linear system `A x = b`, in symmetry-broken form: one
150/// particular solution `x₀` plus a basis of the kernel (null space). Every solution is `x₀ +` a `GF(p)`
151/// combination of the kernel basis, so all `p^{n−rank}` solutions are generated from this compressed
152/// witness. The kernel is the **translation symmetry** of the solution coset — the `GF(p)` analogue of
153/// [`crate::gf2::SolutionSpace`], and the substrate of the affine SAT-side break: a variable the kernel
154/// never moves is forced to a single value.
155#[derive(Clone, Debug, PartialEq, Eq)]
156pub struct SolutionSpaceP {
157    pub num_vars: usize,
158    pub p: u64,
159    pub particular: Vec<u64>,
160    pub kernel_basis: Vec<Vec<u64>>,
161}
162
163impl SolutionSpaceP {
164    /// The number of solutions: `p^{dim kernel}`.
165    pub fn count(&self) -> u128 {
166        (self.p as u128).pow(self.kernel_basis.len() as u32)
167    }
168
169    /// Generate **every** solution: `x₀` plus each `GF(p)` combination of the kernel basis.
170    pub fn enumerate(&self) -> Vec<Vec<u64>> {
171        let k = self.kernel_basis.len();
172        let total = (self.p as u128).pow(k as u32);
173        (0..total as u64)
174            .map(|mut code| {
175                let mut x = self.particular.clone();
176                for b in 0..k {
177                    let coef = code % self.p;
178                    code /= self.p;
179                    if coef != 0 {
180                        for v in 0..self.num_vars {
181                            x[v] = add(x[v], mul(coef, self.kernel_basis[b][v], self.p), self.p);
182                        }
183                    }
184                }
185                x
186            })
187            .collect()
188    }
189}
190
191/// Solve a `GF(p)` system for its **entire** solution space via Gaussian elimination to reduced row
192/// echelon form, returning the symmetry-broken [`SolutionSpaceP`] (particular solution + kernel basis),
193/// or `None` iff the system is inconsistent. Generalizes [`solve`], which returns just one witness, to the
194/// full coset — the `GF(p)` analogue of [`crate::gf2::solve_gf2`]. `p` must be prime.
195pub fn solve_space(equations: &[ModpEquation], num_vars: usize, p: u64) -> Option<SolutionSpaceP> {
196    let mut coeff: Vec<Vec<u64>> = Vec::with_capacity(equations.len());
197    let mut rhs: Vec<u64> = Vec::with_capacity(equations.len());
198    for eq in equations {
199        let mut c = vec![0u64; num_vars];
200        for &(v, a) in &eq.coeffs {
201            if v < num_vars {
202                c[v] = add(c[v], a, p);
203            }
204        }
205        coeff.push(c);
206        rhs.push(eq.rhs % p);
207    }
208
209    let mut pivot_col_of_row: Vec<usize> = Vec::new();
210    let mut row = 0usize;
211    for col in 0..num_vars {
212        let Some(sel) = (row..coeff.len()).find(|&r| coeff[r][col] != 0) else {
213            continue;
214        };
215        coeff.swap(row, sel);
216        rhs.swap(row, sel);
217        let factor = inv(coeff[row][col], p);
218        for v in 0..num_vars {
219            coeff[row][v] = mul(coeff[row][v], factor, p);
220        }
221        rhs[row] = mul(rhs[row], factor, p);
222        // Full reduction: clear this pivot column from every other row.
223        for r in 0..coeff.len() {
224            if r != row && coeff[r][col] != 0 {
225                let f = coeff[r][col];
226                for v in 0..num_vars {
227                    coeff[r][v] = sub(coeff[r][v], mul(f, coeff[row][v], p), p);
228                }
229                rhs[r] = sub(rhs[r], mul(f, rhs[row], p), p);
230            }
231        }
232        pivot_col_of_row.push(col);
233        row += 1;
234    }
235
236    // Inconsistent: a fully-reduced row with no coefficients but a nonzero right-hand side (0 = c ≠ 0).
237    for r in 0..coeff.len() {
238        if coeff[r].iter().all(|&x| x == 0) && rhs[r] != 0 {
239            return None;
240        }
241    }
242
243    let mut is_pivot = vec![false; num_vars];
244    for &c in &pivot_col_of_row {
245        is_pivot[c] = true;
246    }
247    // Particular: free variables 0, each pivot variable = its row's right-hand side.
248    let mut particular = vec![0u64; num_vars];
249    for (r, &pc) in pivot_col_of_row.iter().enumerate() {
250        particular[pc] = rhs[r];
251    }
252    // Kernel: one vector per free column f — set x_f = 1, each pivot var = −(its row's f-coefficient).
253    let mut kernel_basis = Vec::new();
254    for f in 0..num_vars {
255        if is_pivot[f] {
256            continue;
257        }
258        let mut kv = vec![0u64; num_vars];
259        kv[f] = 1;
260        for (r, &pc) in pivot_col_of_row.iter().enumerate() {
261            kv[pc] = sub(0, coeff[r][f], p);
262        }
263        kernel_basis.push(kv);
264    }
265    Some(SolutionSpaceP { num_vars, p, particular, kernel_basis })
266}
267
268/// The canonical scalable mod-`p` obstruction: a cycle of differences `xᵢ − x_{i+1} ≡ 1 (mod p)` around
269/// an `n`-cycle. Summing all `n` equations telescopes the left side to `0` and the right to `n`, so the
270/// system is inconsistent **exactly when `n` is not a multiple of `p`** — the mod-`p` counting fact. For
271/// even `n` with `p > 2 ∤ n` it is satisfiable over `GF(2)` yet refuted over `GF(p)`: a family the parity
272/// cut cannot see. (`x − y` is written `x + (p−1)y`.)
273pub fn cycle_system(n: usize, p: u64) -> Vec<ModpEquation> {
274    (0..n)
275        .map(|i| ModpEquation::new(vec![(i, 1), ((i + 1) % n, p - 1)], 1))
276        .collect()
277}
278
279/// Re-check a satisfying assignment: every congruence holds mod `p`.
280pub fn satisfies(equations: &[ModpEquation], assignment: &[u64], p: u64) -> bool {
281    equations.iter().all(|eq| {
282        let lhs = eq
283            .coeffs
284            .iter()
285            .fold(0u64, |acc, &(v, a)| add(acc, mul(a, *assignment.get(v).unwrap_or(&0), p), p));
286        lhs == eq.rhs % p
287    })
288}
289
290/// Re-check a refutation: the chosen combination of equations has every variable coefficient `≡ 0` and
291/// a nonzero right-hand side mod `p` — a solver-free certificate of inconsistency.
292pub fn is_refutation(
293    equations: &[ModpEquation],
294    num_vars: usize,
295    p: u64,
296    combo: &[(usize, u64)],
297) -> bool {
298    if combo.is_empty() {
299        return false;
300    }
301    let mut lhs = vec![0u64; num_vars];
302    let mut rhs = 0u64;
303    for &(idx, mult) in combo {
304        let Some(eq) = equations.get(idx) else {
305            return false;
306        };
307        for &(v, a) in &eq.coeffs {
308            if v < num_vars {
309                lhs[v] = add(lhs[v], mul(mult, a, p), p);
310            }
311        }
312        rhs = add(rhs, mul(mult, eq.rhs, p), p);
313    }
314    lhs.iter().all(|&x| x == 0) && rhs != 0
315}
316
317/// A mod-`m` linear system recovered from an opaque Boolean CNF: the one-hot groups (each a `ℤ/m`
318/// variable, with the boolean var ids listed in value order) plus the congruences fitted to the
319/// forbidden-combination clauses. The recovered system is **equisatisfiable** to the source CNF, so the
320/// modular verdict carries back — over the prime field [`solve`] when `modulus` is prime, over the
321/// composite ring [`crate::modm::solve`] otherwise. See [`recover_from_cnf`].
322#[derive(Clone, Debug)]
323pub struct ModpRecovery {
324    pub modulus: u64,
325    /// One `ℤ/modulus` variable per one-hot group.
326    pub num_vars: usize,
327    pub equations: Vec<ModpEquation>,
328    /// `groups[g][val]` = the boolean variable that means "variable `g` takes value `val`".
329    pub groups: Vec<Vec<u32>>,
330}
331
332pub fn is_prime(p: u64) -> bool {
333    if p < 2 {
334        return false;
335    }
336    let mut d = 2u64;
337    while d * d <= p {
338        if p % d == 0 {
339            return false;
340        }
341        d += 1;
342    }
343    true
344}
345
346/// A basis of the null space `{ a : (row · a) ≡ 0 for every row }` over `GF(p)`, by reduced row echelon
347/// form: each non-pivot (free) column yields one basis vector. Used to fit a congruence's coefficient
348/// vector as the (unique up to scalar) normal of the hyperplane spanned by the allowed value tuples.
349fn nullspace(rows: &[Vec<u64>], k: usize, p: u64) -> Vec<Vec<u64>> {
350    let mut m: Vec<Vec<u64>> = rows.iter().map(|r| r.iter().map(|&x| x % p).collect()).collect();
351    let mut where_pivot: Vec<isize> = vec![-1; k];
352    let mut row = 0usize;
353    for col in 0..k {
354        let Some(sel) = (row..m.len()).find(|&r| m[r][col] != 0) else {
355            continue;
356        };
357        m.swap(row, sel);
358        let finv = inv(m[row][col], p);
359        for c in 0..k {
360            m[row][c] = mul(m[row][c], finv, p);
361        }
362        for r in 0..m.len() {
363            if r != row && m[r][col] != 0 {
364                let f = m[r][col];
365                for c in 0..k {
366                    m[r][c] = sub(m[r][c], mul(f, m[row][c], p), p);
367                }
368            }
369        }
370        where_pivot[col] = row as isize;
371        row += 1;
372        if row == m.len() {
373            break;
374        }
375    }
376    let mut basis = Vec::new();
377    for free in 0..k {
378        if where_pivot[free] != -1 {
379            continue;
380        }
381        let mut v = vec![0u64; k];
382        v[free] = 1;
383        for (col, &pr) in where_pivot.iter().enumerate() {
384            if pr != -1 {
385                v[col] = sub(0, m[pr as usize][free], p);
386            }
387        }
388        basis.push(v);
389    }
390    basis
391}
392
393/// Fit the unique linear congruence `Σ aᵢ·tᵢ ≡ c (mod m)` whose solution set is EXACTLY `allowed` (the
394/// complement of the forbidden tuples). Over a **prime field** this is the hyperplane normal — the
395/// `allowed` set must be `m^{k-1}` points and `a` is the (up-to-scalar unique) null vector of their
396/// differences. Over a **composite ring** there is no field inverse, so `a` is found by a bounded search
397/// over coefficient vectors (`c` is forced once `a` and a base point are fixed). Either way the returned
398/// `(a, c)` is re-verified to reproduce the split exactly, so soundness never depends on which branch
399/// fired. Returns `None` when no single congruence reproduces the split (then the caller declines).
400fn fit_congruence(
401    k: usize,
402    allowed: &[Vec<u64>],
403    forbidden: &[Vec<u64>],
404    m: u64,
405) -> Option<(Vec<u64>, u64)> {
406    let t0 = allowed.first()?;
407    let mm = m as u128;
408    let eval = |a: &[u64], t: &[u64]| -> u64 {
409        (0..k).fold(0u128, |acc, i| (acc + a[i] as u128 * t[i] as u128) % mm) as u64
410    };
411    let candidate: Option<Vec<u64>> = if is_prime(m) {
412        // Prime field: a single congruence has exactly m^{k-1} solutions, and its normal is the unique
413        // null direction of the allowed-tuple differences.
414        if (allowed.len() as u128) != mm.pow((k - 1) as u32) {
415            return None;
416        }
417        let diffs: Vec<Vec<u64>> =
418            allowed.iter().skip(1).map(|t| (0..k).map(|i| sub(t[i], t0[i], m)).collect()).collect();
419        let basis = nullspace(&diffs, k, m);
420        (basis.len() == 1 && basis[0].iter().any(|&x| x != 0)).then(|| basis[0].clone())
421    } else {
422        // Composite ring: bounded brute search over the coefficient vectors.
423        let total = mm.checked_pow(k as u32)?;
424        if total.checked_mul(total)? > (1u128 << 24) {
425            return None; // refuse an oversized ring fit; let CDCL have it
426        }
427        let mut found = None;
428        for code in 0..total {
429            let mut a = vec![0u64; k];
430            let mut x = code;
431            for slot in a.iter_mut() {
432                *slot = (x % mm) as u64;
433                x /= mm;
434            }
435            if a.iter().all(|&v| v == 0) {
436                continue;
437            }
438            let c = eval(&a, t0);
439            if allowed.iter().all(|t| eval(&a, t) == c) && forbidden.iter().all(|f| eval(&a, f) != c) {
440                found = Some(a);
441                break;
442            }
443        }
444        found
445    };
446    let a = candidate?;
447    let c = eval(&a, t0);
448    if allowed.iter().any(|t| eval(&a, t) != c) || forbidden.iter().any(|f| eval(&a, f) == c) {
449        return None;
450    }
451    Some((a, c))
452}
453
454/// **Lift an opaque Boolean CNF onto `ℤ/m`.** Recognize the canonical one-hot encoding of a mod-`m`
455/// linear system — each variable a group of `m` bits with an at-least-one clause and the full pairwise
456/// at-most-one, plus all-negative "forbidden combination" clauses pinning the congruences — and recover
457/// the system over `ℤ/m`. The modulus `m` is the group size; it may be prime (a field) or composite
458/// (a ring), and [`fit_congruence`] handles both. Returns `None` (declining, never guessing) unless
459/// every clause fits the pattern and every forbidden set is **exactly** the complement of a single
460/// congruence: then the recovered system is equisatisfiable to the CNF, so the modular solver's verdict
461/// transfers (UNSAT certificate carries; a SAT model is re-checked against the clauses by the caller).
462/// The `m = 2` case is the parity cut; this is that cut generalized to every modulus — the obstruction
463/// GF(2) is blind to, decided in polynomial time where resolution (CDCL, Z3, Kissat) needs `2^Ω(n)`.
464pub fn recover_from_cnf(num_bool_vars: usize, clauses: &[Vec<crate::cdcl::Lit>]) -> Option<ModpRecovery> {
465    use std::collections::{BTreeMap, HashMap, HashSet};
466    if clauses.is_empty() {
467        return None;
468    }
469
470    // Pass 1 — discover one-hot groups (all-positive clauses of size ≥ 2) and the at-most-one pairs.
471    let mut group_candidates: Vec<Vec<u32>> = Vec::new();
472    let mut neg_pairs: HashSet<(u32, u32)> = HashSet::new();
473    let mut appears: HashSet<u32> = HashSet::new();
474    for c in clauses {
475        for l in c {
476            appears.insert(l.var());
477        }
478        if c.len() >= 2 && c.iter().all(|l| l.is_positive()) {
479            let mut g: Vec<u32> = c.iter().map(|l| l.var()).collect();
480            g.sort_unstable();
481            g.dedup();
482            if g.len() != c.len() {
483                return None;
484            }
485            group_candidates.push(g);
486        } else if c.len() == 2 && c.iter().all(|l| !l.is_positive()) {
487            let (a, b) = (c[0].var(), c[1].var());
488            neg_pairs.insert((a.min(b), a.max(b)));
489        }
490    }
491    if group_candidates.is_empty() {
492        return None;
493    }
494    let m = group_candidates[0].len() as u64;
495    if m < 2 {
496        return None;
497    }
498
499    // Validate groups: uniform size, disjoint, full pairwise at-most-one present.
500    let mut var_to_group: HashMap<u32, usize> = HashMap::new();
501    let mut groups: Vec<Vec<u32>> = Vec::new();
502    for g in &group_candidates {
503        if g.len() as u64 != m {
504            return None;
505        }
506        for i in 0..g.len() {
507            for j in (i + 1)..g.len() {
508                if !neg_pairs.contains(&(g[i], g[j])) {
509                    return None;
510                }
511            }
512        }
513        let gid = groups.len();
514        for &v in g {
515            if var_to_group.insert(v, gid).is_some() {
516                return None; // a variable in two groups: not a clean one-hot partition
517            }
518        }
519        groups.push(g.clone());
520    }
521    // Every variable that appears must belong to a group, or the encoding is not pure one-hot.
522    if appears.iter().any(|v| !var_to_group.contains_key(v)) {
523        return None;
524    }
525    let _ = num_bool_vars;
526    let pos_in_group = |v: u32, gid: usize| groups[gid].iter().position(|&x| x == v).unwrap() as u64;
527
528    // Pass 2 — classify every clause; collect forbidden tuples per scope (a sorted set of groups).
529    let mut scopes: BTreeMap<Vec<usize>, Vec<Vec<u64>>> = BTreeMap::new();
530    for c in clauses {
531        if c.len() >= 2 && c.iter().all(|l| l.is_positive()) {
532            continue; // at-least-one of a group: one-hot structure
533        }
534        if c.len() == 2 && c.iter().all(|l| !l.is_positive()) {
535            let g0 = *var_to_group.get(&c[0].var())?;
536            let g1 = *var_to_group.get(&c[1].var())?;
537            if g0 == g1 {
538                continue; // at-most-one within a group: one-hot structure
539            }
540        }
541        if !c.iter().all(|l| !l.is_positive()) {
542            return None; // anything mixing polarities is not part of the recognized encoding
543        }
544        let mut pairs: Vec<(usize, u64)> = Vec::new();
545        let mut seen = HashSet::new();
546        for l in c {
547            let g = *var_to_group.get(&l.var())?;
548            if !seen.insert(g) {
549                return None; // two bits of the same group in one forbidden combo
550            }
551            pairs.push((g, pos_in_group(l.var(), g)));
552        }
553        pairs.sort_by_key(|&(g, _)| g);
554        let scope: Vec<usize> = pairs.iter().map(|&(g, _)| g).collect();
555        let tuple: Vec<u64> = pairs.iter().map(|&(_, v)| v).collect();
556        scopes.entry(scope).or_default().push(tuple);
557    }
558    if scopes.is_empty() {
559        return None;
560    }
561
562    // For each scope, fit the unique congruence whose violated set is EXACTLY the forbidden tuples.
563    let mut equations: Vec<ModpEquation> = Vec::new();
564    for (scope, forbidden) in &scopes {
565        let k = scope.len();
566        let total = (m as u128).checked_pow(k as u32)?;
567        if total > (1u128 << 22) {
568            return None; // refuse to enumerate an oversized scope; let CDCL have it
569        }
570        let forbidden_set: HashSet<Vec<u64>> = forbidden.iter().cloned().collect();
571        let mut allowed: Vec<Vec<u64>> = Vec::new();
572        for idx in 0..total {
573            let mut t = vec![0u64; k];
574            let mut x = idx;
575            for slot in t.iter_mut() {
576                *slot = (x % m as u128) as u64;
577                x /= m as u128;
578            }
579            if !forbidden_set.contains(&t) {
580                allowed.push(t);
581            }
582        }
583        let (a, c) = fit_congruence(k, &allowed, forbidden, m)?;
584        let coeffs: Vec<(usize, u64)> =
585            scope.iter().enumerate().map(|(i, &g)| (g, a[i])).filter(|&(_, ai)| ai != 0).collect();
586        if coeffs.is_empty() {
587            return None;
588        }
589        equations.push(ModpEquation::new(coeffs, c));
590    }
591
592    Some(ModpRecovery { modulus: m, num_vars: groups.len(), equations, groups })
593}
594
595/// `|GL(n,p)|` over `GF(p)` via the orbit–stabilizer (ordered-basis) factorization
596/// `Π_{i=0}^{n-1}(pⁿ − pⁱ)`. `GL(n,p)` acts **simply transitively on ordered bases** of `GF(p)ⁿ`, so each
597/// factor `pⁿ − pⁱ` counts the vectors outside the i-dimensional span built so far — the same symmetry
598/// break as over `GF(2)`, now across the field. The `p = 2` case is `gf2::gl_order`.
599pub fn gl_order_p(n: u32, p: u64) -> u128 {
600    let pn = (p as u128).pow(n);
601    (0..n).map(|i| pn - (p as u128).pow(i)).product()
602}
603
604/// Is an `n×n` matrix over `GF(p)` invertible? Gaussian elimination with modular pivots: full rank `n`.
605/// `p` must be prime.
606pub fn is_invertible_modp(n: usize, p: u64, matrix: &[Vec<u64>]) -> bool {
607    let mut a: Vec<Vec<u64>> = matrix.iter().map(|r| r.iter().map(|&x| x % p).collect()).collect();
608    let mut rank = 0usize;
609    for col in 0..n {
610        if let Some(piv) = (rank..n).find(|&r| a[r][col] != 0) {
611            a.swap(rank, piv);
612            let pinv = inv(a[rank][col], p);
613            for c in 0..n {
614                a[rank][c] = mul(a[rank][c], pinv, p);
615            }
616            for r in 0..n {
617                if r != rank && a[r][col] != 0 {
618                    let f = a[r][col];
619                    for c in 0..n {
620                        a[r][c] = sub(a[r][c], mul(f, a[rank][c], p), p);
621                    }
622                }
623            }
624            rank += 1;
625        }
626    }
627    rank == n
628}
629
630/// The invertibility *density* over `GF(p)`: `Π_{j=1}^n (1 − p⁻ʲ) = |GL(n,p)| / p^{n²}`. As the field
631/// grows the density → 1 (fewer linear collisions); `p = 2` is the densest-collision regime, the smallest
632/// constant `φ(½) ≈ 0.28879`.
633pub fn invertibility_density_p(n: u32, p: u64) -> f64 {
634    (1..=n).map(|j| 1.0 - (p as f64).powi(-(j as i32))).product()
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    // A tiny seeded SplitMix64 — reproducible, no wall-clock.
642    fn splitmix(state: &mut u64) -> u64 {
643        *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
644        let mut z = *state;
645        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
646        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
647        z ^ (z >> 31)
648    }
649
650    fn count_invertible_modp_bruteforce(n: usize, p: u64) -> u128 {
651        let cells = (n * n) as u32;
652        let total = (p as u128).pow(cells);
653        let mut count = 0u128;
654        for idx in 0..total {
655            let mut m = vec![vec![0u64; n]; n];
656            let mut x = idx;
657            for row in m.iter_mut() {
658                for cell in row.iter_mut() {
659                    *cell = (x % p as u128) as u64;
660                    x /= p as u128;
661                }
662            }
663            if is_invertible_modp(n, p, &m) {
664                count += 1;
665            }
666        }
667        count
668    }
669
670    /// **The symmetry break across the field: the invertible count is `|GL(n,p)|` over every `GF(p)`.**
671    /// Brute force over all `p^{n²}` matrices equals the orbit–stabilizer product `Π(pⁿ − pⁱ)` — the
672    /// invertible matrices are exactly the ordered bases, the simply-transitive orbit of `GL(n,p)`. Proven
673    /// exhaustively over several prime fields, with pinned group orders.
674    #[test]
675    fn gl_order_p_is_the_invertible_count_over_gf_p() {
676        for &(n, p) in &[(1usize, 2u64), (1, 3), (2, 2), (2, 3), (2, 5), (2, 7), (3, 2)] {
677            assert_eq!(
678                count_invertible_modp_bruteforce(n, p),
679                gl_order_p(n as u32, p),
680                "brute invertible count over GF({p}) must equal |GL({n},{p})| = Π(pⁿ−pⁱ)"
681            );
682        }
683        assert_eq!(gl_order_p(2, 2), 6, "|GL(2,2)| = 6 ≅ S₃");
684        assert_eq!(gl_order_p(2, 3), 48, "|GL(2,3)| = 48");
685        assert_eq!(gl_order_p(2, 5), 480, "|GL(2,5)| = 480");
686        assert_eq!(gl_order_p(3, 2), 168, "|GL(3,2)| = 168");
687    }
688
689    /// **The field size is a symmetry axis, and `GF(2)` is its densest-collision end.** The density
690    /// `Π(1−p⁻ʲ) = |GL(n,p)|/p^{n²}` is exact, increases strictly with `p` (bigger field ⟹ fewer linear
691    /// collisions ⟹ more likely invertible), and → 1 as `p → ∞`. The `p = 2` value is `φ(½)`, the smallest
692    /// — and it agrees with the dedicated GF(2) module (cross-check).
693    #[test]
694    fn the_field_size_is_a_symmetry_axis() {
695        // Exact |GL|/p^(n²) == density — small n only, before the u128 product (≈ p^(n²)) overflows.
696        for &p in &[2u64, 3, 5, 7] {
697            for n in 1..=4u32 {
698                let exact = gl_order_p(n, p) as f64 / (p as f64).powi((n * n) as i32);
699                assert!((invertibility_density_p(n, p) - exact).abs() < 1e-12, "density == |GL|/p^(n²) at n={n},p={p}");
700            }
701        }
702        // strictly increasing in p (denser field ⟹ closer to always-invertible), at fixed n
703        let n = 6u32;
704        let dens: Vec<f64> = [2u64, 3, 5, 7, 11].iter().map(|&p| invertibility_density_p(n, p)).collect();
705        for w in dens.windows(2) {
706            assert!(w[1] > w[0], "density increases with the field size: {dens:?}");
707        }
708        // GF(2) is the densest-collision regime — the smallest constant, exactly φ(½)
709        assert!((invertibility_density_p(40, 2) - 0.288_788_095_1).abs() < 1e-9, "GF(2) density → φ(½)");
710        // cross-module agreement: the p=2 specialization is the gf2 module's own constant
711        for n in 1..=10u32 {
712            assert!((invertibility_density_p(n, 2) - crate::gf2::invertibility_density(n)).abs() < 1e-12, "modp p=2 == gf2 at n={n}");
713            assert_eq!(gl_order_p(n, 2), crate::gf2::gl_order(n), "|GL(n,2)| agrees across modules at n={n}");
714        }
715    }
716
717    fn brute_force_sat(equations: &[ModpEquation], num_vars: usize, p: u64) -> bool {
718        let total = (p as u128).pow(num_vars as u32);
719        for code in 0..total {
720            let mut a = vec![0u64; num_vars];
721            let mut c = code;
722            for slot in a.iter_mut() {
723                *slot = (c % p as u128) as u64;
724                c /= p as u128;
725            }
726            if satisfies(equations, &a, p) {
727                return true;
728            }
729        }
730        false
731    }
732
733    /// The certified mod-`p` cut, verified to the point of absurdity against brute force: over `GF(3)`
734    /// and `GF(5)`, on a fuzz of random systems, `solve`'s verdict always matches exhaustive search —
735    /// every `Sat` witness satisfies, every `Unsat` refutation independently re-checks.
736    #[test]
737    fn modp_gaussian_matches_brute_force() {
738        for &p in &[2u64, 3, 5, 7] {
739            let mut state = 0x1234_5678u64 ^ p;
740            for _ in 0..40 {
741                let num_vars = 2 + (splitmix(&mut state) % 3) as usize; // 2..4 variables
742                let num_eqs = 1 + (splitmix(&mut state) % 5) as usize; // 1..5 equations
743                let equations: Vec<ModpEquation> = (0..num_eqs)
744                    .map(|_| {
745                        let coeffs: Vec<(usize, u64)> = (0..num_vars)
746                            .map(|v| (v, splitmix(&mut state) % p))
747                            .filter(|&(_, a)| a != 0)
748                            .collect();
749                        ModpEquation::new(coeffs, splitmix(&mut state) % p)
750                    })
751                    .collect();
752                let brute = brute_force_sat(&equations, num_vars, p);
753                match solve(&equations, num_vars, p) {
754                    ModpOutcome::Sat(a) => {
755                        assert!(brute, "p={p}: solver Sat but brute force UNSAT: {equations:?}");
756                        assert!(satisfies(&equations, &a, p), "p={p}: the model must satisfy: {a:?}");
757                    }
758                    ModpOutcome::Unsat(combo) => {
759                        assert!(!brute, "p={p}: solver Unsat but a model exists: {equations:?}");
760                        assert!(
761                            is_refutation(&equations, num_vars, p, &combo),
762                            "p={p}: the refutation must re-check: {combo:?}"
763                        );
764                    }
765                }
766            }
767        }
768    }
769
770    /// A concrete mod-3 inconsistency the GF(2) parity cut is **blind** to. The system `x+y+z ≡ 0` and
771    /// `x+y+z ≡ 2 (mod 3)` is inconsistent over `GF(3)` (subtract: `0 ≡ 2`). But reduce the right-hand
772    /// sides mod 2 and *both* become `x+y+z ≡ 0` — the very same GF(2) equation, perfectly consistent.
773    /// So a parity (GF(2)) solver sees no conflict; only the mod-3 cut refutes it, with a re-checkable
774    /// certificate. The new field genuinely reaches a class the old one cannot.
775    #[test]
776    fn mod3_inconsistency_is_invisible_to_gf2() {
777        let p = 3;
778        let eqs = vec![
779            ModpEquation::new(vec![(0, 1), (1, 1), (2, 1)], 0),
780            ModpEquation::new(vec![(0, 1), (1, 1), (2, 1)], 2),
781        ];
782        match solve(&eqs, 3, p) {
783            ModpOutcome::Unsat(combo) => {
784                assert!(is_refutation(&eqs, 3, p, &combo), "the mod-3 refutation re-checks: {combo:?}");
785            }
786            other => panic!("expected the mod-3 system to be refuted, got {other:?}"),
787        }
788        // Over GF(2) both right-hand sides collapse to 0 — the same equation twice, satisfiable — so the
789        // parity cut sees no conflict where the mod-3 cut crushes.
790        let gf2_rhs: Vec<u64> = eqs.iter().map(|e| e.rhs % 2).collect();
791        assert_eq!(gf2_rhs, vec![0, 0], "the GF(2) reduction has no conflict — parity is blind here");
792    }
793
794    /// **The scalable mod-p crush — and the cross-field punch.** The cycle obstruction is refuted in
795    /// polynomial time exactly when `n` is not a multiple of `p`, with the all-ones combination as the
796    /// re-checkable witness — at every length. And a 4-cycle is satisfiable over `GF(2)` (the parity
797    /// cut sees nothing) yet refuted over `GF(3)`: the new field reaches a class the old one cannot.
798    #[test]
799    fn the_mod_p_cycle_obstruction_crushed_at_scale() {
800        for &p in &[3u64, 5, 7] {
801            for n in 2..=40 {
802                let eqs = cycle_system(n, p);
803                match solve(&eqs, n, p) {
804                    ModpOutcome::Unsat(combo) => {
805                        assert_ne!(n as u64 % p, 0, "p={p} n={n}: refuted ⟹ n not a multiple of p");
806                        assert!(is_refutation(&eqs, n, p, &combo), "p={p} n={n}: cycle refutation re-checks");
807                    }
808                    ModpOutcome::Sat(a) => {
809                        assert_eq!(n as u64 % p, 0, "p={p} n={n}: satisfiable ⟹ n is a multiple of p");
810                        assert!(satisfies(&eqs, &a, p), "p={p} n={n}: the model must satisfy");
811                    }
812                }
813            }
814        }
815        // The cross-field punch: a 4-cycle is SAT over GF(2) but UNSAT over GF(3).
816        assert!(matches!(solve(&cycle_system(4, 2), 4, 2), ModpOutcome::Sat(_)), "4-cycle SAT over GF(2)");
817        assert!(matches!(solve(&cycle_system(4, 3), 4, 3), ModpOutcome::Unsat(_)), "4-cycle UNSAT over GF(3)");
818    }
819
820    /// **`modp` over GF(2) *is* `xorsat`** — proven by a differential fuzz, not asserted. On 50 random
821    /// GF(2) systems the mod-2 Gaussian and the dedicated parity engine agree on every verdict, so the
822    /// new field is a faithful generalization of the old one (which is itself brute-force-verified).
823    #[test]
824    fn modp_over_gf2_agrees_with_xorsat() {
825        use crate::xorsat::{self, XorEquation, XorOutcome};
826        let mut state = 0x00AB_CDEFu64;
827        for _ in 0..50 {
828            let num_vars = 2 + (splitmix(&mut state) % 4) as usize;
829            let num_eqs = 1 + (splitmix(&mut state) % 5) as usize;
830            let systems: Vec<(Vec<usize>, bool)> = (0..num_eqs)
831                .map(|_| {
832                    let vars: Vec<usize> =
833                        (0..num_vars).filter(|_| splitmix(&mut state) % 2 == 0).collect();
834                    (vars, splitmix(&mut state) % 2 == 1)
835                })
836                .collect();
837            let xor_eqs: Vec<XorEquation> =
838                systems.iter().map(|(v, r)| XorEquation::new(v.clone(), *r)).collect();
839            let modp_eqs: Vec<ModpEquation> = systems
840                .iter()
841                .map(|(v, r)| {
842                    ModpEquation::new(v.iter().map(|&x| (x, 1u64)).collect::<Vec<_>>(), *r as u64)
843                })
844                .collect();
845            let xor_unsat = matches!(xorsat::solve(&xor_eqs, num_vars), XorOutcome::Unsat(_));
846            let modp_unsat = matches!(solve(&modp_eqs, num_vars, 2), ModpOutcome::Unsat(_));
847            assert_eq!(xor_unsat, modp_unsat, "modp(p=2) must match xorsat on {systems:?}");
848        }
849    }
850
851    /// The mod-`p` cut decides a satisfiable system and returns a real model.
852    #[test]
853    fn modp_solves_a_consistent_system() {
854        // Over GF(5): x + 2y ≡ 3, 3y ≡ 4  ⟹  y ≡ 3 (3⁻¹=2, 2·4=8≡3), x ≡ 3 − 2·3 = −3 ≡ 2.
855        let eqs = vec![
856            ModpEquation::new(vec![(0, 1), (1, 2)], 3),
857            ModpEquation::new(vec![(1, 3)], 4),
858        ];
859        match solve(&eqs, 2, 5) {
860            ModpOutcome::Sat(a) => {
861                assert!(satisfies(&eqs, &a, 5), "model must satisfy: {a:?}");
862                assert_eq!(a, vec![2, 3], "the unique solution over GF(5)");
863            }
864            other => panic!("expected Sat, got {other:?}"),
865        }
866    }
867
868    /// **The GF(p) lift is faithful at every prime.** For `p ∈ {3,5,7}` and several graph sizes/seeds,
869    /// `recover_from_cnf` reconstructs the system from the opaque one-hot CNF, and its verdict matches
870    /// both the hand-built supplied system *and* the family's declared UNSAT/SAT — on the inconsistent
871    /// (Tseitin) and the consistent forms alike. The recovery variables align with the edges, so the
872    /// recovered system is the same dimension as the supplied one. This is the equisatisfiability the
873    /// soundness of the dispatcher route rests on, proven across the field.
874    #[test]
875    fn recover_from_cnf_is_faithful_across_primes() {
876        use crate::families::{mod_p_consistent_onehot, mod_p_tseitin_expander, ExpectedVerdict};
877        for &p in &[3u64, 5, 7] {
878            for &n in &[4usize, 6, 8] {
879                for seed in 0..3u64 {
880                    for (supplied, cnf, expect) in
881                        [mod_p_tseitin_expander(n, p, seed), mod_p_consistent_onehot(n, p, seed)]
882                    {
883                        let rec = recover_from_cnf(cnf.num_vars, &cnf.clauses).unwrap_or_else(|| {
884                            panic!("p={p} n={n} seed={seed}: must recover the GF(p) system")
885                        });
886                        assert_eq!(rec.modulus, p, "p={p} n={n} seed={seed}: recovered the right field");
887                        let rec_unsat =
888                            matches!(solve(&rec.equations, rec.num_vars, p), ModpOutcome::Unsat(_));
889                        let sup_unsat =
890                            matches!(solve(&supplied, rec.num_vars, p), ModpOutcome::Unsat(_));
891                        assert_eq!(rec_unsat, sup_unsat, "p={p} n={n} seed={seed}: recovered vs supplied");
892                        assert_eq!(
893                            rec_unsat,
894                            matches!(expect, ExpectedVerdict::Unsat),
895                            "p={p} n={n} seed={seed}: verdict matches the family's expectation"
896                        );
897                    }
898                }
899            }
900        }
901    }
902
903    /// **Soundness of declining.** The recoverer must guess nothing: it returns `None` on inputs that are
904    /// not a clean one-hot mod-`p` encoding — random 3-SAT (no groups), and an all-positive clause whose
905    /// pairwise at-most-one structure is incomplete (so "exactly one" is not actually enforced).
906    #[test]
907    fn recover_declines_on_inputs_that_are_not_a_one_hot_encoding() {
908        use crate::cdcl::Lit;
909        let rnd = crate::families::random_3sat(20, 80, 0xF00D);
910        assert!(recover_from_cnf(rnd.num_vars, &rnd.clauses).is_none(), "random 3-SAT has no GF(p) structure");
911        let incomplete = vec![
912            vec![Lit::pos(0), Lit::pos(1), Lit::pos(2)],
913            vec![Lit::neg(0), Lit::neg(1)], // only one of the three at-most-one pairs
914        ];
915        assert!(
916            recover_from_cnf(3, &incomplete).is_none(),
917            "an incomplete at-most-one must not be mistaken for a one-hot group"
918        );
919    }
920}