Skip to main content

logicaffeine_proof/
inprocess.rs

1//! Certified inprocessing: clause-database simplifications that emit a machine-checkable proof.
2//!
3//! Where a normal SAT solver simplifies for speed, here every simplification carries its own
4//! certificate — bounded variable elimination adds RUP resolvents and deletes the eliminated
5//! variable's clauses; vivification replaces a clause with a RUP-shorter one. Composed with the
6//! refutation, the whole run stays independently checkable by [`crate::pr::check_pr_refutation`].
7//! This is the certified analogue of the inprocessing that makes Kissat/CaDiCaL fast — a real
8//! differentiator: nobody ships *certified* inprocessing.
9
10use crate::cdcl::{Lit, Var};
11use crate::proof::ProofStep;
12
13/// Resolve `a` (which contains `+x`) with `b` (which contains `¬x`) on `x`: the union of the two
14/// clauses minus both `x` literals. `None` if the resolvent is a tautology (contains some literal
15/// and its negation), which contributes nothing.
16fn resolve(a: &[Lit], b: &[Lit], x: Var) -> Option<Vec<Lit>> {
17    let mut r: Vec<Lit> = a.iter().copied().filter(|l| l.var() != x).collect();
18    for &l in b {
19        if l.var() != x && !r.contains(&l) {
20            r.push(l);
21        }
22    }
23    if r.iter().any(|&l| r.contains(&l.negated())) {
24        return None; // tautology
25    }
26    Some(r)
27}
28
29/// Try to eliminate variable `x` from `clauses` by Davis-Putnam resolution. Returns the new clause
30/// set and the proof steps (RUP resolvents, then the deletions of `x`'s clauses) — but only when
31/// `x` actually occurs and elimination does not grow the clause count (the standard BVE bound).
32/// `None` otherwise. Verdict-invariant: a model of the result extends to a model of the input.
33pub fn eliminate_variable(clauses: &[Vec<Lit>], x: Var) -> Option<(Vec<Vec<Lit>>, Vec<ProofStep>)> {
34    let (pos, neg) = (Lit::pos(x), Lit::neg(x));
35    let pos_clauses: Vec<&Vec<Lit>> = clauses.iter().filter(|c| c.contains(&pos)).collect();
36    let neg_clauses: Vec<&Vec<Lit>> = clauses.iter().filter(|c| c.contains(&neg)).collect();
37    if pos_clauses.is_empty() && neg_clauses.is_empty() {
38        return None; // `x` does not occur
39    }
40    // All non-tautological resolvents.
41    let mut resolvents: Vec<Vec<Lit>> = Vec::new();
42    for a in &pos_clauses {
43        for b in &neg_clauses {
44            if let Some(r) = resolve(a, b, x) {
45                resolvents.push(r);
46            }
47        }
48    }
49    // Bound: never grow the clause database.
50    if resolvents.len() > pos_clauses.len() + neg_clauses.len() {
51        return None;
52    }
53    // Proof: each resolvent is RUP (its two parents force `x` and `¬x` under `¬resolvent`); then
54    // delete every clause mentioning `x`. Resolvents are added BEFORE the deletions so their RUP
55    // check still sees the parents.
56    let mut steps: Vec<ProofStep> = resolvents.iter().map(|r| ProofStep::Rup(r.clone())).collect();
57    for c in pos_clauses.iter().chain(neg_clauses.iter()) {
58        steps.push(ProofStep::Delete((*c).clone()));
59    }
60    let mut new_clauses: Vec<Vec<Lit>> =
61        clauses.iter().filter(|c| !c.contains(&pos) && !c.contains(&neg)).cloned().collect();
62    new_clauses.extend(resolvents);
63    Some((new_clauses, steps))
64}
65
66/// Bounded variable elimination over the whole formula: repeatedly eliminate every variable whose
67/// elimination is non-growing, until a fixpoint (capped at a few passes). Returns the simplified
68/// clause set and the composed proof steps.
69pub fn bve(num_vars: usize, clauses: &[Vec<Lit>]) -> (Vec<Vec<Lit>>, Vec<ProofStep>) {
70    let mut cur = clauses.to_vec();
71    let mut steps: Vec<ProofStep> = Vec::new();
72    for _pass in 0..4 {
73        let mut changed = false;
74        for x in 0..num_vars as Var {
75            if let Some((next, s)) = eliminate_variable(&cur, x) {
76                cur = next;
77                steps.extend(s);
78                changed = true;
79            }
80        }
81        if !changed {
82            break;
83        }
84    }
85    (cur, steps)
86}
87
88/// Equivalent-literal detection via the binary-implication graph's strongly-connected components. Each
89/// 2-clause `(a ∨ b)` contributes the implications `¬a → b` and `¬b → a`; literals in one SCC are logically
90/// equivalent. If some `x` and `¬x` share an SCC, the binary clauses alone force `x ≡ ¬x` — UNSAT — and we
91/// return the RUP certificate (`(x)`, `(¬x)`, then `⊥`, each RUP-derivable by propagation along the chain).
92/// Otherwise returns the SCC representative for each literal, so equal literals can be substituted (a sound
93/// variable-reduction speedup). Iterative Tarjan; nodes are the `2·num_vars` literals (`2v` = `+v`, `2v+1` = `¬v`).
94fn lit_node(l: Lit) -> usize {
95    (l.var() as usize) * 2 + if l.is_positive() { 0 } else { 1 }
96}
97pub enum EquivResult {
98    /// `x ≡ ¬x` forced by the 2-clauses — UNSAT, with a RUP certificate.
99    Unsat(Vec<ProofStep>),
100    /// Per-literal SCC id; two literals with the same id are equivalent (substitutable).
101    Classes(Vec<usize>),
102}
103pub fn equivalent_literal_scc(num_vars: usize, clauses: &[Vec<Lit>]) -> EquivResult {
104    let n = num_vars * 2;
105    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
106    for c in clauses {
107        if c.len() == 2 {
108            let (a, b) = (c[0], c[1]);
109            adj[lit_node(a.negated())].push(lit_node(b)); // ¬a → b
110            adj[lit_node(b.negated())].push(lit_node(a)); // ¬b → a
111        }
112    }
113    // Iterative Tarjan SCC.
114    let mut index = vec![usize::MAX; n];
115    let mut low = vec![0usize; n];
116    let mut on_stack = vec![false; n];
117    let mut stack: Vec<usize> = Vec::new();
118    let mut comp = vec![usize::MAX; n];
119    let mut next_index = 0usize;
120    let mut next_comp = 0usize;
121    for start in 0..n {
122        if index[start] != usize::MAX {
123            continue;
124        }
125        let mut call: Vec<(usize, usize)> = vec![(start, 0)];
126        while let Some(&(v, pi)) = call.last() {
127            if pi == 0 {
128                index[v] = next_index;
129                low[v] = next_index;
130                next_index += 1;
131                stack.push(v);
132                on_stack[v] = true;
133            }
134            if pi < adj[v].len() {
135                let w = adj[v][pi];
136                call.last_mut().unwrap().1 += 1;
137                if index[w] == usize::MAX {
138                    call.push((w, 0));
139                } else if on_stack[w] {
140                    low[v] = low[v].min(index[w]);
141                }
142            } else {
143                if low[v] == index[v] {
144                    loop {
145                        let w = stack.pop().unwrap();
146                        on_stack[w] = false;
147                        comp[w] = next_comp;
148                        if w == v {
149                            break;
150                        }
151                    }
152                    next_comp += 1;
153                }
154                call.pop();
155                if let Some(&(p, _)) = call.last() {
156                    low[p] = low[p].min(low[v]);
157                }
158            }
159        }
160    }
161    for v in 0..num_vars {
162        if comp[v * 2] == comp[v * 2 + 1] {
163            let (x, nx) = (Lit::pos(v as Var), Lit::neg(v as Var));
164            return EquivResult::Unsat(vec![ProofStep::Rup(vec![nx]), ProofStep::Rup(vec![x]), ProofStep::Rup(vec![])]);
165        }
166    }
167    EquivResult::Classes(comp)
168}
169
170/// Full Davis–Putnam bucket elimination in min-degree order, allowing intermediate growth but capping every
171/// resolvent at `width_cap`. If it reaches `⊥`, returns the RUP-resolvents-then-deletions proof — a
172/// width-`≤width_cap` resolution refutation, i.e. a `2^width_cap·n` certificate that crushes every
173/// bounded-treewidth family. Returns `None` the moment a resolvent would exceed the cap (high treewidth — the
174/// certificate would be exponential, which is exactly the Ben-Sasson–Wigderson hardness). Sound: every step is
175/// RUP/Delete and independently re-checkable by [`crate::pr::check_pr_refutation`].
176pub fn bucket_elimination_refute(num_vars: usize, clauses: &[Vec<Lit>], width_cap: usize) -> Option<Vec<ProofStep>> {
177    let mut cur: Vec<Vec<Lit>> = clauses.to_vec();
178    let mut steps: Vec<ProofStep> = Vec::new();
179    for _ in 0..=num_vars {
180        if cur.iter().any(|c| c.is_empty()) {
181            return Some(steps);
182        }
183        let mut vars: Vec<Var> = cur.iter().flatten().map(|l| l.var()).collect();
184        vars.sort_unstable();
185        vars.dedup();
186        if vars.is_empty() {
187            break;
188        }
189        let v = *vars.iter().min_by_key(|&&v| cur.iter().filter(|c| c.iter().any(|l| l.var() == v)).count()).unwrap();
190        let (pos, neg) = (Lit::pos(v), Lit::neg(v));
191        let pos_c: Vec<Vec<Lit>> = cur.iter().filter(|c| c.contains(&pos)).cloned().collect();
192        let neg_c: Vec<Vec<Lit>> = cur.iter().filter(|c| c.contains(&neg)).cloned().collect();
193        // Even under the min-degree order the resolvent product `|pos| · |neg|` can explode once a dense
194        // (high-treewidth) core is reached — each resolvent stays narrow (so the width cap never trips)
195        // yet there are quadratically many. Bail before doing that work: a bounded-treewidth family keeps
196        // this product small, so declining here costs nothing there while a dense family (e.g. Ramsey)
197        // exits in microseconds instead of grinding for seconds. The certificate would be exponential
198        // anyway (the same Ben-Sasson–Wigderson hardness the width cap guards).
199        if pos_c.len().saturating_mul(neg_c.len()) > 16_384 {
200            return None;
201        }
202        let mut resolvents: Vec<Vec<Lit>> = Vec::new();
203        for a in &pos_c {
204            for b in &neg_c {
205                if let Some(r) = resolve(a, b, v) {
206                    if r.len() > width_cap {
207                        return None; // exceeds the width cap — high treewidth, decline
208                    }
209                    resolvents.push(r);
210                }
211            }
212        }
213        for r in &resolvents {
214            steps.push(ProofStep::Rup(r.clone()));
215        }
216        for c in pos_c.iter().chain(neg_c.iter()) {
217            steps.push(ProofStep::Delete(c.clone()));
218        }
219        cur.retain(|c| !c.contains(&pos) && !c.contains(&neg));
220        cur.extend(resolvents);
221        if cur.len() > 50_000 {
222            return None; // database blow-up guard
223        }
224    }
225    if cur.iter().any(|c| c.is_empty()) {
226        Some(steps)
227    } else {
228        None
229    }
230}
231
232/// Vivify a clause: drop every literal whose removal leaves a clause still RUP-implied by `db`.
233/// Returns the strengthened (shorter) clause, or `None` if nothing could be removed. The result
234/// is a sub-clause logically equivalent in context, so it is verdict-invariant.
235pub fn vivify_clause(num_vars: usize, db: &[Vec<Lit>], c: &[Lit]) -> Option<Vec<Lit>> {
236    let mut cur = c.to_vec();
237    let mut improved = false;
238    let mut i = 0;
239    while cur.len() > 1 && i < cur.len() {
240        let mut without = cur.clone();
241        without.remove(i);
242        if crate::rup::is_rup(num_vars, db, &without) {
243            cur = without; // this literal is redundant
244            improved = true;
245        } else {
246            i += 1;
247        }
248    }
249    improved.then_some(cur)
250}
251
252/// Vivify the whole database: replace each clause with its RUP-strengthened form, emitting an
253/// `Rup(shorter)` then `Delete(original)` per improvement. Shorter clauses propagate and delete
254/// better. The composed steps keep the run independently checkable.
255pub fn vivify(num_vars: usize, clauses: &[Vec<Lit>]) -> (Vec<Vec<Lit>>, Vec<ProofStep>) {
256    let mut cur = clauses.to_vec();
257    let mut steps: Vec<ProofStep> = Vec::new();
258    for idx in 0..cur.len() {
259        let c = cur[idx].clone();
260        if c.len() <= 1 {
261            continue;
262        }
263        if let Some(c2) = vivify_clause(num_vars, &cur, &c) {
264            steps.push(ProofStep::Rup(c2.clone()));
265            steps.push(ProofStep::Delete(c.clone()));
266            cur[idx] = c2;
267        }
268    }
269    (cur, steps)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::cdcl::{SolveResult, Solver};
276    use crate::families;
277    use crate::pr::check_pr_refutation;
278
279    fn sat_brute(num_vars: usize, clauses: &[Vec<Lit>]) -> bool {
280        (0u32..(1u32 << num_vars)).any(|mask| {
281            let model: Vec<bool> = (0..num_vars).map(|v| (mask >> v) & 1 == 1).collect();
282            clauses.iter().all(|c| c.iter().any(|l| model[l.var() as usize] == l.is_positive()))
283        })
284    }
285
286    #[test]
287    fn bve_preserves_satisfiability_brute_force() {
288        // Over many seeded random small CNFs, BVE must preserve satisfiability exactly.
289        let mut state = 0xB5E_1234_5678u64;
290        let mut next = || {
291            state ^= state << 13;
292            state ^= state >> 7;
293            state ^= state << 17;
294            state
295        };
296        for _ in 0..2000 {
297            let nv = 3 + (next() % 5) as usize; // 3..7
298            let nc = (next() % 14) as usize;
299            let clauses: Vec<Vec<Lit>> = (0..nc)
300                .map(|_| {
301                    let len = 1 + (next() % 3) as usize;
302                    let mut c = Vec::new();
303                    for _ in 0..len {
304                        let l = Lit::new((next() % nv as u64) as Var, next() & 1 == 0);
305                        if !c.contains(&l) && !c.contains(&l.negated()) {
306                            c.push(l);
307                        }
308                    }
309                    c
310                })
311                .filter(|c: &Vec<Lit>| !c.is_empty())
312                .collect();
313            let (simplified, _) = bve(nv, &clauses);
314            assert_eq!(
315                sat_brute(nv, &clauses),
316                sat_brute(nv, &simplified),
317                "BVE changed satisfiability for {clauses:?}"
318            );
319        }
320    }
321
322    #[test]
323    fn bve_produces_a_certified_refutation_of_php() {
324        // BVE-simplify PHP(n), solve the result, and verify the COMPOSED proof (BVE resolvents +
325        // deletions + the solver's RUP learned clauses) refutes the ORIGINAL formula.
326        for n in 2..=4 {
327            let (cnf, _) = families::php(n);
328            let (simplified, mut steps) = bve(cnf.num_vars, &cnf.clauses);
329
330            let mut solver = Solver::new(cnf.num_vars);
331            for c in &simplified {
332                solver.add_clause(c.clone());
333            }
334            assert_eq!(solver.solve(), SolveResult::Unsat, "simplified PHP({n}) is UNSAT");
335            for lc in solver.learned() {
336                steps.push(ProofStep::Rup(lc.lits.clone()));
337            }
338            assert!(
339                check_pr_refutation(cnf.num_vars, &cnf.clauses, &steps),
340                "BVE-composed proof must refute the original PHP({n})"
341            );
342        }
343    }
344
345    #[test]
346    fn eliminate_variable_yields_the_resolvent() {
347        // (x ∨ a) ∧ (¬x ∨ b): eliminating x yields the single resolvent (a ∨ b), emitting one RUP
348        // step and two deletions. (Full `bve` would then also clear a, b as pure literals — this
349        // pins the single-variable step, where the resolvent is observable.)
350        let (x, a, b) = (0u32, 1u32, 2u32);
351        let clauses = vec![vec![Lit::pos(x), Lit::pos(a)], vec![Lit::neg(x), Lit::pos(b)]];
352        let (simplified, steps) = eliminate_variable(&clauses, x).expect("x is eliminable");
353        assert!(simplified.iter().all(|c| !c.iter().any(|l| l.var() == x)), "x is gone");
354        assert!(simplified.contains(&vec![Lit::pos(a), Lit::pos(b)]), "resolvent (a ∨ b) present");
355        assert!(steps.iter().any(|s| matches!(s, ProofStep::Rup(_))), "emits the RUP resolvent");
356        assert_eq!(steps.iter().filter(|s| matches!(s, ProofStep::Delete(_))).count(), 2, "deletes both x-clauses");
357    }
358
359    #[test]
360    fn vivify_strengthens_via_propagation() {
361        // {(a ∨ b), (¬a ∨ b)} together imply b, so (a ∨ b) vivifies down to the unit (b).
362        let (a, b) = (0u32, 1u32);
363        let db = vec![vec![Lit::pos(a), Lit::pos(b)], vec![Lit::neg(a), Lit::pos(b)]];
364        let c2 = vivify_clause(2, &db, &db[0]).expect("the clause is strengthenable");
365        assert_eq!(c2, vec![Lit::pos(b)], "vivified to the unit (b)");
366    }
367
368    #[test]
369    fn vivify_preserves_satisfiability_brute_force() {
370        let mut state = 0x171F_1ED_9999u64;
371        let mut next = || {
372            state ^= state << 13;
373            state ^= state >> 7;
374            state ^= state << 17;
375            state
376        };
377        for _ in 0..2000 {
378            let nv = 3 + (next() % 5) as usize;
379            let nc = (next() % 14) as usize;
380            let clauses: Vec<Vec<Lit>> = (0..nc)
381                .map(|_| {
382                    let len = 1 + (next() % 3) as usize;
383                    let mut c = Vec::new();
384                    for _ in 0..len {
385                        let l = Lit::new((next() % nv as u64) as Var, next() & 1 == 0);
386                        if !c.contains(&l) && !c.contains(&l.negated()) {
387                            c.push(l);
388                        }
389                    }
390                    c
391                })
392                .filter(|c: &Vec<Lit>| !c.is_empty())
393                .collect();
394            let (simplified, _) = vivify(nv, &clauses);
395            assert_eq!(sat_brute(nv, &clauses), sat_brute(nv, &simplified), "vivify changed SAT for {clauses:?}");
396        }
397    }
398
399    #[test]
400    fn vivify_produces_a_certified_refutation_of_php() {
401        for n in 2..=4 {
402            let (cnf, _) = families::php(n);
403            let (simplified, mut steps) = vivify(cnf.num_vars, &cnf.clauses);
404            let mut solver = Solver::new(cnf.num_vars);
405            for c in &simplified {
406                solver.add_clause(c.clone());
407            }
408            assert_eq!(solver.solve(), SolveResult::Unsat);
409            for lc in solver.learned() {
410                steps.push(ProofStep::Rup(lc.lits.clone()));
411            }
412            assert!(
413                check_pr_refutation(cnf.num_vars, &cnf.clauses, &steps),
414                "vivify-composed proof must refute the original PHP({n})"
415            );
416        }
417    }
418
419    #[test]
420    fn symmetry_then_bve_then_vivify_compose_into_one_certified_refutation() {
421        // THE ultimate stack, all in one machine-checked proof: certified symmetry breaking, then
422        // certified BVE, then certified vivification, then the solver — refuting PHP against the
423        // original formula.
424        for n in 2..=4 {
425            let (cnf, _) = families::php(n);
426            let gens = crate::symmetry_detect::find_generators(cnf.num_vars, &cnf.clauses);
427            // 1) certified symmetry breaking (PR steps over the original).
428            let (sb_db, nv, mut steps) =
429                crate::sym_certify::symmetry_break_certified(cnf.num_vars, &cnf.clauses, &gens);
430            // 2) certified BVE on top.
431            let (bve_db, bve_steps) = bve(nv, &sb_db);
432            steps.extend(bve_steps);
433            // 3) certified vivification.
434            let (viv_db, viv_steps) = vivify(nv, &bve_db);
435            steps.extend(viv_steps);
436            // 4) solve and append RUP.
437            let mut solver = Solver::new(nv);
438            for c in &viv_db {
439                solver.add_clause(c.clone());
440            }
441            assert_eq!(solver.solve(), SolveResult::Unsat, "fully-simplified PHP({n}) is UNSAT");
442            for lc in solver.learned() {
443                steps.push(ProofStep::Rup(lc.lits.clone()));
444            }
445            assert!(
446                check_pr_refutation(nv, &cnf.clauses, &steps),
447                "symmetry + BVE + vivify + solve must compose into ONE certified refutation of PHP({n})"
448            );
449        }
450    }
451}