logicaffeine_proof/xor_drat.rs
1//! The CNF→GF(2) bridge: compile a Gaussian (XOR linear-dependency) refutation into a **strict DRAT
2//! proof** that an external checker (`drat-trim`) accepts.
3//!
4//! Our parity route refutes Tseitin/XOR formulas by linear algebra: it finds a set `S` of recovered
5//! XOR equations whose GF(2) sum telescopes to `0 = 1` ([`crate::xorsat::solve`] → `Unsat(S)`). That
6//! certificate is sound and re-checkable *algebraically* — but it is not the Boolean clausal proof
7//! the competition toolchain consumes. This module closes that gap.
8//!
9//! **The construction (why each emitted line is RUP).** Every equation in `S` is encoded in the CNF
10//! by its full parity gadget (the `2^{k-1}` clauses forbidding wrong-parity assignments). The
11//! conjunction of the gadgets of `S` is unsatisfiable (their linear combination is `0 = 1`), so the
12//! empty clause is derivable from them by *resolution*. We produce that derivation by Davis–Putnam
13//! variable elimination over the support, in min-degree order, emitting each resolvent as a proof
14//! line. A resolvent of two clauses already in the formula is **RUP** (reverse-unit-propagation)
15//! against that formula, and we only ever grow the formula — so every line checks, and the final
16//! line is the empty clause. The proof is polynomial when the elimination keeps clauses narrow
17//! (bounded cut-/tree-width); it can blow up on high-expansion instances, which is the honest limit
18//! of the resolution route (the polynomial extension-variable construction is the next rung).
19//!
20//! Soundness is independent of how the equations were recovered: the gadget clauses we resolve over
21//! are entailed by the CNF, and the emitted proof is checked by `drat-trim` against the *original*
22//! formula, so a faulty recovery cannot produce an accepted proof.
23
24use std::collections::{BTreeSet, HashSet};
25
26use crate::cdcl::Lit;
27use crate::xorsat::XorEquation;
28
29/// The full parity gadget of `eq`: one clause per wrong-parity assignment of its variables. These are
30/// exactly the CNF clauses a Tseitin/XOR encoding carries for the constraint.
31pub fn gadget_clauses(eq: &XorEquation) -> Vec<Vec<Lit>> {
32 let k = eq.vars.len();
33 let mut out = Vec::new();
34 for mask in 0u32..(1u32 << k) {
35 // `mask` is the assignment (bit i = value of vars[i]); it violates the parity when its
36 // popcount-parity disagrees with the rhs. The forbidding clause is false under `mask`.
37 if (mask.count_ones() % 2 == 1) != eq.rhs {
38 out.push((0..k).map(|i| Lit::new(eq.vars[i] as u32, (mask >> i) & 1 == 0)).collect());
39 }
40 }
41 out
42}
43
44/// Canonical key for a clause (sorted, deduped literal codes), for dedup/subsumption-free bookkeeping.
45fn key(c: &[Lit]) -> Vec<u32> {
46 let mut k: Vec<u32> = c.iter().map(|l| l.var() * 2 + u32::from(!l.is_positive())).collect();
47 k.sort_unstable();
48 k.dedup();
49 k
50}
51
52/// Resolve `a` and `b` on variable `v` (which appears positively in one, negatively in the other).
53/// Returns the resolvent with `v` removed, or `None` if it is a tautology (contains `x` and `¬x`).
54fn resolve(a: &[Lit], b: &[Lit], v: u32) -> Option<Vec<Lit>> {
55 let mut seen: std::collections::HashMap<u32, bool> = std::collections::HashMap::new();
56 for l in a.iter().chain(b.iter()) {
57 if l.var() == v {
58 continue;
59 }
60 if let Some(&prev) = seen.get(&l.var()) {
61 if prev != l.is_positive() {
62 return None; // x and ¬x ⇒ tautology
63 }
64 } else {
65 seen.insert(l.var(), l.is_positive());
66 }
67 }
68 let mut out: Vec<Lit> = seen.into_iter().map(|(var, pos)| Lit::new(var, pos)).collect();
69 out.sort_by_key(|l| l.var() * 2 + u32::from(!l.is_positive()));
70 Some(out)
71}
72
73/// Derive the empty clause from an unsatisfiable clause set by Davis–Putnam variable elimination,
74/// returning the ordered list of resolvents (each RUP against the original set plus the earlier
75/// resolvents). Eliminates in min-degree order to keep intermediate clauses small. Returns `None`
76/// only if the input was not actually unsatisfiable (no refutation exists).
77/// Resolvent budget: the resolution route is polynomial only when the elimination keeps clauses
78/// narrow; past this many emitted clauses we declare the route blown (fail-closed) rather than hang.
79const RESOLUTION_BUDGET: usize = 40_000;
80/// Raw-work budget: a single elimination can attempt `|pos|·|neg|` resolutions, which explodes long
81/// before the emitted-clause budget trips (most attempts are duplicates/tautologies). Cap the attempts
82/// so a blown instance bails in seconds rather than minutes.
83const RESOLUTION_WORK_BUDGET: u64 = 4_000_000;
84
85fn resolution_refutation(support: &[Vec<Lit>]) -> Option<Vec<Vec<Lit>>> {
86 // Active working set, deduped; and the set of every clause key already in play (so a resolvent we
87 // re-derive is not emitted twice).
88 let mut active: Vec<Vec<Lit>> = Vec::new();
89 let mut present: HashSet<Vec<u32>> = HashSet::new();
90 for c in support {
91 let k = key(c);
92 if present.insert(k) {
93 active.push({
94 let mut c = c.clone();
95 c.sort_by_key(|l| l.var() * 2 + u32::from(!l.is_positive()));
96 c.dedup();
97 c
98 });
99 }
100 }
101 let mut emitted: Vec<Vec<Lit>> = Vec::new();
102 let mut work: u64 = 0;
103 if active.iter().any(|c| c.is_empty()) {
104 emitted.push(Vec::new());
105 return Some(emitted);
106 }
107
108 loop {
109 let vars: BTreeSet<u32> = active.iter().flat_map(|c| c.iter().map(|l| l.var())).collect();
110 if vars.is_empty() {
111 return None; // nothing left to eliminate but no empty clause: input was satisfiable
112 }
113 // Min-product (min-fill proxy): eliminate the variable whose resolution fan-out `|pos|·|neg|`
114 // is smallest — that bounds both the work and the number of new clauses, keeping the proof as
115 // narrow as the instance's structure allows (the lever before extension variables).
116 let v = *vars
117 .iter()
118 .min_by_key(|&&v| {
119 let (mut p, mut n) = (0usize, 0usize);
120 for c in &active {
121 match c.iter().find(|l| l.var() == v) {
122 Some(l) if l.is_positive() => p += 1,
123 Some(_) => n += 1,
124 None => {}
125 }
126 }
127 p * n
128 })
129 .unwrap();
130
131 let mut pos: Vec<Vec<Lit>> = Vec::new();
132 let mut neg: Vec<Vec<Lit>> = Vec::new();
133 let mut rest: Vec<Vec<Lit>> = Vec::new();
134 for c in active.drain(..) {
135 match c.iter().find(|l| l.var() == v) {
136 Some(l) if l.is_positive() => pos.push(c),
137 Some(_) => neg.push(c),
138 None => rest.push(c),
139 }
140 }
141
142 let mut next = rest;
143 for cp in &pos {
144 for cn in &neg {
145 work += 1;
146 if work > RESOLUTION_WORK_BUDGET {
147 return None; // raw-work blowup: bail fast, fail-closed.
148 }
149 let Some(r) = resolve(cp, cn, v) else { continue };
150 if r.is_empty() {
151 emitted.push(Vec::new());
152 return Some(emitted);
153 }
154 let k = key(&r);
155 if present.insert(k) {
156 emitted.push(r.clone());
157 next.push(r);
158 if emitted.len() > RESOLUTION_BUDGET {
159 return None; // the resolution route blew up on this instance's width.
160 }
161 }
162 }
163 }
164 active = next;
165 // The clauses mentioning `v` are now eliminated; they stay in the DRAT formula (we never
166 // delete), so every future resolvent remains RUP against the growing database.
167 }
168}
169
170/// Compile the GF(2) linear-dependency refutation `refutation` (equation indices whose XOR is `0=1`,
171/// from [`crate::xorsat::solve`]) into a DRAT proof: the ordered resolvent clauses, ending in the
172/// empty clause. Pair these with the original CNF for [`crate::rup::check_refutation`] or `drat-trim`.
173pub fn emit_xor_drat(equations: &[XorEquation], refutation: &[usize]) -> Option<Vec<Vec<Lit>>> {
174 let mut support: Vec<Vec<Lit>> = Vec::new();
175 for &i in refutation {
176 support.extend(gadget_clauses(&equations[i]));
177 }
178 resolution_refutation(&support)
179}
180
181/// Resolution-refute the sub-CNF confined to the `support` variables — the convention-agnostic bridge.
182/// The emitted clauses are resolvents of *real* CNF clauses, so each is RUP against the original
183/// formula by construction and a `drat-trim` check holds. `None` if that sub-CNF is not UNSAT (so the
184/// caller's algebraic verdict could not be reproduced clausally — a fail-closed signal).
185pub fn emit_drat_over_support(clauses: &[Vec<Lit>], support: &BTreeSet<u32>) -> Option<Vec<Vec<Lit>>> {
186 let sub: Vec<Vec<Lit>> = clauses
187 .iter()
188 .filter(|c| c.iter().all(|l| support.contains(&l.var())))
189 .cloned()
190 .collect();
191 resolution_refutation(&sub)
192}
193
194/// The CNF→GF(p) bridge: compile a *modular* (mod-p / counting) UNSAT verdict into a strict DRAT
195/// proof. We recover the one-hot modular system from `clauses`, solve it over the prime field, and —
196/// on UNSAT — resolution-refute the sub-CNF confined to the one-hot bits of the involved groups. That
197/// sub-CNF (the forbidden-tuple gadgets of the combination's equations together with their groups'
198/// at-least-one/at-most-one clauses) is unsatisfiable, so DP elimination over those Boolean variables
199/// yields the empty clause, every line RUP against the original CNF. `None` if the formula is not a
200/// recoverable mod-p encoding or its modular system is satisfiable.
201pub fn emit_modp_drat(num_vars: usize, clauses: &[Vec<Lit>]) -> Option<Vec<Vec<Lit>>> {
202 let rec = crate::modp::recover_from_cnf(num_vars, clauses)?;
203 if !crate::modp::is_prime(rec.modulus) {
204 return None; // composite moduli go through the ring engine; the prime field is the bridge here.
205 }
206 let combo = match crate::modp::solve(&rec.equations, rec.num_vars, rec.modulus) {
207 crate::modp::ModpOutcome::Unsat(combo) => combo,
208 crate::modp::ModpOutcome::Sat(_) => return None,
209 };
210 // The groups (modular variables) that actually appear in the refuting linear combination.
211 let mut groups_involved: BTreeSet<usize> = BTreeSet::new();
212 for &(eq_idx, mult) in &combo {
213 if mult % rec.modulus == 0 {
214 continue;
215 }
216 for &(g, _) in &rec.equations[eq_idx].coeffs {
217 groups_involved.insert(g);
218 }
219 }
220 let support: BTreeSet<u32> =
221 groups_involved.iter().flat_map(|&g| rec.groups[g].iter().copied()).collect();
222 emit_drat_over_support(clauses, &support)
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::xorsat::{self, XorOutcome};
229
230 #[test]
231 fn xor_linear_dependency_compiles_to_a_drat_refutation_checked_by_rup() {
232 // x0⊕x1=0, x1⊕x2=0, x0⊕x2=1 — their GF(2) sum is 0=1, so UNSAT. The CNF is the union of the
233 // three parity gadgets. We compile the linear-dependency certificate to DRAT and require our
234 // INDEPENDENT RUP checker to accept it, ending in the empty clause.
235 let eqs = vec![
236 XorEquation::new(vec![0, 1], false),
237 XorEquation::new(vec![1, 2], false),
238 XorEquation::new(vec![0, 2], true),
239 ];
240 let num_vars = 3;
241 let clauses: Vec<Vec<Lit>> = eqs.iter().flat_map(gadget_clauses).collect();
242
243 let refutation = match xorsat::solve(&eqs, num_vars) {
244 XorOutcome::Unsat(s) => s,
245 XorOutcome::Sat(_) => panic!("the system is UNSAT"),
246 };
247 let drat = emit_xor_drat(&eqs, &refutation).expect("a linear dependency must compile to DRAT");
248 assert!(drat.last().is_some_and(|c| c.is_empty()), "the proof must end in the empty clause");
249 assert!(
250 crate::rup::check_refutation(num_vars, &clauses, &drat),
251 "every DRAT line must be RUP and the proof must refute the CNF"
252 );
253 }
254}