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
68 .iter()
69 .map(|l| l.var() * 2 + u32::from(!l.is_positive()))
70 .collect();
71 k.sort_unstable();
72 k.dedup();
73 k
74}
75
76/// Is `clause` propagation-redundant w.r.t. `db` under `witness`?
77pub fn is_pr(num_vars: usize, db: &[Vec<Lit>], clause: &[Lit], witness: &Witness) -> bool {
78 match witness {
79 Witness::Assignment(omega) => assignment_pr(num_vars, db, clause, omega),
80 Witness::Substitution(sigma) => substitution_sr(num_vars, db, clause, sigma),
81 }
82}
83
84/// [`is_pr`] with the automorphism re-check served by a pre-built incremental
85/// `AutomorphismIndex` (which must hold exactly the clauses of `db`). Identical verdict to
86/// [`is_pr`] — the index is an acceleration structure — but the substitution path no longer
87/// rebuilds the membership set per call, the difference between an `O(n⁴)` and an `O(n³)`
88/// certified refutation.
89pub fn is_pr_indexed(
90 num_vars: usize,
91 db: &[Vec<Lit>],
92 index: &mut crate::symmetry_detect::AutomorphismIndex,
93 clause: &[Lit],
94 witness: &Witness,
95) -> bool {
96 match witness {
97 Witness::Assignment(omega) => assignment_pr(num_vars, db, clause, omega),
98 Witness::Substitution(sigma) => {
99 if !index.is_automorphism(sigma) {
100 return false;
101 }
102 let sigma_c = sigma.apply_clause(clause);
103 let mut assume: Vec<Lit> = clause.iter().map(|l| l.negated()).collect();
104 assume.extend(sigma_c.iter().map(|l| l.negated()));
105 // Occurrence-driven propagation over the index, not a full-database scan.
106 let _ = db;
107 index.propagate_to_conflict(num_vars, &assume)
108 }
109 }
110}
111
112/// [`check_pr_refutation`] accelerated by an incrementally-maintained `AutomorphismIndex` — the
113/// same verdict, but each substitution step's automorphism re-check costs `O(support)` rather than
114/// `O(|db|)`. Falls back to the stateless checker if the proof deletes clauses (the index is
115/// append-only, and certified symmetry refutations do not delete).
116pub fn check_pr_refutation_fast(
117 num_vars: usize,
118 original: &[Vec<Lit>],
119 steps: &[ProofStep],
120) -> bool {
121 if steps.iter().any(|s| matches!(s, ProofStep::Delete(_))) {
122 return check_pr_refutation(num_vars, original, steps);
123 }
124 let mut db: Vec<Vec<Lit>> = original.to_vec();
125 let mut index = crate::symmetry_detect::AutomorphismIndex::with_clauses(num_vars, original);
126 for step in steps {
127 match step {
128 ProofStep::Rup(c) => {
129 if !rup::is_rup(num_vars, &db, c) {
130 return false;
131 }
132 db.push(c.clone());
133 index.insert(c.clone());
134 }
135 ProofStep::Pr { clause, witness } => {
136 if !is_pr_indexed(num_vars, &db, &mut index, clause, witness) {
137 return false;
138 }
139 db.push(clause.clone());
140 index.insert(clause.clone());
141 }
142 ProofStep::Delete(_) => unreachable!("deletes handled by the fallback above"),
143 }
144 }
145 rup::is_rup(num_vars, &db, &[])
146}
147
148/// The substitution-redundancy criterion for a symmetry witness `σ`: `σ` must be an
149/// automorphism of `db` (so the σ-repair of any model stays a model), and `db ∧ ¬C ⊢₁ σ(C)`
150/// (so that repair also satisfies `C`). Both conditions are independently checkable, and the
151/// automorphism is re-verified against the *current* `db` — fail-closed against generator
152/// ordering.
153fn substitution_sr(
154 num_vars: usize,
155 db: &[Vec<Lit>],
156 clause: &[Lit],
157 sigma: &crate::proof::Perm,
158) -> bool {
159 if !crate::symmetry_detect::perm_is_automorphism(db, sigma) {
160 return false;
161 }
162 // `db ∧ ¬C ⊢₁ σ(C)`: assume α = ¬C together with ¬σ(C) and propagate db; require a conflict.
163 let sigma_c = sigma.apply_clause(clause);
164 let mut assume: Vec<Lit> = clause.iter().map(|l| l.negated()).collect();
165 assume.extend(sigma_c.iter().map(|l| l.negated()));
166 up_conflict(num_vars, db, &assume)
167}
168
169/// The propagation-redundancy criterion with an explicit assignment witness `ω` (given as the
170/// set of literals it sets true): `ω` satisfies `C`, and for every clause `D ∈ db`,
171/// `F|α ⊢₁ D|ω` where `α = ¬C`.
172fn assignment_pr(num_vars: usize, db: &[Vec<Lit>], clause: &[Lit], omega_true: &[Lit]) -> bool {
173 // Materialise ω as an assignment; a self-contradictory witness is no witness.
174 let mut omega: Vec<Option<bool>> = vec![None; num_vars];
175 for &l in omega_true {
176 if !rup::set_true(&mut omega, l) {
177 return false;
178 }
179 }
180 // Precondition: ω must satisfy the clause being added.
181 if !clause
182 .iter()
183 .any(|&l| rup::lit_val(&omega, l) == Some(true))
184 {
185 return false;
186 }
187 // α = ¬C, as the set of literals it sets true.
188 let alpha: Vec<Lit> = clause.iter().map(|&l| l.negated()).collect();
189
190 for d in db {
191 // Clauses ω satisfies carry no obligation (D|ω is a tautology).
192 if d.iter().any(|&m| rup::lit_val(&omega, m) == Some(true)) {
193 continue;
194 }
195 // D|ω keeps the ω-unassigned literals (ω-false ones are dropped). The obligation
196 // `F|α ⊢₁ D|ω` is checked by assuming α together with ¬(D|ω) and propagating db: it
197 // must conflict.
198 let mut assume = alpha.clone();
199 for &m in d {
200 if rup::lit_val(&omega, m).is_none() {
201 assume.push(m.negated());
202 }
203 }
204 if !up_conflict(num_vars, db, &assume) {
205 return false;
206 }
207 }
208 true
209}
210
211/// Assume every literal of `assume` and unit-propagate `db`; `true` iff a conflict results
212/// (an immediate clash among the assumptions counts).
213fn up_conflict(num_vars: usize, db: &[Vec<Lit>], assume: &[Lit]) -> bool {
214 let mut assign: Vec<Option<bool>> = vec![None; num_vars];
215 for &l in assume {
216 if !rup::set_true(&mut assign, l) {
217 return true;
218 }
219 }
220 rup::propagate(db, &mut assign)
221}
222
223#[cfg(test)]
224#[path = "../../logicaffeine_tests/unit/logicaffeine_proof/pr/tests.rs"]
225mod tests;