Skip to main content

logicaffeine_proof/
grounding.rs

1//! Finite-domain grounding.
2//!
3//! A logic grid — and any finite, domain-closed problem — becomes DECIDABLE by
4//! expanding bounded quantifiers over the finite set of individuals named in the
5//! premises:
6//!
7//! ```text
8//! ∀x. φ(x)   over {a, b, c, d}   →   φ(a) ∧ φ(b) ∧ φ(c) ∧ φ(d)
9//! ∃x. φ(x)   over {a, b, c, d}   →   φ(a) ∨ φ(b) ∨ φ(c) ∨ φ(d)
10//! ```
11//!
12//! The result is QUANTIFIER-FREE, so our kernel proves it (certified, explainable)
13//! and Z3 decides it in milliseconds — no e-matching, no search blowup, guaranteed
14//! to terminate. This is general and reusable: it knows nothing about any specific
15//! puzzle, only about quantifiers and the finite domain the premises declare.
16
17use crate::{ProofExpr, ProofTerm};
18
19/// Ground every quantifier in `e` over `domain` (Herbrand expansion): a universal
20/// becomes the conjunction of its instances, an existential the disjunction. The
21/// returned expression is quantifier-free when `domain` covers the universe.
22///
23/// Sound AND complete for a domain-CLOSED finite problem (every individual named):
24/// `∀x.φ ≡ ⋀_c φ[x:=c]` and `∃x.φ ≡ ⋁_c φ[x:=c]` exactly when the witness/
25/// counter-example is guaranteed to be one of the `c`.
26pub fn ground(e: &ProofExpr, domain: &[ProofTerm]) -> ProofExpr {
27    match e {
28        ProofExpr::ForAll { variable, body } => fold_conj(
29            domain
30                .iter()
31                .map(|c| ground(&subst(body, variable, c), domain)),
32        ),
33        ProofExpr::Exists { variable, body } => fold_disj(
34            domain
35                .iter()
36                .map(|c| ground(&subst(body, variable, c), domain)),
37        ),
38        ProofExpr::And(l, r) => {
39            ProofExpr::And(Box::new(ground(l, domain)), Box::new(ground(r, domain)))
40        }
41        ProofExpr::Or(l, r) => {
42            ProofExpr::Or(Box::new(ground(l, domain)), Box::new(ground(r, domain)))
43        }
44        ProofExpr::Implies(l, r) => {
45            ProofExpr::Implies(Box::new(ground(l, domain)), Box::new(ground(r, domain)))
46        }
47        ProofExpr::Iff(l, r) => {
48            ProofExpr::Iff(Box::new(ground(l, domain)), Box::new(ground(r, domain)))
49        }
50        ProofExpr::Not(x) => ProofExpr::Not(Box::new(ground(x, domain))),
51        ProofExpr::Temporal { operator, body } => ProofExpr::Temporal {
52            operator: operator.clone(),
53            body: Box::new(ground(body, domain)),
54        },
55        // Quantifier-free leaves (Predicate, Identity, Atom, Term, …) are unchanged.
56        leaf => leaf.clone(),
57    }
58}
59
60/// Left-fold instances into a conjunction; an empty domain makes `∀` vacuously true.
61fn fold_conj(mut it: impl Iterator<Item = ProofExpr>) -> ProofExpr {
62    match it.next() {
63        None => ProofExpr::Atom("True".to_string()),
64        Some(first) => it.fold(first, |acc, e| ProofExpr::And(Box::new(acc), Box::new(e))),
65    }
66}
67
68/// Left-fold instances into a disjunction; an empty domain makes `∃` false.
69fn fold_disj(mut it: impl Iterator<Item = ProofExpr>) -> ProofExpr {
70    match it.next() {
71        None => ProofExpr::Atom("False".to_string()),
72        Some(first) => it.fold(first, |acc, e| ProofExpr::Or(Box::new(acc), Box::new(e))),
73    }
74}
75
76/// Substitute the bound variable `var` with the term `to` throughout `e`, stopping
77/// at any quantifier that re-binds `var` (capture avoidance).
78fn subst(e: &ProofExpr, var: &str, to: &ProofTerm) -> ProofExpr {
79    match e {
80        ProofExpr::Predicate { name, args, world } => ProofExpr::Predicate {
81            name: name.clone(),
82            args: args.iter().map(|a| subst_term(a, var, to)).collect(),
83            world: world.clone(),
84        },
85        ProofExpr::Identity(a, b) => {
86            ProofExpr::Identity(subst_term(a, var, to), subst_term(b, var, to))
87        }
88        ProofExpr::And(l, r) => {
89            ProofExpr::And(Box::new(subst(l, var, to)), Box::new(subst(r, var, to)))
90        }
91        ProofExpr::Or(l, r) => {
92            ProofExpr::Or(Box::new(subst(l, var, to)), Box::new(subst(r, var, to)))
93        }
94        ProofExpr::Implies(l, r) => {
95            ProofExpr::Implies(Box::new(subst(l, var, to)), Box::new(subst(r, var, to)))
96        }
97        ProofExpr::Iff(l, r) => {
98            ProofExpr::Iff(Box::new(subst(l, var, to)), Box::new(subst(r, var, to)))
99        }
100        ProofExpr::Not(x) => ProofExpr::Not(Box::new(subst(x, var, to))),
101        ProofExpr::ForAll { variable, body } if variable != var => ProofExpr::ForAll {
102            variable: variable.clone(),
103            body: Box::new(subst(body, var, to)),
104        },
105        ProofExpr::Exists { variable, body } if variable != var => ProofExpr::Exists {
106            variable: variable.clone(),
107            body: Box::new(subst(body, var, to)),
108        },
109        ProofExpr::Temporal { operator, body } => ProofExpr::Temporal {
110            operator: operator.clone(),
111            body: Box::new(subst(body, var, to)),
112        },
113        ProofExpr::Term(t) => ProofExpr::Term(subst_term(t, var, to)),
114        // Re-bound quantifiers, Atom, etc. — left as-is.
115        other => other.clone(),
116    }
117}
118
119/// The DOMAIN of a problem: every constant named across `exprs`. For a
120/// domain-closed finite problem this is the universe of individuals to ground over.
121pub fn domain_constants(exprs: &[ProofExpr]) -> Vec<ProofTerm> {
122    let mut out: Vec<String> = Vec::new();
123    for e in exprs {
124        collect_constants(e, &mut out);
125    }
126    out.sort();
127    out.dedup();
128    out.into_iter().map(ProofTerm::Constant).collect()
129}
130
131fn collect_constants(e: &ProofExpr, out: &mut Vec<String>) {
132    fn term(t: &ProofTerm, out: &mut Vec<String>) {
133        match t {
134            ProofTerm::Constant(s) => out.push(s.clone()),
135            ProofTerm::Function(_, args) | ProofTerm::Group(args) => {
136                args.iter().for_each(|a| term(a, out))
137            }
138            _ => {}
139        }
140    }
141    match e {
142        ProofExpr::Predicate { args, .. } => args.iter().for_each(|a| term(a, out)),
143        ProofExpr::Identity(a, b) => {
144            term(a, out);
145            term(b, out);
146        }
147        ProofExpr::And(l, r)
148        | ProofExpr::Or(l, r)
149        | ProofExpr::Implies(l, r)
150        | ProofExpr::Iff(l, r) => {
151            collect_constants(l, out);
152            collect_constants(r, out);
153        }
154        ProofExpr::Not(x) => collect_constants(x, out),
155        ProofExpr::ForAll { body, .. }
156        | ProofExpr::Exists { body, .. }
157        | ProofExpr::Temporal { body, .. } => collect_constants(body, out),
158        ProofExpr::Term(t) => term(t, out),
159        _ => {}
160    }
161}
162
163use std::collections::HashMap;
164
165/// Per-sort domains: for each UNARY predicate used as a sort guard (`Trip`,
166/// `Year`, …), the constants asserted to have it. Built from the ground facts the
167/// declarations produce (`Trip(Alpha)`, `Trip(Beta)`, …). This is what lets
168/// grounding expand `∀x(Trip(x)→…)` over the 4 trips, not the whole universe.
169pub fn sort_domains(premises: &[ProofExpr]) -> HashMap<String, Vec<ProofTerm>> {
170    fn collect(e: &ProofExpr, m: &mut HashMap<String, Vec<ProofTerm>>) {
171        match e {
172            ProofExpr::Predicate { name, args, .. } if args.len() == 1 => {
173                if let ProofTerm::Constant(_) = &args[0] {
174                    let dom = m.entry(name.clone()).or_default();
175                    if !dom.contains(&args[0]) {
176                        dom.push(args[0].clone());
177                    }
178                }
179            }
180            // Sort facts are ground premises, possibly under top-level conjunction;
181            // do NOT descend into quantifiers (those are rules, not facts).
182            ProofExpr::And(l, r) => {
183                collect(l, m);
184                collect(r, m);
185            }
186            _ => {}
187        }
188    }
189    let mut m = HashMap::new();
190    for p in premises {
191        collect(p, &mut m);
192    }
193    m
194}
195
196/// The unary-predicate SORT guarding `var` in `e` (e.g. `Trip` in `Trip(x) → …` or
197/// `Trip(x) ∧ …`), if any — used to pick the right finite domain to ground over.
198fn guard_sort(e: &ProofExpr, var: &str) -> Option<String> {
199    match e {
200        ProofExpr::Predicate { name, args, .. }
201            if args.len() == 1 && matches!(&args[0], ProofTerm::Variable(v) | ProofTerm::BoundVarRef(v) if v == var) =>
202        {
203            Some(name.clone())
204        }
205        ProofExpr::And(l, r)
206        | ProofExpr::Or(l, r)
207        | ProofExpr::Implies(l, r)
208        | ProofExpr::Iff(l, r) => guard_sort(l, var).or_else(|| guard_sort(r, var)),
209        ProofExpr::Not(x) | ProofExpr::Temporal { body: x, .. } => guard_sort(x, var),
210        // Recurse through a nested quantifier (that does not re-bind `var`) so an
211        // of-pair "∃x(∃y(Trip(x) ∧ … ))" still finds x's guard `Trip` and grounds x
212        // over the 4 trips, not the whole universe (where a phantom non-trip could
213        // satisfy it).
214        ProofExpr::ForAll { variable, body } | ProofExpr::Exists { variable, body }
215            if variable != var =>
216        {
217            guard_sort(body, var)
218        }
219        _ => None,
220    }
221}
222
223/// SORT-AWARE grounding: a guarded quantifier expands only over its sort's domain
224/// (`∀x(Trip(x)→…)` over the trips), falling back to `fallback` (the full universe)
225/// for an unsorted quantifier. Sound for a domain-closed problem and dramatically
226/// smaller than universe-grounding — the optimization the larger puzzles need.
227pub fn ground_sorted(
228    e: &ProofExpr,
229    sorts: &HashMap<String, Vec<ProofTerm>>,
230    fallback: &[ProofTerm],
231) -> ProofExpr {
232    let domain_for = |body: &ProofExpr, var: &str| -> Vec<ProofTerm> {
233        guard_sort(body, var)
234            .and_then(|s| sorts.get(&s).cloned())
235            .unwrap_or_else(|| fallback.to_vec())
236    };
237    match e {
238        ProofExpr::ForAll { variable, body } => {
239            let dom = domain_for(body, variable);
240            fold_conj(
241                dom.iter()
242                    .map(|c| ground_sorted(&subst(body, variable, c), sorts, fallback)),
243            )
244        }
245        ProofExpr::Exists { variable, body } => {
246            let dom = domain_for(body, variable);
247            fold_disj(
248                dom.iter()
249                    .map(|c| ground_sorted(&subst(body, variable, c), sorts, fallback)),
250            )
251        }
252        ProofExpr::And(l, r) => ProofExpr::And(
253            Box::new(ground_sorted(l, sorts, fallback)),
254            Box::new(ground_sorted(r, sorts, fallback)),
255        ),
256        ProofExpr::Or(l, r) => ProofExpr::Or(
257            Box::new(ground_sorted(l, sorts, fallback)),
258            Box::new(ground_sorted(r, sorts, fallback)),
259        ),
260        ProofExpr::Implies(l, r) => ProofExpr::Implies(
261            Box::new(ground_sorted(l, sorts, fallback)),
262            Box::new(ground_sorted(r, sorts, fallback)),
263        ),
264        ProofExpr::Iff(l, r) => ProofExpr::Iff(
265            Box::new(ground_sorted(l, sorts, fallback)),
266            Box::new(ground_sorted(r, sorts, fallback)),
267        ),
268        ProofExpr::Not(x) => ProofExpr::Not(Box::new(ground_sorted(x, sorts, fallback))),
269        ProofExpr::Temporal { operator, body } => ProofExpr::Temporal {
270            operator: operator.clone(),
271            body: Box::new(ground_sorted(body, sorts, fallback)),
272        },
273        leaf => leaf.clone(),
274    }
275}
276
277/// [`ground_problem`] but sort-aware (each quantifier grounds over its guard
278/// sort). The default entry point for finite-domain solving.
279pub fn ground_problem_sorted(
280    premises: &[ProofExpr],
281    goal: &ProofExpr,
282) -> (Vec<ProofExpr>, ProofExpr) {
283    let mut all: Vec<ProofExpr> = premises.to_vec();
284    all.push(goal.clone());
285    let fallback = domain_constants(&all);
286    let sorts = sort_domains(premises);
287    let gp = premises
288        .iter()
289        .map(|p| ground_sorted(p, &sorts, &fallback))
290        .collect();
291    let gg = ground_sorted(goal, &sorts, &fallback);
292    (gp, gg)
293}
294
295/// Ground a whole problem — premises AND goal — over the domain of constants they
296/// name. The returned premises and goal are quantifier-free (so the kernel proves
297/// them, certified, and Z3 decides them instantly).
298pub fn ground_problem(
299    premises: &[ProofExpr],
300    goal: &ProofExpr,
301) -> (Vec<ProofExpr>, ProofExpr) {
302    let mut all: Vec<ProofExpr> = premises.to_vec();
303    all.push(goal.clone());
304    let domain = domain_constants(&all);
305    let grounded_premises = premises.iter().map(|p| ground(p, &domain)).collect();
306    let grounded_goal = ground(goal, &domain);
307    (grounded_premises, grounded_goal)
308}
309
310/// "Exactly one φ" entails "at most one φ" — for every premise of the form
311/// `∃x(φ(x) ∧ ∀y(φ(y) → y = x))` (what `Cardinal(1)` produces), emit the PAIRWISE
312/// uniqueness `∀x∀y((φ(x) ∧ φ(y)) → x = y)`.
313///
314/// Why: the existence form grounds to a DISJUNCTION whose disjuncts nest the
315/// uniqueness rule, which the certified backward-chainer cannot use directly; the
316/// pairwise form grounds to plain conjunctive implications it discharges by forward
317/// chaining (the shape `grounded_two_value_grid_proves_with_our_kernel` proves). The
318/// lemma is logically ENTAILED by the original, so adding it is sound, and it is
319/// GENERAL over any "exactly one" statement — never tied to a particular puzzle.
320pub fn at_most_one_lemmas(premises: &[ProofExpr]) -> Vec<ProofExpr> {
321    premises.iter().filter_map(at_most_one_of).collect()
322}
323
324/// FUNCTIONALITY — the missing half of a grid's bijection. Each closure
325/// `∀x(guard(x) → L1 ∨ L2 ∨ … ∨ Ln)` says a row takes AT LEAST one value; this emits
326/// the AT MOST ONE companion `∀x(Li → ¬Lj)` for every ordered pair of distinct
327/// disjuncts. A trip is in exactly one state (one year, …), so a value assigned to a
328/// row EXCLUDES the row's other values — the propagation that, together with
329/// value-uniqueness, determines the whole grid. Sound: for a square grid functionality
330/// is entailed by closure + value-uniqueness + counting, and it is in any case the
331/// inherent single-valuedness of the attribute. Each `Li` must be an atom mentioning
332/// the bound variable (a genuine closure), so unrelated `∀→∨` premises are skipped.
333pub fn functionality_lemmas(premises: &[ProofExpr]) -> Vec<ProofExpr> {
334    let mut out = Vec::new();
335    for p in premises {
336        let ProofExpr::ForAll { variable, body } = p else {
337            continue;
338        };
339        let ProofExpr::Implies(_guard, cons) = body.as_ref() else {
340            continue;
341        };
342        let lits = flatten_or(cons);
343        let all_atoms_on_var = lits.len() >= 2
344            && lits.iter().all(|l| {
345                matches!(l, ProofExpr::Predicate { args, .. }
346                    if args.iter().any(|a| matches!(a, ProofTerm::Variable(v) | ProofTerm::BoundVarRef(v) if v == variable)))
347            });
348        if !all_atoms_on_var {
349            continue;
350        }
351        for (i, li) in lits.iter().enumerate() {
352            for (j, lj) in lits.iter().enumerate() {
353                if i == j {
354                    continue;
355                }
356                out.push(ProofExpr::ForAll {
357                    variable: variable.clone(),
358                    body: Box::new(ProofExpr::Implies(
359                        Box::new(li.clone()),
360                        Box::new(ProofExpr::Not(Box::new(lj.clone()))),
361                    )),
362                });
363            }
364        }
365    }
366    out
367}
368
369/// HIDDEN SINGLE — the COLUMN closure, dual of the row closure. Each `Exactly one φ`
370/// premise (`∃x(φ(x) ∧ ∀y(φ(y) → y = x))`, e.g. "exactly one trip is in Florida")
371/// entails that AT LEAST ONE row holds the value: `⋁_{r∈dom} value(r)`. The row closure
372/// says a row takes SOME value; this says a value is taken by SOME row. Together with
373/// functionality (a row takes at most one value), it is the deduction a human calls a
374/// "hidden single" — once every other row is excluded from a value, the remaining row
375/// MUST hold it, and the EXISTING literal unit-propagation forces it.
376///
377/// Grounded over the guard sort's finite domain into a pure value-literal disjunction
378/// (the sort guard `Trip(r)` is dropped — every `r` in the domain is a trip). Sound:
379/// `∃x φ(x)` is just the existence half of "exactly one", here merely grounded — `compile`
380/// otherwise SKIPS the `∃`, so the solver never sees the column closure without this.
381/// Structural over any declared square bijection; never keyed to a particular puzzle.
382pub fn column_closure_lemmas(premises: &[ProofExpr]) -> Vec<ProofExpr> {
383    let domains = sort_domains(premises);
384    let mut flat: Vec<ProofExpr> = Vec::new();
385    for p in premises {
386        flatten_and_into(p, &mut flat);
387    }
388    let mut out = Vec::new();
389    for p in &flat {
390        // Only the bare "exactly one" existentials — `at_most_one_of` recognises exactly
391        // that shape (nested ∃∃ of-pairs and definite descriptions are NOT it).
392        if at_most_one_of(p).is_none() {
393            continue;
394        }
395        let ProofExpr::Exists { variable: x, body } = p else {
396            continue;
397        };
398        let ProofExpr::And(phi_x, _uniq) = body.as_ref() else {
399            continue;
400        };
401        let Some(sort) = guard_sort(phi_x, x) else {
402            continue;
403        };
404        let Some(domain) = domains.get(&sort) else {
405            continue;
406        };
407        if domain.len() < 2 {
408            continue;
409        }
410        // The VALUE part of φ (φ with the sort guard `sort(x)` stripped), grounded at
411        // each row of the domain and OR-folded: `value(r1) ∨ … ∨ value(rn)`.
412        let value = strip_sort_guard(phi_x, &sort, x);
413        let mut disj: Option<ProofExpr> = None;
414        for r in domain {
415            let lit_r = subst(&value, x, r);
416            disj = Some(match disj {
417                None => lit_r,
418                Some(acc) => ProofExpr::Or(Box::new(acc), Box::new(lit_r)),
419            });
420        }
421        if let Some(d) = disj {
422            out.push(d);
423        }
424    }
425    out
426}
427
428/// Drop the unary sort-guard conjunct `sort(var)` from a (conjunctive) property `phi`,
429/// leaving the value literal(s). `Trip(x) ∧ In(x, Florida) ↦ In(x, Florida)`.
430fn strip_sort_guard(phi: &ProofExpr, sort: &str, var: &str) -> ProofExpr {
431    let is_guard = |e: &ProofExpr| {
432        matches!(e, ProofExpr::Predicate { name, args, .. }
433            if name == sort && args.len() == 1
434                && matches!(&args[0], ProofTerm::Variable(v) | ProofTerm::BoundVarRef(v) if v == var))
435    };
436    if let ProofExpr::And(l, r) = phi {
437        if is_guard(l) {
438            return strip_sort_guard(r, sort, var);
439        }
440        if is_guard(r) {
441            return strip_sort_guard(l, sort, var);
442        }
443        return ProofExpr::And(
444            Box::new(strip_sort_guard(l, sort, var)),
445            Box::new(strip_sort_guard(r, sort, var)),
446        );
447    }
448    phi.clone()
449}
450
451fn flatten_or(e: &ProofExpr) -> Vec<ProofExpr> {
452    match e {
453        ProofExpr::Or(l, r) => {
454            let mut v = flatten_or(l);
455            v.extend(flatten_or(r));
456            v
457        }
458        _ => vec![e.clone()],
459    }
460}
461
462/// Discharge known ground UNARY facts (`Trip(Alpha)`, …) throughout the premises:
463/// each is true, so replace it with `True` and simplify `True ∧ X ↦ X`,
464/// `True → X ↦ X`. Unit propagation on the sort facts — it strips the sort guards out
465/// of every grounded clause (`(Trip(x) ∧ In(x,FL)) ∧ … ↦ In(x,FL) ∧ …`, and a closure
466/// `Trip(t) → D ↦ D` becomes a bare disjunction fact), leaving the pure value-literal
467/// clauses propagation runs on in ONE step. Sound: a true conjunct/antecedent carries
468/// no information.
469pub fn discharge_unary_facts(premises: &[ProofExpr]) -> Vec<ProofExpr> {
470    // Collect ground unary facts from FLATTENED premises — a multi-entity declaration
471    // ("Alpha, Beta, Gamma, Delta are four different trips") arrives as one conjunction,
472    // so `Trip(Alpha)…` live nested inside it, not as top-level premises.
473    let mut flat: Vec<ProofExpr> = Vec::new();
474    for p in premises {
475        flatten_and_into(p, &mut flat);
476    }
477    let mut facts: Vec<(String, String)> = Vec::new();
478    for p in &flat {
479        if let ProofExpr::Predicate { name, args, .. } = p {
480            if let [ProofTerm::Constant(c)] = args.as_slice() {
481                let key = (name.clone(), c.clone());
482                if !facts.contains(&key) {
483                    facts.push(key);
484                }
485            }
486        }
487    }
488    premises.iter().map(|p| discharge(p, &facts)).collect()
489}
490
491fn is_true(e: &ProofExpr) -> bool {
492    matches!(e, ProofExpr::Atom(s) if s == "True")
493}
494
495fn is_false(e: &ProofExpr) -> bool {
496    matches!(e, ProofExpr::Atom(s) if s == "False")
497}
498
499/// Fold away trivial (reflexive) identities and the boolean constants they create: a
500/// grounded `∃x∃y` of-pair has a DIAGONAL instance (`x = y = c`) carrying `¬(c = c)`,
501/// which is `False`, so that disjunct must drop. Without it the solver would face an
502/// unsatisfiable disjunct it cannot refute (the kernel has no reflexivity rule to prove
503/// `c = c`). `Identity(c,c) ↦ True`, `¬True ↦ False`, then `∧`/`∨`/`→` constant-fold.
504pub fn simplify_trivial_identities(premises: &[ProofExpr]) -> Vec<ProofExpr> {
505    premises.iter().map(simplify_ident).collect()
506}
507
508fn simplify_ident(e: &ProofExpr) -> ProofExpr {
509    match e {
510        ProofExpr::Identity(a, b) if a == b => ProofExpr::Atom("True".to_string()),
511        ProofExpr::Not(inner) => {
512            let s = simplify_ident(inner);
513            if is_true(&s) {
514                ProofExpr::Atom("False".to_string())
515            } else if is_false(&s) {
516                ProofExpr::Atom("True".to_string())
517            } else {
518                ProofExpr::Not(Box::new(s))
519            }
520        }
521        ProofExpr::And(l, r) => {
522            let (l, r) = (simplify_ident(l), simplify_ident(r));
523            if is_false(&l) || is_false(&r) {
524                ProofExpr::Atom("False".to_string())
525            } else if is_true(&l) {
526                r
527            } else if is_true(&r) {
528                l
529            } else {
530                ProofExpr::And(Box::new(l), Box::new(r))
531            }
532        }
533        ProofExpr::Or(l, r) => {
534            let (l, r) = (simplify_ident(l), simplify_ident(r));
535            if is_true(&l) || is_true(&r) {
536                ProofExpr::Atom("True".to_string())
537            } else if is_false(&l) {
538                r
539            } else if is_false(&r) {
540                l
541            } else {
542                ProofExpr::Or(Box::new(l), Box::new(r))
543            }
544        }
545        ProofExpr::Implies(l, r) => {
546            let (l, r) = (simplify_ident(l), simplify_ident(r));
547            if is_false(&l) || is_true(&r) {
548                ProofExpr::Atom("True".to_string())
549            } else if is_true(&l) {
550                r
551            } else {
552                ProofExpr::Implies(Box::new(l), Box::new(r))
553            }
554        }
555        ProofExpr::ForAll { variable, body } => ProofExpr::ForAll {
556            variable: variable.clone(),
557            body: Box::new(simplify_ident(body)),
558        },
559        ProofExpr::Exists { variable, body } => ProofExpr::Exists {
560            variable: variable.clone(),
561            body: Box::new(simplify_ident(body)),
562        },
563        other => other.clone(),
564    }
565}
566
567fn discharge(e: &ProofExpr, facts: &[(String, String)]) -> ProofExpr {
568    match e {
569        ProofExpr::Predicate { name, args, .. } => {
570            if let [ProofTerm::Constant(c)] = args.as_slice() {
571                if facts.iter().any(|(n, k)| n == name && k == c) {
572                    return ProofExpr::Atom("True".to_string());
573                }
574            }
575            e.clone()
576        }
577        ProofExpr::And(l, r) => {
578            let (l, r) = (discharge(l, facts), discharge(r, facts));
579            match (is_true(&l), is_true(&r)) {
580                (true, _) => r,
581                (_, true) => l,
582                _ => ProofExpr::And(Box::new(l), Box::new(r)),
583            }
584        }
585        ProofExpr::Implies(l, r) => {
586            let (l, r) = (discharge(l, facts), discharge(r, facts));
587            if is_true(&l) {
588                r
589            } else {
590                ProofExpr::Implies(Box::new(l), Box::new(r))
591            }
592        }
593        ProofExpr::Or(l, r) => {
594            ProofExpr::Or(Box::new(discharge(l, facts)), Box::new(discharge(r, facts)))
595        }
596        ProofExpr::Not(x) => ProofExpr::Not(Box::new(discharge(x, facts))),
597        ProofExpr::ForAll { variable, body } => ProofExpr::ForAll {
598            variable: variable.clone(),
599            body: Box::new(discharge(body, facts)),
600        },
601        ProofExpr::Exists { variable, body } => ProofExpr::Exists {
602            variable: variable.clone(),
603            body: Box::new(discharge(body, facts)),
604        },
605        ProofExpr::Temporal { operator, body } => ProofExpr::Temporal {
606            operator: operator.clone(),
607            body: Box::new(discharge(body, facts)),
608        },
609        other => other.clone(),
610    }
611}
612
613fn at_most_one_of(e: &ProofExpr) -> Option<ProofExpr> {
614    let ProofExpr::Exists { variable: x, body } = e else {
615        return None;
616    };
617    let ProofExpr::And(phi_x, uniq) = body.as_ref() else {
618        return None;
619    };
620    let ProofExpr::ForAll { variable: y, body: uniq_body } = uniq.as_ref() else {
621        return None;
622    };
623    // The inner ∀ must be the uniqueness clause `φ(y) → y = x` (either order).
624    let ProofExpr::Implies(_phi_y, ident) = uniq_body.as_ref() else {
625        return None;
626    };
627    let ProofExpr::Identity(l, r) = ident.as_ref() else {
628        return None;
629    };
630    let is_var = |t: &ProofTerm, name: &str| {
631        matches!(t, ProofTerm::Variable(v) | ProofTerm::BoundVarRef(v) if v == name)
632    };
633    if !((is_var(l, y) && is_var(r, x)) || (is_var(l, x) && is_var(r, y))) {
634        return None;
635    }
636    // ∀x∀y'((φ(x) ∧ φ(y')) → x = y'), with a fresh y' so it never collides with x.
637    let y2 = format!("{x}_amo");
638    let phi_x = phi_x.as_ref().clone();
639    let phi_y2 = subst(&phi_x, x, &ProofTerm::Variable(y2.clone()));
640    let body = ProofExpr::Implies(
641        Box::new(ProofExpr::And(Box::new(phi_x), Box::new(phi_y2))),
642        Box::new(ProofExpr::Identity(
643            ProofTerm::Variable(x.clone()),
644            ProofTerm::Variable(y2.clone()),
645        )),
646    );
647    Some(ProofExpr::ForAll {
648        variable: x.clone(),
649        body: Box::new(ProofExpr::ForAll {
650            variable: y2,
651            body: Box::new(body),
652        }),
653    })
654}
655
656/// "The unique D-trip has property P." Each single-`∃` definite-description clue
657/// `∃x(D(x) ∧ … ∧ P(x))` whose anchor `D` is a SINGLETON grid value (it carries an
658/// `Exactly one …` premise, so the description denotes a unique row) entails the
659/// propagatable rule `∀t(D(t) → P(t))`; when `P` is ALSO a singleton value the
660/// biconditional `∀t(P(t) → D(t))` too — e.g. "the Florida trip is the hunting trip"
661/// ⇒ `In(t,Florida) ↔ Hunt(t)`. Sound: a singleton value names a unique row, so any row
662/// holding it IS that row and therefore carries the clue's property. This replaces the
663/// existential (whose sort-aware grounding still explodes to a row-disjunction) with
664/// row-indexed implications the incremental solver propagates.
665///
666/// Structural only — singleton-ness is read from the `Exactly one` premises, never the
667/// literal value names — so it is not specific to any one puzzle. Nested `∃x∃y`
668/// of-pair clues are left untouched (they ground to compound clauses the DPLL decides).
669pub fn definite_property_implications(premises: &[ProofExpr]) -> Vec<ProofExpr> {
670    let row_sorts = row_sort_predicates(premises);
671    let singletons = singleton_value_signatures(premises, &row_sorts);
672    let mut flat: Vec<ProofExpr> = Vec::new();
673    for p in premises {
674        flatten_and_into(p, &mut flat);
675    }
676    let mut out = Vec::new();
677    for p in &flat {
678        let ProofExpr::Exists { variable: x, body } = p else {
679            continue;
680        };
681        // Nested ∃∃ (of-pair) and the bare `Exactly one` premises are not definite
682        // descriptions of a property — leave them to grounding / at-most-one.
683        if matches!(body.as_ref(), ProofExpr::Exists { .. }) || at_most_one_of(p).is_some() {
684            continue;
685        }
686        let lits = clue_literals_on(body, x, &row_sorts);
687        for (i, anchor) in lits.iter().enumerate() {
688            let Some(sig) = positive_signature(anchor) else {
689                continue;
690            };
691            if !singletons.contains(&sig) {
692                continue;
693            }
694            for (j, other) in lits.iter().enumerate() {
695                if i == j {
696                    continue;
697                }
698                out.push(ProofExpr::ForAll {
699                    variable: x.clone(),
700                    body: Box::new(ProofExpr::Implies(Box::new(anchor.clone()), Box::new(other.clone()))),
701                });
702            }
703        }
704    }
705    out
706}
707
708fn flatten_and_into(e: &ProofExpr, out: &mut Vec<ProofExpr>) {
709    match e {
710        ProofExpr::And(l, r) => {
711            flatten_and_into(l, out);
712            flatten_and_into(r, out);
713        }
714        _ => out.push(e.clone()),
715    }
716}
717
718/// Unary predicate names that appear as a ground fact (`Trip(Alpha)`): the row sort
719/// guard, dropped from clue bodies so only the value/property literals remain.
720fn row_sort_predicates(premises: &[ProofExpr]) -> std::collections::HashSet<String> {
721    let mut flat: Vec<ProofExpr> = Vec::new();
722    for p in premises {
723        flatten_and_into(p, &mut flat);
724    }
725    let mut s = std::collections::HashSet::new();
726    for p in &flat {
727        if let ProofExpr::Predicate { name, args, .. } = p {
728            if matches!(args.as_slice(), [ProofTerm::Constant(_)]) {
729                s.insert(name.clone());
730            }
731        }
732    }
733    s
734}
735
736/// `(predicate, value)` signatures of the singleton grid values — the value literal of
737/// every `Exactly one …` (`∃x((guard(x) ∧ φ(x)) ∧ ∀y(φ(y) → y = x))`) premise.
738fn singleton_value_signatures(
739    premises: &[ProofExpr],
740    row_sorts: &std::collections::HashSet<String>,
741) -> std::collections::HashSet<(String, String)> {
742    let mut s = std::collections::HashSet::new();
743    for p in premises {
744        let ProofExpr::Exists { variable: x, body } = p else {
745            continue;
746        };
747        let ProofExpr::And(phi, uniq) = body.as_ref() else {
748            continue;
749        };
750        if !matches!(uniq.as_ref(), ProofExpr::ForAll { .. }) {
751            continue;
752        }
753        for lit in clue_literals_on(phi, x, row_sorts) {
754            if let Some(sig) = positive_signature(&lit) {
755                s.insert(sig);
756            }
757        }
758    }
759    s
760}
761
762/// `(name, value)` for a positive value literal (`In(x,Florida)` → `("In","Florida")`,
763/// the unary `Hunt(x)` → `("Hunt","")`); `None` for negatives / non-value atoms.
764fn positive_signature(e: &ProofExpr) -> Option<(String, String)> {
765    let ProofExpr::Predicate { name, args, .. } = e else {
766        return None;
767    };
768    match args.as_slice() {
769        [_, ProofTerm::Constant(v)] => Some((name.clone(), v.clone())),
770        [_] => Some((name.clone(), String::new())),
771        _ => None,
772    }
773}
774
775fn term_is_var(t: &ProofTerm, x: &str) -> bool {
776    matches!(t, ProofTerm::Variable(v) | ProofTerm::BoundVarRef(v) if v == x)
777}
778
779fn mentions_var(args: &[ProofTerm], x: &str) -> bool {
780    args.iter().any(|a| term_is_var(a, x))
781}
782
783/// The literal value/property conjuncts of a clue body that mention `x`: a conjunction
784/// is split, the row-sort guard is dropped, uniqueness `∀` clauses are ignored, and a
785/// negated conjunction `¬(guard(x) ∧ L(x))` collapses to `¬L(x)`.
786fn clue_literals_on(
787    e: &ProofExpr,
788    x: &str,
789    row_sorts: &std::collections::HashSet<String>,
790) -> Vec<ProofExpr> {
791    match e {
792        ProofExpr::And(l, r) => {
793            let mut v = clue_literals_on(l, x, row_sorts);
794            v.extend(clue_literals_on(r, x, row_sorts));
795            v
796        }
797        ProofExpr::Predicate { name, args, .. } => {
798            if mentions_var(args, x) && !row_sorts.contains(name) {
799                vec![e.clone()]
800            } else {
801                vec![]
802            }
803        }
804        ProofExpr::Not(inner) => match drop_row_guards(inner, x, row_sorts) {
805            Some(lit) => vec![ProofExpr::Not(Box::new(lit))],
806            None => vec![],
807        },
808        _ => vec![],
809    }
810}
811
812/// Strip row-sort guards from a (possibly conjunctive) expression, returning the single
813/// remaining value literal on `x` (or `None` if it is not a clean single literal).
814fn drop_row_guards(
815    e: &ProofExpr,
816    x: &str,
817    row_sorts: &std::collections::HashSet<String>,
818) -> Option<ProofExpr> {
819    let mut lits = clue_literals_on(e, x, row_sorts);
820    if lits.len() == 1 {
821        Some(lits.pop().unwrap())
822    } else {
823        None
824    }
825}
826
827fn subst_term(t: &ProofTerm, var: &str, to: &ProofTerm) -> ProofTerm {
828    match t {
829        ProofTerm::Variable(x) if x == var => to.clone(),
830        ProofTerm::BoundVarRef(x) if x == var => to.clone(),
831        ProofTerm::Function(name, args) => {
832            ProofTerm::Function(name.clone(), args.iter().map(|a| subst_term(a, var, to)).collect())
833        }
834        ProofTerm::Group(args) => {
835            ProofTerm::Group(args.iter().map(|a| subst_term(a, var, to)).collect())
836        }
837        other => other.clone(),
838    }
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844
845    fn c(s: &str) -> ProofTerm {
846        ProofTerm::Constant(s.to_string())
847    }
848    fn v(s: &str) -> ProofTerm {
849        ProofTerm::Variable(s.to_string())
850    }
851    fn pred(name: &str, args: Vec<ProofTerm>) -> ProofExpr {
852        ProofExpr::Predicate {
853            name: name.to_string(),
854            args,
855            world: None,
856        }
857    }
858    fn forall(var: &str, body: ProofExpr) -> ProofExpr {
859        ProofExpr::ForAll {
860            variable: var.to_string(),
861            body: Box::new(body),
862        }
863    }
864    fn exists(var: &str, body: ProofExpr) -> ProofExpr {
865        ProofExpr::Exists {
866            variable: var.to_string(),
867            body: Box::new(body),
868        }
869    }
870
871    /// Clue 2 ("The Florida trip was the hunting trip") — both `In(_,Florida)` and
872    /// `Hunt(_)` are singletons (each has an `Exactly one` premise), so the definite
873    /// description becomes the BICONDITIONAL `In(t,Florida) ↔ Hunt(t)` (two rules).
874    /// Clue 4 ("The Yvonne trip wasn't in Kentucky") yields only the forward rule
875    /// `With(t,Yvonne) → ¬In(t,Kentucky)` (the negative property is not a singleton).
876    #[test]
877    fn definite_descriptions_become_implications() {
878        let trip = |t: ProofTerm| pred("Trip", vec![t]);
879        let in_ = |t: ProofTerm, s: &str| pred("In", vec![t, c(s)]);
880        let with = |t: ProofTerm, f: &str| pred("With", vec![t, c(f)]);
881        let hunt = |t: ProofTerm| pred("Hunt", vec![t]);
882        // Exactly-one premises that establish the singletons.
883        let exactly_one = |phi: ProofExpr, val: ProofExpr| {
884            exists(
885                "x",
886                ProofExpr::And(
887                    Box::new(ProofExpr::And(Box::new(trip(v("x"))), Box::new(phi))),
888                    Box::new(forall(
889                        "y",
890                        ProofExpr::Implies(
891                            Box::new(ProofExpr::And(Box::new(trip(v("y"))), Box::new(val))),
892                            Box::new(ProofExpr::Identity(v("y"), v("x"))),
893                        ),
894                    )),
895                ),
896            )
897        };
898        // Clue 2: ∃x(((Trip(x)∧In(x,Florida)) ∧ ∀y(...→y=x)) ∧ (Hunt(x)∧Trip(x)))
899        let clue2 = exists(
900            "x",
901            ProofExpr::And(
902                Box::new(ProofExpr::And(
903                    Box::new(ProofExpr::And(Box::new(trip(v("x"))), Box::new(in_(v("x"), "Florida")))),
904                    Box::new(forall(
905                        "y",
906                        ProofExpr::Implies(
907                            Box::new(ProofExpr::And(Box::new(trip(v("y"))), Box::new(in_(v("y"), "Florida")))),
908                            Box::new(ProofExpr::Identity(v("y"), v("x"))),
909                        ),
910                    )),
911                )),
912                Box::new(ProofExpr::And(Box::new(hunt(v("x"))), Box::new(trip(v("x"))))),
913            ),
914        );
915        // Clue 4: ∃x(((Trip(x)∧With(x,Yvonne)) ∧ ∀y(...)) ∧ ¬In(x,Kentucky))
916        let clue4 = exists(
917            "x",
918            ProofExpr::And(
919                Box::new(ProofExpr::And(
920                    Box::new(ProofExpr::And(Box::new(trip(v("x"))), Box::new(with(v("x"), "Yvonne")))),
921                    Box::new(forall(
922                        "y",
923                        ProofExpr::Implies(
924                            Box::new(ProofExpr::And(Box::new(trip(v("y"))), Box::new(with(v("y"), "Yvonne")))),
925                            Box::new(ProofExpr::Identity(v("y"), v("x"))),
926                        ),
927                    )),
928                )),
929                Box::new(ProofExpr::Not(Box::new(in_(v("x"), "Kentucky")))),
930            ),
931        );
932        let premises = vec![
933            trip(c("Alpha")),
934            exactly_one(in_(v("x"), "Florida"), in_(v("y"), "Florida")),
935            exactly_one(hunt(v("x")), hunt(v("y"))),
936            exactly_one(with(v("x"), "Yvonne"), with(v("y"), "Yvonne")),
937            clue2,
938            clue4,
939        ];
940        let imps = definite_property_implications(&premises);
941        let fwd = |a: ProofExpr, b: ProofExpr| ProofExpr::ForAll {
942            variable: "x".to_string(),
943            body: Box::new(ProofExpr::Implies(Box::new(a), Box::new(b))),
944        };
945        assert!(
946            imps.contains(&fwd(in_(v("x"), "Florida"), hunt(v("x")))),
947            "clue2 forward In(Florida)→Hunt missing; got: {imps:?}"
948        );
949        assert!(
950            imps.contains(&fwd(hunt(v("x")), in_(v("x"), "Florida"))),
951            "clue2 reverse Hunt→In(Florida) missing (biconditional); got: {imps:?}"
952        );
953        assert!(
954            imps.contains(&fwd(with(v("x"), "Yvonne"), ProofExpr::Not(Box::new(in_(v("x"), "Kentucky"))))),
955            "clue4 forward With(Yvonne)→¬In(Kentucky) missing; got: {imps:?}"
956        );
957        // Clue 4 has no reverse (the negative property is not a singleton anchor).
958        assert!(
959            !imps.iter().any(|e| matches!(e, ProofExpr::ForAll { body, .. }
960                if matches!(body.as_ref(), ProofExpr::Implies(a, _) if matches!(a.as_ref(), ProofExpr::Not(_))))),
961            "no rule may have a negative antecedent; got: {imps:?}"
962        );
963    }
964
965    #[test]
966    fn grounds_universal_to_conjunction() {
967        // ∀x. P(x)  over {a, b}  →  P(a) ∧ P(b)
968        let e = forall("x", pred("P", vec![v("x")]));
969        let g = ground(&e, &[c("a"), c("b")]);
970        let expected = ProofExpr::And(
971            Box::new(pred("P", vec![c("a")])),
972            Box::new(pred("P", vec![c("b")])),
973        );
974        assert_eq!(g, expected);
975    }
976
977    #[test]
978    fn grounds_existential_to_disjunction() {
979        // ∃x. P(x)  over {a, b}  →  P(a) ∨ P(b)
980        let e = exists("x", pred("P", vec![v("x")]));
981        let g = ground(&e, &[c("a"), c("b")]);
982        let expected = ProofExpr::Or(
983            Box::new(pred("P", vec![c("a")])),
984            Box::new(pred("P", vec![c("b")])),
985        );
986        assert_eq!(g, expected);
987    }
988
989    fn is_quantifier_free(e: &ProofExpr) -> bool {
990        match e {
991            ProofExpr::ForAll { .. } | ProofExpr::Exists { .. } => false,
992            ProofExpr::And(l, r)
993            | ProofExpr::Or(l, r)
994            | ProofExpr::Implies(l, r)
995            | ProofExpr::Iff(l, r) => is_quantifier_free(l) && is_quantifier_free(r),
996            ProofExpr::Not(x) | ProofExpr::Temporal { body: x, .. } => is_quantifier_free(x),
997            _ => true,
998        }
999    }
1000
1001    #[test]
1002    fn domain_is_the_named_constants() {
1003        let premises = vec![
1004            pred("Trip", vec![c("Alpha")]),
1005            ProofExpr::And(
1006                Box::new(pred("Trip", vec![c("Beta")])),
1007                Box::new(pred("In", vec![c("Beta"), c("Maine")])),
1008            ),
1009        ];
1010        let d = domain_constants(&premises);
1011        assert_eq!(d, vec![c("Alpha"), c("Beta"), c("Maine")]);
1012    }
1013
1014    #[test]
1015    fn grounding_the_exactly_one_form_leaves_no_quantifier() {
1016        // The shape that hung Z3: ∃x(P(x) ∧ ∀y(P(y) → y = x)).
1017        let inner = ProofExpr::And(
1018            Box::new(pred("P", vec![v("x")])),
1019            Box::new(forall(
1020                "y",
1021                ProofExpr::Implies(
1022                    Box::new(pred("P", vec![v("y")])),
1023                    Box::new(ProofExpr::Identity(v("y"), v("x"))),
1024                ),
1025            )),
1026        );
1027        let e = exists("x", inner);
1028        let g = ground(&e, &[c("a"), c("b")]);
1029        assert!(is_quantifier_free(&g), "grounded exactly-one must be quantifier-free: {g:?}");
1030    }
1031
1032    #[test]
1033    fn kernel_does_grounded_disjunctive_syllogism() {
1034        // The core step the grounded grid needs: In(Beta,FL) ∨ In(Beta,ME), ¬In(Beta,FL) ⊢ In(Beta,ME).
1035        let in_ = |t: &str, s: &str| pred("In", vec![c(t), c(s)]);
1036        let premises = vec![
1037            ProofExpr::Or(Box::new(in_("Beta", "Florida")), Box::new(in_("Beta", "Maine"))),
1038            ProofExpr::Not(Box::new(in_("Beta", "Florida"))),
1039        ];
1040        let goal = in_("Beta", "Maine");
1041        let r = crate::verify::prove_certify_check(&premises, &goal);
1042        assert!(r.verified, "grounded disjunctive syllogism; err: {:?}", r.verification_error);
1043    }
1044
1045    // GROUNDING makes the grid quantifier-free and DECIDABLE, and our kernel then
1046    // CERTIFIES it — no Z3. The disjunctive syllogism needs `¬In(Beta,FL)`, which the
1047    // kernel DERIVES by proof-by-contradiction (assume In(Beta,FL); the grounded
1048    // uniqueness forces Beta=Alpha; ⊥ with ¬Alpha=Beta). This is the certified-kernel
1049    // analogue of the Z3-grounded solve: same grounded premises, our own proof term.
1050    #[test]
1051    fn grounded_two_value_grid_proves_with_our_kernel() {
1052        // The 2-value bijection that the kernel could NOT do while quantified, now
1053        // GROUNDED → quantifier-free → our kernel proves it (certified). Florida is
1054        // taken by Alpha (exactly-one), so Beta — the other trip — is in Maine.
1055        let trip = |t: ProofTerm| pred("Trip", vec![t]);
1056        let in_ = |t: ProofTerm, s: ProofTerm| pred("In", vec![t, s]);
1057        let fl = || c("Florida");
1058        let me_ = || c("Maine");
1059        // ∀x(Trip(x) → In(x,FL) ∨ In(x,ME))
1060        let closure = forall(
1061            "x",
1062            ProofExpr::Implies(
1063                Box::new(trip(v("x"))),
1064                Box::new(ProofExpr::Or(
1065                    Box::new(in_(v("x"), fl())),
1066                    Box::new(in_(v("x"), me_())),
1067                )),
1068            ),
1069        );
1070        // PAIRWISE uniqueness (what exactly-one entails): ∀x∀y((Trip(x)∧In(x,FL)) ∧
1071        // (Trip(y)∧In(y,FL)) → x=y). Grounds to a direct implication per pair.
1072        let exactly_one_fl = forall(
1073            "x",
1074            forall(
1075                "y",
1076                ProofExpr::Implies(
1077                    Box::new(ProofExpr::And(
1078                        Box::new(ProofExpr::And(Box::new(trip(v("x"))), Box::new(in_(v("x"), fl())))),
1079                        Box::new(ProofExpr::And(Box::new(trip(v("y"))), Box::new(in_(v("y"), fl())))),
1080                    )),
1081                    Box::new(ProofExpr::Identity(v("x"), v("y"))),
1082                ),
1083            ),
1084        );
1085        let premises = vec![
1086            trip(c("Alpha")),
1087            trip(c("Beta")),
1088            ProofExpr::Not(Box::new(ProofExpr::Identity(c("Alpha"), c("Beta")))),
1089            closure,
1090            exactly_one_fl,
1091            in_(c("Alpha"), fl()),
1092        ];
1093        let goal = in_(c("Beta"), me_());
1094        let (gp, gg) = ground_problem(&premises, &goal);
1095        let r = crate::verify::prove_certify_check(&gp, &gg);
1096        assert!(
1097            r.verified,
1098            "grounded grid must prove via our kernel; err: {:?}",
1099            r.verification_error
1100        );
1101    }
1102
1103    // The SAME 2-value bijection, but with the uniqueness premise in the form the
1104    // PARSER actually produces for "Exactly one trip is in Florida":
1105    //   ∃x((Trip(x) ∧ In(x,FL)) ∧ ∀y((Trip(y) ∧ In(y,FL)) → y = x))
1106    // Grounding the ∃ yields a DISJUNCTION (one disjunct per trip), unlike the
1107    // hand-built pairwise ∀x∀y form above which grounds to a conjunction. This pins
1108    // the REAL grid shape: the kernel must case-split on the grounded existence and,
1109    // in the In(Beta,FL) branch, derive Beta=Alpha ⊥ to conclude In(Beta,Maine).
1110    #[test]
1111    fn grounded_exactly_one_existential_grid_proves_with_our_kernel() {
1112        let trip = |t: ProofTerm| pred("Trip", vec![t]);
1113        let in_ = |t: ProofTerm, s: ProofTerm| pred("In", vec![t, s]);
1114        let fl = || c("Florida");
1115        let me_ = || c("Maine");
1116        let closure = forall(
1117            "x",
1118            ProofExpr::Implies(
1119                Box::new(trip(v("x"))),
1120                Box::new(ProofExpr::Or(
1121                    Box::new(in_(v("x"), fl())),
1122                    Box::new(in_(v("x"), me_())),
1123                )),
1124            ),
1125        );
1126        // φ(t) = Trip(t) ∧ In(t, FL)
1127        let phi = |t: ProofTerm| {
1128            ProofExpr::And(Box::new(trip(t.clone())), Box::new(in_(t, fl())))
1129        };
1130        let exactly_one_fl = exists(
1131            "x",
1132            ProofExpr::And(
1133                Box::new(phi(v("x"))),
1134                Box::new(forall(
1135                    "y",
1136                    ProofExpr::Implies(
1137                        Box::new(phi(v("y"))),
1138                        Box::new(ProofExpr::Identity(v("y"), v("x"))),
1139                    ),
1140                )),
1141            ),
1142        );
1143        let premises = vec![
1144            trip(c("Alpha")),
1145            trip(c("Beta")),
1146            ProofExpr::Not(Box::new(ProofExpr::Identity(c("Alpha"), c("Beta")))),
1147            closure,
1148            exactly_one_fl,
1149            in_(c("Alpha"), fl()),
1150        ];
1151        let goal = in_(c("Beta"), me_());
1152        // Grid-prep, exactly as the kernel solve path does it: augment the existence
1153        // form with its entailed pairwise at-most-one lemma, THEN ground.
1154        let mut prem = premises;
1155        prem.extend(at_most_one_lemmas(&prem));
1156        let (gp, gg) = ground_problem_sorted(&prem, &goal);
1157        let r = crate::verify::prove_certify_check(&gp, &gg);
1158        assert!(
1159            r.verified,
1160            "grounded exactly-one (∃∀) grid must prove via our kernel; err: {:?}",
1161            r.verification_error
1162        );
1163    }
1164
1165    #[test]
1166    /// A FULL-SIZE multi-category grid (4 trips × 3 four-value categories), solved by
1167    /// PROPAGATION — no Z3. Three states are pinned; the fourth trip's state is forced
1168    /// by elimination over the state closure + at-most-one. The YEAR and FRIEND
1169    /// categories are present (closures + at-most-one for every value) but irrelevant
1170    /// to the queried cell — exactly the noise that made the kernel's search EXPLODE
1171    /// before unit propagation. With BCP it must stay fast and certified.
1172    #[test]
1173    fn multi_category_grid_cell_forced_by_propagation() {
1174        let trips = ["Alpha", "Beta", "Gamma", "Delta"];
1175        let and = |l: ProofExpr, r: ProofExpr| ProofExpr::And(Box::new(l), Box::new(r));
1176        let implies = |l: ProofExpr, r: ProofExpr| ProofExpr::Implies(Box::new(l), Box::new(r));
1177        let neq = |a: &str, b: &str| ProofExpr::Not(Box::new(ProofExpr::Identity(c(a), c(b))));
1178        // A category: a closure per trip + pairwise at-most-one per value.
1179        let category = |rel: &str, vals: &[&str], out: &mut Vec<ProofExpr>| {
1180            for t in trips {
1181                out.push(fold_disj(vals.iter().map(|val| pred(rel, vec![c(t), c(val)]))));
1182            }
1183            for val in vals {
1184                for (i, t) in trips.iter().enumerate() {
1185                    for u in &trips[i + 1..] {
1186                        out.push(implies(
1187                            and(pred(rel, vec![c(t), c(val)]), pred(rel, vec![c(u), c(val)])),
1188                            ProofExpr::Identity(c(t), c(u)),
1189                        ));
1190                    }
1191                }
1192            }
1193        };
1194        let mut premises = Vec::new();
1195        // All trips pairwise distinct.
1196        for (i, t) in trips.iter().enumerate() {
1197            for u in &trips[i + 1..] {
1198                premises.push(neq(t, u));
1199            }
1200        }
1201        category("In", &["2001", "2002", "2003", "2004"], &mut premises); // noise
1202        category("In", &["CT", "FL", "KY", "ME"], &mut premises); // the queried category
1203        category("With", &["Bill", "Lillie", "Neal", "Yvonne"], &mut premises); // noise
1204        // Pin three states; the fourth (Delta → ME) is forced.
1205        premises.push(pred("In", vec![c("Alpha"), c("FL")]));
1206        premises.push(pred("In", vec![c("Beta"), c("KY")]));
1207        premises.push(pred("In", vec![c("Gamma"), c("CT")]));
1208        let goal = pred("In", vec![c("Delta"), c("ME")]);
1209        let r = crate::verify::prove_certify_check(&premises, &goal);
1210        assert!(
1211            r.verified,
1212            "Delta must be forced into ME by propagation (no Z3); err: {:?}",
1213            r.verification_error
1214        );
1215    }
1216
1217    /// An OF-PAIR clue ("of A and B, one is in Florida, the other with Neal") forces
1218    /// a third trip OUT of Florida — and only CASE ANALYSIS on the clue can show it:
1219    /// in BOTH arms Florida lands on A or B, so C (distinct, by at-most-one) is not in
1220    /// Florida. This exercises the DPLL decision (`DisjunctionCases`) layered on unit
1221    /// propagation, all kernel-certified, no Z3.
1222    #[test]
1223    fn of_pair_clue_forces_cell_by_case_analysis() {
1224        let and = |l: ProofExpr, r: ProofExpr| ProofExpr::And(Box::new(l), Box::new(r));
1225        let implies = |l: ProofExpr, r: ProofExpr| ProofExpr::Implies(Box::new(l), Box::new(r));
1226        let in_fl = |t: &str| pred("In", vec![c(t), c("FL")]);
1227        let neq = |a: &str, b: &str| ProofExpr::Not(Box::new(ProofExpr::Identity(c(a), c(b))));
1228        let amo = |t: &str, u: &str| {
1229            implies(and(in_fl(t), in_fl(u)), ProofExpr::Identity(c(t), c(u)))
1230        };
1231        let of_pair = ProofExpr::Or(
1232            Box::new(and(in_fl("A"), pred("With", vec![c("B"), c("Neal")]))),
1233            Box::new(and(in_fl("B"), pred("With", vec![c("A"), c("Neal")]))),
1234        );
1235        let premises = vec![
1236            of_pair,
1237            amo("A", "C"),
1238            amo("B", "C"),
1239            neq("A", "C"),
1240            neq("B", "C"),
1241            neq("A", "B"),
1242        ];
1243        let goal = ProofExpr::Not(Box::new(in_fl("C")));
1244        let r = crate::verify::prove_certify_check(&premises, &goal);
1245        assert!(
1246            r.verified,
1247            "the of-pair clue must force ¬In(C, FL) by case analysis (no Z3); err: {:?}",
1248            r.verification_error
1249        );
1250    }
1251
1252    /// The REAL of-pair shape a grid clue grounds to: a disjunction whose disjuncts
1253    /// are CONJUNCTIONS that themselves carry an inner either/or
1254    /// (`(A ∧ (P ∨ Q)) ∨ (Bad ∧ C)`). Refuting one outer disjunct by a false conjunct
1255    /// (`¬Bad`) collapses it (disjunctive syllogism over a compound disjunct), the
1256    /// survivor is DECOMPOSED to surface its inner `P ∨ Q`, and case analysis on that
1257    /// — both arms forcing `¬R` — discharges the goal. This is exactly the structure
1258    /// that made naive grounding explode; here it is propagated + decided, certified.
1259    #[test]
1260    fn compound_of_pair_disjunction_resolves() {
1261        let and = |l: ProofExpr, r: ProofExpr| ProofExpr::And(Box::new(l), Box::new(r));
1262        let or = |l: ProofExpr, r: ProofExpr| ProofExpr::Or(Box::new(l), Box::new(r));
1263        let implies = |l: ProofExpr, r: ProofExpr| ProofExpr::Implies(Box::new(l), Box::new(r));
1264        let not = |e: ProofExpr| ProofExpr::Not(Box::new(e));
1265        let a = pred("A", vec![c("Obj")]);
1266        let p = pred("P", vec![c("Obj")]);
1267        let q = pred("Q", vec![c("Obj")]);
1268        let bad = pred("Bad", vec![c("Obj")]);
1269        let cc = pred("C", vec![c("Obj")]);
1270        let r = pred("R", vec![c("Obj")]);
1271        let of_pair = or(and(a, or(p.clone(), q.clone())), and(bad.clone(), cc));
1272        let premises = vec![
1273            of_pair,
1274            not(bad),
1275            implies(p, not(r.clone())),
1276            implies(q, not(r.clone())),
1277        ];
1278        let goal = not(r);
1279        let res = crate::verify::prove_certify_check(&premises, &goal);
1280        assert!(
1281            res.verified,
1282            "compound of-pair must resolve by refute + decompose + case analysis; err: {:?}",
1283            res.verification_error
1284        );
1285    }
1286
1287    #[test]
1288    fn at_most_one_lemma_extracted_from_exactly_one() {
1289        // ∃x((Trip(x) ∧ In(x,FL)) ∧ ∀y((Trip(y) ∧ In(y,FL)) → y = x))
1290        let phi = |t: ProofTerm| {
1291            ProofExpr::And(
1292                Box::new(pred("Trip", vec![t.clone()])),
1293                Box::new(pred("In", vec![t, c("Florida")])),
1294            )
1295        };
1296        let exactly_one = exists(
1297            "x",
1298            ProofExpr::And(
1299                Box::new(phi(v("x"))),
1300                Box::new(forall(
1301                    "y",
1302                    ProofExpr::Implies(
1303                        Box::new(phi(v("y"))),
1304                        Box::new(ProofExpr::Identity(v("y"), v("x"))),
1305                    ),
1306                )),
1307            ),
1308        );
1309        let lemmas = at_most_one_lemmas(&[exactly_one]);
1310        assert_eq!(lemmas.len(), 1, "one exactly-one ⇒ one at-most-one lemma");
1311        // The lemma is a pairwise ∀∀ uniqueness implication (no existential).
1312        assert!(
1313            matches!(&lemmas[0], ProofExpr::ForAll { body, .. }
1314                if matches!(body.as_ref(), ProofExpr::ForAll { .. })),
1315            "lemma must be ∀x∀y(…); got {:?}",
1316            lemmas[0]
1317        );
1318        // A plain fact yields no lemma.
1319        assert!(at_most_one_lemmas(&[pred("Trip", vec![c("Alpha")])]).is_empty());
1320    }
1321
1322    fn conjuncts(e: &ProofExpr) -> usize {
1323        match e {
1324            ProofExpr::And(l, r) => conjuncts(l) + conjuncts(r),
1325            _ => 1,
1326        }
1327    }
1328
1329    #[test]
1330    fn sort_domains_collects_declared_members() {
1331        let premises = vec![
1332            pred("Trip", vec![c("Alpha")]),
1333            ProofExpr::And(
1334                Box::new(pred("Trip", vec![c("Beta")])),
1335                Box::new(pred("Year", vec![c("2001")])),
1336            ),
1337        ];
1338        let sorts = sort_domains(&premises);
1339        assert_eq!(sorts.get("Trip"), Some(&vec![c("Alpha"), c("Beta")]));
1340        assert_eq!(sorts.get("Year"), Some(&vec![c("2001")]));
1341    }
1342
1343    #[test]
1344    fn sort_aware_grounds_over_guard_sort_only() {
1345        // ∀x(Trip(x) → In(x, Maine)) grounds x over the 2 TRIPS, not the 4-constant
1346        // universe — the optimization that keeps grids tractable as they grow.
1347        let e = forall(
1348            "x",
1349            ProofExpr::Implies(
1350                Box::new(pred("Trip", vec![v("x")])),
1351                Box::new(pred("In", vec![v("x"), c("Maine")])),
1352            ),
1353        );
1354        let mut sorts = std::collections::HashMap::new();
1355        sorts.insert("Trip".to_string(), vec![c("Alpha"), c("Beta")]);
1356        let fallback = vec![c("Alpha"), c("Beta"), c("Maine"), c("Florida")];
1357        let g = ground_sorted(&e, &sorts, &fallback);
1358        assert_eq!(conjuncts(&g), 2, "should ground over the 2 trips only: {g:?}");
1359    }
1360
1361    #[test]
1362    fn ground_problem_removes_all_quantifiers() {
1363        let premises = vec![
1364            pred("Trip", vec![c("Alpha")]),
1365            forall(
1366                "x",
1367                ProofExpr::Implies(
1368                    Box::new(pred("Trip", vec![v("x")])),
1369                    Box::new(pred("In", vec![v("x"), c("Maine")])),
1370                ),
1371            ),
1372        ];
1373        let goal = pred("In", vec![c("Alpha"), c("Maine")]);
1374        let (gp, gg) = ground_problem(&premises, &goal);
1375        assert!(
1376            gp.iter().all(is_quantifier_free) && is_quantifier_free(&gg),
1377            "ground_problem must remove every quantifier"
1378        );
1379    }
1380}