Skip to main content

logicaffeine_proof/
cnf.rs

1//! Tseitin clausification: a grounded, quantifier-free `ProofExpr` → CNF over the CDCL
2//! core's `Lit`s (Tseitin, 1968 — the standard linear-size equisatisfiable transform that
3//! introduces one auxiliary variable per compound subformula). This is the bridge from the
4//! logic-grid encoding (closures, at-most-one, of-pair, clue clauses) to the [`crate::cdcl`]
5//! SAT engine, giving the **Untrusted** trust tier: solve `premises ∧ ¬goal`; UNSAT ⇒ the
6//! goal is entailed. The same CNF feeds the RUP checker (the fast trust tier) and is the
7//! input an AllDifferent GAC propagator filters.
8
9use crate::cdcl::{Lit, SolveResult, Solver, Var};
10use crate::{ProofExpr, ProofTerm};
11use std::collections::{HashMap, HashSet};
12
13// Tseitin operator tags for the structural-hashing cache key.
14const OP_AND: u8 = 0;
15const OP_OR: u8 = 1;
16const OP_IMPL: u8 = 2;
17const OP_IFF: u8 = 3;
18
19/// Canonical ordering of two literals (by variable, then sign) — for commutative-operator
20/// keys so `a∧b` and `b∧a` share one auxiliary variable.
21#[inline]
22fn order2(a: Lit, b: Lit) -> (Lit, Lit) {
23    if (a.var(), a.is_positive()) <= (b.var(), b.is_positive()) {
24        (a, b)
25    } else {
26        (b, a)
27    }
28}
29
30/// A CNF formula under construction: a variable table (atoms canonicalised so `a = b` and
31/// `b = a` share a variable) plus the accumulating clause set. `Clone` so a prepared
32/// premise CNF can be reused per goal — the of-pair Tseitin work is done once, then each
33/// cell only adds its `¬goal` unit (incremental solving, the IPASIR pattern).
34#[derive(Clone, Default)]
35pub struct Cnf {
36    atom_of: HashMap<String, Var>,
37    num_vars: usize,
38    clauses: Vec<Vec<Lit>>,
39    /// Structural-hashing cache: `(op, lit, lit) → aux literal`, so a subformula encoded
40    /// twice reuses ONE auxiliary variable (and its defining clauses) instead of minting a
41    /// fresh one. This is hash-consing of the Tseitin encoding.
42    expr_cache: HashMap<(u8, Lit, Lit), Lit>,
43    /// Canonicalised (sorted) clauses already added, so a clause produced twice by grounding
44    /// is stored once.
45    clause_seen: HashSet<Vec<Lit>>,
46}
47
48impl Cnf {
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Clausify a fixed premise set ONCE (the expensive of-pair Tseitin work), so many
54    /// goals can be checked against it by cloning + adding `¬goal`. `None` if any premise is
55    /// not encodable. This is the incremental entry for solving a whole puzzle (16+ cells)
56    /// without re-grounding or re-clausifying the shared premises every time.
57    pub fn from_premises(premises: &[ProofExpr]) -> Option<Cnf> {
58        let mut cnf = Cnf::new();
59        for p in premises {
60            cnf.assert(p)?;
61        }
62        Some(cnf)
63    }
64
65    /// Number of CDCL variables allocated (atoms + Tseitin auxiliaries).
66    pub fn num_vars(&self) -> usize {
67        self.num_vars
68    }
69
70    /// Number of distinct ATOM variables (the rest are Tseitin auxiliaries).
71    pub fn num_atoms(&self) -> usize {
72        self.atom_of.len()
73    }
74
75    /// The accumulated clauses.
76    pub fn clauses(&self) -> &[Vec<Lit>] {
77        &self.clauses
78    }
79
80    /// The Boolean value of atom `e` under a SAT `model` (a [`crate::cdcl::SolveResult::Sat`]
81    /// assignment, indexed by variable), or `None` if `e` is not a recognised atom or was
82    /// never encoded into this CNF. This decodes a model back to source atoms (e.g.
83    /// `signal@t`), skipping the Tseitin auxiliaries that carry no source meaning.
84    pub fn atom_value(&self, e: &ProofExpr, model: &[bool]) -> Option<bool> {
85        let key = atom_key(e)?;
86        let v = *self.atom_of.get(&key)?;
87        model.get(v as usize).copied()
88    }
89
90    fn fresh(&mut self) -> Var {
91        let v = self.num_vars as Var;
92        self.num_vars += 1;
93        v
94    }
95
96    /// The variable for an atom, allocating one the first time it is seen.
97    fn atom_var(&mut self, key: String) -> Var {
98        if let Some(&v) = self.atom_of.get(&key) {
99            return v;
100        }
101        let v = self.fresh();
102        self.atom_of.insert(key, v);
103        v
104    }
105
106    /// Add a clause, canonicalised (sorted + within-clause dedup), dropping tautologies and
107    /// EXACT duplicates. Grid grounding emits the same clause many times (≈13% were dups);
108    /// storing each once shrinks the solve and the RUP replay for free.
109    fn push_clause(&mut self, mut lits: Vec<Lit>) {
110        lits.sort_by_key(|l| (l.var(), l.is_positive()));
111        lits.dedup();
112        // Sorted ⇒ both polarities of a variable are adjacent: that is a tautology, drop it.
113        if lits.windows(2).any(|w| w[0].var() == w[1].var()) {
114            return;
115        }
116        if self.clause_seen.insert(lits.clone()) {
117            self.clauses.push(lits);
118        }
119    }
120
121    /// Add a clause verbatim (canonicalised exactly as the Tseitin encoder's own clauses are).
122    /// The seam a caller that builds its clause literals itself — a bit-blaster asserting the
123    /// negation of a gate DAG, say — pushes through, so it shares this table's canonicalisation
124    /// and duplicate suppression instead of reimplementing them.
125    pub fn add_clause(&mut self, lits: Vec<Lit>) {
126        self.push_clause(lits);
127    }
128
129    /// The literal of a named Boolean atom, allocating its variable the first time it is seen.
130    /// Same table [`encode`](Self::encode) uses for `ProofExpr::Atom`, so the two agree on
131    /// variable numbering.
132    pub fn atom_lit(&mut self, name: &str) -> Lit {
133        Lit::pos(self.atom_var(name.to_string()))
134    }
135
136    /// Tseitin-define `x ↔ (a ∧ b)` and return `x`, reusing the cached auxiliary when this
137    /// conjunction has been encoded before. The primitive behind `encode(And(..))`, exposed so a
138    /// caller holding a gate DAG can encode it WITHOUT first materialising the `ProofExpr` tree
139    /// (which is exponentially larger than the DAG for a reconvergent datapath).
140    pub fn encode_and(&mut self, a: Lit, b: Lit) -> Lit {
141        let (a, b) = order2(a, b);
142        if let Some(&x) = self.expr_cache.get(&(OP_AND, a, b)) {
143            return x;
144        }
145        let x = Lit::pos(self.fresh());
146        self.push_clause(vec![x.negated(), a]);
147        self.push_clause(vec![x.negated(), b]);
148        self.push_clause(vec![x, a.negated(), b.negated()]);
149        self.expr_cache.insert((OP_AND, a, b), x);
150        x
151    }
152
153    /// Tseitin-define `x ↔ (a ∨ b)` and return `x`. See [`encode_and`](Self::encode_and).
154    pub fn encode_or(&mut self, a: Lit, b: Lit) -> Lit {
155        let (a, b) = order2(a, b);
156        if let Some(&x) = self.expr_cache.get(&(OP_OR, a, b)) {
157            return x;
158        }
159        let x = Lit::pos(self.fresh());
160        self.push_clause(vec![x.negated(), a, b]);
161        self.push_clause(vec![x, a.negated()]);
162        self.push_clause(vec![x, b.negated()]);
163        self.expr_cache.insert((OP_OR, a, b), x);
164        x
165    }
166
167    /// Tseitin-define `x ↔ (¬a ∨ b)` and return `x`. See [`encode_and`](Self::encode_and).
168    pub fn encode_implies(&mut self, a: Lit, b: Lit) -> Lit {
169        if let Some(&x) = self.expr_cache.get(&(OP_IMPL, a, b)) {
170            return x;
171        }
172        let x = Lit::pos(self.fresh());
173        self.push_clause(vec![x.negated(), a.negated(), b]);
174        self.push_clause(vec![x, a]);
175        self.push_clause(vec![x, b.negated()]);
176        self.expr_cache.insert((OP_IMPL, a, b), x);
177        x
178    }
179
180    /// Tseitin-define `x ↔ (a ↔ b)` and return `x`. See [`encode_and`](Self::encode_and).
181    pub fn encode_iff(&mut self, a: Lit, b: Lit) -> Lit {
182        let (a, b) = order2(a, b);
183        if let Some(&x) = self.expr_cache.get(&(OP_IFF, a, b)) {
184            return x;
185        }
186        let x = Lit::pos(self.fresh());
187        self.push_clause(vec![x.negated(), a.negated(), b]);
188        self.push_clause(vec![x.negated(), a, b.negated()]);
189        self.push_clause(vec![x, a, b]);
190        self.push_clause(vec![x, a.negated(), b.negated()]);
191        self.expr_cache.insert((OP_IFF, a, b), x);
192        x
193    }
194
195    /// Tseitin-encode `e`, returning a literal whose truth equals `e`'s, and emitting the
196    /// defining clauses for any auxiliary variables. Returns `None` if `e` is not a
197    /// quantifier-free propositional formula over recognisable atoms (so the caller can
198    /// fall back to another engine rather than silently mis-encode).
199    pub fn encode(&mut self, e: &ProofExpr) -> Option<Lit> {
200        match e {
201            ProofExpr::Not(p) => Some(self.encode(p)?.negated()),
202            ProofExpr::And(p, q) => {
203                let (a, b) = (self.encode(p)?, self.encode(q)?);
204                Some(self.encode_and(a, b))
205            }
206            ProofExpr::Or(p, q) => {
207                let (a, b) = (self.encode(p)?, self.encode(q)?);
208                Some(self.encode_or(a, b))
209            }
210            ProofExpr::Implies(p, q) => {
211                // p → q  ≡  ¬p ∨ q (NOT commutative, so no `order2`).
212                let a = self.encode(p)?;
213                let b = self.encode(q)?;
214                Some(self.encode_implies(a, b))
215            }
216            ProofExpr::Iff(p, q) => {
217                let (a, b) = (self.encode(p)?, self.encode(q)?);
218                Some(self.encode_iff(a, b))
219            }
220            _ => {
221                // An atom (Predicate / Identity / Atom): a single Boolean variable.
222                let key = atom_key(e)?;
223                Some(Lit::pos(self.atom_var(key)))
224            }
225        }
226    }
227
228    /// Assert `e` as CNF, introducing auxiliary variables ONLY for genuinely non-clausal
229    /// structure (a disjunct that is itself a conjunction — e.g. an of-pair disjunct). A
230    /// top-level conjunction splits into separate clauses; a disjunction or implication
231    /// flattens into ONE clause; a literal stays a literal. So a closure `A∨B∨C∨D` becomes a
232    /// single clause with ZERO aux variables instead of a Tseitin spine. This structure-aware
233    /// clausification (Plaisted & Greenbaum, 1986) is what keeps the CNF — and therefore the
234    /// solve and the RUP replay — small. `None` if `e` is not encodable.
235    pub fn assert(&mut self, e: &ProofExpr) -> Option<()> {
236        match e {
237            ProofExpr::And(a, b) => {
238                self.assert(a)?;
239                self.assert(b)
240            }
241            ProofExpr::Implies(a, b) => {
242                // ¬a ∨ b, flattened into one clause.
243                let mut lits = self.clause_lits(&negate_expr(a))?;
244                lits.extend(self.clause_lits(b)?);
245                self.push_clause(lits);
246                Some(())
247            }
248            _ => {
249                let lits = self.clause_lits(e)?;
250                self.push_clause(lits);
251                Some(())
252            }
253        }
254    }
255
256    /// Assert the NEGATION of `e` (used to refute the goal). A literal goal becomes a unit
257    /// clause; a compound goal asserts its De-Morgan dual, clause by clause.
258    pub fn assert_neg(&mut self, e: &ProofExpr) -> Option<()> {
259        self.assert(&negate_expr(e))
260    }
261
262    /// The literals of `e` viewed as ONE clause: flatten `∨` and `→`, literalise atoms and
263    /// negated atoms, and Tseitin any other (non-literal) disjunct into a single auxiliary
264    /// literal.
265    fn clause_lits(&mut self, e: &ProofExpr) -> Option<Vec<Lit>> {
266        if let Some(l) = self.lit_of_atom(e) {
267            return Some(vec![l]);
268        }
269        match e {
270            ProofExpr::Or(a, b) => {
271                let mut v = self.clause_lits(a)?;
272                v.extend(self.clause_lits(b)?);
273                Some(v)
274            }
275            ProofExpr::Implies(a, b) => {
276                let mut v = self.clause_lits(&negate_expr(a))?;
277                v.extend(self.clause_lits(b)?);
278                Some(v)
279            }
280            // De Morgan keeps a negated subformula a single clause with NO auxiliary:
281            // ¬(a ∧ b) ≡ ¬a ∨ ¬b, and ¬¬x ≡ x. (¬(a ∨ b) is a conjunction, not a clause, so it
282            // still falls through to one auxiliary below.) Every at-most-one pair is `¬(a ∧ b)`,
283            // so this collapses hundreds of Tseitin spines into plain binary clauses.
284            ProofExpr::Not(inner) => match inner.as_ref() {
285                ProofExpr::And(a, b) => {
286                    let mut v = self.clause_lits(&negate_expr(a))?;
287                    v.extend(self.clause_lits(&negate_expr(b))?);
288                    Some(v)
289                }
290                ProofExpr::Not(x) => self.clause_lits(x),
291                _ => Some(vec![self.encode(e)?]),
292            },
293            // A non-clausal subformula (typically a conjunction): one auxiliary variable.
294            _ => Some(vec![self.encode(e)?]),
295        }
296    }
297
298    /// A literal for `e` if it is an atom or a negated atom, else `None`.
299    fn lit_of_atom(&mut self, e: &ProofExpr) -> Option<Lit> {
300        match e {
301            ProofExpr::Not(p) => {
302                let key = atom_key(p)?;
303                Some(Lit::pos(self.atom_var(key)).negated())
304            }
305            ProofExpr::Predicate { .. } | ProofExpr::Identity(..) | ProofExpr::Atom(_) => {
306                let key = atom_key(e)?;
307                Some(Lit::pos(self.atom_var(key)))
308            }
309            _ => None,
310        }
311    }
312
313    /// Hand the accumulated CNF to a fresh CDCL solver.
314    pub fn into_solver(self) -> Solver {
315        self.into_solver_with_atoms().0
316    }
317
318    /// Like [`into_solver`](Self::into_solver) but also hands back the atom→variable map (moved,
319    /// not cloned). Callers that need to decode a SAT model can do so from this small map instead
320    /// of cloning the entire clause database just to keep the table alive.
321    pub fn into_solver_with_atoms(self) -> (Solver, HashMap<String, Var>) {
322        let mut s = Solver::new(self.num_vars);
323        for c in self.clauses {
324            s.add_clause(c);
325        }
326        (s, self.atom_of)
327    }
328}
329
330/// Does `premises ⊨ goal` (propositionally)? Encodes `premises ∧ ¬goal` to CNF and runs
331/// CDCL: UNSAT ⇒ entailed. `None` if the problem is not purely propositional over
332/// recognisable atoms (caller falls back). This is the **Untrusted** tier — the verdict
333/// with no emitted proof, for the raw-speed comparison against Z3.
334pub fn cdcl_entails(premises: &[ProofExpr], goal: &ProofExpr) -> Option<bool> {
335    let mut cnf = Cnf::new();
336    for p in premises {
337        cnf.assert(p)?;
338    }
339    cnf.assert_neg(goal)?;
340    let mut solver = cnf.into_solver();
341    Some(solver.solve() == SolveResult::Unsat)
342}
343
344/// `¬e`, pushing the negation no further than one `Not` (clausification handles the rest).
345fn negate_expr(e: &ProofExpr) -> ProofExpr {
346    match e {
347        ProofExpr::Not(p) => (**p).clone(),
348        _ => ProofExpr::Not(Box::new(e.clone())),
349    }
350}
351
352/// Canonical string key for an atom, so the same proposition always maps to one variable.
353/// Identity is symmetric (`a = b` ≡ `b = a`), so its operands are ordered.
354fn atom_key(e: &ProofExpr) -> Option<String> {
355    match e {
356        ProofExpr::Predicate { name, args, .. } => {
357            let mut s = String::new();
358            s.push_str(name);
359            s.push('(');
360            for (i, a) in args.iter().enumerate() {
361                if i > 0 {
362                    s.push(',');
363                }
364                s.push_str(&term_key(a));
365            }
366            s.push(')');
367            Some(s)
368        }
369        ProofExpr::Identity(a, b) => {
370            let (ka, kb) = (term_key(a), term_key(b));
371            let (lo, hi) = if ka <= kb { (ka, kb) } else { (kb, ka) };
372            Some(format!("={}={}", lo, hi))
373        }
374        ProofExpr::Atom(name) => Some(format!("atom:{name}")),
375        _ => None,
376    }
377}
378
379fn term_key(t: &ProofTerm) -> String {
380    match t {
381        ProofTerm::Constant(s) => s.clone(),
382        ProofTerm::Variable(s) | ProofTerm::BoundVarRef(s) => format!("?{s}"),
383        ProofTerm::Function(n, args) => {
384            let inner: Vec<String> = args.iter().map(term_key).collect();
385            format!("{n}({})", inner.join(","))
386        }
387        ProofTerm::Group(_) => "<group>".to_string(),
388    }
389}
390
391#[cfg(test)]
392#[path = "../../logicaffeine_tests/unit/logicaffeine_proof/cnf/tests.rs"]
393mod tests;
394
395#[cfg(test)]
396#[path = "../../logicaffeine_tests/unit/logicaffeine_proof/cnf/cnf_more_tests.rs"]
397mod cnf_more_tests;
398
399#[cfg(test)]
400#[path = "../../logicaffeine_tests/unit/logicaffeine_proof/cnf/cnf_differential_tests.rs"]
401mod cnf_differential_tests;