Skip to main content

logicaffeine_proof/
counterexample.rs

1//! Counterexamples — when a goal is FALSE, exhibit a model instead of failing
2//! silently. Lean ships no built-in counterexample finder with its automation;
3//! this closes that gap.
4//!
5//! Two routes, both of which RE-VERIFY the model before reporting it (zero
6//! false accusations — a returned witness provably satisfies every premise and
7//! falsifies the goal):
8//!
9//! 1. **Propositional** — treat the ground atoms as booleans, search
10//!    assignments that make all premises true and the goal false.
11//! 2. **Arithmetic QuickCheck** — sample the free integer variables over a
12//!    grid (with shrinking toward the smallest witness) and evaluate.
13//!
14//! The evaluator is a pure-Rust ground interpreter over [`ProofExpr`], so a
15//! counterexample is a plain, checkable assignment — not an opaque solver
16//! verdict.
17
18use std::collections::{BTreeMap, BTreeSet};
19
20use crate::{ProofExpr, ProofTerm};
21
22/// A refuting model.
23#[derive(Debug, Clone, PartialEq)]
24pub enum Counterexample {
25    /// Integer assignment to the free variables (arithmetic goals).
26    Witness(Vec<(String, i64)>),
27    /// Truth assignment to the ground atoms (propositional goals).
28    Valuation(Vec<(String, bool)>),
29}
30
31impl Counterexample {
32    /// A one-line human reading of the model.
33    pub fn render(&self) -> String {
34        match self {
35            Counterexample::Witness(bindings) => {
36                let parts: Vec<String> =
37                    bindings.iter().map(|(v, n)| format!("{v} = {n}")).collect();
38                format!("false when {}", parts.join(", "))
39            }
40            Counterexample::Valuation(bindings) => {
41                let parts: Vec<String> = bindings
42                    .iter()
43                    .map(|(a, b)| format!("{a} = {}", if *b { "true" } else { "false" }))
44                    .collect();
45                format!("false when {}", parts.join(", "))
46            }
47        }
48    }
49}
50
51/// Search for a counterexample to `premises ⊢ goal`: an assignment satisfying
52/// every premise while falsifying the goal. `None` when none is found on the
53/// covered fragment (which does NOT prove the goal — absence of a witness here
54/// is not a proof).
55pub fn find_counterexample(premises: &[ProofExpr], goal: &ProofExpr) -> Option<Counterexample> {
56    // An arithmetic goal is decided ONLY by the grid evaluator: the
57    // propositional route would treat `le(1,x)` and `le(0,x)` as unrelated
58    // atoms and manufacture a spurious model. Absence of a grid witness on an
59    // arithmetic goal is reported as "no counterexample found" (not a proof).
60    if is_arithmetic(goal) {
61        return arithmetic_witness(premises, goal);
62    }
63    propositional_model(premises, goal)
64}
65
66// --- arithmetic QuickCheck --------------------------------------------------
67
68/// The sampling grid for a free integer variable, small values first so
69/// shrinking is automatic (the first witness found is the smallest by |·|).
70const GRID: &[i64] = &[0, 1, -1, 2, -2, 3, -3, 10, -10, 100, -100];
71
72fn arithmetic_witness(premises: &[ProofExpr], goal: &ProofExpr) -> Option<Counterexample> {
73    let mut vars: BTreeSet<String> = BTreeSet::new();
74    for p in premises {
75        arith_vars_expr(p, &mut vars);
76    }
77    arith_vars_expr(goal, &mut vars);
78    let vars: Vec<String> = vars.into_iter().collect();
79    // Only attempt this route when the problem is actually arithmetic and
80    // small enough to grid-search.
81    if vars.is_empty() || vars.len() > 3 || !is_arithmetic(goal) {
82        return None;
83    }
84
85    let mut env = BTreeMap::new();
86    search_grid(&vars, 0, &mut env, premises, goal)
87}
88
89fn search_grid(
90    vars: &[String],
91    i: usize,
92    env: &mut BTreeMap<String, i64>,
93    premises: &[ProofExpr],
94    goal: &ProofExpr,
95) -> Option<Counterexample> {
96    if i == vars.len() {
97        // Re-verify: every premise true, goal false.
98        let premises_hold = premises.iter().all(|p| eval_expr(p, env) == Some(true));
99        let goal_false = eval_expr(goal, env) == Some(false);
100        if premises_hold && goal_false {
101            return Some(Counterexample::Witness(
102                vars.iter().map(|v| (v.clone(), env[v])).collect(),
103            ));
104        }
105        return None;
106    }
107    for &val in GRID {
108        env.insert(vars[i].clone(), val);
109        if let Some(w) = search_grid(vars, i + 1, env, premises, goal) {
110            return Some(w);
111        }
112    }
113    env.remove(&vars[i]);
114    None
115}
116
117/// Whether `e` is in the arithmetic fragment the grid evaluator handles.
118fn is_arithmetic(e: &ProofExpr) -> bool {
119    match e {
120        ProofExpr::Identity(l, r) => is_arith_term(l) && is_arith_term(r),
121        ProofExpr::And(l, r) | ProofExpr::Or(l, r) | ProofExpr::Implies(l, r) => {
122            is_arithmetic(l) && is_arithmetic(r)
123        }
124        ProofExpr::Not(p) => is_arithmetic(p),
125        ProofExpr::ForAll { body, .. } | ProofExpr::Exists { body, .. } => is_arithmetic(body),
126        _ => false,
127    }
128}
129
130fn is_arith_term(t: &ProofTerm) -> bool {
131    match t {
132        ProofTerm::Constant(_) | ProofTerm::Variable(_) => true,
133        ProofTerm::Function(name, args) => {
134            matches!(
135                name.as_str(),
136                "add" | "sub" | "mul" | "le" | "lt" | "ge" | "gt"
137            ) && args.iter().all(is_arith_term)
138        }
139        _ => false,
140    }
141}
142
143/// Free arithmetic variables of an expression. A `∀`/`∃`-bound variable IS a
144/// free grid variable for counterexample purposes (we search for the
145/// instantiation that breaks a universally-claimed goal).
146fn arith_vars_expr(e: &ProofExpr, out: &mut BTreeSet<String>) {
147    match e {
148        ProofExpr::Identity(l, r) => {
149            arith_vars_term(l, out);
150            arith_vars_term(r, out);
151        }
152        ProofExpr::And(l, r)
153        | ProofExpr::Or(l, r)
154        | ProofExpr::Implies(l, r)
155        | ProofExpr::Iff(l, r) => {
156            arith_vars_expr(l, out);
157            arith_vars_expr(r, out);
158        }
159        ProofExpr::Not(p) => arith_vars_expr(p, out),
160        ProofExpr::ForAll { variable, body } | ProofExpr::Exists { variable, body } => {
161            arith_vars_expr(body, out);
162            out.insert(variable.clone());
163        }
164        _ => {}
165    }
166}
167
168fn arith_vars_term(t: &ProofTerm, out: &mut BTreeSet<String>) {
169    match t {
170        ProofTerm::Variable(s) => {
171            out.insert(s.clone());
172        }
173        // A non-numeric constant is a free integer variable — but the boolean
174        // literals (the `= true`/`= false` right-hand sides of a comparison)
175        // are NOT variables to search over.
176        ProofTerm::Constant(s) if s.parse::<i64>().is_err() && s != "true" && s != "false" => {
177            out.insert(s.clone());
178        }
179        ProofTerm::Function(_, args) => {
180            for a in args {
181                arith_vars_term(a, out);
182            }
183        }
184        _ => {}
185    }
186}
187
188/// Ground-evaluate a (closed-under-`env`) expression to a boolean.
189fn eval_expr(e: &ProofExpr, env: &BTreeMap<String, i64>) -> Option<bool> {
190    match e {
191        ProofExpr::Identity(l, r) => {
192            // `cmp(a,b) = true/false` or `a = b` over integers.
193            if let Some(truth) = eval_cmp(l, env) {
194                let claimed = match r {
195                    ProofTerm::Constant(s) if s == "true" => true,
196                    ProofTerm::Constant(s) if s == "false" => false,
197                    _ => return None,
198                };
199                return Some(truth == claimed);
200            }
201            Some(eval_term(l, env)? == eval_term(r, env)?)
202        }
203        ProofExpr::And(l, r) => Some(eval_expr(l, env)? && eval_expr(r, env)?),
204        ProofExpr::Or(l, r) => Some(eval_expr(l, env)? || eval_expr(r, env)?),
205        ProofExpr::Implies(l, r) => Some(!eval_expr(l, env)? || eval_expr(r, env)?),
206        ProofExpr::Not(p) => Some(!eval_expr(p, env)?),
207        // A universally-quantified subgoal is treated as its body under the
208        // current grid binding (the search enumerates the binding).
209        ProofExpr::ForAll { body, .. } | ProofExpr::Exists { body, .. } => eval_expr(body, env),
210        _ => None,
211    }
212}
213
214fn eval_cmp(t: &ProofTerm, env: &BTreeMap<String, i64>) -> Option<bool> {
215    let ProofTerm::Function(name, args) = t else { return None };
216    if args.len() != 2 {
217        return None;
218    }
219    let a = eval_term(&args[0], env)?;
220    let b = eval_term(&args[1], env)?;
221    match name.as_str() {
222        "le" => Some(a <= b),
223        "lt" => Some(a < b),
224        "ge" => Some(a >= b),
225        "gt" => Some(a > b),
226        _ => None,
227    }
228}
229
230fn eval_term(t: &ProofTerm, env: &BTreeMap<String, i64>) -> Option<i64> {
231    match t {
232        ProofTerm::Constant(s) => s
233            .parse::<i64>()
234            .ok()
235            .or_else(|| env.get(s).copied()),
236        ProofTerm::Variable(s) => env.get(s).copied(),
237        ProofTerm::Function(name, args) if args.len() == 2 => {
238            let a = eval_term(&args[0], env)?;
239            let b = eval_term(&args[1], env)?;
240            match name.as_str() {
241                "add" => a.checked_add(b),
242                "sub" => a.checked_sub(b),
243                "mul" => a.checked_mul(b),
244                _ => None,
245            }
246        }
247        _ => None,
248    }
249}
250
251// --- propositional model ----------------------------------------------------
252
253/// Enumerate truth assignments to the ground atoms of a propositional problem,
254/// returning the first that satisfies all premises and falsifies the goal.
255/// Bounded to a small number of atoms (an exhaustive 2ⁿ scan).
256fn propositional_model(premises: &[ProofExpr], goal: &ProofExpr) -> Option<Counterexample> {
257    let mut atoms: BTreeSet<String> = BTreeSet::new();
258    for p in premises {
259        prop_atoms(p, &mut atoms);
260    }
261    prop_atoms(goal, &mut atoms);
262    let atoms: Vec<String> = atoms.into_iter().collect();
263    if atoms.is_empty() || atoms.len() > 16 {
264        return None;
265    }
266    for mask in 0u32..(1u32 << atoms.len()) {
267        let mut val = BTreeMap::new();
268        for (i, a) in atoms.iter().enumerate() {
269            val.insert(a.clone(), (mask >> i) & 1 == 1);
270        }
271        let premises_hold = premises.iter().all(|p| eval_prop(p, &val) == Some(true));
272        let goal_false = eval_prop(goal, &val) == Some(false);
273        if premises_hold && goal_false {
274            return Some(Counterexample::Valuation(
275                atoms.iter().map(|a| (a.clone(), val[a])).collect(),
276            ));
277        }
278    }
279    None
280}
281
282/// The canonical string key of a ground atom (`P(a, b)`, `Atom`, `a = b`).
283fn atom_key(e: &ProofExpr) -> Option<String> {
284    match e {
285        ProofExpr::Predicate { .. } | ProofExpr::Atom(_) | ProofExpr::Identity(..) => {
286            Some(format!("{e}"))
287        }
288        _ => None,
289    }
290}
291
292fn prop_atoms(e: &ProofExpr, out: &mut BTreeSet<String>) {
293    match e {
294        ProofExpr::And(l, r)
295        | ProofExpr::Or(l, r)
296        | ProofExpr::Implies(l, r)
297        | ProofExpr::Iff(l, r) => {
298            prop_atoms(l, out);
299            prop_atoms(r, out);
300        }
301        ProofExpr::Not(p) => prop_atoms(p, out),
302        _ => {
303            if let Some(k) = atom_key(e) {
304                out.insert(k);
305            }
306        }
307    }
308}
309
310fn eval_prop(e: &ProofExpr, val: &BTreeMap<String, bool>) -> Option<bool> {
311    match e {
312        ProofExpr::And(l, r) => Some(eval_prop(l, val)? && eval_prop(r, val)?),
313        ProofExpr::Or(l, r) => Some(eval_prop(l, val)? || eval_prop(r, val)?),
314        ProofExpr::Implies(l, r) => Some(!eval_prop(l, val)? || eval_prop(r, val)?),
315        ProofExpr::Iff(l, r) => Some(eval_prop(l, val)? == eval_prop(r, val)?),
316        ProofExpr::Not(p) => Some(!eval_prop(p, val)?),
317        _ => atom_key(e).and_then(|k| val.get(&k).copied()),
318    }
319}