Skip to main content

logicaffeine_proof/
pr.rs

1//! The **PR** (propagation-redundancy) checker — the trust tier for *model-removing* clause
2//! additions, and the keystone that makes certified symmetry breaking possible.
3//!
4//! RUP ([`crate::rup`]) certifies only clauses *implied* by the formula. A symmetry-breaking
5//! predicate is different in kind: it deletes satisfying assignments (keeping at least one per
6//! symmetry orbit), so it is satisfiability-preserving but **not** implied — RUP must and does
7//! reject it. Propagation redundancy (Heule, Kiesl & Biere, CADE 2017) closes exactly this
8//! gap: a clause `C` is redundant w.r.t. `F` with witness `ω` iff `ω` satisfies `C` and
9//! `F|α ⊢₁ F|ω`, where `α = ¬C` is the assignment falsifying `C` and `⊢₁` is implication by
10//! unit propagation. Adding such a `C` preserves satisfiability, with an independently
11//! checkable certificate.
12//!
13//! Two witness forms are checked, each with its own well-specified criterion:
14//!
15//! - [`Witness::Assignment`] `ω` — the classic PR criterion `ω ⊨ C` and `F|α ⊢₁ F|ω`. This is
16//!   the general redundancy tier (vivification, BVE, …).
17//! - [`Witness::Substitution`] `σ` — the **substitution-redundancy** criterion for a symmetry.
18//!   Repair a model `τ` of `F ∧ ¬C` by composing with `σ`: the result `τ∘σ` satisfies `F`
19//!   automatically when `σ(F) = F`, and satisfies `C` exactly when `τ ⊨ σ(C)`. So `C` is
20//!   redundant iff **`σ` is an automorphism of the database AND `F ∧ ¬C ⊢₁ σ(C)`** (Heule &
21//!   Biere, substitution redundancy). The automorphism is checked against the *current*
22//!   database, so an earlier predicate that breaks a later generator's symmetry simply makes
23//!   that check fail — fail-closed, no generator-ordering hazard.
24//!
25//! The checker reuses the very same tiny unit-propagation core as [`crate::rup`]: its
26//! simplicity IS the trust, and it is wrapped by a brute-force equisatisfiability oracle over
27//! BOTH witness forms in the tests so it can never bless a clause that turns a satisfiable
28//! formula unsatisfiable.
29
30use crate::cdcl::Lit;
31use crate::proof::{ProofStep, Witness};
32use crate::rup;
33
34/// Verify a refutation made of [`ProofStep`]s over `original`. Each step's added clause must
35/// check (RUP or PR) against the database built so far; then the empty clause must be RUP.
36/// Returns `false` if any step fails to check — a bogus or unsound proof is rejected.
37pub fn check_pr_refutation(num_vars: usize, original: &[Vec<Lit>], steps: &[ProofStep]) -> bool {
38    let mut db: Vec<Vec<Lit>> = original.to_vec();
39    for step in steps {
40        match step {
41            ProofStep::Rup(c) => {
42                if !rup::is_rup(num_vars, &db, c) {
43                    return false;
44                }
45                db.push(c.clone());
46            }
47            ProofStep::Pr { clause, witness } => {
48                if !is_pr(num_vars, &db, clause, witness) {
49                    return false;
50                }
51                db.push(clause.clone());
52            }
53            ProofStep::Delete(clause) => {
54                // A deletion is unchecked (always sound); remove the first matching clause.
55                let key = canon_clause(clause);
56                if let Some(pos) = db.iter().position(|d| canon_clause(d) == key) {
57                    db.swap_remove(pos);
58                }
59            }
60        }
61    }
62    rup::is_rup(num_vars, &db, &[])
63}
64
65/// A clause's canonical form (sorted, deduped literal codes) for set-equality comparison.
66fn canon_clause(c: &[Lit]) -> Vec<u32> {
67    let mut k: Vec<u32> = c.iter().map(|l| l.var() * 2 + u32::from(!l.is_positive())).collect();
68    k.sort_unstable();
69    k.dedup();
70    k
71}
72
73/// Is `clause` propagation-redundant w.r.t. `db` under `witness`?
74pub fn is_pr(num_vars: usize, db: &[Vec<Lit>], clause: &[Lit], witness: &Witness) -> bool {
75    match witness {
76        Witness::Assignment(omega) => assignment_pr(num_vars, db, clause, omega),
77        Witness::Substitution(sigma) => substitution_sr(num_vars, db, clause, sigma),
78    }
79}
80
81/// [`is_pr`] with the automorphism re-check served by a pre-built incremental
82/// `AutomorphismIndex` (which must hold exactly the clauses of `db`). Identical verdict to
83/// [`is_pr`] — the index is an acceleration structure — but the substitution path no longer
84/// rebuilds the membership set per call, the difference between an `O(n⁴)` and an `O(n³)`
85/// certified refutation.
86pub fn is_pr_indexed(
87    num_vars: usize,
88    db: &[Vec<Lit>],
89    index: &mut crate::symmetry_detect::AutomorphismIndex,
90    clause: &[Lit],
91    witness: &Witness,
92) -> bool {
93    match witness {
94        Witness::Assignment(omega) => assignment_pr(num_vars, db, clause, omega),
95        Witness::Substitution(sigma) => {
96            if !index.is_automorphism(sigma) {
97                return false;
98            }
99            let sigma_c = sigma.apply_clause(clause);
100            let mut assume: Vec<Lit> = clause.iter().map(|l| l.negated()).collect();
101            assume.extend(sigma_c.iter().map(|l| l.negated()));
102            // Occurrence-driven propagation over the index, not a full-database scan.
103            let _ = db;
104            index.propagate_to_conflict(num_vars, &assume)
105        }
106    }
107}
108
109/// [`check_pr_refutation`] accelerated by an incrementally-maintained `AutomorphismIndex` — the
110/// same verdict, but each substitution step's automorphism re-check costs `O(support)` rather than
111/// `O(|db|)`. Falls back to the stateless checker if the proof deletes clauses (the index is
112/// append-only, and certified symmetry refutations do not delete).
113pub fn check_pr_refutation_fast(num_vars: usize, original: &[Vec<Lit>], steps: &[ProofStep]) -> bool {
114    if steps.iter().any(|s| matches!(s, ProofStep::Delete(_))) {
115        return check_pr_refutation(num_vars, original, steps);
116    }
117    let mut db: Vec<Vec<Lit>> = original.to_vec();
118    let mut index = crate::symmetry_detect::AutomorphismIndex::with_clauses(num_vars, original);
119    for step in steps {
120        match step {
121            ProofStep::Rup(c) => {
122                if !rup::is_rup(num_vars, &db, c) {
123                    return false;
124                }
125                db.push(c.clone());
126                index.insert(c.clone());
127            }
128            ProofStep::Pr { clause, witness } => {
129                if !is_pr_indexed(num_vars, &db, &mut index, clause, witness) {
130                    return false;
131                }
132                db.push(clause.clone());
133                index.insert(clause.clone());
134            }
135            ProofStep::Delete(_) => unreachable!("deletes handled by the fallback above"),
136        }
137    }
138    rup::is_rup(num_vars, &db, &[])
139}
140
141/// The substitution-redundancy criterion for a symmetry witness `σ`: `σ` must be an
142/// automorphism of `db` (so the σ-repair of any model stays a model), and `db ∧ ¬C ⊢₁ σ(C)`
143/// (so that repair also satisfies `C`). Both conditions are independently checkable, and the
144/// automorphism is re-verified against the *current* `db` — fail-closed against generator
145/// ordering.
146fn substitution_sr(num_vars: usize, db: &[Vec<Lit>], clause: &[Lit], sigma: &crate::proof::Perm) -> bool {
147    if !crate::symmetry_detect::perm_is_automorphism(db, sigma) {
148        return false;
149    }
150    // `db ∧ ¬C ⊢₁ σ(C)`: assume α = ¬C together with ¬σ(C) and propagate db; require a conflict.
151    let sigma_c = sigma.apply_clause(clause);
152    let mut assume: Vec<Lit> = clause.iter().map(|l| l.negated()).collect();
153    assume.extend(sigma_c.iter().map(|l| l.negated()));
154    up_conflict(num_vars, db, &assume)
155}
156
157/// The propagation-redundancy criterion with an explicit assignment witness `ω` (given as the
158/// set of literals it sets true): `ω` satisfies `C`, and for every clause `D ∈ db`,
159/// `F|α ⊢₁ D|ω` where `α = ¬C`.
160fn assignment_pr(num_vars: usize, db: &[Vec<Lit>], clause: &[Lit], omega_true: &[Lit]) -> bool {
161    // Materialise ω as an assignment; a self-contradictory witness is no witness.
162    let mut omega: Vec<Option<bool>> = vec![None; num_vars];
163    for &l in omega_true {
164        if !rup::set_true(&mut omega, l) {
165            return false;
166        }
167    }
168    // Precondition: ω must satisfy the clause being added.
169    if !clause.iter().any(|&l| rup::lit_val(&omega, l) == Some(true)) {
170        return false;
171    }
172    // α = ¬C, as the set of literals it sets true.
173    let alpha: Vec<Lit> = clause.iter().map(|&l| l.negated()).collect();
174
175    for d in db {
176        // Clauses ω satisfies carry no obligation (D|ω is a tautology).
177        if d.iter().any(|&m| rup::lit_val(&omega, m) == Some(true)) {
178            continue;
179        }
180        // D|ω keeps the ω-unassigned literals (ω-false ones are dropped). The obligation
181        // `F|α ⊢₁ D|ω` is checked by assuming α together with ¬(D|ω) and propagating db: it
182        // must conflict.
183        let mut assume = alpha.clone();
184        for &m in d {
185            if rup::lit_val(&omega, m).is_none() {
186                assume.push(m.negated());
187            }
188        }
189        if !up_conflict(num_vars, db, &assume) {
190            return false;
191        }
192    }
193    true
194}
195
196/// Assume every literal of `assume` and unit-propagate `db`; `true` iff a conflict results
197/// (an immediate clash among the assumptions counts).
198fn up_conflict(num_vars: usize, db: &[Vec<Lit>], assume: &[Lit]) -> bool {
199    let mut assign: Vec<Option<bool>> = vec![None; num_vars];
200    for &l in assume {
201        if !rup::set_true(&mut assign, l) {
202            return true;
203        }
204    }
205    rup::propagate(db, &mut assign)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::cdcl::Lit;
212    use crate::proof::Perm;
213
214    fn p(v: u32) -> Lit {
215        Lit::pos(v)
216    }
217    fn n(v: u32) -> Lit {
218        Lit::neg(v)
219    }
220
221    /// Brute-force satisfiability over `num_vars` variables — the independent oracle.
222    fn sat_brute(num_vars: usize, clauses: &[Vec<Lit>]) -> bool {
223        for mask in 0u32..(1u32 << num_vars) {
224            let model: Vec<bool> = (0..num_vars).map(|v| (mask >> v) & 1 == 1).collect();
225            if clauses.iter().all(|c| c.iter().any(|l| model[l.var() as usize] == l.is_positive())) {
226                return true;
227            }
228        }
229        false
230    }
231
232    #[test]
233    fn pr_accepts_a_model_removing_clause_that_rup_rejects() {
234        // F = (a ∨ b) is satisfiable and symmetric under a↔b. The lex-leader C = (¬a ∨ b)
235        // deletes the model a=1,b=0 — model-removing, so NOT RUP — but is satisfiability-
236        // preserving, certified by the assignment witness ω = {a=0, b=1}.
237        let (a, b) = (0u32, 1u32);
238        let f = vec![vec![p(a), p(b)]];
239        let c = vec![n(a), p(b)];
240
241        assert!(!rup::is_rup(2, &f, &c), "a model-removing clause is not RUP");
242        let omega = Witness::Assignment(vec![n(a), p(b)]);
243        assert!(is_pr(2, &f, &c, &omega), "but it IS propagation-redundant");
244        // And the addition really is satisfiability-preserving.
245        let mut fc = f.clone();
246        fc.push(c.clone());
247        assert_eq!(sat_brute(2, &f), sat_brute(2, &fc));
248    }
249
250    #[test]
251    fn pr_accepts_via_a_substitution_witness() {
252        // The same break, certified by the symmetry σ = (a↔b) instead of a hand-written ω.
253        let (a, b) = (0u32, 1u32);
254        let f = vec![vec![p(a), p(b)]];
255        let c = vec![n(a), p(b)];
256        let sigma = Perm::from_images(vec![p(b), p(a)]); // +a↦+b, +b↦+a
257        assert!(is_pr(2, &f, &c, &Witness::Substitution(sigma)));
258    }
259
260    #[test]
261    fn pr_rejects_a_witness_that_does_not_satisfy_the_clause() {
262        // ω = α itself (a=1,b=0) falsifies C, so it is not a witness — reject, fail-closed.
263        let (a, b) = (0u32, 1u32);
264        let f = vec![vec![p(a), p(b)]];
265        let c = vec![n(a), p(b)];
266        assert!(!is_pr(2, &f, &c, &Witness::Assignment(vec![p(a), n(b)])));
267    }
268
269    #[test]
270    fn pr_rejects_a_bogus_substitution_on_an_asymmetric_formula() {
271        // F = (a ∨ b) ∧ (¬b) forces the unique model a=1,b=0; it is NOT symmetric under a↔b
272        // (σ maps ¬b to ¬a, which is not a clause of F). The lex-leader C = (¬a ∨ b) deletes
273        // that only model, so blindly trusting σ would flip SAT→UNSAT — PR must reject it.
274        let (a, b) = (0u32, 1u32);
275        let f = vec![vec![p(a), p(b)], vec![n(b)]];
276        let c = vec![n(a), p(b)];
277        let sigma = Perm::from_images(vec![p(b), p(a)]);
278        assert!(!is_pr(2, &f, &c, &Witness::Substitution(sigma)));
279        // Confirm the trap: adding C really would have been unsound.
280        let mut fc = f.clone();
281        fc.push(c.clone());
282        assert!(sat_brute(2, &f) && !sat_brute(2, &fc), "C would flip SAT→UNSAT");
283    }
284
285    #[test]
286    fn check_pr_refutation_agrees_with_rup_on_pure_rup_proofs() {
287        // With only RUP steps, the PR driver must behave exactly like the RUP checker.
288        let (a, b) = (0u32, 1u32);
289        let f = vec![
290            vec![p(a), p(b)],
291            vec![p(a), n(b)],
292            vec![n(a), p(b)],
293            vec![n(a), n(b)],
294        ];
295        let steps = vec![ProofStep::Rup(vec![p(a)]), ProofStep::Rup(vec![n(a)])];
296        assert!(check_pr_refutation(2, &f, &steps));
297        // Without the resolvents it cannot close.
298        assert!(!check_pr_refutation(2, &f, &[]));
299    }
300
301    #[test]
302    fn fast_checker_agrees_with_the_trusted_checker_on_php() {
303        // The accelerated checker must reach the SAME verdict as the stateless trusted checker on
304        // the real certified PHP refutations — and on a deliberately corrupted one (both reject).
305        for n in 3..=6 {
306            let cr = crate::sym_certify::heule_php_refutation(n);
307            let (cnf, _) = crate::families::php(n);
308            assert_eq!(
309                check_pr_refutation_fast(cnf.num_vars, &cnf.clauses, &cr.steps),
310                check_pr_refutation(cnf.num_vars, &cnf.clauses, &cr.steps),
311                "fast vs trusted disagree on PHP({n})"
312            );
313            assert!(check_pr_refutation_fast(cnf.num_vars, &cnf.clauses, &cr.steps), "PHP({n}) refutes");
314
315            // Corrupt the first PR witness → both checkers must reject.
316            let mut bad = cr.steps.clone();
317            if let Some(ProofStep::Pr { witness, .. }) = bad.iter_mut().find(|s| matches!(s, ProofStep::Pr { .. })) {
318                *witness = Witness::Substitution(Perm::identity(cnf.num_vars));
319            }
320            assert_eq!(
321                check_pr_refutation_fast(cnf.num_vars, &cnf.clauses, &bad),
322                check_pr_refutation(cnf.num_vars, &cnf.clauses, &bad),
323                "fast vs trusted disagree on corrupted PHP({n})"
324            );
325        }
326    }
327
328    #[test]
329    fn pr_never_blesses_an_unsound_addition_random() {
330        // Robustness to the point of absurdity: over many seeded random tiny formulas and
331        // random candidate (clause, witness) pairs, WHENEVER is_pr accepts, brute force must
332        // confirm the addition preserved satisfiability. A single false-accept is a hard fail.
333        let mut state = 0x9E3779B97F4A7C15u64;
334        let mut next = || {
335            // SplitMix64 — deterministic, no wall-clock seeding.
336            state = state.wrapping_add(0x9E3779B97F4A7C15);
337            let mut z = state;
338            z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
339            z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
340            z ^ (z >> 31)
341        };
342        let num_vars = 4usize;
343        let rand_clause = |next: &mut dyn FnMut() -> u64| -> Vec<Lit> {
344            let len = 1 + (next() as usize % 3);
345            let mut c = Vec::new();
346            for _ in 0..len {
347                let v = (next() as u32) % num_vars as u32;
348                let lit = Lit::new(v, next() & 1 == 0);
349                if !c.contains(&lit) && !c.contains(&lit.negated()) {
350                    c.push(lit);
351                }
352            }
353            c
354        };
355
356        let mut accepted = 0;
357        for _ in 0..20_000 {
358            // Random small formula.
359            let nclauses = next() as usize % 5;
360            let f: Vec<Vec<Lit>> = (0..nclauses).map(|_| rand_clause(&mut next)).filter(|c| !c.is_empty()).collect();
361            let c = rand_clause(&mut next);
362            if c.is_empty() {
363                continue;
364            }
365            // Random assignment witness over a random subset of variables.
366            let mut omega = Vec::new();
367            for v in 0..num_vars as u32 {
368                if next() & 1 == 0 {
369                    omega.push(Lit::new(v, next() & 1 == 0));
370                }
371            }
372            if is_pr(num_vars, &f, &c, &Witness::Assignment(omega)) {
373                accepted += 1;
374                let before = sat_brute(num_vars, &f);
375                let mut fc = f.clone();
376                fc.push(c.clone());
377                let after = sat_brute(num_vars, &fc);
378                assert_eq!(before, after, "PR accepted a clause that changed satisfiability: F={f:?} C={c:?}");
379            }
380        }
381        assert!(accepted > 0, "the test must actually exercise acceptances");
382    }
383
384    #[test]
385    fn sr_never_blesses_an_unsound_addition_random() {
386        // The substitution-redundancy path's soundness net: over many seeded random formulas,
387        // random literal permutations, and random clauses, WHENEVER is_pr accepts under a
388        // substitution witness, brute force must confirm the addition kept satisfiability.
389        let mut state = 0xD1B54A32D192ED03u64;
390        let mut next = || {
391            state = state.wrapping_add(0x9E3779B97F4A7C15);
392            let mut z = state;
393            z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
394            z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
395            z ^ (z >> 31)
396        };
397        let num_vars = 4usize;
398        let rand_clause = |next: &mut dyn FnMut() -> u64| -> Vec<Lit> {
399            let len = 1 + (next() as usize % 3);
400            let mut c = Vec::new();
401            for _ in 0..len {
402                let v = (next() as u32) % num_vars as u32;
403                let lit = Lit::new(v, next() & 1 == 0);
404                if !c.contains(&lit) && !c.contains(&lit.negated()) {
405                    c.push(lit);
406                }
407            }
408            c
409        };
410        // A random literal permutation: a Fisher-Yates shuffle of variables, each image given a
411        // random phase (identity a quarter of the time, to reliably exercise acceptances).
412        let rand_perm = |next: &mut dyn FnMut() -> u64| -> Perm {
413            if next() % 4 == 0 {
414                return Perm::identity(num_vars);
415            }
416            let mut order: Vec<u32> = (0..num_vars as u32).collect();
417            for i in (1..num_vars).rev() {
418                let j = next() as usize % (i + 1);
419                order.swap(i, j);
420            }
421            Perm::from_images((0..num_vars).map(|v| Lit::new(order[v], next() & 1 == 0)).collect())
422        };
423
424        let mut accepted = 0;
425        for _ in 0..20_000 {
426            let nclauses = next() as usize % 5;
427            let f: Vec<Vec<Lit>> =
428                (0..nclauses).map(|_| rand_clause(&mut next)).filter(|c| !c.is_empty()).collect();
429            let c = rand_clause(&mut next);
430            if c.is_empty() {
431                continue;
432            }
433            let sigma = rand_perm(&mut next);
434            if is_pr(num_vars, &f, &c, &Witness::Substitution(sigma)) {
435                accepted += 1;
436                let before = sat_brute(num_vars, &f);
437                let mut fc = f.clone();
438                fc.push(c.clone());
439                let after = sat_brute(num_vars, &fc);
440                assert_eq!(before, after, "SR accepted a clause that changed satisfiability: F={f:?} C={c:?}");
441            }
442        }
443        assert!(accepted > 0, "the substitution path must actually be exercised");
444    }
445}