Skip to main content

logicaffeine_proof/
sym_certify.rs

1//! Certified symmetry breaking — the centerpiece. Given a formula and a set of verified
2//! symmetry generators, add lex-leader symmetry-breaking predicates as **PR steps** (each
3//! self-checked, fail-closed), solve the augmented formula, and emit a single composed
4//! refutation that an independent checker accepts against the *original* formula alone.
5//!
6//! This closes the soundness gap of the old path (where the symmetry-broken formula was RUP-
7//! certified but the model-removing addition was only argued informally): here every SBP clause
8//! carries a propagation-redundancy witness derived from its symmetry, so the whole UNSAT
9//! result — symmetry steps included — is machine-checkable. The decisive wiring invariant is
10//! that `check_pr_refutation` runs against `formula` ALONE; the SBP clauses appear only as PR
11//! steps, never as free original clauses.
12
13use crate::cdcl::{Lit, SolveResult, Solver, Var};
14use crate::pr::{check_pr_refutation, is_pr};
15use crate::proof::{Perm, ProofStep, Witness};
16use crate::symmetry_detect::find_generators;
17
18/// The outcome of a certified-symmetry-breaking solve.
19#[derive(Clone, Debug)]
20pub struct CertifiedRefutation {
21    /// Whether the formula was refuted (proven UNSAT) AND the composed PR proof checks.
22    pub refuted: bool,
23    /// How many lex-leader SBP clauses were PR-certified and added.
24    pub sbp_clauses: usize,
25    /// The composed proof stream: PR symmetry steps followed by RUP learned clauses.
26    pub steps: Vec<ProofStep>,
27}
28
29/// The first-index lex-leader clause for `sigma`: over the smallest variable `v` that `sigma`
30/// moves, assert `v ⟹ sigma(v)` (the leading bit of `x ≤ₗₑₓ sigma(x)`). Returns `None` if
31/// `sigma` is the identity.
32fn lex_leader_lead_clause(num_vars: usize, sigma: &Perm) -> Option<Vec<Lit>> {
33    for v in 0..num_vars as Var {
34        let image = sigma.apply(Lit::pos(v));
35        if image != Lit::pos(v) {
36            return Some(vec![Lit::neg(v), image]);
37        }
38    }
39    None
40}
41
42/// Build the full lex-leader symmetry-breaking predicate for `sigma` over the moved-variable
43/// order, enforcing `x ≤ₗₑₓ σ(x)`. Returns the clauses (over base variables plus fresh auxiliary
44/// "tied-so-far" variables `e_i`, allocated starting at `aux_start`) and the number of aux
45/// variables used. This is the linear-size encoding that prunes the entire non-leader half of
46/// every σ-orbit — the real search-collapse predicate.
47pub fn lex_leader_clauses(num_vars: usize, aux_start: usize, sigma: &Perm) -> (Vec<Vec<Lit>>, usize) {
48    let support: Vec<Var> =
49        (0..num_vars as Var).filter(|&v| sigma.apply(Lit::pos(v)) != Lit::pos(v)).collect();
50    let mut clauses: Vec<Vec<Lit>> = Vec::new();
51    if support.is_empty() {
52        return (clauses, 0);
53    }
54    let a = |i: usize| Lit::pos(support[i]);
55    let b = |i: usize| sigma.apply(Lit::pos(support[i]));
56
57    // Position 0: a_0 ≤ b_0 unconditionally (the prefix before it is empty, hence tied).
58    clauses.push(vec![a(0).negated(), b(0)]);
59
60    // For i ≥ 1: define e_i ⟺ (tied through i-1) ∧ (a_{i-1} = b_{i-1}), then constrain
61    // e_i → (a_i ≤ b_i). `prev` is the tied-so-far literal (None at position 1 means "true").
62    let mut next_aux = aux_start;
63    let mut prev: Option<Lit> = None;
64    for i in 1..support.len() {
65        let e = Lit::pos(next_aux as Var);
66        next_aux += 1;
67        let (ap, bp) = (a(i - 1), b(i - 1));
68        match prev {
69            None => {
70                // e ⟺ (a_{i-1} = b_{i-1}).
71                clauses.push(vec![e.negated(), ap.negated(), bp]);
72                clauses.push(vec![e.negated(), ap, bp.negated()]);
73                clauses.push(vec![e, ap, bp]);
74                clauses.push(vec![e, ap.negated(), bp.negated()]);
75            }
76            Some(pe) => {
77                // e ⟺ pe ∧ (a_{i-1} = b_{i-1}).
78                clauses.push(vec![e.negated(), pe]);
79                clauses.push(vec![e.negated(), ap.negated(), bp]);
80                clauses.push(vec![e.negated(), ap, bp.negated()]);
81                clauses.push(vec![pe.negated(), e, ap, bp]);
82                clauses.push(vec![pe.negated(), e, ap.negated(), bp.negated()]);
83            }
84        }
85        clauses.push(vec![e.negated(), a(i).negated(), b(i)]);
86        prev = Some(e);
87    }
88    (clauses, next_aux - aux_start)
89}
90
91/// Solve `formula` with certified symmetry breaking under the given `generators` (each must be
92/// a genuine automorphism — they are re-checked implicitly by the per-clause PR self-check).
93///
94/// For each generator we propose its lead lex-leader clause, PR-self-check it against the
95/// database built so far (so a generator invalidated by earlier SBPs is simply skipped), add
96/// the survivors as PR steps, solve the augmented formula, and — if UNSAT — append the solver's
97/// learned clauses as RUP steps. The composed stream is verified against `formula` alone.
98pub fn certified_unsat(num_vars: usize, formula: &[Vec<Lit>], generators: &[Perm]) -> CertifiedRefutation {
99    let mut db: Vec<Vec<Lit>> = formula.to_vec();
100    let mut steps: Vec<ProofStep> = Vec::new();
101
102    for sigma in generators {
103        let Some(clause) = lex_leader_lead_clause(num_vars, sigma) else { continue };
104        let witness = Witness::Substitution(sigma.clone());
105        if is_pr(num_vars, &db, &clause, &witness) {
106            db.push(clause.clone());
107            steps.push(ProofStep::Pr { clause, witness });
108        }
109    }
110    let sbp_clauses = steps.len();
111
112    // Solve the augmented formula F ∧ SBP and collect its learned clauses as RUP steps.
113    let mut solver = Solver::new(num_vars);
114    for c in &db {
115        solver.add_clause(c.clone());
116    }
117    let refuted = match solver.solve() {
118        SolveResult::Sat(_) => false,
119        SolveResult::Unsat => {
120            for lc in solver.learned() {
121                steps.push(ProofStep::Rup(lc.lits.clone()));
122            }
123            // The decisive check: the whole stream is replayed against the ORIGINAL formula.
124            check_pr_refutation(num_vars, formula, &steps)
125        }
126    };
127
128    CertifiedRefutation { refuted, sbp_clauses, steps }
129}
130
131/// Find a PR assignment-witness that certifies lex-leader clause `c` against `db`, drawing from
132/// a small principled candidate set (single literals of the clause, then the tied-prefix aux
133/// chain optionally combined with a clause literal). Every candidate is verified by [`is_pr`],
134/// so a returned witness is genuinely sound; `None` means none of the candidates worked.
135fn find_lex_witness(nv: usize, db: &[Vec<Lit>], c: &[Lit], base_nv: usize, sigma: &Perm) -> Option<Witness> {
136    // The substitution witness certifies the lead clause at ANY scale (its `db ∧ ¬C ⊢₁ σ(C)`
137    // check conflicts immediately), where an assignment witness would need F|α to be UP-refutable
138    // — which fails on large formulas. Try it first; it is rejected (fail-closed) once an earlier
139    // predicate has broken σ's automorphism of the database. σ is lifted to the auxiliary
140    // variables (as the identity) so the check is defined over the extended database.
141    let subst = Witness::Substitution(sigma.extended(nv));
142    if is_pr(nv, db, c, &subst) {
143        return Some(subst);
144    }
145    let accept = |lits: Vec<Lit>| {
146        let w = Witness::Assignment(lits);
147        is_pr(nv, db, c, &w).then_some(w)
148    };
149    // The witness only ever touches the clause's own variables and the auxiliary "tied" chain up
150    // to the clause's highest aux. Gather both polarities of those as the candidate pool.
151    let max_aux = c.iter().map(|l| l.var()).filter(|&v| (v as usize) >= base_nv).max();
152    let mut vars: Vec<Var> = c.iter().map(|l| l.var()).collect();
153    if let Some(ma) = max_aux {
154        vars.extend(base_nv as Var..=ma);
155    }
156    vars.sort_unstable();
157    vars.dedup();
158    let lits: Vec<Lit> = vars.iter().flat_map(|&v| [Lit::pos(v), Lit::neg(v)]).collect();
159
160    // Size 1.
161    for &l in &lits {
162        if let Some(w) = accept(vec![l]) {
163            return Some(w);
164        }
165    }
166    // The tied-prefix chain (the monotonicity clauses' witness), alone and with a clause literal.
167    if let Some(ma) = max_aux {
168        let prefix: Vec<Lit> = (base_nv as Var..ma).map(Lit::pos).collect();
169        if let Some(w) = accept(prefix.clone()) {
170            return Some(w);
171        }
172        for &l in c {
173            let mut cand = prefix.clone();
174            cand.push(l);
175            if let Some(w) = accept(cand) {
176                return Some(w);
177            }
178        }
179    }
180    // Size 2 over the candidate pool (no two literals of the same variable). Bounded on purpose:
181    // deeper witnesses do not exist for clauses whose certification would need F|α to be
182    // UP-refutable, so searching further only wastes time failing.
183    for i in 0..lits.len() {
184        for j in (i + 1)..lits.len() {
185            if lits[i].var() == lits[j].var() {
186                continue;
187            }
188            if let Some(w) = accept(vec![lits[i], lits[j]]) {
189                return Some(w);
190            }
191        }
192    }
193    None
194}
195
196/// Solve `formula` with FULL certified lex-leader symmetry breaking. For each generator, add the
197/// complete lex-leader chain (enforcing `x ≤ₗₑₓ σ(x)`) as PR steps — each clause certified by a
198/// principled witness — then solve and append the learned clauses as RUP steps. This prunes the
199/// non-leader half of every σ-orbit (real search collapse), with the whole refutation checked
200/// against `formula` alone. A generator whose chain cannot be fully certified is skipped
201/// (fail-closed); the result stays sound.
202/// Augment `formula` with the certified full lex-leader symmetry-breaking clauses for each
203/// generator, committing a generator's chain only when every clause of it certifies (else the
204/// generator is skipped — fail-closed). Returns the augmented clause set, the extended variable
205/// count (base + auxiliaries), and the PR proof steps that justify the added clauses. This is
206/// the symmetry-breaking front half shared by certified solving and by search-collapse
207/// measurement.
208pub fn symmetry_break_certified(
209    num_vars: usize,
210    formula: &[Vec<Lit>],
211    generators: &[Perm],
212) -> (Vec<Vec<Lit>>, usize, Vec<ProofStep>) {
213    let mut db: Vec<Vec<Lit>> = formula.to_vec();
214    let mut steps: Vec<ProofStep> = Vec::new();
215    let mut nv = num_vars;
216    for sigma in generators {
217        let (clauses, num_aux) = lex_leader_clauses(num_vars, nv, sigma);
218        if clauses.is_empty() {
219            continue;
220        }
221        let ext_nv = nv + num_aux;
222        // Commit each clause of the chain that certifies (a partial lex-leader is still sound —
223        // every committed clause is independently PR-checked); skip any that don't.
224        let mut committed = false;
225        for c in &clauses {
226            if let Some(w) = find_lex_witness(ext_nv, &db, c, num_vars, sigma) {
227                db.push(c.clone());
228                steps.push(ProofStep::Pr { clause: c.clone(), witness: w });
229                committed = true;
230            }
231        }
232        if committed {
233            nv = ext_nv;
234        }
235    }
236    (db, nv, steps)
237}
238
239pub fn certified_unsat_lex(num_vars: usize, formula: &[Vec<Lit>], generators: &[Perm]) -> CertifiedRefutation {
240    let (db, nv, mut steps) = symmetry_break_certified(num_vars, formula, generators);
241    let sbp_clauses = steps.len();
242
243    let mut solver = Solver::new(nv);
244    for c in &db {
245        solver.add_clause(c.clone());
246    }
247    let refuted = match solver.solve() {
248        SolveResult::Sat(_) => false,
249        SolveResult::Unsat => {
250            for lc in solver.learned() {
251                steps.push(ProofStep::Rup(lc.lits.clone()));
252            }
253            check_pr_refutation(nv, formula, &steps)
254        }
255    };
256
257    CertifiedRefutation { refuted, sbp_clauses, steps }
258}
259
260/// The permutation that swaps two pigeons of PHP(n) (exchanging their whole hole-rows) — a
261/// genuine automorphism of the pigeonhole formula.
262fn swap_pigeons(n: usize, holes: usize, i: usize, j: usize) -> Perm {
263    Perm::from_images(
264        (0..n * holes)
265            .map(|v| {
266                let (p, h) = (v / holes, v % holes);
267                let np = if p == i {
268                    j
269                } else if p == j {
270                    i
271                } else {
272                    p
273                };
274                Lit::pos((np * holes + h) as Var)
275            })
276            .collect(),
277    )
278}
279
280/// The Heule–Kiesl–Biere short PR refutation of PHP(n): a polynomial-size, fully certified proof
281/// — where the lex-leader provably cannot scale on UNSAT instances.
282///
283/// It frees holes one at a time. To free the last active hole `h` of PHP(m), it forces every
284/// non-last pigeon `i` out of it with the clause `¬x(i, h)`, certified by the **substitution
285/// witness "swap pigeon i with the last pigeon"** — a PHP automorphism whose SR check conflicts
286/// at once on the hole-`h` conflict clause (so it is sound at any scale). That confines the
287/// remaining pigeons to one fewer hole, reducing PHP(m) → PHP(m-1); after `O(n²)` such units RUP
288/// closes. The whole refutation is checked against the original PHP(n) alone.
289pub fn heule_php_refutation(n: usize) -> CertifiedRefutation {
290    let (cnf, _) = crate::families::php(n);
291    let holes = n.saturating_sub(1);
292    let nv = cnf.num_vars;
293    let mut db = cnf.clauses.clone();
294    let mut index = crate::symmetry_detect::AutomorphismIndex::with_clauses(nv, &cnf.clauses);
295    let mut steps: Vec<ProofStep> = Vec::new();
296
297    // Reduce PHP(m) to PHP(m-1) for m = n, n-1, …, 2, freeing hole (m-2) each round. The
298    // automorphism re-check rides the incrementally-grown index, so each step is O(support).
299    for m in (2..=n).rev() {
300        let hole = m - 2;
301        let last_pigeon = m - 1;
302        for i in 0..last_pigeon {
303            let clause = vec![Lit::neg((i * holes + hole) as Var)];
304            let witness = Witness::Substitution(swap_pigeons(n, holes, i, last_pigeon));
305            if crate::pr::is_pr_indexed(nv, &db, &mut index, &clause, &witness) {
306                db.push(clause.clone());
307                index.insert(clause.clone());
308                steps.push(ProofStep::Pr { clause, witness });
309            }
310        }
311    }
312    let sbp_clauses = steps.len();
313
314    let mut solver = Solver::new(nv);
315    for c in &db {
316        solver.add_clause(c.clone());
317    }
318    let refuted = match solver.solve() {
319        SolveResult::Sat(_) => false,
320        SolveResult::Unsat => {
321            for lc in solver.learned() {
322                steps.push(ProofStep::Rup(lc.lits.clone()));
323            }
324            crate::pr::check_pr_refutation_fast(nv, &cnf.clauses, &steps)
325        }
326    };
327
328    CertifiedRefutation { refuted, sbp_clauses, steps }
329}
330
331/// The vertex-swap automorphism of `clique_coloring(n, k)`: exchange vertices `a` and `b` (every
332/// color of one ↔ the same color of the other), fixing all other vertices. A genuine symmetry of
333/// `Kₙ` (any two vertices are interchangeable), so it certifies the SR witnesses of a steered
334/// coloring refutation — variable layout `v*k + c`.
335fn swap_vertices(n: usize, k: usize, a: usize, b: usize) -> Perm {
336    let nv = n * k;
337    Perm::from_images(
338        (0..nv)
339            .map(|idx| {
340                let (v, c) = (idx / k, idx % k);
341                let nv2 = if v == a {
342                    b
343                } else if v == b {
344                    a
345                } else {
346                    v
347                };
348                Lit::pos((nv2 * k + c) as Var)
349            })
350            .collect(),
351    )
352}
353
354/// **Full-chain structural steering** of `clique_coloring(n, k)` (UNSAT for `k < n`): the Heule
355/// pigeonhole refutation transplanted onto the coloring encoding, wielding the *a-priori*
356/// vertex-swap symmetry rather than detecting it. `k + 1` mutually-adjacent vertices already form a
357/// PHP(k+1, k), so the proof forces those `k+1` vertices out of the colors one at a time — each
358/// clause `¬x(i, color)` certified by the substitution "swap vertex `i` with the last active
359/// vertex", whose SR check clashes at once on the corresponding at-most-one clause. The whole
360/// stream is verified against the original clique formula alone.
361pub fn heule_clique_refutation(n: usize, k: usize) -> CertifiedRefutation {
362    let (cnf, _) = crate::families::clique_coloring(n, k);
363    let nv = cnf.num_vars;
364    let mut db = cnf.clauses.clone();
365    let mut index = crate::symmetry_detect::AutomorphismIndex::with_clauses(nv, &cnf.clauses);
366    let mut steps: Vec<ProofStep> = Vec::new();
367    // k+1 vertices (capped at n) are a tight pigeonhole over the k colors.
368    let items = (k + 1).min(n);
369    let var = |v: usize, c: usize| (v * k + c) as Var;
370
371    for m in (2..=items).rev() {
372        let color = m - 2;
373        let last_vertex = m - 1;
374        for i in 0..last_vertex {
375            let clause = vec![Lit::neg(var(i, color))];
376            let witness = Witness::Substitution(swap_vertices(n, k, i, last_vertex));
377            if crate::pr::is_pr_indexed(nv, &db, &mut index, &clause, &witness) {
378                db.push(clause.clone());
379                index.insert(clause.clone());
380                steps.push(ProofStep::Pr { clause, witness });
381            }
382        }
383    }
384    let sbp_clauses = steps.len();
385
386    let mut solver = Solver::new(nv);
387    for c in &db {
388        solver.add_clause(c.clone());
389    }
390    let refuted = match solver.solve() {
391        SolveResult::Sat(_) => false,
392        SolveResult::Unsat => {
393            for lc in solver.learned() {
394                steps.push(ProofStep::Rup(lc.lits.clone()));
395            }
396            crate::pr::check_pr_refutation_fast(nv, &cnf.clauses, &steps)
397        }
398    };
399
400    CertifiedRefutation { refuted, sbp_clauses, steps }
401}
402
403/// The Heule PHP(n) refutation **with its rank function attached** — each symmetry-breaking step
404/// is tagged by the round (`m` = active items remaining) it belongs to, a non-increasing measure
405/// whose descent bounds the proof size. The closing RUP steps take the bottom rank `1`. Feed the
406/// result to [`crate::complexity::RankedRefutation::certify`] to get a checkable `O(n²)` size bound
407/// alongside the correctness check.
408pub fn heule_php_ranked(n: usize) -> crate::complexity::RankedRefutation {
409    let (cnf, _) = crate::families::php(n);
410    let holes = n.saturating_sub(1);
411    let nv = cnf.num_vars;
412    let mut db = cnf.clauses.clone();
413    let mut index = crate::symmetry_detect::AutomorphismIndex::with_clauses(nv, &cnf.clauses);
414    let mut steps: Vec<ProofStep> = Vec::new();
415    let mut ranks: Vec<u64> = Vec::new();
416
417    for m in (2..=n).rev() {
418        let hole = m - 2;
419        let last_pigeon = m - 1;
420        for i in 0..last_pigeon {
421            let clause = vec![Lit::neg((i * holes + hole) as Var)];
422            let witness = Witness::Substitution(swap_pigeons(n, holes, i, last_pigeon));
423            if crate::pr::is_pr_indexed(nv, &db, &mut index, &clause, &witness) {
424                db.push(clause.clone());
425                index.insert(clause.clone());
426                steps.push(ProofStep::Pr { clause, witness });
427                ranks.push(m as u64); // rank = active items remaining this round (descends with m)
428            }
429        }
430    }
431
432    let mut solver = Solver::new(nv);
433    for c in &db {
434        solver.add_clause(c.clone());
435    }
436    let refuted = match solver.solve() {
437        SolveResult::Sat(_) => false,
438        SolveResult::Unsat => {
439            for lc in solver.learned() {
440                steps.push(ProofStep::Rup(lc.lits.clone()));
441                ranks.push(1); // the closing chain sits at the bottom level
442            }
443            crate::pr::check_pr_refutation_fast(nv, &cnf.clauses, &steps)
444        }
445    };
446
447    crate::complexity::RankedRefutation { refuted, steps, ranks }
448}
449
450/// The steered clique-coloring refutation **with its rank function attached** — the analogue of
451/// [`heule_php_ranked`] over the coloring encoding, so a clique refutation also ships a checkable
452/// `O(n²)` size certificate (rank = active vertices remaining).
453pub fn heule_clique_ranked(n: usize, k: usize) -> crate::complexity::RankedRefutation {
454    let (cnf, _) = crate::families::clique_coloring(n, k);
455    let nv = cnf.num_vars;
456    let mut db = cnf.clauses.clone();
457    let mut index = crate::symmetry_detect::AutomorphismIndex::with_clauses(nv, &cnf.clauses);
458    let mut steps: Vec<ProofStep> = Vec::new();
459    let mut ranks: Vec<u64> = Vec::new();
460    let items = (k + 1).min(n);
461    let var = |v: usize, c: usize| (v * k + c) as Var;
462
463    for m in (2..=items).rev() {
464        let color = m - 2;
465        let last_vertex = m - 1;
466        for i in 0..last_vertex {
467            let clause = vec![Lit::neg(var(i, color))];
468            let witness = Witness::Substitution(swap_vertices(n, k, i, last_vertex));
469            if crate::pr::is_pr_indexed(nv, &db, &mut index, &clause, &witness) {
470                db.push(clause.clone());
471                index.insert(clause.clone());
472                steps.push(ProofStep::Pr { clause, witness });
473                ranks.push(m as u64);
474            }
475        }
476    }
477
478    let mut solver = Solver::new(nv);
479    for c in &db {
480        solver.add_clause(c.clone());
481    }
482    let refuted = match solver.solve() {
483        SolveResult::Sat(_) => false,
484        SolveResult::Unsat => {
485            for lc in solver.learned() {
486                steps.push(ProofStep::Rup(lc.lits.clone()));
487                ranks.push(1);
488            }
489            crate::pr::check_pr_refutation_fast(nv, &cnf.clauses, &steps)
490        }
491    };
492
493    crate::complexity::RankedRefutation { refuted, steps, ranks }
494}
495
496/// A safety cap on the number of symmetry-breaking rounds — far above any real need (the group
497/// is finite and strictly shrinks each round), a guard against pathological inputs.
498const MAX_SBP_ROUNDS: usize = 100_000;
499
500/// Solve `formula` with FULL certified symmetry breaking, discovering the symmetries itself.
501///
502/// Each round: detect the residual symmetry group of the current database, certify ONE lead
503/// lex-leader predicate as a PR step (always sound — its generator is a fresh automorphism of
504/// the current database, so the SR check's conflict is immediate), and re-detect. Adding the
505/// predicate strictly shrinks the automorphism group, so the loop terminates with the whole group
506/// broken; then the augmented formula is solved and the learned clauses appended as RUP steps. The
507/// entire composed stream is verified against `formula` ALONE.
508///
509/// This breaks the *complete* group rather than one clause per generator, by re-detecting the
510/// stabilizer after each predicate — the natural "lift and shift" of detection + the SR checker.
511pub fn certified_unsat_auto(num_vars: usize, formula: &[Vec<Lit>]) -> CertifiedRefutation {
512    let mut db: Vec<Vec<Lit>> = formula.to_vec();
513    let mut steps: Vec<ProofStep> = Vec::new();
514
515    for _ in 0..MAX_SBP_ROUNDS {
516        let mut progressed = false;
517        for sigma in find_generators(num_vars, &db) {
518            let Some(clause) = lex_leader_lead_clause(num_vars, &sigma) else { continue };
519            let witness = Witness::Substitution(sigma);
520            if is_pr(num_vars, &db, &clause, &witness) {
521                db.push(clause.clone());
522                steps.push(ProofStep::Pr { clause, witness });
523                progressed = true;
524                break;
525            }
526        }
527        if !progressed {
528            break;
529        }
530    }
531    let sbp_clauses = steps.len();
532
533    let mut solver = Solver::new(num_vars);
534    for c in &db {
535        solver.add_clause(c.clone());
536    }
537    let refuted = match solver.solve() {
538        SolveResult::Sat(_) => false,
539        SolveResult::Unsat => {
540            for lc in solver.learned() {
541                steps.push(ProofStep::Rup(lc.lits.clone()));
542            }
543            check_pr_refutation(num_vars, formula, &steps)
544        }
545    };
546
547    CertifiedRefutation { refuted, sbp_clauses, steps }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use crate::cdcl::Lit;
554    use crate::families;
555    use crate::symmetry_detect::perm_is_automorphism;
556
557    #[test]
558    fn heule_php_ranked_certifies_quadratic_size() {
559        // The refutation must carry a rank function that certifies its OWN size is O(n²), checked
560        // against the original formula alongside correctness. The certified bound (levels · width)
561        // must be quadratic in n, and the actual proof must fit under it.
562        for n in 3..=8 {
563            let ranked = heule_php_ranked(n);
564            assert!(ranked.refuted, "PHP({n}) must refute");
565            let (cnf, _) = families::php(n);
566            let bound = ranked
567                .certify(cnf.num_vars, &cnf.clauses)
568                .expect("a valid descent over a correct refutation must certify");
569            // Levels ≤ n (ranks n..2 plus the bottom), width ≤ n−1 (the first round) ⇒ bound ≤ n².
570            assert!(bound.levels <= n as u64, "levels {} must be ≤ n={n}", bound.levels);
571            assert!(bound.max_width <= n as u64, "width {} must be ≤ n={n}", bound.max_width);
572            assert!(bound.bound <= (n as u64) * (n as u64), "certified bound must be ≤ n²");
573            assert!(bound.actual <= bound.bound, "actual size must fit the certified bound");
574            // And the actual symmetry-breaking work is exactly the rank function's sum.
575            let sbp = ranked.ranks.iter().filter(|&&r| r >= 2).count() as u64;
576            assert_eq!(sbp, (n as u64) * (n as u64 - 1) / 2, "sbp must equal n(n-1)/2 exactly");
577        }
578    }
579
580    #[test]
581    fn heule_clique_ranked_certifies_quadratic_size() {
582        // Every steered family ships a cost certificate: the clique refutation must certify its own
583        // O(n²) size (rank = active vertices) together with correctness, across tight and loose k.
584        for (n, k) in [(5, 4), (7, 6), (8, 5), (9, 4)] {
585            let ranked = heule_clique_ranked(n, k);
586            assert!(ranked.refuted, "clique({n},{k}) must refute");
587            let (cnf, _) = families::clique_coloring(n, k);
588            let bound = ranked
589                .certify(cnf.num_vars, &cnf.clauses)
590                .expect("a valid descent over a correct clique refutation must certify");
591            let items = (k + 1).min(n) as u64;
592            assert!(bound.bound <= items * items, "certified bound must be ≤ (k+1)²");
593            assert!(bound.actual <= bound.bound, "actual size fits the certified bound");
594        }
595    }
596
597    #[test]
598    fn heule_clique_refutation_certifies_across_shapes() {
599        // Full-chain structural steering must refute clique-coloring (UNSAT for k < n) with a proof
600        // that independently checks — tight pigeonhole shapes (k = n-1) and looser ones (k < n-1).
601        for (n, k) in [(4, 3), (5, 4), (6, 5), (7, 6), (6, 3), (7, 4), (8, 5)] {
602            let cr = heule_clique_refutation(n, k);
603            assert!(cr.refuted, "clique({n},{k}) must be refuted with a checking proof");
604            assert!(cr.sbp_clauses > 0, "clique({n},{k}) must actually break symmetry");
605            let (cnf, _) = families::clique_coloring(n, k);
606            assert!(
607                crate::pr::check_pr_refutation_fast(cnf.num_vars, &cnf.clauses, &cr.steps),
608                "clique({n},{k}) steered proof must re-check against the original formula"
609            );
610        }
611    }
612
613    /// Swap two pigeon rows of PHP(n) — a known automorphism, used to feed the certifier known
614    /// generators while the general detector is built out separately.
615    fn swap_pigeon_rows(n: usize, p0: usize, p1: usize) -> Perm {
616        let holes = n - 1;
617        Perm::from_images(
618            (0..n * holes)
619                .map(|v| {
620                    let (p, h) = (v / holes, v % holes);
621                    let np = if p == p0 {
622                        p1
623                    } else if p == p1 {
624                        p0
625                    } else {
626                        p
627                    };
628                    Lit::pos((np * holes + h) as u32)
629                })
630                .collect(),
631        )
632    }
633
634    #[test]
635    fn php3_is_refuted_with_a_pr_certified_symmetry_proof() {
636        let (cnf, _) = families::php(3);
637        // Adjacent pigeon-row swaps generate the pigeon symmetry group S_3.
638        let gens: Vec<Perm> = [(0usize, 1usize), (1, 2)].iter().map(|&(a, b)| swap_pigeon_rows(3, a, b)).collect();
639        for g in &gens {
640            assert!(perm_is_automorphism(&cnf.clauses, g), "fed generators must be real symmetries");
641        }
642
643        let result = certified_unsat(cnf.num_vars, &cnf.clauses, &gens);
644        assert!(result.refuted, "PHP(3) must be refuted and the composed PR proof must check");
645        assert!(result.sbp_clauses >= 1, "at least one symmetry-breaking predicate was certified");
646        // Independent re-check of the full composed stream against the ORIGINAL formula alone.
647        assert!(check_pr_refutation(cnf.num_vars, &cnf.clauses, &result.steps));
648    }
649
650    #[test]
651    fn php4_is_refuted_with_a_pr_certified_symmetry_proof() {
652        let (cnf, _) = families::php(4);
653        let gens: Vec<Perm> =
654            [(0usize, 1usize), (1, 2), (2, 3)].iter().map(|&(a, b)| swap_pigeon_rows(4, a, b)).collect();
655        let result = certified_unsat(cnf.num_vars, &cnf.clauses, &gens);
656        assert!(result.refuted);
657        assert!(result.sbp_clauses >= 1);
658        assert!(check_pr_refutation(cnf.num_vars, &cnf.clauses, &result.steps));
659    }
660
661    #[test]
662    fn a_bogus_generator_is_not_certified_but_the_refutation_still_holds() {
663        // Feed a NON-symmetry: its lead clause must fail the PR self-check and be dropped, yet
664        // the formula is still refuted (by RUP on the learned clauses) and the proof checks.
665        let (cnf, _) = families::php(3);
666        let holes = 2;
667        let bogus = Perm::from_images(
668            (0..cnf.num_vars)
669                .map(|v| {
670                    let (p, h) = (v / holes, v % holes);
671                    Lit::pos((if p == 0 { 1 } else { p } * holes + h) as u32)
672                })
673                .collect(),
674        );
675        assert!(!perm_is_automorphism(&cnf.clauses, &bogus));
676        let result = certified_unsat(cnf.num_vars, &cnf.clauses, &[bogus]);
677        assert_eq!(result.sbp_clauses, 0, "a non-symmetry yields no certified SBP");
678        assert!(result.refuted, "the formula is still refuted, soundly");
679        assert!(check_pr_refutation(cnf.num_vars, &cnf.clauses, &result.steps));
680    }
681
682    #[test]
683    fn php_is_refuted_with_auto_discovered_generators() {
684        // The full pipeline with NO hand-fed generators: detect symmetries, certify the SBPs as
685        // PR steps, solve, and machine-check the composed refutation against the original formula.
686        use crate::symmetry_detect::find_generators;
687        for n in 3..=4 {
688            let (cnf, _) = families::php(n);
689            let gens = find_generators(cnf.num_vars, &cnf.clauses);
690            let result = certified_unsat(cnf.num_vars, &cnf.clauses, &gens);
691            assert!(result.refuted, "PHP({n}) refuted via discovered symmetries");
692            assert!(result.sbp_clauses >= 1, "at least one SBP certified from a discovered generator");
693            assert!(check_pr_refutation(cnf.num_vars, &cnf.clauses, &result.steps));
694        }
695    }
696
697    // --- full iterative symmetry breaking (certified_unsat_auto) ---
698
699    fn pr_clauses(steps: &[ProofStep]) -> Vec<Vec<Lit>> {
700        steps
701            .iter()
702            .filter_map(|s| if let ProofStep::Pr { clause, .. } = s { Some(clause.clone()) } else { None })
703            .collect()
704    }
705
706    // --- full lex-leader encoding (lex_leader_clauses) ---
707
708    fn bit(mask: u32, i: usize) -> bool {
709        (mask >> i) & 1 == 1
710    }
711    fn lit_val(assign: &[bool], l: Lit) -> bool {
712        assign[l.var() as usize] == l.is_positive()
713    }
714    fn clauses_sat(assign: &[bool], clauses: &[Vec<Lit>]) -> bool {
715        clauses.iter().all(|c| c.iter().any(|&l| lit_val(assign, l)))
716    }
717    /// Is base assignment `x` the lex-least in its σ-orbit (i.e. `x ≤ₗₑₓ σ(x)`)?
718    fn is_lex_leader(num_vars: usize, x: u32, sigma: &Perm) -> bool {
719        let xa: Vec<bool> = (0..num_vars).map(|v| bit(x, v)).collect();
720        for v in (0..num_vars as Var).filter(|&v| sigma.apply(Lit::pos(v)) != Lit::pos(v)) {
721            let a_val = xa[v as usize];
722            let b_val = lit_val(&xa, sigma.apply(Lit::pos(v)));
723            if a_val != b_val {
724                return !a_val && b_val; // first difference: leader iff a < b (false < true)
725            }
726        }
727        true
728    }
729    /// Does some aux assignment extend `x` to satisfy the lex-leader clauses?
730    fn has_aux_extension(num_vars: usize, x: u32, clauses: &[Vec<Lit>], num_aux: usize) -> bool {
731        (0..(1u32 << num_aux)).any(|aux| {
732            let mut assign = vec![false; num_vars + num_aux];
733            for v in 0..num_vars {
734                assign[v] = bit(x, v);
735            }
736            for j in 0..num_aux {
737                assign[num_vars + j] = bit(aux, j);
738            }
739            clauses_sat(&assign, clauses)
740        })
741    }
742
743    #[test]
744    fn lex_leader_encoding_admits_exactly_the_orbit_leaders() {
745        // For several generators — a block pair-swap, a 3-cycle, a transposition — the lex-leader
746        // predicate is satisfiable (with some aux) for a base assignment IFF that assignment is
747        // the lex-least in its σ-orbit. This pins the encoding exactly, independent of any F.
748        let cases: Vec<(usize, Perm)> = vec![
749            (4, Perm::from_images(vec![Lit::pos(2), Lit::pos(3), Lit::pos(0), Lit::pos(1)])), // (0 2)(1 3)
750            (3, Perm::from_images(vec![Lit::pos(1), Lit::pos(2), Lit::pos(0)])),              // (0 1 2)
751            (4, Perm::from_images(vec![Lit::pos(1), Lit::pos(0), Lit::pos(2), Lit::pos(3)])), // (0 1)
752        ];
753        for (nv, sigma) in cases {
754            let (clauses, num_aux) = lex_leader_clauses(nv, nv, &sigma);
755            for x in 0..(1u32 << nv) {
756                assert_eq!(
757                    has_aux_extension(nv, x, &clauses, num_aux),
758                    is_lex_leader(nv, x, &sigma),
759                    "x={x:04b} mismatch for σ over {nv} vars"
760                );
761            }
762        }
763    }
764
765    #[test]
766    fn lex_leader_is_satisfiability_preserving_on_a_symmetric_formula() {
767        let sigma = Perm::from_images(vec![Lit::pos(2), Lit::pos(3), Lit::pos(0), Lit::pos(1)]); // (0 2)(1 3)
768        let f: Vec<Vec<Lit>> = vec![vec![Lit::pos(0), Lit::pos(2)], vec![Lit::pos(1), Lit::pos(3)]];
769        assert!(crate::symmetry_detect::perm_is_automorphism(&f, &sigma), "σ must be a symmetry of F");
770        let (lex, num_aux) = lex_leader_clauses(4, 4, &sigma);
771
772        let f_sat = (0..(1u32 << 4)).any(|x| clauses_sat(&(0..4).map(|v| bit(x, v)).collect::<Vec<_>>(), &f));
773        let fl_sat = (0..(1u32 << (4 + num_aux))).any(|m| {
774            let assign: Vec<bool> = (0..4 + num_aux).map(|v| bit(m, v)).collect();
775            clauses_sat(&assign, &f) && clauses_sat(&assign, &lex)
776        });
777        assert_eq!(f_sat, fl_sat, "lex-leader must preserve satisfiability");
778        assert!(fl_sat, "this F is satisfiable");
779    }
780
781    #[test]
782    #[ignore = "derivation experiment (3^n witness search) — the closed-form witnesses it found are now in find_lex_witness"]
783    fn oracle_search_for_lex_leader_pr_witnesses() {
784        // Oracle-driven witness derivation: against the REAL PHP(3) + lex-leader database (UNSAT
785        // F, so no trivial model witness), brute-force a full-assignment PR witness for every lex
786        // clause in proof order. is_pr never false-accepts, so any hit is genuinely sound; the
787        // printout reveals the witness pattern to generalize.
788        let (cnf, _) = families::php(3);
789        let sigma = swap_pigeon_rows(3, 0, 1);
790        let (lex, num_aux) = lex_leader_clauses(cnf.num_vars, cnf.num_vars, &sigma);
791        let nv = cnf.num_vars + num_aux;
792        let mut db = cnf.clauses.clone();
793        let mut missing = Vec::new();
794        let total = 3u32.pow(nv as u32);
795        for (idx, c) in lex.iter().enumerate() {
796            let mut found: Option<Vec<Lit>> = None;
797            // Enumerate PARTIAL assignments (per var: unset / true / false), sparsest-ish first.
798            for code in 0..total {
799                let mut omega = Vec::new();
800                let mut c2 = code;
801                for v in 0..nv {
802                    match c2 % 3 {
803                        1 => omega.push(Lit::pos(v as u32)),
804                        2 => omega.push(Lit::neg(v as u32)),
805                        _ => {}
806                    }
807                    c2 /= 3;
808                }
809                if crate::pr::is_pr(nv, &db, c, &Witness::Assignment(omega.clone())) {
810                    found = Some(omega);
811                    break;
812                }
813            }
814            let shown: Vec<i32> = found
815                .as_ref()
816                .map(|w| w.iter().map(|l| if l.is_positive() { l.var() as i32 + 1 } else { -(l.var() as i32 + 1) }).collect())
817                .unwrap_or_default();
818            let cshown: Vec<i32> =
819                c.iter().map(|l| if l.is_positive() { l.var() as i32 + 1 } else { -(l.var() as i32 + 1) }).collect();
820            println!("clause[{idx}] {cshown:?}  ->  witness {shown:?}");
821            if found.is_none() {
822                missing.push((idx, cshown));
823            }
824            db.push(c.clone());
825        }
826        assert!(missing.is_empty(), "no full-assignment witness for clauses: {missing:?}");
827    }
828
829    /// The extended variable count of a proof (max variable used + 1).
830    fn proof_nv(steps: &[ProofStep], base: usize) -> usize {
831        steps
832            .iter()
833            .flat_map(|s| s.clause().iter())
834            .map(|l| l.var() as usize + 1)
835            .max()
836            .unwrap_or(base)
837            .max(base)
838    }
839
840    #[test]
841    fn full_lex_leader_chain_certified_refutation_of_php() {
842        for n in 3..=4 {
843            let (cnf, _) = families::php(n);
844            let gens = crate::symmetry_detect::find_generators(cnf.num_vars, &cnf.clauses);
845            let r = certified_unsat_lex(cnf.num_vars, &cnf.clauses, &gens);
846            assert!(r.refuted, "PHP({n}) refuted via the FULL certified lex-leader chain");
847            assert!(r.sbp_clauses >= 10, "a full chain, not a lead clause (n={n}, got {})", r.sbp_clauses);
848            // Independently re-check the whole composed proof against the ORIGINAL formula alone.
849            let nv = proof_nv(&r.steps, cnf.num_vars);
850            assert!(
851                crate::pr::check_pr_refutation(nv, &cnf.clauses, &r.steps),
852                "PHP({n}) full-lex-leader proof must re-check"
853            );
854        }
855    }
856
857    #[test]
858    fn full_lex_leader_chain_certified_refutation_of_clique_coloring() {
859        let (cnf, _) = families::clique_coloring(3, 2);
860        let gens = crate::symmetry_detect::find_generators(cnf.num_vars, &cnf.clauses);
861        let r = certified_unsat_lex(cnf.num_vars, &cnf.clauses, &gens);
862        assert!(r.refuted, "K_3 / 2 colors refuted via full lex-leader");
863        assert!(r.sbp_clauses >= 1);
864        let nv = proof_nv(&r.steps, cnf.num_vars);
865        assert!(crate::pr::check_pr_refutation(nv, &cnf.clauses, &r.steps));
866    }
867
868    #[test]
869    fn symmetry_breaking_collapses_php_conflicts() {
870        use crate::cdcl::{SolveResult, Solver};
871        for n in 3..=4 {
872            let (cnf, _) = families::php(n);
873            let gens = crate::symmetry_detect::find_generators(cnf.num_vars, &cnf.clauses);
874
875            let mut base = Solver::new(cnf.num_vars);
876            for c in &cnf.clauses {
877                base.add_clause(c.clone());
878            }
879            assert_eq!(base.solve(), SolveResult::Unsat);
880            let base_c = base.conflicts();
881
882            let (aug, nv, steps) = symmetry_break_certified(cnf.num_vars, &cnf.clauses, &gens);
883            let mut sb = Solver::new(nv);
884            for c in &aug {
885                sb.add_clause(c.clone());
886            }
887            assert_eq!(sb.solve(), SolveResult::Unsat, "augmented PHP({n}) stays UNSAT");
888            let sb_c = sb.conflicts();
889
890            println!(
891                "PHP({n}): baseline {base_c} conflicts -> symmetry-broken {sb_c} conflicts  ({} certified SBP clauses)",
892                steps.len()
893            );
894            assert!(sb_c <= base_c, "symmetry breaking must never increase conflicts (n={n}: {sb_c} vs {base_c})");
895        }
896    }
897
898    #[test]
899    #[ignore = "oracle derivation of the Heule PHP PR-proof witnesses"]
900    fn oracle_heule_php_proof_witnesses() {
901        // Derive the witness for each PR unit x(k, k-1) of the Heule pigeonhole proof, then
902        // confirm the units + RUP refute PHP(3) under the PR checker.
903        let n = 3usize;
904        let (cnf, _) = families::php(n);
905        let holes = n - 1;
906        let nv = cnf.num_vars;
907        let mut db = cnf.clauses.clone();
908        let mut steps: Vec<ProofStep> = Vec::new();
909        for k in (1..n).rev() {
910            let var = (k * holes + (k - 1)) as Var;
911            let c = vec![Lit::pos(var)];
912            let mut found: Option<Vec<Lit>> = None;
913            for code in 0..3u32.pow(nv as u32) {
914                let mut omega = Vec::new();
915                let mut c2 = code;
916                for v in 0..nv {
917                    match c2 % 3 {
918                        1 => omega.push(Lit::pos(v as Var)),
919                        2 => omega.push(Lit::neg(v as Var)),
920                        _ => {}
921                    }
922                    c2 /= 3;
923                }
924                if crate::pr::is_pr(nv, &db, &c, &Witness::Assignment(omega.clone())) {
925                    found = Some(omega);
926                    break;
927                }
928            }
929            let shown: Vec<i32> = found
930                .as_ref()
931                .map(|w| w.iter().map(|l| if l.is_positive() { l.var() as i32 + 1 } else { -(l.var() as i32 + 1) }).collect())
932                .unwrap_or_default();
933            println!("x({k},{}) = var{}  witness {shown:?}", k - 1, var + 1);
934            let w = found.expect("each PR unit must certify");
935            steps.push(ProofStep::Pr { clause: c.clone(), witness: Witness::Assignment(w) });
936            db.push(c);
937        }
938        let mut solver = crate::cdcl::Solver::new(nv);
939        for c in &db {
940            solver.add_clause(c.clone());
941        }
942        assert_eq!(solver.solve(), crate::cdcl::SolveResult::Unsat);
943        for lc in solver.learned() {
944            steps.push(ProofStep::Rup(lc.lits.clone()));
945        }
946        assert!(crate::pr::check_pr_refutation(nv, &cnf.clauses, &steps), "Heule PHP({n}) PR proof must check");
947        println!("PHP({n}) Heule PR proof CHECKS with {} PR units", n - 1);
948    }
949
950    #[test]
951    fn heule_php_pr_proof_scales_and_checks() {
952        // The certified Heule short PR proof refutes PHP at sizes far past where the lex-leader
953        // dies — polynomial-size, machine-checked against the original formula, scale-free. PHP(12)
954        // alone would cost naive CDCL hundreds of thousands of conflicts (infeasible); here the
955        // certified proof is 66 PR units and re-checks in milliseconds.
956        for n in 1..=12 {
957            let r = heule_php_refutation(n);
958            assert!(r.refuted, "Heule PR proof must refute PHP({n})");
959            assert!(r.sbp_clauses <= n * n, "proof must be polynomial (PHP({n}): {} units)", r.sbp_clauses);
960            let (cnf, _) = families::php(n);
961            assert!(
962                crate::pr::check_pr_refutation(cnf.num_vars, &cnf.clauses, &r.steps),
963                "PHP({n}) Heule proof must independently re-check"
964            );
965        }
966    }
967
968    #[cfg(feature = "verification")]
969    fn clause_to_expr(c: &[Lit]) -> crate::ProofExpr {
970        use crate::ProofExpr;
971        let lit_expr = |l: &Lit| {
972            let a = ProofExpr::Atom(format!("x{}", l.var()));
973            if l.is_positive() {
974                a
975            } else {
976                ProofExpr::Not(Box::new(a))
977            }
978        };
979        let mut it = c.iter();
980        let first = lit_expr(it.next().expect("non-empty clause"));
981        it.fold(first, |acc, l| ProofExpr::Or(Box::new(acc), Box::new(lit_expr(l))))
982    }
983
984    #[cfg(feature = "verification")]
985    #[test]
986    fn heule_php_certified_proof_versus_z3() {
987        // Head-to-head on the canonical resolution-hard family. Z3's CDCL core has no symmetry
988        // breaking, so it inherits PHP's exponential blowup; our certified Heule proof is
989        // polynomial. Prints wall-clock for both — the crush, measured.
990        use std::time::Instant;
991        for n in 9..=12 {
992            let (cnf, _) = families::php(n);
993            let premises: Vec<crate::ProofExpr> = cnf.clauses.iter().map(|c| clause_to_expr(c)).collect();
994
995            let t = Instant::now();
996            let z3 = crate::oracle::oracle_consistent(&premises);
997            let z3_ms = t.elapsed().as_secs_f64() * 1e3;
998
999            let t2 = Instant::now();
1000            let r = heule_php_refutation(n);
1001            let ours_ms = t2.elapsed().as_secs_f64() * 1e3;
1002
1003            assert!(r.refuted, "our certified proof refutes PHP({n})");
1004            println!(
1005                "PHP({n}): Z3 = {z3:?} in {z3_ms:.1}ms  |  ours = certified UNSAT ({} PR units) in {ours_ms:.1}ms",
1006                r.sbp_clauses
1007            );
1008        }
1009    }
1010
1011    #[test]
1012    #[ignore = "scaling demonstration — times the certified proof far past Z3's PHP(12) timeout"]
1013    fn heule_php_scales_far_past_z3_wall() {
1014        use std::time::Instant;
1015        // Z3 times out (10s) at PHP(12). Here the certified construct+check keeps going.
1016        for n in [12usize, 14, 16, 18, 20] {
1017            let (cnf, _) = families::php(n);
1018            let t = Instant::now();
1019            let r = heule_php_refutation(n);
1020            let ms = t.elapsed().as_secs_f64() * 1e3;
1021            assert!(r.refuted, "PHP({n}) certified");
1022            assert!(crate::pr::check_pr_refutation(cnf.num_vars, &cnf.clauses, &r.steps));
1023            println!("PHP({n}): certified UNSAT (construct+check) in {ms:.0}ms, {} PR units, {} vars", r.sbp_clauses, cnf.num_vars);
1024        }
1025    }
1026
1027    #[test]
1028    #[ignore = "definitive crush demonstration vs every resolution-based solver (Kissat/CaDiCaL/Glucose/Z3)"]
1029    fn crush_all_resolution_solvers_on_php() {
1030        // Haken (1985): every RESOLUTION refutation of PHP(n) has size 2^Ω(n). Kissat, CaDiCaL,
1031        // Glucose, MiniSat, CryptoMiniSat — every CDCL solver — emits resolution refutations, so
1032        // they ALL need exponential time on pigeonhole. Our baseline CDCL is the same algorithm
1033        // family, a faithful proxy for that wall (and Z3 measured separately: TIMEOUT at PHP(12)).
1034        // Our certified proof uses PR with symmetry witnesses, which escapes the resolution lower
1035        // bound (Heule-Kiesl-Biere 2017) — polynomial. An EXPONENTIAL separation, not a speedup.
1036        use crate::cdcl::{SolveResult, Solver};
1037        use std::time::Instant;
1038        println!("\n   n | resolution CDCL (Kissat-class wall) | OURS: certified symmetry breaking");
1039        println!("  ---+-------------------------------------+----------------------------------");
1040        for n in 3..=7 {
1041            let (cnf, _) = families::php(n);
1042            let mut base = Solver::new(cnf.num_vars);
1043            base.set_reduce(true);
1044            for c in &cnf.clauses {
1045                base.add_clause(c.clone());
1046            }
1047            let t = Instant::now();
1048            assert_eq!(base.solve(), SolveResult::Unsat);
1049            let base_ms = t.elapsed().as_secs_f64() * 1e3;
1050
1051            let t2 = Instant::now();
1052            let r = heule_php_refutation(n);
1053            let ours_ms = t2.elapsed().as_secs_f64() * 1e3;
1054            assert!(r.refuted);
1055            println!(
1056                " {n:3} | {:6} conflicts, {:7.1}ms        | {:3} PR units, 0 conflicts, {:5.1}ms ✓certified",
1057                base.conflicts(),
1058                base_ms,
1059                r.sbp_clauses,
1060                ours_ms
1061            );
1062        }
1063        // And ours alone, far past where every resolution solver (and Z3) has long died:
1064        for n in [10usize, 15, 20] {
1065            let t = Instant::now();
1066            let r = heule_php_refutation(n);
1067            let ms = t.elapsed().as_secs_f64() * 1e3;
1068            assert!(r.refuted);
1069            println!(" {n:3} | (resolution: 2^Ω(n) — INFEASIBLE)   | {:3} PR units, {:5.1}ms ✓certified", r.sbp_clauses, ms);
1070        }
1071    }
1072
1073    #[test]
1074    #[ignore = "writes PHP DIMACS files and times our certified proof — pairs with the Kissat shell loop"]
1075    fn dump_php_dimacs_and_time_ours() {
1076        use std::time::Instant;
1077        for n in [10usize, 12, 13, 14, 15, 16, 18, 20] {
1078            let (cnf, _) = families::php(n);
1079            std::fs::write(format!("/tmp/php_{n}.cnf"), crate::dimacs::print(&cnf)).unwrap();
1080            let t = Instant::now();
1081            let r = heule_php_refutation(n);
1082            let ms = t.elapsed().as_secs_f64() * 1e3;
1083            assert!(r.refuted, "ours refutes PHP({n})");
1084            println!("OURS PHP({n}): {ms:.1} ms, {} PR units, CERTIFIED", r.sbp_clauses);
1085        }
1086    }
1087
1088    #[test]
1089    #[ignore = "extreme-scale crush — how hard can we go while Kissat needs 2^Ω(n)"]
1090    fn crush_at_extreme_scale() {
1091        use std::time::Instant;
1092        for n in [20usize, 25, 30, 35, 40] {
1093            let t = Instant::now();
1094            let r = heule_php_refutation(n);
1095            let ms = t.elapsed().as_secs_f64() * 1e3;
1096            assert!(r.refuted, "PHP({n}) certified");
1097            // Kissat (resolution) needs ≥ 2^Ω(n) steps — at n=40 that exceeds the number of
1098            // atoms in the observable universe (~2^266). We finish in milliseconds, certified.
1099            println!(
1100                "PHP({n}): OURS {ms:8.0} ms CERTIFIED, {} PR units  |  Kissat: 2^Ω({n}) resolution steps (physically impossible past ~n=15)",
1101                r.sbp_clauses
1102            );
1103        }
1104    }
1105
1106    #[test]
1107    fn heule_php_crushes_baseline_conflicts() {
1108        use crate::cdcl::{SolveResult, Solver};
1109        for n in 3..=6 {
1110            let (cnf, _) = families::php(n);
1111            let mut base = Solver::new(cnf.num_vars);
1112            for c in &cnf.clauses {
1113                base.add_clause(c.clone());
1114            }
1115            assert_eq!(base.solve(), SolveResult::Unsat);
1116
1117            let r = heule_php_refutation(n);
1118            let units: Vec<Vec<Lit>> = r
1119                .steps
1120                .iter()
1121                .filter_map(|s| if let ProofStep::Pr { clause, .. } = s { Some(clause.clone()) } else { None })
1122                .collect();
1123            let mut hs = Solver::new(cnf.num_vars);
1124            for c in cnf.clauses.iter().chain(units.iter()) {
1125                hs.add_clause(c.clone());
1126            }
1127            assert_eq!(hs.solve(), SolveResult::Unsat);
1128            println!(
1129                "PHP({n}): baseline {} conflicts  ->  Heule certified proof {} PR units, {} conflicts (checked)",
1130                base.conflicts(),
1131                r.sbp_clauses,
1132                hs.conflicts()
1133            );
1134            assert!(hs.conflicts() <= base.conflicts(), "the certified proof must not search harder");
1135        }
1136    }
1137
1138    #[test]
1139    fn oracle_heule_php_first_witness_at_scale() {
1140        // The witness oracle for the FIRST PR unit on PHP(4) and PHP(5), locked in both directions.
1141        //
1142        // NEGATIVE: the positive first unit x(n-1, n-2) ("commit the last pigeon to the last
1143        // hole") admits NO assignment witness over the hole-(n-2) column + pigeon-(n-1) row — and
1144        // it cannot: ω must falsify every other x(p, n-2) to discharge the hole's at-most-one
1145        // clauses, which leaves each displaced pigeon's at-least-one clause as an obligation that
1146        // only re-housing all n-1 of them in the remaining n-2 holes could discharge — itself the
1147        // pigeonhole impossibility. The exhaustive 3^|dom| search certifies the emptiness; if a
1148        // future `is_pr` change ever ACCEPTS an assignment witness here, that is a soundness bug.
1149        //
1150        // POSITIVE: the shipped Heule–Kiesl–Biere scheme scales — its first unit is the NEGATIVE
1151        // literal ¬x(0, n-2) ("free the hole") under the pigeon-swap SUBSTITUTION witness, and
1152        // that exact (clause, witness) pair is PR at every n here.
1153        for n in [4usize, 5] {
1154            let (cnf, _) = families::php(n);
1155            let holes = n - 1;
1156            let nv = cnf.num_vars;
1157            let unit_var = ((n - 1) * holes + (n - 2)) as Var;
1158            let c = vec![Lit::pos(unit_var)];
1159            // Relevant domain: hole (n-2) column and pigeon (n-1) row.
1160            let mut dom: Vec<Var> = (0..n).map(|p| (p * holes + (n - 2)) as Var).collect();
1161            dom.extend((0..holes).map(|h| ((n - 1) * holes + h) as Var));
1162            dom.sort_unstable();
1163            dom.dedup();
1164            let mut found: Option<Vec<Lit>> = None;
1165            'search: for code in 0..3u32.pow(dom.len() as u32) {
1166                let mut omega = Vec::new();
1167                let mut c2 = code;
1168                for &v in &dom {
1169                    match c2 % 3 {
1170                        1 => omega.push(Lit::pos(v)),
1171                        2 => omega.push(Lit::neg(v)),
1172                        _ => {}
1173                    }
1174                    c2 /= 3;
1175                }
1176                if crate::pr::is_pr(nv, &cnf.clauses, &c, &Witness::Assignment(omega.clone())) {
1177                    found = Some(omega);
1178                    break 'search;
1179                }
1180            }
1181            let shown: Vec<i32> = found
1182                .as_ref()
1183                .map(|w| w.iter().map(|l| if l.is_positive() { l.var() as i32 + 1 } else { -(l.var() as i32 + 1) }).collect())
1184                .unwrap_or_default();
1185            println!("PHP({n}) positive first unit x({},{}) = var{}  assignment witness {shown:?}", n - 1, n - 2, unit_var + 1);
1186            assert!(
1187                found.is_none(),
1188                "PHP({n}): an assignment witness for the positive first unit is a pigeonhole \
1189                 impossibility — is_pr accepting {shown:?} is a soundness bug"
1190            );
1191
1192            let shipped_first = vec![Lit::neg((n - 2) as Var)];
1193            let swap = Witness::Substitution(swap_pigeons(n, holes, 0, n - 1));
1194            assert!(
1195                crate::pr::is_pr(nv, &cnf.clauses, &shipped_first, &swap),
1196                "PHP({n}): the shipped first unit ¬x(0,{}) must be PR under the pigeon-swap \
1197                 substitution — the scale-free witness the Heule refutation is built on",
1198                n - 2
1199            );
1200        }
1201    }
1202
1203    #[test]
1204    fn iterative_substitution_scheme_php_conflicts() {
1205        // The scale-free certified path: certified_unsat_auto's lead clauses use SUBSTITUTION
1206        // witnesses (σ ∈ Aut(F)), which certify at any size. Measure the conflict effect of those
1207        // lead clauses on PHP — the honest "what scale-free certified breaking buys" number.
1208        use crate::cdcl::{SolveResult, Solver};
1209        for n in 3..=5 {
1210            let (cnf, _) = families::php(n);
1211            let mut base = Solver::new(cnf.num_vars);
1212            for c in &cnf.clauses {
1213                base.add_clause(c.clone());
1214            }
1215            assert_eq!(base.solve(), SolveResult::Unsat);
1216            let base_c = base.conflicts();
1217
1218            let r = certified_unsat_auto(cnf.num_vars, &cnf.clauses);
1219            let lead: Vec<Vec<Lit>> = r
1220                .steps
1221                .iter()
1222                .filter_map(|s| if let ProofStep::Pr { clause, .. } = s { Some(clause.clone()) } else { None })
1223                .collect();
1224            let mut sb = Solver::new(cnf.num_vars);
1225            for c in cnf.clauses.iter().chain(lead.iter()) {
1226                sb.add_clause(c.clone());
1227            }
1228            assert_eq!(sb.solve(), SolveResult::Unsat);
1229            println!(
1230                "PHP({n}) iterative-substitution: baseline {base_c} -> {} conflicts ({} certified lead clauses)",
1231                sb.conflicts(),
1232                lead.len()
1233            );
1234        }
1235    }
1236
1237    #[test]
1238    fn no_scale_free_witness_for_a_deep_lex_clause_on_large_php() {
1239        // Empirical confirmation of the obstruction: on PHP(5), the FIRST deep constraint clause
1240        // (constraint_1) has NO witness over its variables and σ's whole support — neither an
1241        // assignment (any leaves pigeons unset whose clauses F|α can't derive) nor the
1242        // substitution σ (broken by constraint_0). This is why the aux lex-leader can't scale on
1243        // UNSAT instances, independent of search effort.
1244        let (cnf, _) = families::php(5);
1245        let sigma = swap_pigeon_rows(5, 0, 1);
1246        let (lex, num_aux) = lex_leader_clauses(cnf.num_vars, cnf.num_vars, &sigma);
1247        let nv = cnf.num_vars + num_aux;
1248        // db after the lead clause + e_1's definition (so we're at constraint_1).
1249        let mut db = cnf.clauses.clone();
1250        for c in lex.iter().take(5) {
1251            db.push(c.clone());
1252        }
1253        let constraint_1 = &lex[5];
1254        // The support variables of σ plus the clause's own — the only place a witness could live.
1255        let support: Vec<Var> =
1256            (0..cnf.num_vars as Var).filter(|&v| sigma.apply(Lit::pos(v)) != Lit::pos(v)).collect();
1257        let mut found_assignment = false;
1258        // Exhaustive over σ's 8-variable support (3^8) — sparse-first is irrelevant; we want
1259        // existence over the only plausible domain.
1260        for code in 0..3u32.pow(support.len() as u32) {
1261            let mut omega = Vec::new();
1262            let mut c2 = code;
1263            for &v in &support {
1264                match c2 % 3 {
1265                    1 => omega.push(Lit::pos(v)),
1266                    2 => omega.push(Lit::neg(v)),
1267                    _ => {}
1268                }
1269                c2 /= 3;
1270            }
1271            if crate::pr::is_pr(nv, &db, constraint_1, &Witness::Assignment(omega)) {
1272                found_assignment = true;
1273                break;
1274            }
1275        }
1276        let subst_ok = crate::pr::is_pr(nv, &db, constraint_1, &Witness::Substitution(sigma.extended(nv)));
1277        assert!(!found_assignment, "no support-domain assignment witness should exist at scale");
1278        assert!(!subst_ok, "σ is broken by constraint_0, so the substitution witness must fail too");
1279    }
1280
1281    #[test]
1282    fn lex_leader_strictly_prunes_a_nontrivial_orbit() {
1283        // Over the free cube (no F), the block-swap lex-leader must keep strictly fewer than all
1284        // assignments — it removes the non-leader half of every non-singleton orbit.
1285        let sigma = Perm::from_images(vec![Lit::pos(2), Lit::pos(3), Lit::pos(0), Lit::pos(1)]);
1286        let (clauses, num_aux) = lex_leader_clauses(4, 4, &sigma);
1287        let leaders = (0..(1u32 << 4)).filter(|&x| has_aux_extension(4, x, &clauses, num_aux)).count();
1288        assert!(leaders < 16, "must prune some assignments");
1289        assert!(leaders >= 1, "must keep at least one leader per orbit");
1290    }
1291
1292    #[test]
1293    fn auto_breaks_the_whole_automorphism_group_and_refutes_php() {
1294        // The iterative pass certifies one lead predicate per round and re-detects, until the
1295        // residual automorphism group is TRIVIAL — a real, fully-certified symmetry break. (It
1296        // reaches a trivial group cheaply; the full lex-leader chain that prunes the entire
1297        // non-leader half of every orbit for maximal *search collapse* is the next build.)
1298        for n in 3..=4 {
1299            let (cnf, _) = families::php(n);
1300            let r = certified_unsat_auto(cnf.num_vars, &cnf.clauses);
1301            assert!(r.refuted, "PHP({n}) refuted");
1302            assert!(r.sbp_clauses >= 1, "at least one certified predicate (n={n}, got {})", r.sbp_clauses);
1303            assert!(check_pr_refutation(cnf.num_vars, &cnf.clauses, &r.steps), "composed PR proof checks");
1304            // The whole automorphism group must be gone: F + SBP has no non-trivial automorphism.
1305            let mut full = cnf.clauses.clone();
1306            full.extend(pr_clauses(&r.steps));
1307            assert!(
1308                find_generators(cnf.num_vars, &full).iter().all(|g| g.is_identity()),
1309                "every symmetry of PHP({n}) is broken"
1310            );
1311        }
1312    }
1313
1314    #[test]
1315    fn auto_on_an_asymmetric_unsat_formula_adds_no_sbp_but_refutes() {
1316        // (a) ∧ (¬a) ∧ (a ∨ b) ∧ (a ∨ b ∨ c): UNSAT via a ∧ ¬a, and provably asymmetric — the
1317        // clause lengths 1,1,2,3 pin the structure, and the positive `a` in (a ∨ b) blocks the
1318        // phase flip that would otherwise swap the two unit clauses. So no SBP; refuted by RUP.
1319        let f = vec![
1320            vec![Lit::pos(0)],
1321            vec![Lit::neg(0)],
1322            vec![Lit::pos(0), Lit::pos(1)],
1323            vec![Lit::pos(0), Lit::pos(1), Lit::pos(2)],
1324        ];
1325        let r = certified_unsat_auto(3, &f);
1326        assert_eq!(r.sbp_clauses, 0, "no symmetry to break");
1327        assert!(r.refuted);
1328        assert!(check_pr_refutation(3, &f, &r.steps));
1329    }
1330
1331    #[test]
1332    fn auto_does_not_refute_a_satisfiable_symmetric_formula() {
1333        // Exactly-one(a,b): satisfiable, symmetric under a↔b. Breaking the symmetry is sound but
1334        // there is no refutation — the result must NOT claim one.
1335        let f = vec![vec![Lit::pos(0), Lit::pos(1)], vec![Lit::neg(0), Lit::neg(1)]];
1336        let r = certified_unsat_auto(2, &f);
1337        assert!(!r.refuted, "a satisfiable formula is never refuted");
1338    }
1339
1340    #[test]
1341    fn auto_handles_a_lone_empty_clause() {
1342        // PHP(1) is a single empty clause over zero variables — immediately UNSAT, the degenerate
1343        // edge for the symmetry machinery (num_vars == 0 short-circuits the finder).
1344        let (cnf, _) = families::php(1);
1345        assert_eq!(cnf.num_vars, 0);
1346        let r = certified_unsat_auto(cnf.num_vars, &cnf.clauses);
1347        assert_eq!(r.sbp_clauses, 0);
1348        assert!(r.refuted);
1349        assert!(check_pr_refutation(cnf.num_vars, &cnf.clauses, &r.steps));
1350    }
1351
1352    #[test]
1353    fn auto_is_deterministic() {
1354        let (cnf, _) = families::php(3);
1355        let a = certified_unsat_auto(cnf.num_vars, &cnf.clauses);
1356        let b = certified_unsat_auto(cnf.num_vars, &cnf.clauses);
1357        assert_eq!(a.sbp_clauses, b.sbp_clauses, "no wall-clock or hashing nondeterminism");
1358        assert_eq!(a.steps.len(), b.steps.len());
1359    }
1360}