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    /// Tseitin-encode `e`, returning a literal whose truth equals `e`'s, and emitting the
122    /// defining clauses for any auxiliary variables. Returns `None` if `e` is not a
123    /// quantifier-free propositional formula over recognisable atoms (so the caller can
124    /// fall back to another engine rather than silently mis-encode).
125    pub fn encode(&mut self, e: &ProofExpr) -> Option<Lit> {
126        match e {
127            ProofExpr::Not(p) => Some(self.encode(p)?.negated()),
128            ProofExpr::And(p, q) => {
129                let (a, b) = order2(self.encode(p)?, self.encode(q)?);
130                if let Some(&x) = self.expr_cache.get(&(OP_AND, a, b)) {
131                    return Some(x);
132                }
133                let x = Lit::pos(self.fresh());
134                // x ↔ (a ∧ b)
135                self.push_clause(vec![x.negated(), a]);
136                self.push_clause(vec![x.negated(), b]);
137                self.push_clause(vec![x, a.negated(), b.negated()]);
138                self.expr_cache.insert((OP_AND, a, b), x);
139                Some(x)
140            }
141            ProofExpr::Or(p, q) => {
142                let (a, b) = order2(self.encode(p)?, self.encode(q)?);
143                if let Some(&x) = self.expr_cache.get(&(OP_OR, a, b)) {
144                    return Some(x);
145                }
146                let x = Lit::pos(self.fresh());
147                // x ↔ (a ∨ b)
148                self.push_clause(vec![x.negated(), a, b]);
149                self.push_clause(vec![x, a.negated()]);
150                self.push_clause(vec![x, b.negated()]);
151                self.expr_cache.insert((OP_OR, a, b), x);
152                Some(x)
153            }
154            ProofExpr::Implies(p, q) => {
155                // p → q  ≡  ¬p ∨ q (NOT commutative, so no `order2`).
156                let a = self.encode(p)?;
157                let b = self.encode(q)?;
158                if let Some(&x) = self.expr_cache.get(&(OP_IMPL, a, b)) {
159                    return Some(x);
160                }
161                let x = Lit::pos(self.fresh());
162                // x ↔ (¬a ∨ b)
163                self.push_clause(vec![x.negated(), a.negated(), b]);
164                self.push_clause(vec![x, a]);
165                self.push_clause(vec![x, b.negated()]);
166                self.expr_cache.insert((OP_IMPL, a, b), x);
167                Some(x)
168            }
169            ProofExpr::Iff(p, q) => {
170                let (a, b) = order2(self.encode(p)?, self.encode(q)?);
171                if let Some(&x) = self.expr_cache.get(&(OP_IFF, a, b)) {
172                    return Some(x);
173                }
174                let x = Lit::pos(self.fresh());
175                // x ↔ (a ↔ b)
176                self.push_clause(vec![x.negated(), a.negated(), b]);
177                self.push_clause(vec![x.negated(), a, b.negated()]);
178                self.push_clause(vec![x, a, b]);
179                self.push_clause(vec![x, a.negated(), b.negated()]);
180                self.expr_cache.insert((OP_IFF, a, b), x);
181                Some(x)
182            }
183            _ => {
184                // An atom (Predicate / Identity / Atom): a single Boolean variable.
185                let key = atom_key(e)?;
186                Some(Lit::pos(self.atom_var(key)))
187            }
188        }
189    }
190
191    /// Assert `e` as CNF, introducing auxiliary variables ONLY for genuinely non-clausal
192    /// structure (a disjunct that is itself a conjunction — e.g. an of-pair disjunct). A
193    /// top-level conjunction splits into separate clauses; a disjunction or implication
194    /// flattens into ONE clause; a literal stays a literal. So a closure `A∨B∨C∨D` becomes a
195    /// single clause with ZERO aux variables instead of a Tseitin spine. This structure-aware
196    /// clausification (Plaisted & Greenbaum, 1986) is what keeps the CNF — and therefore the
197    /// solve and the RUP replay — small. `None` if `e` is not encodable.
198    pub fn assert(&mut self, e: &ProofExpr) -> Option<()> {
199        match e {
200            ProofExpr::And(a, b) => {
201                self.assert(a)?;
202                self.assert(b)
203            }
204            ProofExpr::Implies(a, b) => {
205                // ¬a ∨ b, flattened into one clause.
206                let mut lits = self.clause_lits(&negate_expr(a))?;
207                lits.extend(self.clause_lits(b)?);
208                self.push_clause(lits);
209                Some(())
210            }
211            _ => {
212                let lits = self.clause_lits(e)?;
213                self.push_clause(lits);
214                Some(())
215            }
216        }
217    }
218
219    /// Assert the NEGATION of `e` (used to refute the goal). A literal goal becomes a unit
220    /// clause; a compound goal asserts its De-Morgan dual, clause by clause.
221    pub fn assert_neg(&mut self, e: &ProofExpr) -> Option<()> {
222        self.assert(&negate_expr(e))
223    }
224
225    /// The literals of `e` viewed as ONE clause: flatten `∨` and `→`, literalise atoms and
226    /// negated atoms, and Tseitin any other (non-literal) disjunct into a single auxiliary
227    /// literal.
228    fn clause_lits(&mut self, e: &ProofExpr) -> Option<Vec<Lit>> {
229        if let Some(l) = self.lit_of_atom(e) {
230            return Some(vec![l]);
231        }
232        match e {
233            ProofExpr::Or(a, b) => {
234                let mut v = self.clause_lits(a)?;
235                v.extend(self.clause_lits(b)?);
236                Some(v)
237            }
238            ProofExpr::Implies(a, b) => {
239                let mut v = self.clause_lits(&negate_expr(a))?;
240                v.extend(self.clause_lits(b)?);
241                Some(v)
242            }
243            // De Morgan keeps a negated subformula a single clause with NO auxiliary:
244            // ¬(a ∧ b) ≡ ¬a ∨ ¬b, and ¬¬x ≡ x. (¬(a ∨ b) is a conjunction, not a clause, so it
245            // still falls through to one auxiliary below.) Every at-most-one pair is `¬(a ∧ b)`,
246            // so this collapses hundreds of Tseitin spines into plain binary clauses.
247            ProofExpr::Not(inner) => match inner.as_ref() {
248                ProofExpr::And(a, b) => {
249                    let mut v = self.clause_lits(&negate_expr(a))?;
250                    v.extend(self.clause_lits(&negate_expr(b))?);
251                    Some(v)
252                }
253                ProofExpr::Not(x) => self.clause_lits(x),
254                _ => Some(vec![self.encode(e)?]),
255            },
256            // A non-clausal subformula (typically a conjunction): one auxiliary variable.
257            _ => Some(vec![self.encode(e)?]),
258        }
259    }
260
261    /// A literal for `e` if it is an atom or a negated atom, else `None`.
262    fn lit_of_atom(&mut self, e: &ProofExpr) -> Option<Lit> {
263        match e {
264            ProofExpr::Not(p) => {
265                let key = atom_key(p)?;
266                Some(Lit::pos(self.atom_var(key)).negated())
267            }
268            ProofExpr::Predicate { .. } | ProofExpr::Identity(..) | ProofExpr::Atom(_) => {
269                let key = atom_key(e)?;
270                Some(Lit::pos(self.atom_var(key)))
271            }
272            _ => None,
273        }
274    }
275
276    /// Hand the accumulated CNF to a fresh CDCL solver.
277    pub fn into_solver(self) -> Solver {
278        self.into_solver_with_atoms().0
279    }
280
281    /// Like [`into_solver`](Self::into_solver) but also hands back the atom→variable map (moved,
282    /// not cloned). Callers that need to decode a SAT model can do so from this small map instead
283    /// of cloning the entire clause database just to keep the table alive.
284    pub fn into_solver_with_atoms(self) -> (Solver, HashMap<String, Var>) {
285        let mut s = Solver::new(self.num_vars);
286        for c in self.clauses {
287            s.add_clause(c);
288        }
289        (s, self.atom_of)
290    }
291}
292
293/// Does `premises ⊨ goal` (propositionally)? Encodes `premises ∧ ¬goal` to CNF and runs
294/// CDCL: UNSAT ⇒ entailed. `None` if the problem is not purely propositional over
295/// recognisable atoms (caller falls back). This is the **Untrusted** tier — the verdict
296/// with no emitted proof, for the raw-speed comparison against Z3.
297pub fn cdcl_entails(premises: &[ProofExpr], goal: &ProofExpr) -> Option<bool> {
298    let mut cnf = Cnf::new();
299    for p in premises {
300        cnf.assert(p)?;
301    }
302    cnf.assert_neg(goal)?;
303    let mut solver = cnf.into_solver();
304    Some(solver.solve() == SolveResult::Unsat)
305}
306
307/// `¬e`, pushing the negation no further than one `Not` (clausification handles the rest).
308fn negate_expr(e: &ProofExpr) -> ProofExpr {
309    match e {
310        ProofExpr::Not(p) => (**p).clone(),
311        _ => ProofExpr::Not(Box::new(e.clone())),
312    }
313}
314
315/// Canonical string key for an atom, so the same proposition always maps to one variable.
316/// Identity is symmetric (`a = b` ≡ `b = a`), so its operands are ordered.
317fn atom_key(e: &ProofExpr) -> Option<String> {
318    match e {
319        ProofExpr::Predicate { name, args, .. } => {
320            let mut s = String::new();
321            s.push_str(name);
322            s.push('(');
323            for (i, a) in args.iter().enumerate() {
324                if i > 0 {
325                    s.push(',');
326                }
327                s.push_str(&term_key(a));
328            }
329            s.push(')');
330            Some(s)
331        }
332        ProofExpr::Identity(a, b) => {
333            let (ka, kb) = (term_key(a), term_key(b));
334            let (lo, hi) = if ka <= kb { (ka, kb) } else { (kb, ka) };
335            Some(format!("={}={}", lo, hi))
336        }
337        ProofExpr::Atom(name) => Some(format!("atom:{name}")),
338        _ => None,
339    }
340}
341
342fn term_key(t: &ProofTerm) -> String {
343    match t {
344        ProofTerm::Constant(s) => s.clone(),
345        ProofTerm::Variable(s) | ProofTerm::BoundVarRef(s) => format!("?{s}"),
346        ProofTerm::Function(n, args) => {
347            let inner: Vec<String> = args.iter().map(term_key).collect();
348            format!("{n}({})", inner.join(","))
349        }
350        ProofTerm::Group(_) => "<group>".to_string(),
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::{ProofExpr, ProofTerm};
358
359    fn c(s: &str) -> ProofTerm {
360        ProofTerm::Constant(s.to_string())
361    }
362    fn pred(name: &str, args: Vec<ProofTerm>) -> ProofExpr {
363        ProofExpr::Predicate { name: name.to_string(), args, world: None }
364    }
365    fn not(e: ProofExpr) -> ProofExpr {
366        ProofExpr::Not(Box::new(e))
367    }
368    fn or(a: ProofExpr, b: ProofExpr) -> ProofExpr {
369        ProofExpr::Or(Box::new(a), Box::new(b))
370    }
371    fn and(a: ProofExpr, b: ProofExpr) -> ProofExpr {
372        ProofExpr::And(Box::new(a), Box::new(b))
373    }
374    fn imp(a: ProofExpr, b: ProofExpr) -> ProofExpr {
375        ProofExpr::Implies(Box::new(a), Box::new(b))
376    }
377    fn id(a: ProofTerm, b: ProofTerm) -> ProofExpr {
378        ProofExpr::Identity(a, b)
379    }
380
381    #[test]
382    fn reflexive_entailment() {
383        let p = pred("P", vec![c("a")]);
384        assert_eq!(cdcl_entails(&[p.clone()], &p), Some(true));
385    }
386
387    #[test]
388    fn disjunctive_syllogism() {
389        // P ∨ Q, ¬P ⊨ Q
390        let p = pred("P", vec![c("a")]);
391        let q = pred("Q", vec![c("a")]);
392        let prem = vec![or(p.clone(), q.clone()), not(p.clone())];
393        assert_eq!(cdcl_entails(&prem, &q), Some(true));
394        // …but P ∨ Q alone does NOT entail Q.
395        assert_eq!(cdcl_entails(&[or(p.clone(), q.clone())], &q), Some(false));
396    }
397
398    #[test]
399    fn modus_ponens() {
400        // P, P → Q ⊨ Q
401        let p = pred("P", vec![c("a")]);
402        let q = pred("Q", vec![c("a")]);
403        let prem = vec![p.clone(), imp(p.clone(), q.clone())];
404        assert_eq!(cdcl_entails(&prem, &q), Some(true));
405    }
406
407    #[test]
408    fn non_entailment_is_false_not_none() {
409        let p = pred("P", vec![c("a")]);
410        let q = pred("Q", vec![c("a")]);
411        assert_eq!(cdcl_entails(&[p], &q), Some(false));
412    }
413
414    #[test]
415    fn identity_is_symmetric() {
416        // a = b ⊨ b = a (same variable, so ¬(b=a) clashes immediately).
417        let prem = vec![id(c("a"), c("b"))];
418        assert_eq!(cdcl_entails(&prem, &id(c("b"), c("a"))), Some(true));
419    }
420
421    #[test]
422    fn two_value_grid_forces_the_cell() {
423        // A 2-trip × 2-state bijection: each trip in FL or ME; at most one trip per state;
424        // Alpha in FL ⇒ Beta in ME. The Untrusted CDCL tier must force it.
425        let in_ = |t: &str, s: &str| pred("In", vec![c(t), c(s)]);
426        let fl = "Florida";
427        let me = "Maine";
428        let prem = vec![
429            // closures: each trip takes a state
430            or(in_("Alpha", fl), in_("Alpha", me)),
431            or(in_("Beta", fl), in_("Beta", me)),
432            // at-most-one per state (functionality): two trips can't share a state
433            imp(in_("Alpha", fl), not(in_("Beta", fl))),
434            imp(in_("Alpha", me), not(in_("Beta", me))),
435            // each state taken by some trip (column closure)
436            or(in_("Alpha", fl), in_("Beta", fl)),
437            or(in_("Alpha", me), in_("Beta", me)),
438            // the pin
439            in_("Alpha", fl),
440        ];
441        assert_eq!(cdcl_entails(&prem, &in_("Beta", me)), Some(true), "Beta∈Maine is forced");
442        assert_eq!(cdcl_entails(&prem, &in_("Beta", fl)), Some(false), "Beta∈Florida is refuted");
443    }
444
445    #[test]
446    fn negated_conjunction_clausifies_directly_without_aux() {
447        // ¬(P ∧ Q) ≡ ¬P ∨ ¬Q — one binary clause, NO Tseitin auxiliary. Every at-most-one
448        // constraint is this shape; minting an aux per pair bloated the CNF and the solve.
449        let p = pred("P", vec![c("a")]);
450        let q = pred("Q", vec![c("a")]);
451        let mut cnf = Cnf::new();
452        cnf.assert(&not(and(p, q))).unwrap();
453        assert_eq!(cnf.num_atoms(), 2, "only P and Q are atoms");
454        assert_eq!(cnf.num_vars(), 2, "no auxiliary variable should be minted");
455        assert_eq!(cnf.clauses().len(), 1, "exactly one clause");
456        assert_eq!(cnf.clauses()[0].len(), 2, "the binary clause ¬P ∨ ¬Q");
457    }
458
459    #[test]
460    fn negated_conjunction_preserves_models() {
461        // Soundness guard for the De Morgan clause: P ∧ ¬(P ∧ Q) ⊨ ¬Q, but ¬(P ∧ Q) alone does not.
462        let p = pred("P", vec![c("a")]);
463        let q = pred("Q", vec![c("a")]);
464        assert_eq!(
465            cdcl_entails(&[p.clone(), not(and(p.clone(), q.clone()))], &not(q.clone())),
466            Some(true)
467        );
468        assert_eq!(cdcl_entails(&[not(and(p, q.clone()))], &not(q)), Some(false));
469    }
470
471    #[test]
472    fn nested_demorgan_and_double_negation_clausify() {
473        // ¬((P ∧ Q) ∧ R) ≡ ¬P ∨ ¬Q ∨ ¬R (one ternary clause, no aux); ¬¬P ≡ P.
474        let (p, q, r) = (pred("P", vec![c("a")]), pred("Q", vec![c("a")]), pred("R", vec![c("a")]));
475        let mut cnf = Cnf::new();
476        cnf.assert(&not(and(and(p, q), r))).unwrap();
477        assert_eq!(cnf.num_vars(), 3, "no aux for a nested negated conjunction");
478        assert_eq!(cnf.clauses().len(), 1);
479        assert_eq!(cnf.clauses()[0].len(), 3, "ternary clause ¬P ∨ ¬Q ∨ ¬R");
480
481        let p2 = pred("P", vec![c("a")]);
482        let mut cnf2 = Cnf::new();
483        cnf2.assert(&not(not(p2))).unwrap();
484        assert_eq!(cnf2.num_vars(), 1, "¬¬P is just P");
485    }
486}