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;