Skip to main content

logicaffeine_proof/
sdcl.rs

1//! Satisfaction-Driven Clause Learning (Heule, Kiesl & Seidl) — the engine that reasons at the
2//! Extended-Frege level by *discovering* propagation-redundant clauses, not just learning implied
3//! ones. It is symmetry breaking taken to its limit: where certified symmetry breaking needs an
4//! automorphism to supply a witness, SDCL **finds the witness by solving the positive reduct** — a
5//! smaller SAT problem — so it works on *any* structure, symmetric or not.
6//!
7//! When the search is stuck at a partial assignment `α` it cannot extend, the clause `C = ¬α` would
8//! block that subtree. `C` is propagation-redundant iff the positive reduct `{C} ∪ {D ∈ F : α ⊨ D}`
9//! is satisfiable; a satisfying `ω` is the witness. Every clause this finds is gated through the
10//! independent PR checker ([`crate::pr::is_pr`]), so it is sound by construction and emits a
11//! checkable PR step — the same machine-checkable currency as the rest of the campaign.
12
13use crate::cdcl::{Lit, SolveResult, Solver, Var};
14use crate::pr::{check_pr_refutation, is_pr};
15use crate::proof::{Perm, ProofStep, Witness};
16use crate::sym_certify::CertifiedRefutation;
17use crate::symmetry_detect::find_generators;
18use std::collections::HashSet;
19
20/// From a "stuck" partial assignment `alpha` (its true-literals), try to discover a PR clause
21/// `C = ¬alpha` that blocks it, via the positive reduct. Returns the clause and its witness, both
22/// verified by the PR checker (fail-closed). This is the SDCL primitive — a universal,
23/// automorphism-free generalization of certified symmetry breaking.
24pub fn find_pr_clause(num_vars: usize, clauses: &[Vec<Lit>], alpha: &[Lit]) -> Option<(Vec<Lit>, Witness)> {
25    // C = ¬alpha — every literal of alpha, negated.
26    let c: Vec<Lit> = alpha.iter().map(|l| l.negated()).collect();
27    if c.is_empty() {
28        return None;
29    }
30    // Positive reduct: a witness ω must satisfy C and every clause alpha already satisfies. The
31    // witness is read off over just the *relevant* variables (C's plus those satisfied clauses'),
32    // so untouched clauses impose no obligation on the PR check.
33    let mut relevant: HashSet<Var> = c.iter().map(|l| l.var()).collect();
34    let mut reduct: Vec<Vec<Lit>> = vec![c.clone()];
35    for d in clauses {
36        if d.iter().any(|l| alpha.contains(l)) {
37            for l in d {
38                relevant.insert(l.var());
39            }
40            reduct.push(d.clone());
41        }
42    }
43    let mut solver = Solver::new(num_vars);
44    for cl in &reduct {
45        solver.add_clause(cl.clone());
46    }
47    if let SolveResult::Sat(model) = solver.solve() {
48        let omega: Vec<Lit> = relevant.iter().map(|&v| Lit::new(v, model[v as usize])).collect();
49        let witness = Witness::Assignment(omega);
50        // Independent sound gate: only return a clause the PR checker actually certifies.
51        if is_pr(num_vars, clauses, &c, &witness) {
52            return Some((c, witness));
53        }
54    }
55    None
56}
57
58/// A fully automatic SDCL refutation: repeatedly discover propagation-redundant **unit** clauses
59/// (by probing each literal as a stuck state and solving the positive reduct), add them as PR
60/// steps, until the formula collapses to a RUP refutation. No symmetry, no problem-specific
61/// knowledge, no hint — the solver finds the Extended-Frege proof itself, and emits it for the
62/// independent checker. This is the universal engine the whole campaign was climbing toward.
63/// The SDCL discovery core: repeatedly probe each literal and add any propagation-redundant clause
64/// the unified finder certifies (positive-reduct assignment witness, or a substitution witness from
65/// the round's freshly-detected symmetry group), until no more are found. Returns the augmented
66/// database (a superset of the input — every added clause is satisfiability-preserving) and the PR
67/// proof steps that justify the additions.
68fn sdcl_discover(num_vars: usize, clauses: &[Vec<Lit>]) -> (Vec<Vec<Lit>>, Vec<ProofStep>) {
69    let mut db = clauses.to_vec();
70    let mut steps: Vec<ProofStep> = Vec::new();
71    let cap = num_vars * num_vars + 8;
72    while steps.len() < cap {
73        let gens = find_generators(num_vars, &db);
74        let mut progressed = false;
75        'probe: for v in 0..num_vars as Var {
76            for &val in &[true, false] {
77                let alpha = [Lit::new(v, val)];
78                if let Some((c, w)) = find_pr_clause_unified(num_vars, &db, &alpha, &gens) {
79                    if !db.iter().any(|d| d == &c) {
80                        db.push(c.clone());
81                        steps.push(ProofStep::Pr { clause: c, witness: w });
82                        progressed = true;
83                        break 'probe;
84                    }
85                }
86            }
87        }
88        if !progressed {
89            break;
90        }
91    }
92    (db, steps)
93}
94
95pub fn sdcl_refute(num_vars: usize, clauses: &[Vec<Lit>]) -> CertifiedRefutation {
96    let (db, mut steps) = sdcl_discover(num_vars, clauses);
97    let sbp_clauses = steps.len();
98    let mut solver = Solver::new(num_vars);
99    for c in &db {
100        solver.add_clause(c.clone());
101    }
102    match solver.solve() {
103        SolveResult::Sat(_) => CertifiedRefutation { refuted: false, sbp_clauses, steps },
104        SolveResult::Unsat => {
105            for lc in solver.learned() {
106                steps.push(ProofStep::Rup(lc.lits.clone()));
107            }
108            // Fail-closed: the composed proof, or a plain CDCL refutation if it doesn't certify.
109            if check_pr_refutation(num_vars, clauses, &steps) {
110                CertifiedRefutation { refuted: true, sbp_clauses, steps }
111            } else {
112                CertifiedRefutation { refuted: true, sbp_clauses: 0, steps: plain_cdcl_refutation(num_vars, clauses) }
113            }
114        }
115    }
116}
117
118/// The verdict of the unified certified solver.
119pub enum CertifiedOutcome {
120    /// Unsatisfiable, with a machine-checkable refutation (PR discovery steps + RUP learned steps)
121    /// that re-checks against the original formula, and how many clauses SDCL discovered.
122    Unsat { steps: Vec<ProofStep>, discovered: usize },
123    /// Satisfiable, with a model over `0..num_vars`.
124    Sat(Vec<bool>),
125}
126
127/// The unified certified solver — the apex of the campaign. Given ANY formula it (1) auto-discovers
128/// propagation-redundant clauses via SDCL (positive reduct + live symmetry), then (2) solves the
129/// augmented formula with the modernized CDCL core. On UNSAT it returns a single machine-checked
130/// refutation composed of every step; on SAT a model (valid for the original, since SDCL only adds
131/// satisfiability-preserving clauses). Symmetry breaking, SDCL, and CDCL, fused into one certified
132/// engine.
133pub fn solve_certified(num_vars: usize, clauses: &[Vec<Lit>]) -> CertifiedOutcome {
134    let (db, mut steps) = sdcl_discover(num_vars, clauses);
135    let discovered = steps.len();
136    let mut solver = Solver::new(num_vars);
137    for c in &db {
138        solver.add_clause(c.clone());
139    }
140    match solver.solve() {
141        SolveResult::Sat(model) => CertifiedOutcome::Sat(model),
142        SolveResult::Unsat => {
143            for lc in solver.learned() {
144                steps.push(ProofStep::Rup(lc.lits.clone()));
145            }
146            // FAIL-CLOSED: a certified solver must never return an unchecked proof. Verify the
147            // composed refutation; if the SDCL composition does not certify, fall back to a plain
148            // CDCL refutation of the original (always RUP-checkable). The original is UNSAT iff the
149            // augmented database is, since every discovered clause is satisfiability-preserving.
150            if check_pr_refutation(num_vars, clauses, &steps) {
151                CertifiedOutcome::Unsat { steps, discovered }
152            } else {
153                CertifiedOutcome::Unsat { steps: plain_cdcl_refutation(num_vars, clauses), discovered: 0 }
154            }
155        }
156    }
157}
158
159/// A plain CDCL refutation of `clauses` as RUP proof steps — always re-checkable. The certified
160/// fallback that guarantees [`solve_certified`] and [`sdcl_refute`] never emit an unchecked proof,
161/// and the universal source of a `cake_lpr`-checkable LRAT certificate for any UNSAT instance.
162pub fn plain_cdcl_refutation(num_vars: usize, clauses: &[Vec<Lit>]) -> Vec<ProofStep> {
163    let mut solver = Solver::new(num_vars);
164    for c in clauses {
165        solver.add_clause(c.clone());
166    }
167    let _ = solver.solve();
168    solver.learned().iter().map(|lc| ProofStep::Rup(lc.lits.clone())).collect()
169}
170
171/// The unified PR-clause finder: the universal SDCL discovery (positive-reduct assignment witness)
172/// PLUS the scalable symmetry path (substitution witnesses from the supplied generators). The
173/// assignment witness is general but can fail to certify at scale (it needs `F|α` UP-refutable); a
174/// substitution witness from a live automorphism certifies the lead clauses at *any* size via an
175/// immediate conflict. Trying both gives SDCL that discovers AND scales.
176pub fn find_pr_clause_unified(
177    num_vars: usize,
178    clauses: &[Vec<Lit>],
179    alpha: &[Lit],
180    generators: &[Perm],
181) -> Option<(Vec<Lit>, Witness)> {
182    if let Some(found) = find_pr_clause(num_vars, clauses, alpha) {
183        return Some(found);
184    }
185    let c: Vec<Lit> = alpha.iter().map(|l| l.negated()).collect();
186    for sigma in generators {
187        let witness = Witness::Substitution(sigma.extended(num_vars));
188        if is_pr(num_vars, clauses, &c, &witness) {
189            return Some((c, witness));
190        }
191    }
192    None
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::families;
199
200    fn sat_brute(num_vars: usize, clauses: &[Vec<Lit>]) -> bool {
201        (0u32..(1u32 << num_vars)).any(|mask| {
202            let model: Vec<bool> = (0..num_vars).map(|v| (mask >> v) & 1 == 1).collect();
203            clauses.iter().all(|c| c.iter().any(|l| model[l.var() as usize] == l.is_positive()))
204        })
205    }
206
207    #[test]
208    fn sdcl_rediscovers_a_pigeonhole_pr_clause_with_no_symmetry_input() {
209        // No automorphism, no Heule construction — given only the stuck attempt "pigeon 0 in the
210        // last hole", SDCL's positive reduct DISCOVERS that ¬x(0, last_hole) is propagation-
211        // redundant: the very clause certified symmetry breaking adds, found from structure alone.
212        for n in 3..=4 {
213            let (cnf, _) = families::php(n);
214            let last_hole = n - 2;
215            let stuck = vec![Lit::pos(last_hole as Var)]; // x(0, last_hole) = 0*(n-1)+last_hole
216            let (c, w) = find_pr_clause(cnf.num_vars, &cnf.clauses, &stuck)
217                .expect("SDCL must discover a PR clause");
218            assert_eq!(c, vec![Lit::neg(last_hole as Var)], "rediscovered ¬x(0, last_hole)");
219            assert!(is_pr(cnf.num_vars, &cnf.clauses, &c, &w), "and it is genuinely PR");
220        }
221    }
222
223    #[test]
224    fn sdcl_refutes_pigeonhole_fully_automatically() {
225        // THE payoff: no symmetry, no Heule construction, no hint — sdcl_refute discovers the
226        // certified Extended-Frege refutation of pigeonhole entirely on its own, and the composed
227        // proof re-checks against the original formula.
228        for n in 2..=4 {
229            let (cnf, _) = families::php(n);
230            let r = sdcl_refute(cnf.num_vars, &cnf.clauses);
231            assert!(r.refuted, "SDCL must auto-refute PHP({n})");
232            assert!(
233                check_pr_refutation(cnf.num_vars, &cnf.clauses, &r.steps),
234                "the self-discovered proof must independently re-check"
235            );
236            // PHP(2) is directly RUP-refutable (no PR needed); from n≥3 SDCL must discover PR clauses.
237            if n >= 3 {
238                assert!(r.sbp_clauses >= 1, "PHP({n}) needs self-discovered PR clauses");
239            }
240        }
241    }
242
243    #[test]
244    #[ignore = "scaling SDCL — auto-refutes larger pigeonhole via discovered substitution witnesses"]
245    fn sdcl_refute_scales_with_symmetry_fallback() {
246        use crate::cdcl::{SolveResult, Solver};
247        for n in 5..=7 {
248            let (cnf, _) = families::php(n);
249            let r = sdcl_refute(cnf.num_vars, &cnf.clauses);
250            assert!(r.refuted, "scaling SDCL must auto-refute PHP({n})");
251            assert!(r.sbp_clauses >= 1, "discovered PR clauses at scale");
252            assert!(check_pr_refutation(cnf.num_vars, &cnf.clauses, &r.steps));
253            // How much search was left after the self-discovered breaking?
254            let sbp: Vec<Vec<Lit>> = r
255                .steps
256                .iter()
257                .filter_map(|s| if let ProofStep::Pr { clause, .. } = s { Some(clause.clone()) } else { None })
258                .collect();
259            let mut solver = Solver::new(cnf.num_vars);
260            for c in cnf.clauses.iter().chain(sbp.iter()) {
261                solver.add_clause(c.clone());
262            }
263            assert_eq!(solver.solve(), SolveResult::Unsat);
264            println!(
265                "SDCL PHP({n}): {} self-discovered PR clauses (no symmetry input) → {} conflicts left, CERTIFIED",
266                r.sbp_clauses,
267                solver.conflicts()
268            );
269        }
270    }
271
272    #[test]
273    fn solve_certified_is_a_complete_certified_solver() {
274        use crate::pr::check_pr_refutation;
275        // UNSAT side: pigeonhole → a machine-checked refutation that re-checks against the original.
276        let (php, _) = families::php(4);
277        match solve_certified(php.num_vars, &php.clauses) {
278            CertifiedOutcome::Unsat { steps, discovered } => {
279                assert!(discovered >= 1, "SDCL discovered breaking clauses");
280                assert!(check_pr_refutation(php.num_vars, &php.clauses, &steps), "PHP(4) refutation re-checks");
281            }
282            CertifiedOutcome::Sat(_) => panic!("PHP(4) is UNSAT"),
283        }
284        // SAT side: (a ∨ b) ∧ (¬a ∨ b) forces b → satisfiable, and the model must satisfy it.
285        let sat = vec![vec![Lit::pos(0), Lit::pos(1)], vec![Lit::neg(0), Lit::pos(1)]];
286        match solve_certified(2, &sat) {
287            CertifiedOutcome::Sat(m) => {
288                assert!(sat.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())), "valid model");
289            }
290            CertifiedOutcome::Unsat { .. } => panic!("formula is satisfiable"),
291        }
292    }
293
294    #[test]
295    fn solve_certified_matches_brute_force_with_checkable_proofs() {
296        use crate::pr::check_pr_refutation;
297        // The whole unified engine, verdict-invariant: over many random formulas it must agree with
298        // brute force, every SAT answer carrying a valid model, every UNSAT answer a proof that
299        // re-checks against the original. Soundness end to end.
300        let mut state = 0x501_E_CE271F1Eu64;
301        let mut next = || {
302            state ^= state << 13;
303            state ^= state >> 7;
304            state ^= state << 17;
305            state
306        };
307        for _ in 0..600 {
308            let nv = 3 + (next() % 4) as usize; // 3..6
309            let nc = (next() % 12) as usize;
310            let clauses: Vec<Vec<Lit>> = (0..nc)
311                .map(|_| {
312                    (0..2 + (next() % 2) as usize)
313                        .map(|_| Lit::new((next() % nv as u64) as Var, next() & 1 == 0))
314                        .collect::<Vec<_>>()
315                })
316                .filter(|c: &Vec<Lit>| !c.is_empty())
317                .collect();
318            let expected = sat_brute(nv, &clauses);
319            match solve_certified(nv, &clauses) {
320                CertifiedOutcome::Sat(m) => {
321                    assert!(expected, "solver said SAT but brute force says UNSAT: {clauses:?}");
322                    assert!(clauses.iter().all(|c| c.iter().any(|l| m[l.var() as usize] == l.is_positive())), "model invalid");
323                }
324                CertifiedOutcome::Unsat { steps, .. } => {
325                    assert!(!expected, "solver said UNSAT but a model exists: {clauses:?}");
326                    assert!(check_pr_refutation(nv, &clauses, &steps), "UNSAT proof must re-check: {clauses:?}");
327                }
328            }
329        }
330    }
331
332    #[test]
333    fn sdcl_discovered_clauses_preserve_satisfiability() {
334        // Robustness: over many seeded random formulas and stuck assignments, whenever SDCL returns
335        // a clause, adding it preserves satisfiability exactly (brute force). The PR gate guarantees
336        // it; this proves it.
337        let mut state = 0x5DC1_9999u64;
338        let mut next = || {
339            state ^= state << 13;
340            state ^= state >> 7;
341            state ^= state << 17;
342            state
343        };
344        let mut found = 0;
345        for _ in 0..4000 {
346            let nv = 3 + (next() % 4) as usize; // 3..6
347            let nc = (next() % 12) as usize;
348            let clauses: Vec<Vec<Lit>> = (0..nc)
349                .map(|_| {
350                    (0..2 + (next() % 2) as usize)
351                        .map(|_| Lit::new((next() % nv as u64) as Var, next() & 1 == 0))
352                        .collect::<Vec<_>>()
353                })
354                .filter(|c: &Vec<Lit>| !c.is_empty())
355                .collect();
356            // A random partial assignment as the "stuck" state.
357            let mut alpha: Vec<Lit> = Vec::new();
358            for v in 0..nv as Var {
359                if next() & 1 == 0 {
360                    alpha.push(Lit::new(v, next() & 1 == 0));
361                }
362            }
363            if alpha.is_empty() {
364                continue;
365            }
366            if let Some((c, _)) = find_pr_clause(nv, &clauses, &alpha) {
367                found += 1;
368                let before = sat_brute(nv, &clauses);
369                let mut with = clauses.clone();
370                with.push(c.clone());
371                assert_eq!(before, sat_brute(nv, &with), "SDCL clause changed satisfiability: {clauses:?} C={c:?}");
372            }
373        }
374        assert!(found > 0, "the discovery path must actually be exercised");
375    }
376}