Skip to main content

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}