Skip to main content

logicaffeine_proof/
families.rs

1//! Parametric generators for symmetry-rich SAT families — the canonical hard cases where
2//! symmetry breaking earns its keep. They are programmatic (reproducible, offline, parametric)
3//! rather than vendored `.cnf` files, and each is pinned to its known verdict so the solver and
4//! the certified pipeline can be tested against ground truth.
5
6use crate::cdcl::Lit;
7use crate::dimacs::DimacsCnf;
8
9/// The known verdict of a generated instance, for test oracles.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum ExpectedVerdict {
12    Sat,
13    Unsat,
14}
15
16/// The pigeonhole principle PHP(n): `n` pigeons into `n-1` holes — unsatisfiable, and the
17/// textbook symmetry / resolution-hard family (any resolution refutation is exponential, while
18/// breaking the `S_n × S_{n-1}` symmetry collapses it). The variable for "pigeon `p` sits in
19/// hole `h`" lives at index `p*(n-1) + h`, so pigeons index rows and holes index columns.
20pub fn php(n: usize) -> (DimacsCnf, ExpectedVerdict) {
21    let holes = n.saturating_sub(1);
22    let num_vars = n * holes;
23    let var = |p: usize, h: usize| Lit::pos((p * holes + h) as u32);
24    let mut clauses: Vec<Vec<Lit>> = Vec::new();
25    // Each pigeon occupies at least one hole (an empty disjunction when holes == 0).
26    for p in 0..n {
27        clauses.push((0..holes).map(|h| var(p, h)).collect());
28    }
29    // No two pigeons share a hole.
30    for h in 0..holes {
31        for p in 0..n {
32            for q in (p + 1)..n {
33                clauses.push(vec![var(p, h).negated(), var(q, h).negated()]);
34            }
35        }
36    }
37    (DimacsCnf { num_vars, clauses }, ExpectedVerdict::Unsat)
38}
39
40/// **Coupled exactly-one + parity** — a scalable MIXED family that needs BOTH structures at once. The
41/// selectors `x₀…x_{n-1}` are constrained to *exactly one* true (an at-most-one clique + an at-least-one
42/// clause — a cardinality covering) AND to *even* total parity `⊕ xᵢ = 0` (a GF(2) system, encoded by a
43/// width-3 prefix-XOR chain with auxiliaries `sᵢ = ⊕_{j≤i} xⱼ` plus the unit `s_{n-1} = 0`). Exactly-one
44/// forces an ODD selector count, the parity forces EVEN — **UNSAT**, yet neither substructure alone is
45/// (the parity chain is satisfiable, exactly-one is satisfiable). The canonical shape for the fused
46/// parity+cardinality route ([`crate::lyapunov::fused_parity_cardinality_decide`]); `2n` variables.
47pub fn parity_exactly_one(n: usize) -> (DimacsCnf, ExpectedVerdict) {
48    assert!(n >= 2, "need at least two selectors");
49    let x = |i: usize| i as u32; // selectors 0..n-1
50    let s = |i: usize| (n + i) as u32; // prefix-XOR auxiliaries n..2n-1
51    let mut clauses: Vec<Vec<Lit>> = Vec::new();
52    // exactly-one of the selectors: at-least-one ∨, then pairwise at-most-one.
53    clauses.push((0..n).map(|i| Lit::pos(x(i))).collect());
54    for i in 0..n {
55        for j in (i + 1)..n {
56            clauses.push(vec![Lit::neg(x(i)), Lit::neg(x(j))]);
57        }
58    }
59    // Prefix-XOR chain: s₀ = x₀, sᵢ = s_{i-1} ⊕ xᵢ, s_{n-1} = 0 — each a small XOR gadget.
60    let gadget = |vars: &[u32], out: &mut Vec<Vec<Lit>>| {
61        let k = vars.len();
62        for mask in 0u32..(1 << k) {
63            if mask.count_ones() % 2 == 1 {
64                out.push((0..k).map(|t| Lit::new(vars[t], (mask >> t) & 1 == 0)).collect());
65            }
66        }
67    };
68    gadget(&[s(0), x(0)], &mut clauses);
69    for i in 1..n {
70        gadget(&[s(i), s(i - 1), x(i)], &mut clauses);
71    }
72    clauses.push(vec![Lit::neg(s(n - 1))]);
73    (DimacsCnf { num_vars: 2 * n, clauses }, ExpectedVerdict::Unsat)
74}
75
76/// Graph `k`-coloring of the complete graph `K_n` — the canonical *color-permutation* symmetry
77/// family (the color group `S_k` acts, on top of the vertex group `S_n`). It is unsatisfiable
78/// exactly when `k < n` (a clique of size `n` needs `n` colors). The variable "vertex `v` takes
79/// color `c`" lives at index `v*k + c`.
80pub fn clique_coloring(n: usize, k: usize) -> (DimacsCnf, ExpectedVerdict) {
81    let num_vars = n * k;
82    let var = |v: usize, c: usize| Lit::pos((v * k + c) as u32);
83    let mut clauses: Vec<Vec<Lit>> = Vec::new();
84    // Every vertex takes at least one color.
85    for v in 0..n {
86        clauses.push((0..k).map(|c| var(v, c)).collect());
87    }
88    // Adjacent vertices (every pair, in K_n) must differ in color.
89    for u in 0..n {
90        for w in (u + 1)..n {
91            for c in 0..k {
92                clauses.push(vec![var(u, c).negated(), var(w, c).negated()]);
93            }
94        }
95    }
96    let verdict = if k < n { ExpectedVerdict::Unsat } else { ExpectedVerdict::Sat };
97    (DimacsCnf { num_vars, clauses }, verdict)
98}
99
100/// The mutilated chessboard: an `n×n` board with two OPPOSITE corners removed, asking for a perfect
101/// domino tiling. Every domino covers one black and one white square, but the two removed corners
102/// share a colour (for even `n`), so the black/white counts differ by two and no tiling exists —
103/// UNSAT, and a textbook resolution-exponential family. The infeasibility is a bipartite-matching
104/// (Hall) obstruction, which our covering route certifies in polynomial time. Variables are dominoes
105/// (grid edges between adjacent surviving squares); each surviving square must be covered by exactly
106/// one. `n` must be even (so the parity argument bites).
107pub fn mutilated_chessboard(n: usize) -> (DimacsCnf, ExpectedVerdict) {
108    assert!(n >= 4 && n % 2 == 0, "the parity argument needs an even board ≥ 4");
109    let removed = |r: usize, c: usize| (r == 0 && c == 0) || (r == n - 1 && c == n - 1);
110    let sq = |r: usize, c: usize| r * n + c;
111    // Edges: horizontal (r,c)–(r,c+1) and vertical (r,c)–(r+1,c), both endpoints surviving.
112    let mut edges: Vec<(usize, usize)> = Vec::new();
113    for r in 0..n {
114        for c in 0..n {
115            if removed(r, c) {
116                continue;
117            }
118            if c + 1 < n && !removed(r, c + 1) {
119                edges.push((sq(r, c), sq(r, c + 1)));
120            }
121            if r + 1 < n && !removed(r + 1, c) {
122                edges.push((sq(r, c), sq(r + 1, c)));
123            }
124        }
125    }
126    let num_vars = edges.len();
127    let mut incident: std::collections::HashMap<usize, Vec<usize>> = std::collections::HashMap::new();
128    for (e, &(a, b)) in edges.iter().enumerate() {
129        incident.entry(a).or_default().push(e);
130        incident.entry(b).or_default().push(e);
131    }
132    let mut clauses: Vec<Vec<Lit>> = Vec::new();
133    for r in 0..n {
134        for c in 0..n {
135            if removed(r, c) {
136                continue;
137            }
138            let inc = incident.get(&sq(r, c)).cloned().unwrap_or_default();
139            clauses.push(inc.iter().map(|&e| Lit::pos(e as u32)).collect()); // covered ≥ once
140            for i in 0..inc.len() {
141                for j in (i + 1)..inc.len() {
142                    clauses.push(vec![Lit::pos(inc[i] as u32).negated(), Lit::pos(inc[j] as u32).negated()]); // ≤ once
143                }
144            }
145        }
146    }
147    (DimacsCnf { num_vars, clauses }, ExpectedVerdict::Unsat)
148}
149
150/// The linear ordering principle GT(n): no finite strict total order lacks a maximal element — yet
151/// this formula asserts exactly that, so it is UNSAT. `x[i][j]` (index `i*n + j`) reads "i < j".
152/// Antisymmetry + totality + transitivity force a linear order, and the "every element has a greater
153/// one" clauses then contradict finiteness. A canonical resolution-hard family — it stresses the
154/// CDCL core / cutting-plane reasoning rather than a matching or parity specialist.
155pub fn ordering_principle(n: usize) -> (DimacsCnf, ExpectedVerdict) {
156    assert!(n >= 2);
157    let var = |i: usize, j: usize| Lit::pos((i * n + j) as u32);
158    let num_vars = n * n;
159    let mut clauses: Vec<Vec<Lit>> = Vec::new();
160    for i in 0..n {
161        for j in (i + 1)..n {
162            clauses.push(vec![var(i, j), var(j, i)]); // totality
163            clauses.push(vec![var(i, j).negated(), var(j, i).negated()]); // antisymmetry
164        }
165    }
166    for i in 0..n {
167        for j in 0..n {
168            for k in 0..n {
169                if i != j && j != k && i != k {
170                    clauses.push(vec![var(i, j).negated(), var(j, k).negated(), var(i, k)]); // transitivity
171                }
172            }
173        }
174    }
175    for i in 0..n {
176        clauses.push((0..n).filter(|&j| j != i).map(|j| var(i, j)).collect()); // i has a greater element
177    }
178    (DimacsCnf { num_vars, clauses }, ExpectedVerdict::Unsat)
179}
180
181/// A random 3-SAT instance: `num_clauses` clauses of three distinct variables with random signs,
182/// over `vars` variables, from a seeded SplitMix64 stream (reproducible — no wall-clock). Near the
183/// clause/variable ratio 4.26 these are the canonical *hard, non-symmetric* benchmarks — the
184/// general-instance control where raw CDCL quality (not symmetry breaking) decides the race.
185pub fn random_3sat(vars: usize, num_clauses: usize, seed: u64) -> DimacsCnf {
186    let mut state = seed;
187    let mut next = move || {
188        state = state.wrapping_add(0x9E3779B97F4A7C15);
189        let mut z = state;
190        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
191        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
192        z ^ (z >> 31)
193    };
194    let mut clauses: Vec<Vec<Lit>> = Vec::with_capacity(num_clauses);
195    while clauses.len() < num_clauses {
196        let mut vs: Vec<u32> = Vec::with_capacity(3);
197        while vs.len() < 3 {
198            let v = (next() as usize % vars) as u32;
199            if !vs.contains(&v) {
200                vs.push(v);
201            }
202        }
203        clauses.push(vs.iter().map(|&v| Lit::new(v, next() & 1 == 0)).collect());
204    }
205    DimacsCnf { num_vars: vars, clauses }
206}
207
208/// A random **k-SAT** instance: `num_clauses` clauses of `k` distinct variables with random signs, over
209/// `vars` variables, from a seeded SplitMix64 stream (reproducible — no wall-clock). Generalizes
210/// [`random_3sat`]. The satisfiability threshold climbs with `k` roughly as `α_k ≈ 2ᵏ ln 2` (≈ 4.27 for
211/// k=3, ≈ 9.93 for k=4, ≈ 21.1 for k=5). Requires `k ≤ vars`.
212pub fn random_ksat(k: usize, vars: usize, num_clauses: usize, seed: u64) -> DimacsCnf {
213    let mut state = seed;
214    let mut next = move || {
215        state = state.wrapping_add(0x9E3779B97F4A7C15);
216        let mut z = state;
217        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
218        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
219        z ^ (z >> 31)
220    };
221    let mut clauses: Vec<Vec<Lit>> = Vec::with_capacity(num_clauses);
222    while clauses.len() < num_clauses {
223        let mut vs: Vec<u32> = Vec::with_capacity(k);
224        while vs.len() < k {
225            let v = (next() as usize % vars) as u32;
226            if !vs.contains(&v) {
227                vs.push(v);
228            }
229        }
230        clauses.push(vs.iter().map(|&v| Lit::new(v, next() & 1 == 0)).collect());
231    }
232    DimacsCnf { num_vars: vars, clauses }
233}
234
235/// The CNF clauses encoding the parity constraint `v0 ⊕ v1 ⊕ … = rhs` over any arity (forbid every
236/// wrong-parity assignment of the listed variables — `2^{|vs|-1}` clauses, each of width `|vs|`).
237fn xor_clauses(vs: &[u32], rhs: bool) -> Vec<Vec<Lit>> {
238    let mut out = Vec::new();
239    for mask in 0u32..(1 << vs.len()) {
240        let odd = mask.count_ones() % 2 == 1;
241        if odd != rhs {
242            // Forbid this assignment: a literal is true exactly when it differs from the mask bit.
243            out.push(vs.iter().enumerate().map(|(i, &v)| Lit::new(v, (mask >> i) & 1 == 0)).collect());
244        }
245    }
246    out
247}
248
249/// Fold a freshly sampled `k`-subset's coefficient row into the running GF(2) echelon basis (rows keyed
250/// by pivot = least set variable). Returns `true` and extends the basis if the row is linearly
251/// independent of those seen so far, `false` if it lies in their span. This is the rank meter that lets
252/// [`random_kxor`] build a *guaranteed*-inconsistent system rather than a merely-probable one.
253fn gf2_absorb(basis: &mut std::collections::HashMap<u32, Vec<u32>>, vars: &[u32]) -> bool {
254    let mut row: std::collections::BTreeSet<u32> = vars.iter().copied().collect();
255    while let Some(&pivot) = row.iter().next() {
256        match basis.get(&pivot) {
257            Some(b) => {
258                for &v in b {
259                    if !row.remove(&v) {
260                        row.insert(v); // symmetric difference: row ⊕ basis[pivot]
261                    }
262                }
263            }
264            None => {
265                basis.insert(pivot, row.iter().copied().collect());
266                return true;
267            }
268        }
269    }
270    false
271}
272
273/// A **guaranteed-unsatisfiable** random **k-XOR** (parity) system over `n` variables: at least `m`
274/// equations of `k` distinct variables, all consistent with a planted assignment `p` except one whose
275/// right-hand side is flipped. Generalizes [`parity_unsat`] (the `k = 3` case).
276///
277/// The flip alone does **not** force inconsistency — it does so only once the flipped row lies in the
278/// span of the others, which a naive "flip the last equation" does not guarantee near `m ≈ n` (it
279/// silently yields *satisfiable* instances). So we build the consistent rows until the coefficient
280/// matrix reaches its **maximum achievable GF(2) rank** (`n` for odd `k`; `n − 1` for even `k`, whose
281/// even-weight rows can never span the all-ones functional) and *at least* `m` rows total, then append
282/// one more equation with a flipped right-hand side. At maximal rank that final row is redundant, so the
283/// system's solution set — `{p}` for odd `k`, `{p, p̄}` for even `k` — is pinned, and *every* pinned
284/// solution violates the flipped row: **the system is inconsistent by construction**, for any seed.
285/// Full-rank `k`-XOR above the XOR-SAT threshold is also exponentially hard for resolution
286/// (Ben-Sasson–Wigderson 2001) — hence for every CDCL solver — yet linear for Gaussian elimination over
287/// GF(2). Each equation expands to `2^{k-1}` width-`k` clauses, so keep `k` modest. `m` is a floor on the
288/// equation count (full rank may need a few more). Returns the XOR equations and the equisatisfiable CNF.
289pub fn random_kxor(k: usize, n: usize, m: usize, seed: u64) -> (Vec<crate::xorsat::XorEquation>, DimacsCnf) {
290    assert!((1..=n).contains(&k) && m >= 1, "need 1 ≤ k ≤ n and m ≥ 1");
291    let mut state = seed;
292    let mut next = move || {
293        state = state.wrapping_add(0x9E3779B97F4A7C15);
294        let mut z = state;
295        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
296        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
297        z ^ (z >> 31)
298    };
299    let planted: Vec<bool> = (0..n).map(|_| next() & 1 == 0).collect();
300    let mut draw = || {
301        let mut vs: Vec<u32> = Vec::with_capacity(k);
302        while vs.len() < k {
303            let v = (next() as usize % n) as u32;
304            if !vs.contains(&v) {
305                vs.push(v);
306            }
307        }
308        vs
309    };
310    let consistent_rhs = |vs: &[u32]| vs.iter().fold(false, |a, &v| a ^ planted[v as usize]);
311
312    // Phase 1: consistent rows until the coefficient matrix is at maximal rank AND there are ≥ m of them.
313    let target_rank = if k % 2 == 1 { n } else { n.saturating_sub(1) };
314    let cap = 8 * n + 64; // generous guard; random k-subsets reach maximal rank far inside this.
315    let mut basis: std::collections::HashMap<u32, Vec<u32>> = std::collections::HashMap::new();
316    let mut rows: Vec<(Vec<u32>, bool)> = Vec::new();
317    let mut rank = 0usize;
318    while (rank < target_rank || rows.len() < m) && rows.len() < cap {
319        let vs = draw();
320        if gf2_absorb(&mut basis, &vs) {
321            rank += 1;
322        }
323        let rhs = consistent_rhs(&vs);
324        rows.push((vs, rhs));
325    }
326    // Phase 2: one more equation, right-hand side FLIPPED. At maximal rank it is redundant, so the pinned
327    // solution set is unchanged by it — yet no pinned solution satisfies it ⇒ the system is UNSAT.
328    let vs = draw();
329    let rhs = !consistent_rhs(&vs);
330    rows.push((vs, rhs));
331
332    let mut eqs = Vec::new();
333    let mut clauses = Vec::new();
334    for (vs, rhs) in &rows {
335        eqs.push(crate::xorsat::XorEquation::new(vs.iter().map(|&v| v as usize).collect::<Vec<_>>(), *rhs));
336        clauses.extend(xor_clauses(vs, *rhs));
337    }
338    (eqs, DimacsCnf { num_vars: n, clauses })
339}
340
341/// A **guaranteed-unsatisfiable** random 3-XOR (parity) system — the `k = 3` case of [`random_kxor`]:
342/// at least `m` 3-XOR equations over `n` variables, all consistent with a planted assignment except one
343/// flipped row, built up to maximal GF(2) rank so the inconsistency holds for *any* seed (see
344/// [`random_kxor`] for why the naive single flip does not suffice). Full-rank 3-XOR above the XOR-SAT
345/// threshold is **exponentially hard for resolution** (Ben-Sasson–Wigderson 2001), hence for every CDCL
346/// solver — yet linear for Gaussian elimination over GF(2). Returns the XOR equations (for
347/// [`crate::xorsat`]) and the equisatisfiable CNF (for a resolution solver).
348pub fn parity_unsat(n: usize, m: usize, seed: u64) -> (Vec<crate::xorsat::XorEquation>, DimacsCnf) {
349    random_kxor(3, n, m, seed)
350}
351
352/// A random 3-regular graph on `n` (even) vertices via the configuration model with self-loop /
353/// multi-edge rejection — an expander with high probability, which is what makes the Tseitin
354/// formula on it *exponentially* hard for resolution (Urquhart 1987; Ben-Sasson–Wigderson 2001).
355fn random_3regular(n: usize, seed: u64) -> Vec<(usize, usize)> {
356    let mut state = seed ^ 0x9E3779B97F4A7C15;
357    let mut next = move || {
358        state = state.wrapping_add(0x9E3779B97F4A7C15);
359        let mut z = state;
360        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
361        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
362        z ^ (z >> 31)
363    };
364    for _ in 0..4000 {
365        let mut stubs: Vec<usize> = (0..n).flat_map(|v| [v, v, v]).collect();
366        for i in (1..stubs.len()).rev() {
367            let j = (next() as usize) % (i + 1);
368            stubs.swap(i, j);
369        }
370        let mut edges = Vec::new();
371        let mut seen = std::collections::HashSet::new();
372        let mut ok = true;
373        for c in stubs.chunks(2) {
374            let (a, b) = (c[0].min(c[1]), c[0].max(c[1]));
375            if a == b || !seen.insert((a, b)) {
376                ok = false;
377                break;
378            }
379            edges.push((a, b));
380        }
381        if ok {
382            return edges;
383        }
384    }
385    panic!("could not build a simple 3-regular graph on {n} vertices");
386}
387
388/// The **Tseitin formula on a random 3-regular expander** with odd total charge — UNSATISFIABLE,
389/// exponentially hard for resolution (so every CDCL solver blows up), yet solved in polynomial time
390/// by Gaussian elimination over GF(2). A *non-pigeonhole* hard family: the hardness is parity /
391/// graph expansion, not covering symmetry. One Boolean per edge; per vertex, the XOR of its incident
392/// edges equals that vertex's charge (vertex 0 charged, the rest not — an odd sum, hence
393/// inconsistent). Returns the XOR system (for [`crate::xorsat`]) and the equisatisfiable CNF.
394pub fn tseitin_expander(n: usize, seed: u64) -> (Vec<crate::xorsat::XorEquation>, DimacsCnf, ExpectedVerdict) {
395    assert!(n % 2 == 0 && n >= 4, "a 3-regular graph needs an even vertex count ≥ 4");
396    let edges = random_3regular(n, seed);
397    let m = edges.len();
398    let mut incident: Vec<Vec<usize>> = vec![Vec::new(); n];
399    for (e, &(a, b)) in edges.iter().enumerate() {
400        incident[a].push(e);
401        incident[b].push(e);
402    }
403    let mut eqs = Vec::new();
404    let mut clauses = Vec::new();
405    for v in 0..n {
406        let inc = &incident[v];
407        let r = v == 0; // odd total charge ⇒ UNSAT
408        eqs.push(crate::xorsat::XorEquation::new(inc.clone(), r));
409        let d = inc.len();
410        for mask in 0u32..(1u32 << d) {
411            if ((mask.count_ones() % 2) == 1) != r {
412                clauses.push((0..d).map(|i| Lit::new(inc[i] as u32, (mask >> i) & 1 == 0)).collect());
413            }
414        }
415    }
416    (eqs, DimacsCnf { num_vars: m, clauses }, ExpectedVerdict::Unsat)
417}
418
419/// The **Tseitin formula on a `w × n` grid** with odd total charge — UNSATISFIABLE, and the sharpest
420/// indictment of resolution search there is. A grid has treewidth exactly `w` (a fixed constant,
421/// independent of the length `n`), so a POLYNOMIAL-size, bounded-width resolution refutation provably
422/// EXISTS. Yet CDCL solvers without Gaussian reasoning blow up on the parity regardless — they cannot
423/// find the short proof that is known to exist — while Gaussian elimination over GF(2) decides it in
424/// near-linear time. One Boolean per grid edge; per vertex, the XOR of its incident edges equals its
425/// charge (vertex 0 charged, the rest not — an odd sum, hence inconsistent). Returns the XOR system
426/// (for [`crate::xorsat`]) and the equisatisfiable CNF.
427pub fn grid_tseitin(w: usize, n: usize) -> (Vec<crate::xorsat::XorEquation>, DimacsCnf, ExpectedVerdict) {
428    assert!(w >= 2 && n >= 2, "a grid needs both dimensions ≥ 2");
429    let vid = |i: usize, j: usize| i * n + j;
430    let mut incident: Vec<Vec<usize>> = vec![Vec::new(); w * n];
431    let mut num_edges = 0usize;
432    for i in 0..w {
433        for j in 0..n {
434            let v = vid(i, j);
435            if j + 1 < n {
436                let e = num_edges;
437                num_edges += 1;
438                incident[v].push(e);
439                incident[vid(i, j + 1)].push(e);
440            }
441            if i + 1 < w {
442                let e = num_edges;
443                num_edges += 1;
444                incident[v].push(e);
445                incident[vid(i + 1, j)].push(e);
446            }
447        }
448    }
449    let mut eqs = Vec::new();
450    let mut clauses = Vec::new();
451    for v in 0..(w * n) {
452        let inc = &incident[v];
453        let r = v == 0; // odd total charge ⇒ UNSAT
454        eqs.push(crate::xorsat::XorEquation::new(inc.clone(), r));
455        let d = inc.len();
456        for mask in 0u32..(1u32 << d) {
457            if ((mask.count_ones() % 2) == 1) != r {
458                clauses.push((0..d).map(|i| Lit::new(inc[i] as u32, (mask >> i) & 1 == 0)).collect());
459            }
460        }
461    }
462    (eqs, DimacsCnf { num_vars: num_edges, clauses }, ExpectedVerdict::Unsat)
463}
464
465/// The **mod-`p` Tseitin obstruction on a random 3-regular expander** — the parity crush carried to
466/// every prime. Each edge carries a `GF(p)` flow variable; orient each edge and impose, at every
467/// vertex, the signed divergence `Σ_out − Σ_in ≡ charge (mod p)`. Summing all vertices telescopes the
468/// edge variables to `0`, so the system is inconsistent exactly when the total charge `≢ 0 (mod p)`.
469/// We charge two vertices (total `2`): **inconsistent over `GF(p)` for every odd prime `p`, yet
470/// consistent over `GF(2)`** (`2 ≡ 0`) — a family the parity cut is structurally blind to, decided
471/// instantly by Gaussian elimination over the *right* `GF(p)`, and (on an expander) exponentially hard
472/// for resolution. Returns the `GF(p)` system (for [`crate::modp`]), the equisatisfiable one-hot
473/// Boolean CNF (for resolution solvers), and the `GF(p)` verdict.
474pub fn mod_p_tseitin_expander(
475    n: usize,
476    p: u64,
477    seed: u64,
478) -> (Vec<crate::modp::ModpEquation>, DimacsCnf, ExpectedVerdict) {
479    assert!(p >= 3, "the mod-p obstruction needs an odd prime (p=2 is the parity case, consistent here)");
480    mod_tseitin_expander_core(n, p, seed)
481}
482
483/// The **composite-modulus** sibling of [`mod_p_tseitin_expander`]: the same total-charge-2 divergence
484/// obstruction over `ℤ/m` for a squarefree composite `m` (e.g. `m = 6`), with a mixed-radix one-hot
485/// Boolean encoding (`m` values per edge). By CRT `ℤ/m ≅ ∏ GF(pᵢ)`, so the system is inconsistent over
486/// `ℤ/m` exactly when it is inconsistent over some prime factor — total charge `2 ≢ 0 (mod m)` for any
487/// `m ≥ 3` that does not divide `2` — and [`crate::modm::solve`] decides it through that factor with a
488/// solver-free re-checkable certificate ([`crate::modm::is_refutation`]). This drives the **ring**
489/// route (`ℤ/m` Gaussian via CRT), not the prime-field route, on a CNF that resolution still walls on.
490pub fn mod_m_tseitin_expander(
491    n: usize,
492    m: u64,
493    seed: u64,
494) -> (Vec<crate::modp::ModpEquation>, DimacsCnf, ExpectedVerdict) {
495    assert!(m >= 3, "the composite obstruction needs a modulus ≥ 3 (m=2 is the parity case, consistent here)");
496    mod_tseitin_expander_core(n, m, seed)
497}
498
499/// Shared construction for the total-charge-2 divergence obstruction over `ℤ/modulus` on a random
500/// 3-regular expander, used by both the prime-field ([`mod_p_tseitin_expander`]) and composite-ring
501/// ([`mod_m_tseitin_expander`]) families. Returns the `ℤ/modulus` divergence system, the equisatisfiable
502/// one-hot Boolean CNF (`modulus` values per edge), and the UNSAT verdict. Charging two vertices makes
503/// the total `2`, which is `≢ 0 (mod modulus)` for every `modulus ≥ 3` yet `≡ 0 (mod 2)` — so the parity
504/// cut is blind to it and only Gaussian elimination over the right characteristic decides it.
505fn mod_tseitin_expander_core(
506    n: usize,
507    modulus: u64,
508    seed: u64,
509) -> (Vec<crate::modp::ModpEquation>, DimacsCnf, ExpectedVerdict) {
510    use crate::cdcl::Lit;
511    use crate::modp::ModpEquation;
512    assert!(n % 2 == 0 && n >= 4, "a 3-regular graph needs an even vertex count ≥ 4");
513    let edges = random_3regular(n, seed); // each (a, b) with a < b; orient a → b (tail a, head b)
514    let ne = edges.len();
515    let charge = |v: usize| u64::from(v == 0 || v == 1); // total charge 2: ≢0 mod modulus (≥3), ≡0 mod 2
516
517    // The ℤ/modulus divergence system: +x_e at the tail, −x_e (= (modulus−1)·x_e) at the head.
518    let mut eqs: Vec<ModpEquation> = Vec::new();
519    for v in 0..n {
520        let mut coeffs: Vec<(usize, u64)> = Vec::new();
521        for (e, &(a, b)) in edges.iter().enumerate() {
522            if a == v {
523                coeffs.push((e, 1));
524            } else if b == v {
525                coeffs.push((e, modulus - 1));
526            }
527        }
528        eqs.push(ModpEquation::new(coeffs, charge(v)));
529    }
530
531    // The equisatisfiable Boolean CNF: one-hot bit b(e, val) = "edge e takes value val" (var e*modulus+val).
532    let bvar = |e: usize, val: u64| (e * modulus as usize + val as usize) as u32;
533    let mut clauses: Vec<Vec<Lit>> = Vec::new();
534    for e in 0..ne {
535        clauses.push((0..modulus).map(|val| Lit::pos(bvar(e, val))).collect()); // ≥1 value
536        for v1 in 0..modulus {
537            for v2 in (v1 + 1)..modulus {
538                clauses.push(vec![Lit::neg(bvar(e, v1)), Lit::neg(bvar(e, v2))]); // ≤1 value
539            }
540        }
541    }
542    // Per vertex, forbid every assignment of its incident edge-values whose signed sum ≢ charge.
543    for v in 0..n {
544        let incident: Vec<(usize, i64)> = edges
545            .iter()
546            .enumerate()
547            .filter_map(|(e, &(a, b))| {
548                if a == v {
549                    Some((e, 1i64))
550                } else if b == v {
551                    Some((e, -1i64))
552                } else {
553                    None
554                }
555            })
556            .collect();
557        let d = incident.len();
558        let want = charge(v) as i64;
559        let pi = modulus as i64;
560        for idx in 0..modulus.pow(d as u32) {
561            let mut x = idx;
562            let mut combo = vec![0u64; d];
563            for slot in combo.iter_mut() {
564                *slot = x % modulus;
565                x /= modulus;
566            }
567            let s = incident.iter().zip(&combo).fold(0i64, |acc, (&(_, sign), &val)| acc + sign * val as i64);
568            if (s.rem_euclid(pi)) != (want.rem_euclid(pi)) {
569                clauses.push(
570                    incident.iter().zip(&combo).map(|(&(e, _), &val)| Lit::neg(bvar(e, val))).collect(),
571                );
572            }
573        }
574    }
575
576    (eqs, DimacsCnf { num_vars: ne * modulus as usize, clauses }, ExpectedVerdict::Unsat)
577}
578
579/// A **satisfiable** sibling of [`mod_p_tseitin_expander`] over the same 3-regular graph and the same
580/// one-hot Boolean encoding, but with a charge distribution whose total `≡ 0 (mod p)`. The divergence
581/// system `Σ_out x − Σ_in x ≡ charge(v)` is consistent exactly when the charges sum to zero (summing all
582/// vertex equations telescopes the left side to `0`), so this instance is SAT — the control for the GF(p)
583/// route's model-returning path. Charge: `+1` at vertex 0, `−1 (= p−1)` at vertex 1, zero elsewhere.
584pub fn mod_p_consistent_onehot(
585    n: usize,
586    p: u64,
587    seed: u64,
588) -> (Vec<crate::modp::ModpEquation>, DimacsCnf, ExpectedVerdict) {
589    use crate::cdcl::Lit;
590    use crate::modp::ModpEquation;
591    assert!(n % 2 == 0 && n >= 4, "a 3-regular graph needs an even vertex count ≥ 4");
592    assert!(p >= 3, "the mod-p one-hot encoding needs an odd prime");
593    let edges = random_3regular(n, seed);
594    let ne = edges.len();
595    let charge = |v: usize| -> u64 {
596        match v {
597            0 => 1,
598            1 => p - 1,
599            _ => 0,
600        }
601    }; // total charge ≡ 0 (mod p) ⟹ consistent
602
603    let mut eqs: Vec<ModpEquation> = Vec::new();
604    for v in 0..n {
605        let mut coeffs: Vec<(usize, u64)> = Vec::new();
606        for (e, &(a, b)) in edges.iter().enumerate() {
607            if a == v {
608                coeffs.push((e, 1));
609            } else if b == v {
610                coeffs.push((e, p - 1));
611            }
612        }
613        eqs.push(ModpEquation::new(coeffs, charge(v)));
614    }
615
616    let bvar = |e: usize, val: u64| (e * p as usize + val as usize) as u32;
617    let mut clauses: Vec<Vec<Lit>> = Vec::new();
618    for e in 0..ne {
619        clauses.push((0..p).map(|val| Lit::pos(bvar(e, val))).collect());
620        for v1 in 0..p {
621            for v2 in (v1 + 1)..p {
622                clauses.push(vec![Lit::neg(bvar(e, v1)), Lit::neg(bvar(e, v2))]);
623            }
624        }
625    }
626    for v in 0..n {
627        let incident: Vec<(usize, i64)> = edges
628            .iter()
629            .enumerate()
630            .filter_map(|(e, &(a, b))| {
631                if a == v {
632                    Some((e, 1i64))
633                } else if b == v {
634                    Some((e, -1i64))
635                } else {
636                    None
637                }
638            })
639            .collect();
640        let d = incident.len();
641        let want = charge(v) as i64;
642        let pi = p as i64;
643        for idx in 0..p.pow(d as u32) {
644            let mut x = idx;
645            let mut combo = vec![0u64; d];
646            for slot in combo.iter_mut() {
647                *slot = x % p;
648                x /= p;
649            }
650            let s = incident.iter().zip(&combo).fold(0i64, |acc, (&(_, sign), &val)| acc + sign * val as i64);
651            if (s.rem_euclid(pi)) != (want.rem_euclid(pi)) {
652                clauses.push(
653                    incident.iter().zip(&combo).map(|(&(e, _), &val)| Lit::neg(bvar(e, val))).collect(),
654                );
655            }
656        }
657    }
658
659    (eqs, DimacsCnf { num_vars: ne * p as usize, clauses }, ExpectedVerdict::Sat)
660}
661
662/// The **first-moment (counting) upper bound** on the random k-SAT satisfiability threshold:
663/// `α*(k) = ln 2 / ln(2ᵏ / (2ᵏ − 1))`. Above this clause density the expected number of satisfying
664/// assignments `E[X] = 2ⁿ(1 − 2⁻ᵏ)^{αn} = (2·(1 − 2⁻ᵏ)^α)ⁿ` has per-variable base `< 1`, so `E[X] → 0`
665/// and the instance is UNSAT with high probability (Markov) — a **rigorous** upper bound on the true
666/// threshold, exact and closed-form (no sampling). The sequence climbs as `2ᵏ ln 2 − (ln 2)/2`,
667/// asymptotically **doubling per k**: `2.41, 5.19, 10.74, 21.83, 44.02, …` for `k = 2, 3, 4, 5, 6, …`.
668/// The *true* thresholds (≈ 1, 4.27, 9.93, 21.1, …) lie below it; the gap → ½ as k → ∞ (the difference
669/// between the first-moment bound `2ᵏln2 − ½ln2` and the sharp value `2ᵏln2 − (1+ln2)/2`).
670pub fn ksat_threshold_first_moment_upper(k: u32) -> f64 {
671    let pow = (1u64 << k) as f64;
672    std::f64::consts::LN_2 / (pow / (pow - 1.0)).ln()
673}
674
675/// All `q`-element subsets of `{0, …, n−1}` in lexicographic order (the standard combinatorial
676/// odometer). Returns empty when `q > n`. Used to build the hyperedge / clique families below; keep
677/// `n` and `q` small, the count is `C(n, q)`.
678fn combinations(n: usize, q: usize) -> Vec<Vec<usize>> {
679    let mut out = Vec::new();
680    if q == 0 || q > n {
681        return out;
682    }
683    let mut idx: Vec<usize> = (0..q).collect();
684    loop {
685        out.push(idx.clone());
686        let mut i = q;
687        loop {
688            if i == 0 {
689                return out;
690            }
691            i -= 1;
692            if idx[i] != i + n - q {
693                break;
694            }
695        }
696        idx[i] += 1;
697        for j in (i + 1)..q {
698            idx[j] = idx[j - 1] + 1;
699        }
700    }
701}
702
703/// The **weak pigeonhole principle** `PHP^{holes}_{pigeons}`: `pigeons` pigeons into `holes` holes —
704/// unsatisfiable exactly when `pigeons > holes`. Generalizes [`php`] (which is the tight
705/// `holes = pigeons − 1` case) to any hole count, including the *weak* regime (`holes = 2·pigeons`,
706/// `pigeons²`, …) used to probe how far symmetry breaking scales as the holes-to-pigeons ratio grows.
707/// Same variable layout as [`php`] — "pigeon `p` in hole `h`" at index `p*holes + h` — so
708/// `weak_php(n, n−1)` is byte-for-byte `php(n)`.
709pub fn weak_php(pigeons: usize, holes: usize) -> (DimacsCnf, ExpectedVerdict) {
710    let num_vars = pigeons * holes;
711    let var = |p: usize, h: usize| Lit::pos((p * holes + h) as u32);
712    let mut clauses: Vec<Vec<Lit>> = Vec::new();
713    for p in 0..pigeons {
714        clauses.push((0..holes).map(|h| var(p, h)).collect()); // each pigeon in ≥ 1 hole
715    }
716    for h in 0..holes {
717        for p in 0..pigeons {
718            for q in (p + 1)..pigeons {
719                clauses.push(vec![var(p, h).negated(), var(q, h).negated()]); // ≤ 1 pigeon per hole
720            }
721        }
722    }
723    let verdict = if pigeons > holes { ExpectedVerdict::Unsat } else { ExpectedVerdict::Sat };
724    (DimacsCnf { num_vars, clauses }, verdict)
725}
726
727/// The **functional pigeonhole principle** FPHP(n): `php(n)` strengthened so each pigeon sits in *at
728/// most* one hole (the placement is a function, not a relation). Adds the "no pigeon in two holes"
729/// clauses on top of PHP; still UNSAT, still `S_n × S_{n−1}`-symmetric, and the standard strengthening
730/// used to check that symmetry breaking survives the extra functional clauses. Same layout as [`php`].
731pub fn functional_php(n: usize) -> (DimacsCnf, ExpectedVerdict) {
732    let holes = n.saturating_sub(1);
733    let (mut cnf, _) = php(n);
734    let var = |p: usize, h: usize| Lit::pos((p * holes + h) as u32);
735    for p in 0..n {
736        for h1 in 0..holes {
737            for h2 in (h1 + 1)..holes {
738                cnf.clauses.push(vec![var(p, h1).negated(), var(p, h2).negated()]); // ≤ 1 hole per pigeon
739            }
740        }
741    }
742    (cnf, ExpectedVerdict::Unsat)
743}
744
745/// The **onto (bijective) pigeonhole principle** onto-FPHP(n): `functional_php(n)` further forced to
746/// be *onto* — every hole receives at least one pigeon. The placement is now a bijection `n → n−1`,
747/// which cannot exist; UNSAT. This is the hardest standard PHP variant for symmetry reasoning (it pins
748/// both the pigeon and the hole side), and the maximal clause set over the shared [`php`] layout.
749pub fn onto_php(n: usize) -> (DimacsCnf, ExpectedVerdict) {
750    let holes = n.saturating_sub(1);
751    let (mut cnf, _) = functional_php(n);
752    let var = |p: usize, h: usize| Lit::pos((p * holes + h) as u32);
753    for h in 0..holes {
754        cnf.clauses.push((0..n).map(|p| var(p, h)).collect()); // each hole gets ≥ 1 pigeon (onto)
755    }
756    (cnf, ExpectedVerdict::Unsat)
757}
758
759/// The **modular counting principle** `Count_q(n)`: can the `n`-element set `{0,…,n−1}` be exactly
760/// partitioned into blocks of size `q`? A Boolean per `q`-subset (hyperedge); clauses force every
761/// element to lie in ≥ 1 chosen block and forbid any two *overlapping* blocks — so the chosen blocks
762/// are a perfect `q`-cover (an exact partition). That exists iff `q ∣ n`, hence the formula is
763/// **UNSAT exactly when `q ∤ n`**. This is the canonical *modular* family — the obstruction is a
764/// counting argument mod `q` that resolution cannot make at low width (Ajtai; Beame–Pitassi), the
765/// natural target of the GF(q) / mod-`m` rung. `q = 2` is perfect matching on the complete graph `K_n`
766/// (UNSAT for odd `n`). The hyperedge count is `C(n, q)`, so keep `n`, `q` small.
767pub fn mod_counting(n: usize, q: usize) -> (DimacsCnf, ExpectedVerdict) {
768    assert!(q >= 2 && n >= q, "need a block size q ≥ 2 with n ≥ q");
769    let edges = combinations(n, q);
770    let num_vars = edges.len();
771    let mut incident: Vec<Vec<usize>> = vec![Vec::new(); n];
772    for (e, edge) in edges.iter().enumerate() {
773        for &v in edge {
774            incident[v].push(e);
775        }
776    }
777    let mut clauses: Vec<Vec<Lit>> = Vec::new();
778    for inc in &incident {
779        clauses.push(inc.iter().map(|&e| Lit::pos(e as u32)).collect()); // every element covered ≥ once
780    }
781    for e in 0..edges.len() {
782        for f in (e + 1)..edges.len() {
783            if edges[e].iter().any(|v| edges[f].contains(v)) {
784                clauses.push(vec![Lit::neg(e as u32), Lit::neg(f as u32)]); // overlapping blocks are exclusive
785            }
786        }
787    }
788    let verdict = if n % q == 0 { ExpectedVerdict::Sat } else { ExpectedVerdict::Unsat };
789    (DimacsCnf { num_vars, clauses }, verdict)
790}
791
792/// The exactly-one groups of [`mod_counting`]'s **linear encoding**: for each point, the variables of
793/// its covering clause — the incident `q`-subsets, in the same variable order as [`mod_counting`].
794/// Feed to `polycalc::exactly_one_linear_generators` for the degree-1 point generators plus overlap
795/// pairs (the encoding the modular-counting degree lower bounds are stated against).
796pub fn mod_counting_groups(n: usize, q: usize) -> Vec<Vec<u32>> {
797    let (cnf, _) = mod_counting(n, q);
798    cnf.clauses
799        .iter()
800        .filter(|c| c.iter().all(|l| l.is_positive()))
801        .map(|c| c.iter().map(|l| l.var()).collect())
802        .collect()
803}
804
805/// The edge layout of [`mod_counting`]: variable `e` is the `q`-subset `mod_counting_edges(n, q)[e]`
806/// of the point set `{0,…,n−1}` — the map a witness-support predicate needs to read a monomial as a
807/// set of blocks.
808pub fn mod_counting_edges(n: usize, q: usize) -> Vec<Vec<usize>> {
809    combinations(n, q)
810}
811
812/// The known two-colour Ramsey numbers `R(s, t)` for the small `(s, t)` we can actually build CNFs for
813/// (symmetric: `R(s, t) = R(t, s)`); `None` when the value is still an open problem. `R(s, t)` is the
814/// least `n` such that every red/blue colouring of `K_n`'s edges contains a red `K_s` or a blue `K_t`.
815pub fn ramsey_number(s: usize, t: usize) -> Option<usize> {
816    let (a, b) = (s.min(t), s.max(t));
817    match (a, b) {
818        (1, _) => Some(1),
819        (2, b) => Some(b),
820        (3, 3) => Some(6),
821        (3, 4) => Some(9),
822        (3, 5) => Some(14),
823        (3, 6) => Some(18),
824        (3, 7) => Some(23),
825        (3, 8) => Some(28),
826        (3, 9) => Some(36),
827        (4, 4) => Some(18),
828        (4, 5) => Some(25),
829        _ => None,
830    }
831}
832
833/// The **Ramsey formula** `Ramsey(s, t; n)`: 2-colour the edges of the complete graph `K_n` (one
834/// Boolean per edge — true = red, false = blue) avoiding every red `K_s` and every blue `K_t`. For each
835/// `s`-clique a clause forbids all its edges being red; for each `t`-clique a clause forbids all blue.
836/// Such a colouring exists iff `n < R(s, t)`, so the formula is **UNSAT exactly when `n ≥ R(s, t)`**.
837/// A genuinely different geometry from pigeonhole/parity — clique structure, not covering or counting —
838/// and a classic CDCL stress family (`Ramsey(3,3;6)` is the smallest UNSAT case). Panics for `(s, t)`
839/// whose Ramsey number is unknown (so the verdict is never guessed); see [`ramsey_number`].
840pub fn ramsey(s: usize, t: usize, n: usize) -> (DimacsCnf, ExpectedVerdict) {
841    assert!(s >= 2 && t >= 2 && n >= 2);
842    let r = ramsey_number(s, t).expect("Ramsey number R(s,t) must be known to pin the verdict");
843    let mut edge_id = std::collections::HashMap::new();
844    for (e, pair) in combinations(n, 2).iter().enumerate() {
845        edge_id.insert((pair[0], pair[1]), e as u32);
846    }
847    let num_vars = edge_id.len();
848    let mut clauses: Vec<Vec<Lit>> = Vec::new();
849    let clique_edges = |clique: &[usize]| -> Vec<u32> {
850        combinations(clique.len(), 2).iter().map(|p| edge_id[&(clique[p[0]], clique[p[1]])]).collect()
851    };
852    for clique in combinations(n, s) {
853        clauses.push(clique_edges(&clique).into_iter().map(Lit::neg).collect()); // not all red
854    }
855    for clique in combinations(n, t) {
856        clauses.push(clique_edges(&clique).into_iter().map(Lit::pos).collect()); // not all blue
857    }
858    let verdict = if n >= r { ExpectedVerdict::Unsat } else { ExpectedVerdict::Sat };
859    (DimacsCnf { num_vars, clauses }, verdict)
860}
861
862/// The **pebbling contradiction** on the pyramid DAG of the given `height` — the canonical resolution
863/// *space* family (Ben-Sasson–Wigderson; Nordström). The pyramid has rows `0..=height` with row `r`
864/// holding `r+1` nodes; each node `(r, i)` for `r < height` has the two predecessors `(r+1, i)` and
865/// `(r+1, i+1)` below it. One Boolean per node "is pebbled": every source (bottom row) is asserted
866/// pebbled (unit), pebbling propagates up (`pebbled(a) ∧ pebbled(b) → pebbled(parent)`), and the apex
867/// `(0,0)` is asserted *un*pebbled — a contradiction, so UNSAT. Refutation size is linear (unit
868/// propagation alone closes it), but refuting it in small *space* provably is not — the axis pigeonhole
869/// and parity do not touch.
870pub fn pebbling_pyramid(height: usize) -> (DimacsCnf, ExpectedVerdict) {
871    let node = |r: usize, i: usize| (r * (r + 1) / 2 + i) as u32; // triangular id, apex = node(0,0) = 0
872    let num_vars = (height + 1) * (height + 2) / 2;
873    let mut clauses: Vec<Vec<Lit>> = Vec::new();
874    for i in 0..=height {
875        clauses.push(vec![Lit::pos(node(height, i))]); // sources (bottom row) are pebbled
876    }
877    for r in 0..height {
878        for i in 0..=r {
879            clauses.push(vec![
880                Lit::neg(node(r + 1, i)),
881                Lit::neg(node(r + 1, i + 1)),
882                Lit::pos(node(r, i)),
883            ]); // both children pebbled ⇒ this node pebbled
884        }
885    }
886    clauses.push(vec![Lit::neg(node(0, 0))]); // the apex is asserted unpebbled — the contradiction
887    (DimacsCnf { num_vars, clauses }, ExpectedVerdict::Unsat)
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893    use crate::cdcl::SolveResult;
894
895    /// **The k-SAT threshold sequence — generalized and pinned down.** The first-moment upper bound
896    /// `α*(k) = ln2 / ln(2ᵏ/(2ᵏ−1))` is a rigorous, exact, closed-form sequence: it increases, doubles
897    /// per k (→ 2ᵏ ln2 − ½ln2), and genuinely upper-bounds satisfiability — above it the first-moment
898    /// base is `< 1`, forcing E[X] → 0 (UNSAT whp by Markov). It brackets the cited true thresholds from
899    /// above. No sampling: every claim is arithmetic on the exact formula.
900    #[test]
901    fn the_ksat_threshold_sequence_first_moment_upper_bound() {
902        let ln2 = std::f64::consts::LN_2;
903        let ub = ksat_threshold_first_moment_upper;
904
905        // (1) The sequence matches the closed form (k = 2..=5, high precision).
906        let known = [(2u32, 2.40942), (3, 5.19089), (4, 10.73970), (5, 21.83239)];
907        for (k, want) in known {
908            assert!((ub(k) - want).abs() < 5e-3, "α*({k}) = {} want {want}", ub(k));
909        }
910
911        // (2) Strictly increasing, and asymptotically doubling per k.
912        for k in 2..=20 {
913            assert!(ub(k + 1) > ub(k), "increasing at k={k}");
914        }
915        for k in 4..=20 {
916            let ratio = ub(k + 1) / ub(k);
917            assert!((1.9..=2.1).contains(&ratio), "ratio ≈ 2 at k={k}: {ratio}");
918        }
919
920        // (3) Converges to 2ᵏ ln2 − ½ln2, and stays below the leading term 2ᵏ ln2.
921        for k in 2..=20 {
922            let lead = (1u64 << k) as f64 * ln2;
923            assert!(ub(k) < lead, "below the leading term 2ᵏln2 at k={k}");
924            assert!((ub(k) - (lead - ln2 / 2.0)).abs() < 0.05, "→ 2ᵏln2 − ½ln2 at k={k}");
925        }
926
927        // (4) RIGOR: α* is exactly where the first-moment base crosses 1 — above it E[X]→0 (UNSAT whp),
928        //     below it E[X]→∞. This is *why* it is an upper bound (Markov), not a fitted constant.
929        for k in 2..=20 {
930            let p = 1.0 - 2f64.powi(-(k as i32));
931            let base = |a: f64| 2.0 * p.powf(a);
932            assert!((base(ub(k)) - 1.0).abs() < 1e-9, "α*(k={k}) is exactly base = 1");
933            assert!(base(ub(k) + 0.5) < 1.0, "above α*(k={k}): E[X] → 0");
934            assert!(base(ub(k) - 0.5) > 1.0, "below α*(k={k}): E[X] → ∞");
935        }
936
937        // (5) The bound brackets the cited SHARP thresholds from above (the gap → ½ as k → ∞).
938        for (k, sharp) in [(2u32, 1.0), (3, 4.267), (4, 9.931), (5, 21.117)] {
939            assert!(ub(k) > sharp, "first-moment bound α*({k})={} exceeds the true threshold {sharp}", ub(k));
940        }
941    }
942
943    /// **Summing the threshold sequence — interesting constants pop out.** The thresholds α*_k grow like
944    /// 2ᵏ, so Σα*_k diverges; but their RECIPROCALS telescope, because `1/α*_k = −log₂(1−2⁻ᵏ)`, turning
945    /// the sum into the log of a product:
946    ///
947    /// `Σ_{k≥1} 1/α*_k = −log₂ Π_{k≥1}(1−2⁻ᵏ) = −log₂ φ(½) ≈ 1.79192`,
948    ///
949    /// where `φ(½) = 0.2887880951` is the Euler function at ½ — *exactly* the asymptotic probability that
950    /// a random matrix over **GF(2)** is invertible. So the reciprocal SAT-threshold sum lands on our
951    /// PARITY rung's own constant: the counting thresholds and their GF(2) reciprocal-sum meet here. The
952    /// ALTERNATING reciprocal sum telescopes likewise to `log₂ Π(1−2⁻ᵏ)^{(−1)ᵏ} ≈ 0.71513`. Both identities
953    /// are *exact* (sum of logs = log of product), not numerical coincidences.
954    #[test]
955    fn reciprocal_threshold_sums_telescope_to_euler_and_gf2_constants() {
956        let ln2 = std::f64::consts::LN_2;
957        let recip = |k: u32| 1.0 / ksat_threshold_first_moment_upper(k);
958        let (mut s, mut p, mut alt, mut palt) = (0.0f64, 1.0f64, 0.0f64, 1.0f64);
959        for k in 1..=50u32 {
960            let term = 1.0 - 2f64.powi(-(k as i32));
961            s += recip(k);
962            p *= term;
963            // EXACT telescoping: the partial sum equals −log₂ of the partial product.
964            assert!((s + p.ln() / ln2).abs() < 1e-9, "Σ1/α* telescopes to −log₂Π(1−2⁻ᵏ) at k={k}");
965            let sign = if k % 2 == 1 { 1.0 } else { -1.0 };
966            alt += sign * recip(k);
967            palt *= term.powf(if k % 2 == 1 { -1.0 } else { 1.0 });
968            assert!((alt - palt.ln() / ln2).abs() < 1e-9, "alternating sum telescopes to log₂Π(1−2⁻ᵏ)^((−1)ᵏ) at k={k}");
969        }
970        // The Euler / GF(2) random-matrix invertibility constant, and the sum it produces.
971        let phi_half = 0.288_788_095_1;
972        assert!((p - phi_half).abs() < 1e-9, "Π(1−2⁻ᵏ) → φ(½) = the GF(2) invertibility constant 0.28879");
973        assert!((s - 1.791_916_824_7).abs() < 1e-6, "Σ 1/α*_k ≈ 1.79192");
974        assert!((s + phi_half.ln() / ln2).abs() < 1e-6, "and it equals −log₂ φ(½) exactly");
975        // The alternating reciprocal sum's constant.
976        assert!((alt - 0.715_131_251_2).abs() < 1e-6, "Σ(−1)ᵏ⁺¹/α*_k ≈ 0.71513");
977    }
978
979    #[test]
980    fn mutilated_chessboard_and_ordering_are_correctly_unsat() {
981        // Both families are UNSAT by construction; the dispatcher must decide them correctly at small
982        // n regardless of which route fires. (These are honest measuring families, not yet our
983        // crushes — the contract here is correctness, not speed.)
984        for n in [4, 6, 8] {
985            let (cnf, v) = mutilated_chessboard(n);
986            assert_eq!(v, ExpectedVerdict::Unsat);
987            assert!(cnf.clauses.iter().all(|c| !c.is_empty()), "chessboard {n} has no empty clause");
988            let solved = crate::solve::solve_structured(cnf.num_vars, &cnf.clauses);
989            assert!(matches!(solved.answer, crate::solve::Answer::Unsat), "mutilated chessboard {n} must be UNSAT");
990        }
991        for n in [3, 4, 5, 6] {
992            let (cnf, v) = ordering_principle(n);
993            assert_eq!(v, ExpectedVerdict::Unsat);
994            let solved = crate::solve::solve_structured(cnf.num_vars, &cnf.clauses);
995            assert!(matches!(solved.answer, crate::solve::Answer::Unsat), "ordering principle GT({n}) must be UNSAT");
996        }
997    }
998
999    #[test]
1000    fn tseitin_expander_is_unsat_and_gaussian_refutes_it() {
1001        // The XOR engine must refute the expander-Tseitin system with an independently-checkable
1002        // Gaussian refutation, and the equisatisfiable CNF must agree (UNSAT) by our CDCL solver at
1003        // a size small enough to still terminate.
1004        for seed in [1u64, 7, 42] {
1005            let (eqs, cnf, verdict) = tseitin_expander(10, seed);
1006            assert_eq!(verdict, ExpectedVerdict::Unsat);
1007            match crate::xorsat::solve(&eqs, cnf.num_vars) {
1008                crate::xorsat::XorOutcome::Unsat(refutation) => {
1009                    assert!(
1010                        crate::xorsat::is_refutation(&eqs, cnf.num_vars, &refutation),
1011                        "the Gaussian refutation must independently check"
1012                    );
1013                }
1014                crate::xorsat::XorOutcome::Sat(_) => panic!("expander-Tseitin must be UNSAT"),
1015            }
1016            // The CNF encoding is genuinely the same UNSAT problem.
1017            assert_eq!(cnf.into_solver().solve(), SolveResult::Unsat, "CNF encoding must be UNSAT too");
1018        }
1019    }
1020
1021    #[test]
1022    fn mod_p_tseitin_is_refuted_over_gf_p_with_a_checkable_certificate() {
1023        // The parity crush at every prime: the same expander obstruction is UNSAT over GF(p) for every
1024        // odd prime — Gaussian elimination over the right field refutes it with an independently-
1025        // checkable certificate — and its Boolean CNF is genuinely the same UNSAT problem.
1026        for &p in &[3u64, 5, 7] {
1027            for seed in [1u64, 7] {
1028                let (eqs, cnf, verdict) = mod_p_tseitin_expander(6, p, seed);
1029                assert_eq!(verdict, ExpectedVerdict::Unsat);
1030                let edges = cnf.num_vars / p as usize; // GF(p) variables = one per edge
1031                match crate::modp::solve(&eqs, edges, p) {
1032                    crate::modp::ModpOutcome::Unsat(combo) => assert!(
1033                        crate::modp::is_refutation(&eqs, edges, p, &combo),
1034                        "the GF({p}) refutation must independently re-check"
1035                    ),
1036                    crate::modp::ModpOutcome::Sat(_) => panic!("mod-{p} obstruction must be UNSAT over GF({p})"),
1037                }
1038                assert_eq!(cnf.into_solver().solve(), SolveResult::Unsat, "the Boolean CNF must be UNSAT");
1039            }
1040        }
1041    }
1042
1043    #[test]
1044    fn mod_p_obstruction_is_invisible_to_gf2() {
1045        // The "blind" half made explicit: reinterpret the SAME graph + charges as a GF(2) parity
1046        // system. Total charge 2 is even, so it is SATISFIABLE over GF(2) — the parity cut cannot see
1047        // the mod-p contradiction. Only the right characteristic decides it.
1048        let p = 3u64;
1049        let (eqs, cnf, _) = mod_p_tseitin_expander(6, p, 7);
1050        let edges = cnf.num_vars / p as usize;
1051        let gf2: Vec<crate::xorsat::XorEquation> = eqs
1052            .iter()
1053            .map(|eq| {
1054                let vars: Vec<usize> = eq.coeffs.iter().map(|&(v, _)| v).collect();
1055                crate::xorsat::XorEquation::new(vars, eq.rhs % 2 == 1)
1056            })
1057            .collect();
1058        assert!(
1059            matches!(crate::xorsat::solve(&gf2, edges), crate::xorsat::XorOutcome::Sat(_)),
1060            "over GF(2) the even-charge obstruction is satisfiable — the parity engine is blind to it"
1061        );
1062    }
1063
1064    #[test]
1065    fn mod_m_tseitin_is_refuted_over_the_ring_with_a_checkable_certificate() {
1066        // The composite-modulus crush: the same total-charge-2 expander obstruction over ℤ/m (m=6=2·3,
1067        // m=15=3·5) is UNSAT — decided by CRT over the prime factors — with an independently
1068        // re-checkable certificate, and its mixed-radix one-hot CNF is genuinely the same UNSAT problem.
1069        for &m in &[6u64, 15] {
1070            for seed in [1u64, 7] {
1071                let (eqs, cnf, verdict) = mod_m_tseitin_expander(6, m, seed);
1072                assert_eq!(verdict, ExpectedVerdict::Unsat);
1073                let vars = cnf.num_vars / m as usize; // ring variables = one per edge
1074                match crate::modm::solve(&eqs, vars, m) {
1075                    Some(crate::modm::ModmOutcome::Unsat { modulus, combo }) => assert!(
1076                        crate::modm::is_refutation(&eqs, vars, modulus, &combo),
1077                        "the ℤ/{m} refutation must independently re-check (via its GF({modulus}) factor)"
1078                    ),
1079                    other => panic!("mod-{m} obstruction must be UNSAT over ℤ/{m}, got {other:?}"),
1080                }
1081                assert_eq!(cnf.into_solver().solve(), SolveResult::Unsat, "the Boolean CNF must be UNSAT");
1082            }
1083        }
1084    }
1085
1086    /// **TRANSFER: the parity wall.** The same exponential-vs-polynomial separation we measured for
1087    /// pigeonhole, carried to a DIFFERENT resolution-hard family — Tseitin formulas over expander graphs
1088    /// (Urquhart 1987: every resolution refutation is `2^Ω(n)`). Our CDCL is pure resolution, so its conflict
1089    /// count on the CNF explodes. But the formula is just a linear system over GF(2): the `xorsat` Gaussian
1090    /// reasoner refutes it in polynomial time with an independently-checkable certificate (a subset of
1091    /// equations summing to `0 = 1`). Different symmetry (parity, not permutation), same collapse — measured.
1092    #[test]
1093    #[ignore = "heavy: CDCL on Tseitin is exponential — that's the wall. Charts it vs the Gaussian collapse."]
1094    fn tseitin_parity_wall_collapses_under_gaussian() {
1095        use crate::xorsat::{is_refutation, solve, XorOutcome};
1096        let seed = 42u64;
1097        let mut rows = vec![
1098            "  n | edges | CDCL: conflicts / time   | Gaussian     | certified".to_string(),
1099            "----+-------+--------------------------+--------------+----------".to_string(),
1100        ];
1101        for n in [16usize, 24, 32, 40, 48, 56] {
1102            let (eqs, cnf, verdict) = tseitin_expander(n, seed);
1103            assert_eq!(verdict, ExpectedVerdict::Unsat);
1104
1105            let mut solver = cnf.into_solver();
1106            let ct = std::time::Instant::now();
1107            assert_eq!(solver.solve(), SolveResult::Unsat, "Tseitin CNF is UNSAT");
1108            let cdcl_time = ct.elapsed();
1109            let conflicts = solver.conflicts();
1110
1111            let t = std::time::Instant::now();
1112            let out = solve(&eqs, cnf.num_vars);
1113            let gauss = t.elapsed();
1114            let certified = match &out {
1115                XorOutcome::Unsat(r) => is_refutation(&eqs, cnf.num_vars, r),
1116                XorOutcome::Sat(_) => false,
1117            };
1118            assert!(certified, "Gaussian gives a checkable refutation for n={n}");
1119            rows.push(format!("{n:3} | {:5} | {conflicts:10} {cdcl_time:>10?} | {gauss:>11?} | yes", cnf.num_vars));
1120        }
1121        let chart = rows.join("\n");
1122        eprintln!("\nTSEITIN PARITY WALL — resolution (CDCL) exponential vs Gaussian polynomial+certified\n{chart}\n");
1123        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../logs/derived_facts");
1124        if std::fs::create_dir_all(&dir).is_ok() {
1125            let _ = std::fs::write(dir.join("tseitin_parity_wall.txt"), format!("TSEITIN PARITY WALL — CDCL (resolution, 2^Ω(n)) vs Gaussian (poly, certified)\n\n{chart}\n"));
1126        }
1127    }
1128
1129
1130
1131    /// **THE TWO GEOMETRIES OF HARDNESS.** The break into geometry: a resolution-hard UNSAT fact is a
1132    /// region of the Boolean cube that the clause-polytope cannot exclude, and *which* geometry excludes it
1133    /// is the whole story.
1134    ///
1135    /// * **Pigeonhole = convex geometry over ℝ.** The half-point `x ≡ ½` satisfies every *clause's* LP
1136    ///   relaxation (each clause has ≥ 2 literals, so its value is `len/2 ≥ 1`) — so resolution lives forever
1137    ///   inside a polytope that still contains `½`. Cutting planes *recovers* the strong cardinality cut
1138    ///   `Σ_p x_{p,h} ≤ 1` from the exclusion clique (which `½` violates: `n/2 > 1`), sums it against the
1139    ///   `Σ_h x_{p,h} ≥ 1` rows, and lands on `0 ≥ 1`: a **Farkas separating hyperplane**, the polytope is
1140    ///   *empty*. `refute_clausal` is exactly this.
1141    /// * **Parity = affine geometry over GF(2).** `x ≡ ½` *also* satisfies every Tseitin clause's LP — but
1142    ///   here there is **no** cardinality clique to recover, so the convex reasoner stays blind (the polytope
1143    ///   is genuinely non-empty; cutting planes is exponential too). The obstruction is a different geometry:
1144    ///   the all-ones functional lies in the GF(2) row space, which **Gaussian elimination** sees at once.
1145    ///
1146    /// Neither reasoner crosses over — convex refutes pigeonhole and not parity; GF(2) refutes parity and is
1147    /// not even defined on pigeonhole. Two geometries, two obstructions, provably incomparable. That is the
1148    /// geometric reason there is no single symmetry break for all of NP.
1149    #[test]
1150    fn the_two_geometries_of_hardness() {
1151        // A clause is satisfied by x ≡ ½ in the LP relaxation iff it has ≥ 2 literals (value = len·½ ≥ 1).
1152        let half_point_satisfies = |cnf: &DimacsCnf| cnf.clauses.iter().all(|c| c.len() >= 2);
1153
1154        // BOTH clause-polytopes contain the half-point x ≡ ½ — so resolution, which only ever reasons inside
1155        // the clause polytope, can never exclude EITHER at the clause level. The separation must come from a
1156        // richer geometry, and which one differs by family.
1157        let (php_cnf, _) = php(8);
1158        let (eqs, tse_cnf, _) = tseitin_expander(12, 7);
1159        assert!(half_point_satisfies(&php_cnf), "x≡½ ∈ the PHP clause-polytope");
1160        assert!(half_point_satisfies(&tse_cnf), "x≡½ ∈ the Tseitin clause-polytope");
1161
1162        // PIGEONHOLE — convex geometry over ℝ. Recover the cardinality cut (Σ_p x_{p,h} ≤ 1, which x≡½
1163        // violates) and sum against the rows (Σ_h x_{p,h} ≥ 1): `n ≤ Σx ≤ n−1`, i.e. 0 ≥ 1. That conic
1164        // combination IS a Farkas separating hyperplane — the integer (indeed the LP-cardinality) polytope is
1165        // empty. Our O(1) counting certificate is exactly this hyperplane's content.
1166        assert!(crate::pigeonhole::certify_pigeonhole_unsat(8, 7).is_some(), "convex/Farkas counting hyperplane refutes PHP(8)");
1167
1168        // PARITY — affine geometry over GF(2). There is NO cardinality clique to recover (it is not a
1169        // pigeon/hole problem), so the convex certificate is simply undefined here — convex geometry is blind.
1170        // The obstruction is that the all-ones functional lies in the GF(2) row space; Gaussian elimination
1171        // sees it and emits an independently-checkable refutation.
1172        match crate::xorsat::solve(&eqs, tse_cnf.num_vars) {
1173            crate::xorsat::XorOutcome::Unsat(r) => assert!(crate::xorsat::is_refutation(&eqs, tse_cnf.num_vars, &r), "GF(2) geometry refutes parity, certified"),
1174            crate::xorsat::XorOutcome::Sat(_) => panic!("Tseitin must be UNSAT"),
1175        }
1176        // Two geometries (convex-ℝ vs affine-GF(2)), two obstructions, neither reasoner crossing over — the
1177        // geometric reason there is no single symmetry break for all of NP.
1178    }
1179
1180    /// **COVERING ≠ COUNTABLE — the exact place the hypercube-cover/counting attack breaks.** SAT is a
1181    /// covering of the hypercube by subcubes; pigeonhole's covering collapses under counting. The tempting
1182    /// leap is "so every covering does." It does not, and here is the undeniable witness: **two coverings of
1183    /// the SAME hypercube, with byte-for-byte identical COUNTING profiles — same clause count, same
1184    /// clause-length multiset, same per-variable occurrence counts — yet one is SAT and one is UNSAT.**
1185    /// They are the same Tseitin graph with total parity flipped at one vertex. Every blocker still covers
1186    /// the same number of vertices; every counting invariant (`footprint_card`, `vertexEnergy`, global
1187    /// balance — the whole §G toolkit) returns identically on both. So no counting argument can decide them.
1188    /// The one bit that does — the GF(2) total parity — is invisible to counting and visible to Gaussian
1189    /// elimination. *Covering, yes. Countable, no.* That is precisely where `ThreeSATInP` cannot close.
1190    #[test]
1191    fn counting_is_provably_blind_two_covers_same_counts_opposite_answers() {
1192        use crate::xorsat::{solve, XorEquation, XorOutcome};
1193        // Build a Tseitin cover on a fixed 3-regular graph with a chosen per-vertex charge vector.
1194        let build = |edges: &[(usize, usize)], n: usize, charges: &[bool]| -> (Vec<XorEquation>, DimacsCnf) {
1195            let mut incident = vec![Vec::new(); n];
1196            for (e, &(a, b)) in edges.iter().enumerate() {
1197                incident[a].push(e);
1198                incident[b].push(e);
1199            }
1200            let (mut eqs, mut clauses) = (Vec::new(), Vec::new());
1201            for v in 0..n {
1202                let (inc, r, d) = (&incident[v], charges[v], incident[v].len());
1203                eqs.push(XorEquation::new(inc.clone(), r));
1204                for mask in 0u32..(1u32 << d) {
1205                    if ((mask.count_ones() % 2) == 1) != r {
1206                        clauses.push((0..d).map(|i| Lit::new(inc[i] as u32, (mask >> i) & 1 == 0)).collect());
1207                    }
1208                }
1209            }
1210            (eqs, DimacsCnf { num_vars: edges.len(), clauses })
1211        };
1212
1213        let edges = super::random_3regular(12, 7);
1214        let (eqs_sat, cnf_sat) = build(&edges, 12, &vec![false; 12]); // total parity even → SAT
1215        let mut odd = vec![false; 12];
1216        odd[0] = true; // flip ONE vertex → total parity odd → UNSAT
1217        let (eqs_uns, cnf_uns) = build(&edges, 12, &odd);
1218
1219        // The COUNTING profile is identical — every cardinality/footprint invariant is blind to the flip.
1220        let profile = |cnf: &DimacsCnf| {
1221            let mut lens: Vec<usize> = cnf.clauses.iter().map(|c| c.len()).collect();
1222            lens.sort_unstable();
1223            let mut occ = vec![0usize; cnf.num_vars];
1224            for c in &cnf.clauses {
1225                for l in c {
1226                    occ[l.var() as usize] += 1;
1227                }
1228            }
1229            occ.sort_unstable();
1230            (cnf.clauses.len(), lens, occ)
1231        };
1232        assert_eq!(profile(&cnf_sat), profile(&cnf_uns), "IDENTICAL counting profile — counting cannot tell them apart");
1233
1234        // Yet GF(2) — the parity invariant — gives OPPOSITE verdicts. One bit, invisible to every count.
1235        assert!(matches!(solve(&eqs_sat, cnf_sat.num_vars), XorOutcome::Sat(_)), "even total parity ⇒ SAT");
1236        match solve(&eqs_uns, cnf_uns.num_vars) {
1237            XorOutcome::Unsat(r) => assert!(crate::xorsat::is_refutation(&eqs_uns, cnf_uns.num_vars, &r), "odd total parity ⇒ UNSAT, certified by GF(2)"),
1238            XorOutcome::Sat(_) => panic!("odd-parity Tseitin must be UNSAT"),
1239        }
1240    }
1241
1242    #[test]
1243    fn php_has_the_expected_shape() {
1244        let (cnf, verdict) = php(4);
1245        assert_eq!(verdict, ExpectedVerdict::Unsat);
1246        assert_eq!(cnf.num_vars, 4 * 3);
1247        // 4 "at least one hole" clauses + 3 holes × C(4,2)=6 conflict clauses.
1248        assert_eq!(cnf.clauses.len(), 4 + 3 * 6);
1249    }
1250
1251    #[test]
1252    fn php_is_unsatisfiable_for_small_n() {
1253        for n in 1..=5 {
1254            let (cnf, _) = php(n);
1255            assert_eq!(
1256                cnf.into_solver().solve(),
1257                SolveResult::Unsat,
1258                "PHP({n}) must be unsatisfiable"
1259            );
1260        }
1261    }
1262
1263    #[test]
1264    #[ignore = "core benchmark on hard random 3-SAT — the general-instance (non-symmetric) baseline"]
1265    fn bench_core_on_random_3sat() {
1266        use std::time::Instant;
1267        for &(vars, ratio) in &[(50usize, 4.26), (75, 4.26), (100, 4.26), (125, 4.26)] {
1268            let nc = (vars as f64 * ratio) as usize;
1269            let cnf = random_3sat(vars, nc, 0xC0FFEE_1234);
1270            let mut s = cnf.into_solver();
1271            let t = Instant::now();
1272            let res = s.solve();
1273            let ms = t.elapsed().as_secs_f64() * 1e3;
1274            println!(
1275                "rand3sat(v={vars}, c={nc}): {res:?} in {ms:.1}ms — {} conflicts, {} learned clauses (unbounded!)",
1276                s.conflicts(),
1277                s.learned().len()
1278            );
1279        }
1280    }
1281
1282    #[test]
1283    #[ignore = "A/B benchmark: LBD clause deletion ON vs OFF on hard random 3-SAT"]
1284    fn bench_lbd_reduction_ab() {
1285        use std::time::Instant;
1286        for &v in &[140usize, 160, 180, 200] {
1287            let nc = (v as f64 * 4.26) as usize;
1288            let cnf = random_3sat(v, nc, 0xBADC0DE_99);
1289            for on in [false, true] {
1290                let mut s = cnf.into_solver();
1291                s.set_reduce(on);
1292                let t = Instant::now();
1293                let res = s.solve();
1294                let ms = t.elapsed().as_secs_f64() * 1e3;
1295                println!(
1296                    "v={v} reduce={on:5}: {} in {ms:7.0}ms — {} conflicts, {} LIVE learned clauses",
1297                    if matches!(res, SolveResult::Sat(_)) { "SAT  " } else { "UNSAT" },
1298                    s.conflicts(),
1299                    s.live_learned()
1300                );
1301            }
1302        }
1303    }
1304
1305    #[test]
1306    #[ignore = "parity grave-dance: dump expander-XOR CNF + time Gaussian (xorsat), pairs with Kissat loop"]
1307    fn dump_parity_and_time_xorsat() {
1308        use std::time::Instant;
1309        for n in [40usize, 60, 80, 100, 120] {
1310            let m = (n as f64 * 1.1) as usize;
1311            let (eqs, cnf) = parity_unsat(n, m, 0x9A2173_5C);
1312            std::fs::write(format!("/tmp/parity_{n}.cnf"), crate::dimacs::print(&cnf)).unwrap();
1313            // Sanity: our own CDCL also agrees it's UNSAT (equisatisfiable encoding).
1314            let t = Instant::now();
1315            let outcome = crate::xorsat::solve(&eqs, n);
1316            let us = t.elapsed().as_secs_f64() * 1e6;
1317            assert!(matches!(outcome, crate::xorsat::XorOutcome::Unsat(_)), "parity(n={n}) must be UNSAT");
1318            println!(
1319                "XORSAT parity(n={n}, m={m}): UNSAT in {us:.1}µs via Gaussian elimination  |  {} CNF clauses dumped for Kissat",
1320                cnf.clauses.len()
1321            );
1322        }
1323    }
1324
1325    #[test]
1326    fn parity_unsat_is_genuinely_unsat() {
1327        // Small instance: both the XOR engine and our CDCL on the CNF must agree it's UNSAT.
1328        let (eqs, cnf) = parity_unsat(12, 14, 0x9A2173_5C);
1329        assert!(matches!(crate::xorsat::solve(&eqs, 12), crate::xorsat::XorOutcome::Unsat(_)));
1330        assert_eq!(cnf.into_solver().solve(), SolveResult::Unsat, "CNF encoding is UNSAT too");
1331    }
1332
1333    #[test]
1334    fn clique_coloring_verdict_tracks_colors_vs_clique() {
1335        // K_n needs exactly n colors: UNSAT with fewer, SAT with enough.
1336        for n in 2..=4 {
1337            let (unsat, v_unsat) = clique_coloring(n, n - 1);
1338            assert_eq!(v_unsat, ExpectedVerdict::Unsat);
1339            assert_eq!(unsat.into_solver().solve(), SolveResult::Unsat, "K_{n} with {} colors", n - 1);
1340            let (sat, v_sat) = clique_coloring(n, n);
1341            assert_eq!(v_sat, ExpectedVerdict::Sat);
1342            assert!(matches!(sat.into_solver().solve(), SolveResult::Sat(_)), "K_{n} with {n} colors");
1343        }
1344    }
1345
1346    #[test]
1347    fn clique_coloring_exposes_color_permutation_symmetry() {
1348        // The finder must discover the color group (a different symmetry kind than pigeon/hole):
1349        // swapping two colors across all vertices is an automorphism.
1350        let (cnf, _) = clique_coloring(3, 2);
1351        let gens = crate::symmetry_detect::find_generators(cnf.num_vars, &cnf.clauses);
1352        assert!(!gens.iter().all(|g| g.is_identity()), "color symmetry must be detected");
1353        for g in &gens {
1354            assert!(crate::symmetry_detect::perm_is_automorphism(&cnf.clauses, g));
1355        }
1356    }
1357
1358    #[test]
1359    fn clique_coloring_is_refuted_with_certified_symmetry_breaking() {
1360        for (n, k) in [(3usize, 2usize), (4, 3)] {
1361            let (cnf, _) = clique_coloring(n, k);
1362            let r = crate::sym_certify::certified_unsat_auto(cnf.num_vars, &cnf.clauses);
1363            assert!(r.refuted, "K_{n} / {k} colors refuted");
1364            assert!(r.sbp_clauses >= 1, "color symmetry certified-broken");
1365            assert!(crate::pr::check_pr_refutation(cnf.num_vars, &cnf.clauses, &r.steps));
1366        }
1367    }
1368
1369    /// The dispatcher decides `cnf` exactly as its ground-truth verdict says — and when SAT, the
1370    /// returned model is independently re-checked against every clause. The single oracle every new
1371    /// family below is held to.
1372    fn decides(cnf: &DimacsCnf, verdict: ExpectedVerdict) {
1373        let solved = crate::solve::solve_structured(cnf.num_vars, &cnf.clauses);
1374        match verdict {
1375            ExpectedVerdict::Unsat => {
1376                assert!(matches!(solved.answer, crate::solve::Answer::Unsat), "expected UNSAT, solver said SAT");
1377            }
1378            ExpectedVerdict::Sat => match &solved.answer {
1379                crate::solve::Answer::Sat(model) => assert!(
1380                    cnf.clauses.iter().all(|c| c.iter().any(|l| model[l.var() as usize] == l.is_positive())),
1381                    "the returned SAT model must satisfy every clause"
1382                ),
1383                crate::solve::Answer::Unsat => panic!("expected SAT, solver said UNSAT"),
1384            },
1385        }
1386    }
1387
1388    #[test]
1389    fn weak_php_generalizes_php_and_tracks_the_pigeon_hole_ratio() {
1390        // The tight case is byte-for-byte the existing PHP — same layout, same clauses.
1391        for n in 1..=5 {
1392            let (tight, tv) = weak_php(n, n.saturating_sub(1));
1393            let (base, bv) = php(n);
1394            assert_eq!(tight.num_vars, base.num_vars, "weak_php(n,n-1) shares PHP's variable count");
1395            assert_eq!(tight.clauses, base.clauses, "weak_php(n,n-1) is byte-identical to php(n)");
1396            assert_eq!(tv, bv);
1397        }
1398        // UNSAT iff pigeons > holes, across the tight, weak, and satisfiable regimes — solver-confirmed.
1399        for &(p, h) in &[(3usize, 2usize), (5, 4), (4, 7), (2, 3), (3, 3), (6, 2)] {
1400            let (cnf, v) = weak_php(p, h);
1401            assert_eq!(v, if p > h { ExpectedVerdict::Unsat } else { ExpectedVerdict::Sat }, "PHP^{h}_{p} verdict");
1402            decides(&cnf, v);
1403        }
1404    }
1405
1406    #[test]
1407    fn functional_and_onto_php_are_unsat_strengthenings_of_php() {
1408        for n in 2..=5 {
1409            let (base, _) = php(n);
1410            let (func, fv) = functional_php(n);
1411            let (onto, ov) = onto_php(n);
1412            assert_eq!(fv, ExpectedVerdict::Unsat);
1413            assert_eq!(ov, ExpectedVerdict::Unsat);
1414            // Each strengthening is a clause-superset of the previous (it only ADDS constraints).
1415            assert!(func.clauses.starts_with(&base.clauses), "FPHP({n}) ⊇ PHP({n})");
1416            assert!(onto.clauses.starts_with(&func.clauses), "onto-FPHP({n}) ⊇ FPHP({n})");
1417            decides(&func, ExpectedVerdict::Unsat);
1418            decides(&onto, ExpectedVerdict::Unsat);
1419        }
1420        // Strictly more clauses once a pigeon can reach ≥ 2 holes (n ≥ 3).
1421        for n in 3..=5 {
1422            let (base, _) = php(n);
1423            let (func, _) = functional_php(n);
1424            let (onto, _) = onto_php(n);
1425            assert!(func.clauses.len() > base.clauses.len(), "FPHP adds functional clauses at n={n}");
1426            assert!(onto.clauses.len() > func.clauses.len(), "onto-FPHP adds surjectivity clauses at n={n}");
1427        }
1428    }
1429
1430    #[test]
1431    fn mod_counting_is_unsat_exactly_when_q_does_not_divide_n() {
1432        // q=2 is perfect matching on K_n (UNSAT for odd n); q=3 is the mod-3 partition principle.
1433        for &(n, q) in &[(3usize, 2usize), (4, 2), (5, 2), (6, 2), (4, 3), (6, 3), (7, 3), (8, 4)] {
1434            let (cnf, v) = mod_counting(n, q);
1435            assert_eq!(v, if n % q == 0 { ExpectedVerdict::Sat } else { ExpectedVerdict::Unsat }, "Count_{q}({n})");
1436            assert!(cnf.clauses.iter().all(|c| !c.is_empty()), "Count_{q}({n}) has no empty clause");
1437            decides(&cnf, v);
1438        }
1439        // The variable count is exactly the number of q-subsets, C(n, q).
1440        let (c52, _) = mod_counting(5, 2);
1441        assert_eq!(c52.num_vars, 10, "C(5,2) = 10 edges of K_5");
1442    }
1443
1444    #[test]
1445    fn ramsey_tracks_the_known_ramsey_numbers() {
1446        // The smallest UNSAT Ramsey instance and its satisfiable predecessor, plus an off-diagonal pair —
1447        // each bracketing R(s,t) from below (SAT) and at the boundary (UNSAT), solver-confirmed.
1448        for &(s, t, n) in &[(3usize, 3usize, 5usize), (3, 3, 6), (3, 4, 8), (3, 4, 9)] {
1449            let (cnf, v) = ramsey(s, t, n);
1450            let r = ramsey_number(s, t).unwrap();
1451            assert_eq!(v, if n >= r { ExpectedVerdict::Unsat } else { ExpectedVerdict::Sat }, "Ramsey({s},{t};{n}) vs R={r}");
1452            decides(&cnf, v);
1453        }
1454        // Diagonal Ramsey carries the colour-swap symmetry: flipping every edge's colour (negating every
1455        // literal) maps the clause set onto itself — red-K_3 bans become blue-K_3 bans and vice-versa.
1456        let (cnf, _) = ramsey(3, 3, 6);
1457        let key = |c: &Vec<Lit>, flip: bool| {
1458            let mut k: Vec<(u32, bool)> = c.iter().map(|l| (l.var(), l.is_positive() ^ flip)).collect();
1459            k.sort_unstable();
1460            k
1461        };
1462        let set: std::collections::HashSet<Vec<(u32, bool)>> = cnf.clauses.iter().map(|c| key(c, false)).collect();
1463        for c in &cnf.clauses {
1464            assert!(set.contains(&key(c, true)), "global colour-flip is an automorphism of diagonal Ramsey");
1465        }
1466    }
1467
1468    #[test]
1469    fn random_kxor_is_guaranteed_unsat_for_every_seed_arity_and_size() {
1470        // The whole point of the maximal-rank construction: inconsistent for ANY seed (the naive
1471        // single-flip silently produced SAT instances near m ≈ n). Hammer it — both the GF(2) Gaussian
1472        // engine and the CDCL solver on the CNF must agree UNSAT on every instance.
1473        use crate::cdcl::SolveResult;
1474        for k in [2usize, 3, 4, 5] {
1475            for n in [8usize, 12, 16] {
1476                for seed in 0..24u64 {
1477                    let (eqs, cnf) = random_kxor(k, n, n, seed.wrapping_mul(0x1000193) ^ k as u64);
1478                    assert!(
1479                        matches!(crate::xorsat::solve(&eqs, cnf.num_vars), crate::xorsat::XorOutcome::Unsat(_)),
1480                        "k={k} n={n} seed={seed}: maximal-rank k-XOR must be UNSAT over GF(2)"
1481                    );
1482                    assert_eq!(cnf.into_solver().solve(), SolveResult::Unsat, "k={k} n={n} seed={seed}: CNF must be UNSAT");
1483                }
1484            }
1485        }
1486    }
1487
1488    #[test]
1489    fn random_kxor_generalizes_parity_and_gaussian_refutes_every_arity() {
1490        // k=3 reproduces parity_unsat byte-for-byte (the lift-and-shift is behaviour-preserving).
1491        for seed in [1u64, 7, 0x9A2173_5C] {
1492            let (_, a) = parity_unsat(12, 14, seed);
1493            let (_, b) = random_kxor(3, 12, 14, seed);
1494            assert_eq!(a.clauses, b.clauses, "parity_unsat is exactly random_kxor(k=3)");
1495        }
1496        // Every arity stays UNSAT and the Gaussian (GF(2)) refutation independently checks; the CNF agrees.
1497        for k in [2usize, 4, 5] {
1498            let (eqs, cnf) = random_kxor(k, 14, 16, 0xC0FFEE ^ k as u64);
1499            match crate::xorsat::solve(&eqs, cnf.num_vars) {
1500                crate::xorsat::XorOutcome::Unsat(r) => assert!(
1501                    crate::xorsat::is_refutation(&eqs, cnf.num_vars, &r),
1502                    "{k}-XOR Gaussian refutation must re-check"
1503                ),
1504                crate::xorsat::XorOutcome::Sat(_) => panic!("planted-then-flipped {k}-XOR must be UNSAT"),
1505            }
1506            assert_eq!(cnf.into_solver().solve(), SolveResult::Unsat, "{k}-XOR CNF encoding is UNSAT");
1507        }
1508    }
1509
1510    #[test]
1511    fn pebbling_pyramid_is_unsat_with_the_expected_triangular_shape() {
1512        for h in 1..=5 {
1513            let (cnf, v) = pebbling_pyramid(h);
1514            assert_eq!(v, ExpectedVerdict::Unsat);
1515            let nodes = (h + 1) * (h + 2) / 2;
1516            assert_eq!(cnf.num_vars, nodes, "pyramid of height {h} has T(h+1) nodes");
1517            // sources (h+1 units) + propagation (one per non-source node) + 1 apex unit = nodes + 1.
1518            assert_eq!(cnf.clauses.len(), nodes + 1, "pebbling({h}) clause count = nodes + 1");
1519            assert!(cnf.clauses.iter().all(|c| !c.is_empty()), "no empty clause");
1520            decides(&cnf, ExpectedVerdict::Unsat);
1521        }
1522    }
1523}