Skip to main content

alkahest_cas/real/
cad.rs

1//! Cylindrical Algebraic Decomposition scaffolding and univariate quantifier
2//! elimination (V2-9).
3//!
4//! This module provides:
5//!
6//! - [`cad_project`] — Brown-style projection eliminating one variable via
7//!   discriminants (\(`\mathrm{res}(f, \partial_x f)`\)) and pairwise resultants.
8//! - [`cad_lift`] — produce isolating intervals for a squarefree algebraic
9//!   core built from polynomials in `main_var` (CAD lift stage along one axis).
10//! - [`decide`] — decides closed prenex formulas over a purely polynomial body
11//!   with rational/integer literals, for:
12//!   - **one quantifier**, one variable (`\exists x`, `\forall x`) — the
13//!     original V2-9 fragment;
14//!   - **two quantifiers over two distinct variables**, same flavor
15//!     (`\exists x \exists y`, `\forall x \forall y`) — decided by eliminating
16//!     `y` via [`cad_project`] and re-deciding the resulting univariate-in-`x`
17//!     sentence at each CAD cell with the one-variable engine;
18//!   - **mixed alternation** (`\exists x \forall y`, `\forall x \exists y`) —
19//!     reuses the same projection (it only depends on the atoms' polynomials,
20//!     not on which quantifier binds which variable), so it is decided by the
21//!     same cell-sampling scheme.
22//!
23//!   The two-variable path samples only *rational* points of each CAD cell of
24//!   `x` (open-cell midpoints, plus any projection root that happens to be
25//!   exactly rational). If some projection root is irrational **and** the body
26//!   contains an equality/inequation atom, that cell cannot be tested exactly
27//!   with rational arithmetic alone (it would need algebraic-number CAD
28//!   lifting); `decide` reports `Unsupported` in that case rather than risk an
29//!   unsound `true`/`false`. Pure-inequality bodies (no `==`/`!=`) never hit
30//!   this restriction, since a change of sign of `\exists y.\,\phi(x,y)`
31//!   across a projection root is always visible in the neighboring open cells.
32//!
33//! Three or more variables, or quantifier prefixes longer than two, are left
34//! for future passes (full CAD / general multivariate QE).
35
36use crate::diff::{diff, DiffError};
37use crate::errors::AlkahestError;
38use crate::kernel::expr::PredicateKind;
39use crate::kernel::subs;
40use crate::kernel::Domain;
41use crate::kernel::{ExprId, ExprPool};
42use crate::logic::{formula_from_expr, Formula, LogicError};
43use crate::poly::resultant::{self, resultant};
44use crate::poly::{
45    poly_normal, real_roots, ConversionError, RealRootError, ResultantError, RootInterval, UniPoly,
46};
47use std::collections::{BTreeSet, HashMap};
48use std::fmt;
49
50// ---------------------------------------------------------------------------
51// Errors and result wrapper
52// ---------------------------------------------------------------------------
53
54/// Errors from CAD helpers and [`decide`].
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum CadError {
57    NotPolynomial(ConversionError),
58    Diff(DiffError),
59    Resultant(ResultantError),
60    RealRoots(RealRootError),
61    Logic(LogicError),
62    /// Feature gap (nested quantifiers, parametric polynomials, transcendental atoms, …).
63    Unsupported(&'static str),
64}
65
66impl fmt::Display for CadError {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            CadError::NotPolynomial(e) => write!(f, "{e}"),
70            CadError::Diff(e) => write!(f, "{e}"),
71            CadError::Resultant(e) => write!(f, "{e}"),
72            CadError::RealRoots(e) => write!(f, "{e}"),
73            CadError::Logic(e) => write!(f, "{e}"),
74            CadError::Unsupported(s) => write!(f, "CAD: {s}"),
75        }
76    }
77}
78
79impl std::error::Error for CadError {}
80
81impl AlkahestError for CadError {
82    fn code(&self) -> &'static str {
83        match self {
84            CadError::NotPolynomial(e) => e.code(),
85            CadError::Diff(e) => e.code(),
86            CadError::Resultant(e) => e.code(),
87            CadError::RealRoots(e) => e.code(),
88            CadError::Logic(e) => e.code(),
89            CadError::Unsupported(_) => "E-CAD-001",
90        }
91    }
92
93    fn remediation(&self) -> Option<&'static str> {
94        match self {
95            CadError::NotPolynomial(e) => e.remediation(),
96            CadError::Diff(e) => e.remediation(),
97            CadError::Resultant(e) => e.remediation(),
98            CadError::RealRoots(e) => e.remediation(),
99            CadError::Logic(e) => e.remediation(),
100            CadError::Unsupported(_) => Some(
101                "use a purely polynomial constraint in one or two real variables with at most \
102                 a 2-quantifier prefix; deeper nesting and full multivariate QE are incremental",
103            ),
104        }
105    }
106}
107
108impl From<ConversionError> for CadError {
109    fn from(value: ConversionError) -> Self {
110        CadError::NotPolynomial(value)
111    }
112}
113
114impl From<DiffError> for CadError {
115    fn from(value: DiffError) -> Self {
116        CadError::Diff(value)
117    }
118}
119
120impl From<ResultantError> for CadError {
121    fn from(value: ResultantError) -> Self {
122        CadError::Resultant(value)
123    }
124}
125
126impl From<RealRootError> for CadError {
127    fn from(value: RealRootError) -> Self {
128        CadError::RealRoots(value)
129    }
130}
131
132impl From<LogicError> for CadError {
133    fn from(value: LogicError) -> Self {
134        CadError::Logic(value)
135    }
136}
137
138/// Outcome of real QE [`decide`].
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct QeResult {
141    pub truth: bool,
142    pub witness: Option<HashMap<ExprId, rug::Rational>>,
143}
144
145// ---------------------------------------------------------------------------
146// NNF helpers (localized copy of [`crate::logic`] helpers)
147// ---------------------------------------------------------------------------
148
149fn dual_kind(kind: PredicateKind) -> PredicateKind {
150    use PredicateKind::{Eq, Ge, Gt, Le, Lt, Ne};
151    match kind {
152        Lt => Ge,
153        Le => Gt,
154        Gt => Le,
155        Ge => Lt,
156        Eq => Ne,
157        Ne => Eq,
158        other => other,
159    }
160}
161
162fn is_rel(kind: &PredicateKind) -> bool {
163    use PredicateKind::*;
164    matches!(kind, Lt | Le | Gt | Ge | Eq | Ne)
165}
166
167fn simplify_formula_constants(f: Formula) -> Formula {
168    match f {
169        Formula::And(a, b) => {
170            let la = simplify_formula_constants(*a);
171            let lb = simplify_formula_constants(*b);
172            match (&la, &lb) {
173                (Formula::False, _) | (_, Formula::False) => Formula::False,
174                (Formula::True, x) => x.clone(),
175                (x, Formula::True) => x.clone(),
176                _ => Formula::and(la, lb),
177            }
178        }
179        Formula::Or(a, b) => {
180            let la = simplify_formula_constants(*a);
181            let lb = simplify_formula_constants(*b);
182            match (&la, &lb) {
183                (Formula::True, _) | (_, Formula::True) => Formula::True,
184                (Formula::False, x) => x.clone(),
185                (x, Formula::False) => x.clone(),
186                _ => Formula::or(la, lb),
187            }
188        }
189        Formula::Not(x) => Formula::not(simplify_formula_constants(*x)),
190        Formula::Forall { var, body } => Formula::Forall {
191            var,
192            body: Box::new(simplify_formula_constants(*body)),
193        },
194        Formula::Exists { var, body } => Formula::Exists {
195            var,
196            body: Box::new(simplify_formula_constants(*body)),
197        },
198        other => other,
199    }
200}
201
202fn nnf_formula(f: Formula) -> Formula {
203    match f {
204        Formula::Not(inner) => match *inner {
205            Formula::True => Formula::False,
206            Formula::False => Formula::True,
207            Formula::Not(g) => nnf_formula(*g),
208            Formula::And(a, b) => nnf_formula(Formula::or(Formula::not(*a), Formula::not(*b))),
209            Formula::Or(a, b) => nnf_formula(Formula::and(Formula::not(*a), Formula::not(*b))),
210            Formula::Forall { var, body } => nnf_formula(Formula::Exists {
211                var,
212                body: Box::new(Formula::not(*body)),
213            }),
214            Formula::Exists { var, body } => nnf_formula(Formula::Forall {
215                var,
216                body: Box::new(Formula::not(*body)),
217            }),
218            Formula::Atom {
219                kind: PredicateKind::True,
220                ..
221            } => Formula::False,
222            Formula::Atom {
223                kind: PredicateKind::False,
224                ..
225            } => Formula::True,
226            Formula::Atom { kind, args } if is_rel(&kind) => Formula::Atom {
227                kind: dual_kind(kind),
228                args,
229            },
230            inner => Formula::Not(Box::new(inner)),
231        },
232        Formula::And(a, b) => Formula::and(nnf_formula(*a), nnf_formula(*b)),
233        Formula::Or(a, b) => Formula::or(nnf_formula(*a), nnf_formula(*b)),
234        Formula::Forall { var, body } => Formula::Forall {
235            var,
236            body: Box::new(nnf_formula(*body)),
237        },
238        Formula::Exists { var, body } => Formula::Exists {
239            var,
240            body: Box::new(nnf_formula(*body)),
241        },
242        other => other,
243    }
244}
245
246// ---------------------------------------------------------------------------
247// Variable sets
248// ---------------------------------------------------------------------------
249
250fn insert_formula_vars(pool: &ExprPool, expr: ExprId, out: &mut BTreeSet<ExprId>) {
251    for v in resultant::collect_free_vars(expr, pool) {
252        out.insert(v);
253    }
254}
255
256fn free_vars_formula(f: &Formula, pool: &ExprPool) -> BTreeSet<ExprId> {
257    match f {
258        Formula::Atom { args, .. } => {
259            let mut s = BTreeSet::new();
260            for &a in args {
261                insert_formula_vars(pool, a, &mut s);
262            }
263            s
264        }
265        Formula::And(a, b) | Formula::Or(a, b) => {
266            let mut s = free_vars_formula(a, pool);
267            s.extend(free_vars_formula(b, pool));
268            s
269        }
270        Formula::Not(x) => free_vars_formula(x, pool),
271        Formula::Exists { var, body } => {
272            let mut s = free_vars_formula(body, pool);
273            s.insert(*var);
274            s
275        }
276        Formula::Forall { var, body } => {
277            let mut s = free_vars_formula(body, pool);
278            s.insert(*var);
279            s
280        }
281        Formula::True | Formula::False => BTreeSet::new(),
282    }
283}
284
285fn contains_quantifier(f: &Formula) -> bool {
286    match f {
287        Formula::Exists { .. } | Formula::Forall { .. } => true,
288        Formula::And(a, b) | Formula::Or(a, b) => contains_quantifier(a) || contains_quantifier(b),
289        Formula::Not(x) => contains_quantifier(x),
290        Formula::True | Formula::False | Formula::Atom { .. } => false,
291    }
292}
293
294fn is_quantifier_free(f: &Formula) -> bool {
295    !contains_quantifier(f)
296}
297
298fn free_vars_subset_of_binding(pool: &ExprPool, f: &Formula, allowed: &BTreeSet<ExprId>) -> bool {
299    free_vars_formula(f, pool).is_subset(allowed)
300}
301
302fn poly_exprs_from_atom(
303    pool: &ExprPool,
304    kind: &PredicateKind,
305    args: &[ExprId],
306    _quant_var: ExprId,
307) -> Result<Vec<ExprId>, CadError> {
308    use PredicateKind::{False, True};
309    if matches!(kind, True | False) {
310        return Ok(vec![]);
311    }
312    if !is_rel(kind) {
313        return Err(CadError::Unsupported(
314            "only relation atoms are supported in CAD QE",
315        ));
316    }
317    if args.len() != 2 {
318        return Err(CadError::Logic(LogicError::UnsupportedExpr(
319            "relational predicate arity must be 2",
320        )));
321    }
322    let lhs = args[0];
323    let rhs = args[1];
324    let lhs_mrhs = poly_diff(pool, lhs, rhs)?;
325    Ok(vec![lhs_mrhs])
326}
327
328fn poly_exprs_from_formula(
329    pool: &ExprPool,
330    f: &Formula,
331    var: ExprId,
332) -> Result<Vec<ExprId>, CadError> {
333    match f {
334        Formula::True | Formula::False => Ok(vec![]),
335        Formula::Atom { kind, args } => poly_exprs_from_atom(pool, kind, args, var),
336        Formula::Not(inner) => {
337            if let Formula::Atom { kind, args } = inner.as_ref() {
338                if is_rel(kind) {
339                    poly_exprs_from_atom(pool, kind, args, var)
340                } else {
341                    Err(CadError::Unsupported(
342                        "NOT is only supported on relation atoms",
343                    ))
344                }
345            } else {
346                Err(CadError::Unsupported(
347                    "`Not` expects a relational atom underneath in this CAD fragment",
348                ))
349            }
350        }
351        Formula::And(a, b) | Formula::Or(a, b) => {
352            let mut v = poly_exprs_from_formula(pool, a, var)?;
353            v.extend(poly_exprs_from_formula(pool, b, var)?);
354            Ok(v)
355        }
356        _ => Err(CadError::Unsupported(
357            "expected quantifier-free Boolean combination of polynomials",
358        )),
359    }
360}
361
362fn eq_polynomials_for_sampling(
363    pool: &ExprPool,
364    f: &Formula,
365    var: ExprId,
366) -> Result<Vec<UniPoly>, CadError> {
367    fn rec(
368        pool: &ExprPool,
369        f: &Formula,
370        var: ExprId,
371        out: &mut Vec<UniPoly>,
372    ) -> Result<(), CadError> {
373        match f {
374            // `Eq`, and also the *non-strict* inequalities.
375            //
376            // A `≤`/`≥` atom can be satisfied at nothing but its boundary:
377            // `x² ≤ 0` holds only at `x = 0`. The open-cell sampling above
378            // never lands on such a point, so omitting `Le`/`Ge` here made
379            // `∃x. x² ≤ 0` return false — and, by negation, made
380            // `∀x. x² > 0` return *true*, a proof of a false statement.
381            //
382            // Strict atoms need no boundary sample: their solution sets are
383            // open, so if non-empty they contain a whole interval and the
384            // open-cell pass is already complete for them.
385            Formula::Atom {
386                kind: PredicateKind::Eq | PredicateKind::Le | PredicateKind::Ge,
387                args,
388            } => {
389                if args.len() != 2 {
390                    return Err(CadError::Logic(LogicError::UnsupportedExpr(
391                        "comparison arity must be 2",
392                    )));
393                }
394                let d = UniPoly::from_symbolic_clear_denoms(
395                    poly_diff(pool, args[0], args[1])?,
396                    var,
397                    pool,
398                )?;
399                if !d.is_zero() {
400                    out.push(d);
401                }
402                Ok(())
403            }
404            Formula::And(a, b) | Formula::Or(a, b) => {
405                rec(pool, a, var, out)?;
406                rec(pool, b, var, out)
407            }
408            Formula::Not(x) => {
409                if let Formula::Atom {
410                    kind: PredicateKind::Eq | PredicateKind::Le | PredicateKind::Ge,
411                    args,
412                } = x.as_ref()
413                {
414                    // Negating any of these yields a *strict* atom (`≠`, `>`,
415                    // `<`), whose solution set is open — the open-cell pass
416                    // already covers it, so its boundary roots are irrelevant.
417                    let _ = args;
418                    Ok(())
419                } else {
420                    Err(CadError::Unsupported(
421                        "NOT over strict comparison unsupported for sampling roots",
422                    ))
423                }
424            }
425            _ => Ok(()),
426        }
427    }
428
429    let mut out = Vec::new();
430    rec(pool, f, var, &mut out)?;
431    Ok(out)
432}
433
434// ---------------------------------------------------------------------------
435// Polynomial utilities
436// ---------------------------------------------------------------------------
437
438fn poly_diff(pool: &ExprPool, lhs: ExprId, rhs: ExprId) -> Result<ExprId, CadError> {
439    let minus_one = pool.integer(-1_i32);
440    let neg_rhs = pool.mul(vec![minus_one, rhs]);
441    Ok(pool.add(vec![lhs, neg_rhs]))
442}
443
444fn combine_algebraic_master(main_var: ExprId, polys: &[UniPoly]) -> UniPoly {
445    let mut nz: Vec<UniPoly> = polys
446        .iter()
447        .filter(|p| !p.is_zero())
448        .map(|p| p.squarefree_part())
449        .collect();
450    if nz.is_empty() {
451        UniPoly::constant(main_var, 1)
452    } else {
453        let mut m = nz.swap_remove(0);
454        for q in nz {
455            m = UniPoly::lcm_poly(&m, &q);
456        }
457        m.squarefree_part()
458    }
459}
460
461/// Bisection budget for tightening an isolating interval.
462///
463/// 60 halvings shrink a bracket to ~1e-18 of its width, far below any gap
464/// these degree-bounded polynomials produce between distinct roots.
465const ISOLATION_REFINEMENTS: u32 = 60;
466
467/// Tighten an isolating interval around its single root by bisection.
468///
469/// Root isolation only promises "exactly one root in here", and the bracket it
470/// returns can be wide enough to swallow the neighbouring cell: the roots of
471/// `2x⁴ + x³ - 4x² + 3` isolate to `(-2,-1)` and the exact point `-1`, and the
472/// entire region where that polynomial is negative lies *inside* the first
473/// bracket. No midpoint of the raw breakpoints lands in it.
474///
475/// Sound because `master` is squarefree (see [`combine_algebraic_master`]), so
476/// every root is simple and a sign change is guaranteed across it. That is the
477/// precondition bisection needs — on a polynomial with an even-multiplicity
478/// root both endpoints share a sign and this would walk away from the root.
479fn refine_isolating(master: &UniPoly, iv: &RootInterval) -> (rug::Rational, rug::Rational) {
480    let mut lo = iv.lo.clone();
481    let mut hi = iv.hi.clone();
482
483    // Which endpoint's sign steers the bisection. An endpoint can itself be a
484    // root of `master` — of a *different* root than the one this bracket
485    // isolates, since neighbouring brackets share endpoints: `2x⁴+x³-4x²+3`
486    // isolates one root inside `(-2,-1)` while `-1` is another root outright.
487    // Collapsing the bracket onto such an endpoint throws away the root it was
488    // supposed to isolate, so steer by whichever endpoint has a usable sign.
489    let v_lo = master.eval_rational(&lo);
490    let v_hi = master.eval_rational(&hi);
491    let (anchor_positive, anchor_is_lo) = if v_lo != 0 {
492        (v_lo > 0, true)
493    } else if v_hi != 0 {
494        (v_hi > 0, false)
495    } else {
496        // Both endpoints are roots: nothing to bisect against. Leave the
497        // bracket alone rather than guess.
498        return (lo, hi);
499    };
500
501    for _ in 0..ISOLATION_REFINEMENTS {
502        let mid = iv_midpoint(&lo, &hi);
503        let v = master.eval_rational(&mid);
504        if v == 0 {
505            return (mid.clone(), mid);
506        }
507        // Move the endpoint on the anchor's side when the midpoint agrees with
508        // it; otherwise move the other one.
509        if ((v > 0) == anchor_positive) == anchor_is_lo {
510            lo = mid;
511        } else {
512            hi = mid;
513        }
514    }
515    (lo, hi)
516}
517
518fn cauchy_bound(p: &UniPoly) -> rug::Rational {
519    let coeffs = p.coefficients();
520    if coeffs.is_empty() || p.degree() <= 0 {
521        return rug::Rational::from((1_u32, 1_u32));
522    }
523    let n = coeffs.len() - 1;
524    let lead = coeffs[n].clone().abs();
525    if lead.is_zero() {
526        return rug::Rational::from((1_u32, 1_u32));
527    }
528    let mut num = rug::Integer::from(0);
529    for c in coeffs.iter().take(n) {
530        num += c.clone().abs();
531    }
532    let frac = rug::Rational::from((num, lead)) + rug::Rational::from(1);
533    frac + rug::Rational::from(1)
534}
535
536fn iv_midpoint(lo: &rug::Rational, hi: &rug::Rational) -> rug::Rational {
537    (lo.clone() + hi.clone()) / rug::Rational::from((2_u32, 1_u32))
538}
539
540// ---------------------------------------------------------------------------
541// Sign evaluation at a rational sample
542// ---------------------------------------------------------------------------
543
544fn cmp_atom(
545    pool: &ExprPool,
546    kind: &PredicateKind,
547    args: &[ExprId],
548    var: ExprId,
549    pt: &rug::Rational,
550) -> Result<bool, CadError> {
551    use PredicateKind::{Eq, False, Ge, Gt, Le, Lt, Ne, True};
552    if matches!(kind, True) {
553        return Ok(true);
554    }
555    if matches!(kind, False) {
556        return Ok(false);
557    }
558    let diff = UniPoly::from_symbolic_clear_denoms(poly_diff(pool, args[0], args[1])?, var, pool)?;
559    let v = diff.eval_rational(pt);
560    let z = rug::Rational::from(0);
561    Ok(match kind {
562        Eq => v == z,
563        Ne => v != z,
564        Lt => v < z,
565        Le => v <= z,
566        Gt => v > z,
567        Ge => v >= z,
568        _ => {
569            return Err(CadError::Unsupported("non-relational predicate in atom"));
570        }
571    })
572}
573
574fn eval_qf_formula(
575    pool: &ExprPool,
576    var: ExprId,
577    f: &Formula,
578    pt: &rug::Rational,
579) -> Result<bool, CadError> {
580    match f {
581        Formula::True => Ok(true),
582        Formula::False => Ok(false),
583        Formula::Atom { kind, args } => cmp_atom(pool, kind, args.as_slice(), var, pt),
584        Formula::And(a, b) => {
585            Ok(eval_qf_formula(pool, var, a, pt)? && eval_qf_formula(pool, var, b, pt)?)
586        }
587        Formula::Or(a, b) => {
588            Ok(eval_qf_formula(pool, var, a, pt)? || eval_qf_formula(pool, var, b, pt)?)
589        }
590        Formula::Not(x) => Ok(!eval_qf_formula(pool, var, x, pt)?),
591        _ => Err(CadError::Unsupported(
592            "quantifiers not allowed inside QF eval",
593        )),
594    }
595}
596
597fn intervals_overlap(a: &RootInterval, b: &RootInterval) -> bool {
598    !(a.hi < b.lo || b.hi < a.lo)
599}
600
601/// Some root of `g` lies in the closure of `iv` (overlap with an isolating interval of `squarefree(g)`).
602fn gcd_interval_shares_root_iv(g: &UniPoly, iv: &RootInterval) -> Result<bool, CadError> {
603    if g.is_zero() || g.degree() <= 0 {
604        return Ok(false);
605    }
606    let sg = g.squarefree_part();
607    let roots_g = real_roots(&sg)?;
608    Ok(roots_g.into_iter().any(|rj| intervals_overlap(iv, &rj)))
609}
610
611fn eval_qf_formula_on_iv(
612    pool: &ExprPool,
613    var: ExprId,
614    phi: &Formula,
615    iv: &RootInterval,
616    focus_sf: &UniPoly,
617) -> Result<bool, CadError> {
618    match phi {
619        Formula::True => Ok(true),
620        Formula::False => Ok(false),
621        Formula::Atom { kind, args } => {
622            use PredicateKind::{Eq, False, Ne, True};
623            if matches!(kind, True) {
624                return Ok(true);
625            }
626            if matches!(kind, False) {
627                return Ok(false);
628            }
629            let d_poly =
630                UniPoly::from_symbolic_clear_denoms(poly_diff(pool, args[0], args[1])?, var, pool)?;
631            if matches!(kind, Eq) {
632                let gx = focus_sf.gcd(&d_poly).unwrap_or_else(|| UniPoly::zero(var));
633                return gcd_interval_shares_root_iv(&gx, iv);
634            }
635            if matches!(kind, Ne) {
636                let gx = focus_sf.gcd(&d_poly).unwrap_or_else(|| UniPoly::zero(var));
637                return Ok(!gcd_interval_shares_root_iv(&gx, iv)?);
638            }
639            let mid = iv_midpoint(&iv.lo, &iv.hi);
640            eval_qf_formula(pool, var, phi, &mid)
641        }
642        Formula::And(a, b) => Ok(eval_qf_formula_on_iv(pool, var, a, iv, focus_sf)?
643            && eval_qf_formula_on_iv(pool, var, b, iv, focus_sf)?),
644        Formula::Or(a, b) => Ok(eval_qf_formula_on_iv(pool, var, a, iv, focus_sf)?
645            || eval_qf_formula_on_iv(pool, var, b, iv, focus_sf)?),
646        Formula::Not(x) => Ok(!eval_qf_formula_on_iv(pool, var, x, iv, focus_sf)?),
647        _ => Err(CadError::Unsupported(
648            "unexpected quantifier during CAD sample refinement",
649        )),
650    }
651}
652
653// ---------------------------------------------------------------------------
654// One-quantifier elimination (univariate)
655// ---------------------------------------------------------------------------
656
657fn decide_exists_univariate(
658    pool: &ExprPool,
659    var: ExprId,
660    phi: Formula,
661) -> Result<QeResult, CadError> {
662    let allowed: BTreeSet<ExprId> = [var].into_iter().collect();
663    if !free_vars_subset_of_binding(pool, &phi, &allowed) {
664        return Err(CadError::Unsupported(
665            "quantifier-free body may only reference the bound variable (constants allowed)",
666        ));
667    }
668
669    let poly_exprs = poly_exprs_from_formula(pool, &phi, var)?;
670    let mut polys_uni = Vec::<UniPoly>::new();
671    for e in poly_exprs.iter().copied() {
672        match UniPoly::from_symbolic_clear_denoms(e, var, pool) {
673            Ok(p) => {
674                if !p.is_zero() {
675                    polys_uni.push(p.clone());
676                }
677            }
678            Err(err) => return Err(CadError::NotPolynomial(err)),
679        }
680    }
681
682    let mut candidates: BTreeSet<rug::Rational> = BTreeSet::new();
683    let master = combine_algebraic_master(var, &polys_uni);
684    let br = cauchy_bound(&master);
685    let roots_iv = real_roots(&master)?;
686
687    let mut breakpoints: Vec<rug::Rational> = Vec::new();
688    breakpoints.push(-br.clone());
689    for iv in roots_iv.iter() {
690        breakpoints.push(iv.lo.clone());
691        breakpoints.push(iv.hi.clone());
692        // Tight brackets *in addition to* the raw ones. Adding them creates
693        // consecutive pairs that straddle the narrow cells between nearby
694        // roots, which the raw endpoints are often too coarse to expose.
695        //
696        // Additive on purpose: replacing the raw endpoints deletes the
697        // midpoints that were covering other cells, which is a net loss. Every
698        // candidate is verified exactly before it is accepted, so a larger
699        // breakpoint set can only find witnesses it was previously missing.
700        let (rlo, rhi) = refine_isolating(&master, iv);
701        breakpoints.push(rlo);
702        breakpoints.push(rhi);
703    }
704    breakpoints.push(br.clone());
705
706    breakpoints.sort();
707    breakpoints.dedup_by(|a, b| *a == *b);
708    // The breakpoints themselves, and the midpoints between them.
709    //
710    // Sampling only the midpoints misses cells that happen to sit *around* a
711    // breakpoint. `1 - 3x² + 2x³ + 3x⁴` isolates its roots to `(-2,-1)` and
712    // `(-1,0)` and is negative only between them — a band containing `x = -1`.
713    // The midpoints `-1.5` and `-0.5` both fall outside it, so `∃x. p < 0`
714    // came back false and `∀x. p > 0` came back *true*, a proof of a false
715    // statement.
716    //
717    // Adding candidates is monotonically safe: every candidate is checked by
718    // `eval_qf_formula` at that exact rational before it is accepted, so a
719    // larger sample set can only turn a missed witness into a found one — it
720    // can never manufacture a wrong answer.
721    for b in &breakpoints {
722        candidates.insert(b.clone());
723    }
724    for w in breakpoints.windows(2) {
725        let lo = &w[0];
726        let hi = &w[1];
727        if lo < hi {
728            candidates.insert(iv_midpoint(lo, hi));
729        }
730    }
731
732    for p in eq_polynomials_for_sampling(pool, &phi, var)? {
733        let sf = p.squarefree_part();
734        let riv = real_roots(&sf)?;
735        for iv in riv {
736            candidates.insert(iv_midpoint(&iv.lo, &iv.hi));
737        }
738    }
739
740    for pt in candidates {
741        if eval_qf_formula(pool, var, &phi, &pt)? {
742            let mut wm = HashMap::new();
743            wm.insert(var, pt.clone());
744            return Ok(QeResult {
745                truth: true,
746                witness: Some(wm),
747            });
748        }
749    }
750
751    // Algebraic equality literals are rarely satisfied exactly at purely rational samples;
752    // use isolating intervals of squarefree Eq-polynomial factors with gcd-based Eq checks.
753    let mut untested_algebraic_boundary = false;
754    for p_focus in eq_polynomials_for_sampling(pool, &phi, var)? {
755        let sf = p_focus.squarefree_part();
756        if sf.is_zero() {
757            continue;
758        }
759        for iv in real_roots(&sf)? {
760            if iv.lo != iv.hi {
761                untested_algebraic_boundary = true;
762            }
763            if eval_qf_formula_on_iv(pool, var, &phi, &iv, &sf)? {
764                // The witness is the *root* in `iv`, which is irrational
765                // whenever the bracket has not collapsed — and the bracket
766                // midpoint is then not a witness at all. `∃x. 3x − 2 = 0` used
767                // to come back with `x = 1/2`, which fails the very equation it
768                // is offered as a solution to. Report a witness only when it
769                // survives the same check any caller would apply.
770                let mid = iv_midpoint(&iv.lo, &iv.hi);
771                let witness = if eval_qf_formula(pool, var, &phi, &mid)? {
772                    let mut wm = HashMap::new();
773                    wm.insert(var, mid);
774                    Some(wm)
775                } else {
776                    None
777                };
778                return Ok(QeResult {
779                    truth: true,
780                    witness,
781                });
782            }
783        }
784    }
785
786    // Nothing satisfied the formula at any sampled point. That is only a *proof*
787    // of unsatisfiability if the sample set met every cell — including the
788    // zero-dimensional cells at the roots themselves, which are reachable only
789    // when the root is an exact rational. With an irrational root and a
790    // non-strict atom, the one point that could have satisfied the formula was
791    // never tested, and answering `false` here is how `∀x. (x² − 2)² > 0` came
792    // back `true`: a machine-checked-looking proof of a false theorem. Refuse.
793    if untested_algebraic_boundary && body_has_boundary_atom(&phi) {
794        return Err(CadError::Unsupported(ALGEBRAIC_BOUNDARY_MSG));
795    }
796
797    Ok(QeResult {
798        truth: false,
799        witness: None,
800    })
801}
802
803const ALGEBRAIC_BOUNDARY_MSG: &str = "the formula has a non-strict atom (=, <=, >=) whose only \
804    possible solutions are roots of an irrational algebraic number; deciding it needs \
805    algebraic-number CAD lifting (full CAD). Refusing rather than reporting an unsatisfiability \
806    that was never checked at that point";
807
808fn decide_closed_qf(pool: &ExprPool, phi: Formula) -> Result<QeResult, CadError> {
809    if !free_vars_formula(&phi, pool).is_empty() {
810        return Err(CadError::Unsupported(
811            "closed formula unexpectedly contains free symbols",
812        ));
813    }
814    let zero = rug::Rational::from(0);
815    let dummy = pool.symbol("__cad_iv_local", Domain::Real);
816    Ok(QeResult {
817        truth: eval_qf_formula(pool, dummy, &phi, &zero)?,
818        witness: None,
819    })
820}
821
822/// Quantifier flavor for the (at most two) leading blocks of a prenex sentence.
823#[derive(Debug, Clone, Copy, PartialEq, Eq)]
824enum Quant {
825    Exists,
826    Forall,
827}
828
829fn decide_formula_inner(pool: &ExprPool, phi: Formula) -> Result<QeResult, CadError> {
830    let phi = simplify_formula_constants(nnf_formula(phi));
831    if is_quantifier_free(&phi) {
832        return decide_closed_qf(pool, phi);
833    }
834    match phi {
835        Formula::Exists { var, body } => decide_quantified(pool, Quant::Exists, var, *body),
836        Formula::Forall { var, body } => decide_quantified(pool, Quant::Forall, var, *body),
837        Formula::True => Ok(QeResult {
838            truth: true,
839            witness: None,
840        }),
841        Formula::False => Ok(QeResult {
842            truth: false,
843            witness: None,
844        }),
845        _ => Err(CadError::Unsupported(
846            "sentence must begin with forall/exists after quantifiers are outermost",
847        )),
848    }
849}
850
851/// Dispatch on whether `body` is quantifier-free (one-variable fragment, V2-9) or
852/// itself begins with exactly one more quantifier (two-variable fragment).
853///
854/// Anything deeper (three or more nested quantifier blocks) is `Unsupported`.
855fn decide_quantified(
856    pool: &ExprPool,
857    outer_q: Quant,
858    outer_var: ExprId,
859    body: Formula,
860) -> Result<QeResult, CadError> {
861    if !contains_quantifier(&body) {
862        return decide_one_var(pool, outer_q, outer_var, body);
863    }
864    match body {
865        Formula::Exists {
866            var: inner_var,
867            body: inner_body,
868        } if !contains_quantifier(&inner_body) => decide_two_var(
869            pool,
870            outer_q,
871            outer_var,
872            Quant::Exists,
873            inner_var,
874            *inner_body,
875        ),
876        Formula::Forall {
877            var: inner_var,
878            body: inner_body,
879        } if !contains_quantifier(&inner_body) => decide_two_var(
880            pool,
881            outer_q,
882            outer_var,
883            Quant::Forall,
884            inner_var,
885            *inner_body,
886        ),
887        _ => Err(CadError::Unsupported(
888            "quantifier prefixes of length > 2 are not implemented",
889        )),
890    }
891}
892
893fn decide_one_var(
894    pool: &ExprPool,
895    q: Quant,
896    var: ExprId,
897    body: Formula,
898) -> Result<QeResult, CadError> {
899    match q {
900        Quant::Exists => decide_exists_univariate(pool, var, body),
901        Quant::Forall => {
902            let neg_body = nnf_formula(Formula::Not(Box::new(body)));
903            let inner = decide_exists_univariate(pool, var, neg_body)?;
904            Ok(QeResult {
905                truth: !inner.truth,
906                witness: None,
907            })
908        }
909    }
910}
911
912/// Two-variable, one-quantifier-alternation-block dispatcher.
913///
914/// `∃x∃y` and `∀x∀y` (same-flavor blocks) are decided directly by projecting `y`
915/// away via [`cad_project`] and re-deciding a univariate-in-`y` sentence at every
916/// rational sample of the resulting CAD cells of `x` (see [`decide_exists_exists`]
917/// for the soundness argument). Mixed alternation (`∃x∀y`, `∀x∃y`) reuses the same
918/// projection — the projection set only depends on the polynomials in the atoms,
919/// not on which quantifier binds which variable — so it is handled by the same
920/// machinery via De Morgan rewrites.
921fn decide_two_var(
922    pool: &ExprPool,
923    outer_q: Quant,
924    outer_var: ExprId,
925    inner_q: Quant,
926    inner_var: ExprId,
927    body: Formula,
928) -> Result<QeResult, CadError> {
929    match (outer_q, inner_q) {
930        (Quant::Exists, Quant::Exists) => decide_exists_exists(pool, outer_var, inner_var, body),
931        (Quant::Exists, Quant::Forall) => decide_exists_forall(pool, outer_var, inner_var, body),
932        (Quant::Forall, Quant::Forall) => {
933            // ∀x∀y φ  ≡  ¬∃x∃y ¬φ
934            let neg = nnf_formula(Formula::Not(Box::new(body)));
935            let inner = decide_exists_exists(pool, outer_var, inner_var, neg)?;
936            Ok(QeResult {
937                truth: !inner.truth,
938                witness: None,
939            })
940        }
941        (Quant::Forall, Quant::Exists) => {
942            // ∀x∃y φ  ≡  ¬∃x∀y ¬φ
943            let neg = nnf_formula(Formula::Not(Box::new(body)));
944            let inner = decide_exists_forall(pool, outer_var, inner_var, neg)?;
945            Ok(QeResult {
946                truth: !inner.truth,
947                witness: None,
948            })
949        }
950    }
951}
952
953/// Does `f` (in NNF) contain an atom whose truth can turn on a single boundary
954/// point — `=`, `≤` or `≥`?
955///
956/// Strict atoms (`<`, `>`, `≠`) have *open* solution sets: if one is satisfiable
957/// at all it is satisfiable on a whole interval, so the open-cell sampling in
958/// [`decide_exists_univariate`] is complete for them and a bracket that never
959/// lands on a root costs nothing. A non-strict atom can be satisfied at nothing
960/// but a root (`x² ≤ 0` holds only at `x = 0`), and then the root itself has to
961/// be in the sample set or the search is incomplete.
962fn body_has_boundary_atom(f: &Formula) -> bool {
963    match f {
964        Formula::Atom { kind, .. } => matches!(
965            kind,
966            PredicateKind::Eq | PredicateKind::Le | PredicateKind::Ge
967        ),
968        Formula::And(a, b) | Formula::Or(a, b) => {
969            body_has_boundary_atom(a) || body_has_boundary_atom(b)
970        }
971        Formula::Not(x) => body_has_boundary_atom(x),
972        _ => false,
973    }
974}
975
976/// Does `f` (in NNF) contain an atom that is not strict — `=`, `≠`, `≤` or `≥`?
977///
978/// The two-variable analogue of [`body_has_boundary_atom`], and the guard for
979/// [`project_and_sample_x`]'s `ambiguous_irrational_root`. `≤`/`≥` are the
980/// reason it exists: the original guard tested `=`/`≠` only, so
981/// `∃x∃y. (x² − 2)² + y² ≤ 0` — true at `(±√2, 0)` and nowhere else — was
982/// answered `false`, and its dual `∀x∀y. (x² − 2)² + y² > 0` came back `true`.
983/// That is the *same* completeness gap that
984/// [`decide_exists_univariate`] closes one dimension down: an atom whose
985/// solution set can be a single boundary point needs that point in the sample
986/// set, and no rational sample ever lands on an irrational projection root.
987///
988/// `≠` is kept in the set even though its solution set is open (so open-cell
989/// sampling is already complete for it): the pre-existing guard refused on it,
990/// and loosening a refusal is the one direction in which a change here could
991/// introduce an unsound answer.
992///
993/// Strict atoms (`<`, `>`) have open solution sets, so the open-cell midpoints
994/// are complete for them and no refusal is warranted.
995fn body_has_nonstrict_atom(f: &Formula) -> bool {
996    match f {
997        Formula::Atom { kind, .. } => matches!(
998            kind,
999            PredicateKind::Eq | PredicateKind::Ne | PredicateKind::Le | PredicateKind::Ge
1000        ),
1001        Formula::And(a, b) | Formula::Or(a, b) => {
1002            body_has_nonstrict_atom(a) || body_has_nonstrict_atom(b)
1003        }
1004        Formula::Not(x) => body_has_nonstrict_atom(x),
1005        _ => false,
1006    }
1007}
1008
1009/// Rebuild a symbolic rational-literal `ExprId` for `r`.
1010fn rational_to_expr(pool: &ExprPool, r: &rug::Rational) -> ExprId {
1011    if *r.denom() == 1_u32 {
1012        pool.integer(r.numer().clone())
1013    } else {
1014        pool.rational(r.numer().clone(), r.denom().clone())
1015    }
1016}
1017
1018/// Substitute `var -> value` (a rational-literal `ExprId`) throughout `body`,
1019/// round-tripping through the kernel expression DAG so ordinary polynomial
1020/// arithmetic (e.g. `0^2`) collapses without a separate simplification pass.
1021fn subst_body_var(
1022    pool: &ExprPool,
1023    body: &Formula,
1024    var: ExprId,
1025    value: ExprId,
1026) -> Result<Formula, CadError> {
1027    let expr = body.to_expr(pool);
1028    let mut map = HashMap::new();
1029    map.insert(var, value);
1030    let substituted = subs(expr, &map, pool);
1031    Ok(formula_from_expr(substituted, pool)?)
1032}
1033
1034/// CAD cells of `x` obtained by projecting `y` out of the atoms of a bivariate
1035/// formula: rational sample points (open-cell midpoints, plus any *exactly
1036/// rational* projection root) together with a flag noting whether some
1037/// projection-polynomial root is irrational and therefore untested.
1038struct XCells {
1039    candidates: Vec<rug::Rational>,
1040    ambiguous_irrational_root: bool,
1041}
1042
1043/// Compute [`XCells`] for `∃y`/`∀y`-eliminated `x` from the atoms of `body(x, y)`.
1044///
1045/// This is the shared "base case" of 2-variable CAD: project `y` away with
1046/// [`cad_project`] (Brown projection: discriminants + pairwise resultants),
1047/// combine the projected (and any `y`-free) polynomials into one squarefree
1048/// master polynomial in `x`, and isolate its real roots. Between and at those
1049/// roots, every `y`-dependent atom of `body` is sign-invariant in `x` — the
1050/// same guarantee [`decide_exists_univariate`] relies on for the univariate
1051/// fragment, one dimension up.
1052fn project_and_sample_x(
1053    pool: &ExprPool,
1054    x: ExprId,
1055    y: ExprId,
1056    body: &Formula,
1057) -> Result<XCells, CadError> {
1058    let allowed: BTreeSet<ExprId> = [x, y].into_iter().collect();
1059    if !free_vars_subset_of_binding(pool, body, &allowed) {
1060        return Err(CadError::Unsupported(
1061            "quantifier-free body may only reference the two bound variables (constants allowed)",
1062        ));
1063    }
1064
1065    let bivariate_polys = poly_exprs_from_formula(pool, body, y)?;
1066    let mut y_dep: Vec<ExprId> = Vec::new();
1067    let mut y_free: Vec<ExprId> = Vec::new();
1068    for e in bivariate_polys {
1069        if resultant::collect_free_vars(e, pool).contains(&y) {
1070            y_dep.push(e);
1071        } else {
1072            // Doesn't depend on `y` at all — it's already a constraint purely on
1073            // `x` (or a constant); carry it through unprojected.
1074            y_free.push(e);
1075        }
1076    }
1077
1078    let projected = cad_project(&y_dep, y, pool)?;
1079
1080    let mut polys_x_uni: Vec<UniPoly> = Vec::new();
1081    for e in projected.into_iter().chain(y_free) {
1082        match UniPoly::from_symbolic_clear_denoms(e, x, pool) {
1083            Ok(p) => {
1084                if !p.is_zero() {
1085                    polys_x_uni.push(p);
1086                }
1087            }
1088            Err(err) => return Err(CadError::NotPolynomial(err)),
1089        }
1090    }
1091
1092    let master = combine_algebraic_master(x, &polys_x_uni);
1093    let br = cauchy_bound(&master);
1094    let roots_iv = real_roots(&master)?;
1095
1096    let mut breakpoints: Vec<rug::Rational> = vec![-br.clone()];
1097    for iv in &roots_iv {
1098        breakpoints.push(iv.lo.clone());
1099        breakpoints.push(iv.hi.clone());
1100    }
1101    breakpoints.push(br);
1102    breakpoints.sort();
1103    breakpoints.dedup_by(|a, b| *a == *b);
1104
1105    let mut candidates: BTreeSet<rug::Rational> = BTreeSet::new();
1106    for w in breakpoints.windows(2) {
1107        if w[0] < w[1] {
1108            candidates.insert(iv_midpoint(&w[0], &w[1]));
1109        }
1110    }
1111
1112    let mut ambiguous_irrational_root = false;
1113    for iv in &roots_iv {
1114        if iv.lo == iv.hi {
1115            candidates.insert(iv.lo.clone());
1116        } else {
1117            ambiguous_irrational_root = true;
1118        }
1119    }
1120
1121    Ok(XCells {
1122        candidates: candidates.into_iter().collect(),
1123        ambiguous_irrational_root,
1124    })
1125}
1126
1127const IRRATIONAL_ROOT_MSG: &str = "a non-strict atom (=, /=, <=, >=) combined with an irrational \
1128    projection root of the eliminated variable would require algebraic-number CAD lifting \
1129    (full CAD); refusing to guess rather than risk an unsound answer";
1130
1131/// Decide `∃x∃y. body(x, y)` for a polynomial `body` (2-variable, same-flavor
1132/// quantifier block).
1133///
1134/// Samples the CAD cells of `x` induced by projecting `y` out of `body`'s atoms
1135/// (see [`project_and_sample_x`]); at each rational sample `x0` the remaining
1136/// sentence `∃y. body(x0, y)` is genuinely univariate in `y` and decided exactly
1137/// by [`decide_exists_univariate`] (including its own algebraic-root handling for
1138/// `y`). If no witness is found and every projection root sampled was rational,
1139/// the cell decomposition is complete and `false` is sound. If some projection
1140/// root is irrational *and* `body` contains a non-strict atom (see
1141/// [`body_has_nonstrict_atom`]), the cell at that exact root cannot be tested
1142/// rationally, so we report `Unsupported` rather than risk a false negative.
1143fn decide_exists_exists(
1144    pool: &ExprPool,
1145    x: ExprId,
1146    y: ExprId,
1147    body: Formula,
1148) -> Result<QeResult, CadError> {
1149    let cells = project_and_sample_x(pool, x, y, &body)?;
1150    for x0 in &cells.candidates {
1151        let x_expr = rational_to_expr(pool, x0);
1152        let subst = subst_body_var(pool, &body, x, x_expr)?;
1153        let inner = decide_exists_univariate(pool, y, subst)?;
1154        if inner.truth {
1155            let mut wm = HashMap::new();
1156            wm.insert(x, x0.clone());
1157            if let Some(inner_w) = inner.witness {
1158                if let Some(yv) = inner_w.get(&y) {
1159                    wm.insert(y, yv.clone());
1160                }
1161            }
1162            return Ok(QeResult {
1163                truth: true,
1164                witness: Some(wm),
1165            });
1166        }
1167    }
1168    if cells.ambiguous_irrational_root && body_has_nonstrict_atom(&body) {
1169        return Err(CadError::Unsupported(IRRATIONAL_ROOT_MSG));
1170    }
1171    Ok(QeResult {
1172        truth: false,
1173        witness: None,
1174    })
1175}
1176
1177/// Decide `∃x∀y. body(x, y)` — same projection/sampling scheme as
1178/// [`decide_exists_exists`], but the inner univariate sentence is `∀y` (decided
1179/// via the usual `¬∃y¬` rewrite over [`decide_exists_univariate`]).
1180fn decide_exists_forall(
1181    pool: &ExprPool,
1182    x: ExprId,
1183    y: ExprId,
1184    body: Formula,
1185) -> Result<QeResult, CadError> {
1186    let cells = project_and_sample_x(pool, x, y, &body)?;
1187    for x0 in &cells.candidates {
1188        let x_expr = rational_to_expr(pool, x0);
1189        let subst = subst_body_var(pool, &body, x, x_expr)?;
1190        let neg = nnf_formula(Formula::Not(Box::new(subst)));
1191        let inner = decide_exists_univariate(pool, y, neg)?;
1192        if !inner.truth {
1193            let mut wm = HashMap::new();
1194            wm.insert(x, x0.clone());
1195            return Ok(QeResult {
1196                truth: true,
1197                witness: Some(wm),
1198            });
1199        }
1200    }
1201    if cells.ambiguous_irrational_root && body_has_nonstrict_atom(&body) {
1202        return Err(CadError::Unsupported(IRRATIONAL_ROOT_MSG));
1203    }
1204    Ok(QeResult {
1205        truth: false,
1206        witness: None,
1207    })
1208}
1209
1210/// Decide a closed first-order polynomial sentence (`forall` / `exists` prefix,
1211/// optionally empty), built from Boolean combinations of polynomial relations.
1212pub fn decide(formula: &Formula, pool: &ExprPool) -> Result<QeResult, CadError> {
1213    decide_formula_inner(pool, formula.clone())
1214}
1215
1216/// Decide from a predicate / quantified [`ExprId`], via [`formula_from_expr`].
1217pub fn decide_expr(expr: ExprId, pool: &ExprPool) -> Result<QeResult, CadError> {
1218    let fm = formula_from_expr(expr, pool)?;
1219    decide(&fm, pool)
1220}
1221
1222/// Brown-style projection polynomials for elimination of `elim_var`.
1223///
1224/// The returned polynomials are canonically rewritten with [`poly_normal`] in the
1225/// union of remaining variables (+ constants only).
1226///
1227/// Projection set:
1228/// resultant(`f`,`∂ f/ ∂ elim`,`elim`), all distinct pairwise resultants (`f`,`g`).
1229pub fn cad_project(
1230    polynomials: &[ExprId],
1231    elim_var: ExprId,
1232    pool: &ExprPool,
1233) -> Result<Vec<ExprId>, CadError> {
1234    if polynomials.is_empty() {
1235        return Ok(Vec::new());
1236    }
1237    let mut all_vars = BTreeSet::new();
1238    all_vars.insert(elim_var);
1239    for &p in polynomials {
1240        all_vars.extend(resultant::collect_free_vars(p, pool));
1241    }
1242    let vars_no_elim: Vec<ExprId> = all_vars
1243        .iter()
1244        .copied()
1245        .filter(|&v| v != elim_var)
1246        .collect();
1247
1248    let mut uniq: Vec<ExprId> = Vec::new();
1249    let mut seen: BTreeSet<ExprId> = BTreeSet::new();
1250
1251    for i in 0..polynomials.len() {
1252        let f_expr = polynomials[i];
1253        let df = diff(f_expr, elim_var, pool)?.value;
1254
1255        let is_zero_f = UniPoly::from_symbolic(f_expr, elim_var, pool)
1256            .map(|u| u.is_zero())
1257            .unwrap_or(false);
1258        let is_zero_df = UniPoly::from_symbolic(df, elim_var, pool)
1259            .map(|u| u.is_zero())
1260            .unwrap_or(true);
1261
1262        // Discriminant / projection coefficient via resultant with ∂f.
1263        if !is_zero_f && !is_zero_df {
1264            let rp = resultant(f_expr, df, elim_var, pool)?.value;
1265            if seen.insert(rp) {
1266                uniq.push(rp);
1267            }
1268        }
1269
1270        // Pairwise resultants don't require ∂f to be non-zero (Brown projection).
1271        for &g_expr in polynomials.iter().skip(i + 1) {
1272            let is_zero_g = UniPoly::from_symbolic(g_expr, elim_var, pool)
1273                .map(|u| u.is_zero())
1274                .unwrap_or(false);
1275            if is_zero_f || is_zero_g {
1276                continue;
1277            }
1278            let r = resultant(f_expr, g_expr, elim_var, pool)?.value;
1279            if seen.insert(r) {
1280                uniq.push(r);
1281            }
1282        }
1283    }
1284
1285    let mut normed = Vec::<ExprId>::new();
1286    for e in uniq {
1287        let simplified = if vars_no_elim.is_empty() {
1288            e
1289        } else {
1290            poly_normal(e, vars_no_elim.clone(), pool)?
1291        };
1292        normed.push(simplified);
1293    }
1294
1295    normed.sort_unstable();
1296    normed.dedup();
1297    Ok(normed)
1298}
1299
1300/// CAD lifting along `main_var`: isolate real roots of a squarefree amalgam built
1301/// from projections of the listed polynomial expressions when viewed in `main_var`.
1302pub fn cad_lift(
1303    polynomials: &[ExprId],
1304    main_var: ExprId,
1305    pool: &ExprPool,
1306) -> Result<Vec<RootInterval>, CadError> {
1307    let mut polys_uni = Vec::new();
1308    for &e in polynomials {
1309        match UniPoly::from_symbolic(e, main_var, pool) {
1310            Ok(u) => {
1311                if !u.is_zero() {
1312                    polys_uni.push(u);
1313                }
1314            }
1315            Err(e) => return Err(CadError::NotPolynomial(e)),
1316        }
1317    }
1318    let m = combine_algebraic_master(main_var, &polys_uni);
1319    Ok(real_roots(&m)?)
1320}
1321
1322// ---------------------------------------------------------------------------
1323// Tests
1324// ---------------------------------------------------------------------------
1325
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329    use crate::kernel::Domain;
1330
1331    #[test]
1332    fn forall_x_squared_plus_one_positive() {
1333        let p = ExprPool::new();
1334        let x = p.symbol("x", Domain::Real);
1335        let one = p.integer(1_i32);
1336        let x_sq = p.pow(x, p.integer(2_i32));
1337        let body = p.pred_gt(p.add(vec![x_sq, one]), p.integer(0_i32));
1338
1339        let f = Formula::Forall {
1340            var: x,
1341            body: Box::new(formula_from_expr(body, &p).unwrap()),
1342        };
1343        let r = decide(&f, &p).unwrap();
1344        assert!(r.truth);
1345        assert!(r.witness.is_none());
1346    }
1347
1348    #[test]
1349    fn exists_roots_x_squared_minus_two() {
1350        let p = ExprPool::new();
1351        let x = p.symbol("x", Domain::Real);
1352        let two = p.integer(2_i32);
1353        let xs = p.pow(x, p.integer(2_i32));
1354        let body = p.pred_eq(xs, two);
1355        let f = Formula::Exists {
1356            var: x,
1357            body: Box::new(formula_from_expr(body, &p).unwrap()),
1358        };
1359        let r = decide(&f, &p).unwrap();
1360        assert!(r.truth);
1361        // `√2` is not rational, so there is no rational witness to report. This
1362        // used to assert `witness.is_some()` and passed on the isolating
1363        // interval's midpoint — a "solution" of `x² = 2` that is not one. A
1364        // witness is a certificate; a wrong one is worse than none.
1365        assert!(r.witness.is_none());
1366    }
1367
1368    /// Every witness `decide` reports must satisfy the sentence it witnesses.
1369    ///
1370    /// `∃x. 3x − 2 = 0` has the rational solution `2/3`. Before exact
1371    /// rational-root recovery in `real_roots`, the isolating bracket stayed at
1372    /// `[0, 1]` and the reported witness was its midpoint `1/2`, which fails the
1373    /// equation outright.
1374    #[test]
1375    fn exists_witness_satisfies_the_equation() {
1376        let p = ExprPool::new();
1377        let x = p.symbol("x", Domain::Real);
1378        let lhs = p.add(vec![p.mul(vec![p.integer(3_i32), x]), p.integer(-2_i32)]);
1379        let body = p.pred_eq(lhs, p.integer(0_i32));
1380        let f = Formula::Exists {
1381            var: x,
1382            body: Box::new(formula_from_expr(body, &p).unwrap()),
1383        };
1384        let r = decide(&f, &p).unwrap();
1385        assert!(r.truth);
1386        let w = r
1387            .witness
1388            .expect("2/3 is rational, so a witness is reportable");
1389        assert_eq!(w[&x], rug::Rational::from((2, 3)));
1390    }
1391
1392    /// `∀x. (3x + 2)² > 0` is **false**: the square vanishes at `x = −2/3`.
1393    ///
1394    /// The CAD sample set is built from bracket endpoints and midpoints, all
1395    /// dyadic, so `−2/3` was never tested and the sentence came back `true` —
1396    /// a proof of a false theorem. Exact rational-root recovery puts the root
1397    /// itself in the sample set.
1398    #[test]
1399    fn forall_square_positive_is_false_at_a_non_dyadic_root() {
1400        let p = ExprPool::new();
1401        let x = p.symbol("x", Domain::Real);
1402        let inner = p.add(vec![p.mul(vec![p.integer(3_i32), x]), p.integer(2_i32)]);
1403        let body = p.pred_gt(p.pow(inner, p.integer(2_i32)), p.integer(0_i32));
1404        let f = Formula::Forall {
1405            var: x,
1406            body: Box::new(formula_from_expr(body, &p).unwrap()),
1407        };
1408        assert!(!decide(&f, &p).unwrap().truth);
1409    }
1410
1411    /// The same sentence with an *irrational* touching root is refused, not
1412    /// answered. `∀x. (x² − 2)² > 0` is false (at `±√2`), and no rational
1413    /// sample can show it; the honest answer is `Unsupported`.
1414    #[test]
1415    fn forall_square_positive_refuses_at_an_irrational_root() {
1416        let p = ExprPool::new();
1417        let x = p.symbol("x", Domain::Real);
1418        let inner = p.add(vec![p.pow(x, p.integer(2_i32)), p.integer(-2_i32)]);
1419        let body = p.pred_gt(p.pow(inner, p.integer(2_i32)), p.integer(0_i32));
1420        let f = Formula::Forall {
1421            var: x,
1422            body: Box::new(formula_from_expr(body, &p).unwrap()),
1423        };
1424        assert!(matches!(
1425            decide(&f, &p),
1426            Err(CadError::Unsupported(ALGEBRAIC_BOUNDARY_MSG))
1427        ));
1428    }
1429
1430    #[test]
1431    fn cad_lift_univariate_quadratic() {
1432        let p = ExprPool::new();
1433        let x = p.symbol("x", Domain::Real);
1434        let xs = p.add(vec![p.pow(x, p.integer(2_i32)), p.integer(-2_i32)]);
1435        let ivs = cad_lift(&[xs], x, &p).unwrap();
1436        assert_eq!(ivs.len(), 2);
1437        assert!(ivs.iter().all(|iv| iv.lo <= iv.hi));
1438    }
1439
1440    #[test]
1441    fn cad_project_circle_eliminates_y() {
1442        let p = ExprPool::new();
1443        let x = p.symbol("x", Domain::Real);
1444        let y = p.symbol("y", Domain::Real);
1445        let circle = p.add(vec![
1446            p.pow(x, p.integer(2_i32)),
1447            p.pow(y, p.integer(2_i32)),
1448            p.integer(-1_i32),
1449        ]);
1450        let line = p.add(vec![y, pool_neg_x(&p, x)]); // y - x
1451        let pr = cad_project(&[circle, line], y, &p).unwrap();
1452        assert!(!pr.is_empty());
1453    }
1454
1455    fn pool_neg_x(pool: &ExprPool, x: ExprId) -> ExprId {
1456        pool.mul(vec![pool.integer(-1_i32), x])
1457    }
1458
1459    #[test]
1460    fn unipoly_eval_rational_zero() {
1461        let p = ExprPool::new();
1462        let x = p.symbol("x", Domain::Real);
1463        let qp = UniPoly::from_symbolic(p.add(vec![x, p.integer(2_i32)]), x, &p).unwrap();
1464        let z = qp.eval_rational(&rug::Rational::from(-2));
1465        assert_eq!(z, 0);
1466    }
1467
1468    // -----------------------------------------------------------------------
1469    // Two-variable, same-flavor and mixed-alternation quantifier blocks.
1470    // -----------------------------------------------------------------------
1471
1472    fn xy_pool() -> (ExprPool, ExprId, ExprId) {
1473        let p = ExprPool::new();
1474        let x = p.symbol("x", Domain::Real);
1475        let y = p.symbol("y", Domain::Real);
1476        (p, x, y)
1477    }
1478
1479    #[test]
1480    fn exists_exists_circle_through_origin_true() {
1481        // ∃x∃y. x^2 + y^2 == 0  →  true, witness (0, 0)
1482        let (p, x, y) = xy_pool();
1483        let sum_sq = p.add(vec![p.pow(x, p.integer(2_i32)), p.pow(y, p.integer(2_i32))]);
1484        let body = p.pred_eq(sum_sq, p.integer(0_i32));
1485        let f = Formula::Exists {
1486            var: x,
1487            body: Box::new(Formula::Exists {
1488                var: y,
1489                body: Box::new(formula_from_expr(body, &p).unwrap()),
1490            }),
1491        };
1492        let r = decide(&f, &p).unwrap();
1493        assert!(r.truth);
1494        let wit = r.witness.expect("witness expected for true ∃∃");
1495        assert_eq!(wit.get(&x), Some(&rug::Rational::from(0)));
1496        assert_eq!(wit.get(&y), Some(&rug::Rational::from(0)));
1497    }
1498
1499    #[test]
1500    fn exists_exists_circle_plus_one_false() {
1501        // ∃x∃y. x^2 + y^2 + 1 == 0  →  false (sum of squares can't be negative)
1502        let (p, x, y) = xy_pool();
1503        let sum_sq = p.add(vec![
1504            p.pow(x, p.integer(2_i32)),
1505            p.pow(y, p.integer(2_i32)),
1506            p.integer(1_i32),
1507        ]);
1508        let body = p.pred_eq(sum_sq, p.integer(0_i32));
1509        let f = Formula::Exists {
1510            var: x,
1511            body: Box::new(Formula::Exists {
1512                var: y,
1513                body: Box::new(formula_from_expr(body, &p).unwrap()),
1514            }),
1515        };
1516        let r = decide(&f, &p).unwrap();
1517        assert!(!r.truth);
1518        assert!(r.witness.is_none());
1519    }
1520
1521    #[test]
1522    fn forall_forall_sum_of_squares_nonneg_true() {
1523        // ∀x∀y. x^2 + y^2 >= 0  →  true
1524        let (p, x, y) = xy_pool();
1525        let sum_sq = p.add(vec![p.pow(x, p.integer(2_i32)), p.pow(y, p.integer(2_i32))]);
1526        let body = p.pred_ge(sum_sq, p.integer(0_i32));
1527        let f = Formula::Forall {
1528            var: x,
1529            body: Box::new(Formula::Forall {
1530                var: y,
1531                body: Box::new(formula_from_expr(body, &p).unwrap()),
1532            }),
1533        };
1534        let r = decide(&f, &p).unwrap();
1535        assert!(r.truth);
1536        assert!(r.witness.is_none());
1537    }
1538
1539    #[test]
1540    fn forall_forall_product_positive_false() {
1541        // ∀x∀y. x*y > 0  →  false (e.g. x = -1, y = 1)
1542        let (p, x, y) = xy_pool();
1543        let xy = p.mul(vec![x, y]);
1544        let body = p.pred_gt(xy, p.integer(0_i32));
1545        let f = Formula::Forall {
1546            var: x,
1547            body: Box::new(Formula::Forall {
1548                var: y,
1549                body: Box::new(formula_from_expr(body, &p).unwrap()),
1550            }),
1551        };
1552        let r = decide(&f, &p).unwrap();
1553        assert!(!r.truth);
1554    }
1555
1556    #[test]
1557    fn forall_exists_every_x_has_bigger_y_true() {
1558        // ∀x∃y. y > x  →  true (no upper bound on ℝ)
1559        let (p, x, y) = xy_pool();
1560        let body = p.pred_gt(y, x);
1561        let f = Formula::Forall {
1562            var: x,
1563            body: Box::new(Formula::Exists {
1564                var: y,
1565                body: Box::new(formula_from_expr(body, &p).unwrap()),
1566            }),
1567        };
1568        let r = decide(&f, &p).unwrap();
1569        assert!(r.truth);
1570    }
1571
1572    #[test]
1573    fn exists_forall_no_x_is_upper_bound_false() {
1574        // ∃x∀y. x >= y  →  false (no real x bounds every real y from above)
1575        let (p, x, y) = xy_pool();
1576        let body = p.pred_ge(x, y);
1577        let f = Formula::Exists {
1578            var: x,
1579            body: Box::new(Formula::Forall {
1580                var: y,
1581                body: Box::new(formula_from_expr(body, &p).unwrap()),
1582            }),
1583        };
1584        let r = decide(&f, &p).unwrap();
1585        assert!(!r.truth);
1586    }
1587
1588    #[test]
1589    fn three_variable_prefix_is_unsupported() {
1590        // ∃x∃y∃z. ... — quantifier prefixes of length > 2 are out of scope.
1591        let p = ExprPool::new();
1592        let x = p.symbol("x", Domain::Real);
1593        let y = p.symbol("y", Domain::Real);
1594        let z = p.symbol("z", Domain::Real);
1595        let body = p.pred_eq(p.add(vec![x, y, z]), p.integer(0_i32));
1596        let f = Formula::Exists {
1597            var: x,
1598            body: Box::new(Formula::Exists {
1599                var: y,
1600                body: Box::new(Formula::Exists {
1601                    var: z,
1602                    body: Box::new(formula_from_expr(body, &p).unwrap()),
1603                }),
1604            }),
1605        };
1606        let err = decide(&f, &p).unwrap_err();
1607        assert_eq!(err.code(), "E-CAD-001");
1608        assert!(matches!(err, CadError::Unsupported(_)));
1609    }
1610
1611    #[test]
1612    fn univariate_regression_still_works_after_two_var_addition() {
1613        // Guards against accidental regressions in decide_exists_univariate /
1614        // decide_one_var routing while adding the two-variable path.
1615        let p = ExprPool::new();
1616        let x = p.symbol("x", Domain::Real);
1617        let body = p.pred_gt(p.pow(x, p.integer(2_i32)), p.integer(4_i32));
1618        let f = Formula::Exists {
1619            var: x,
1620            body: Box::new(formula_from_expr(body, &p).unwrap()),
1621        };
1622        let r = decide(&f, &p).unwrap();
1623        assert!(r.truth);
1624    }
1625}
1626
1627#[cfg(test)]
1628mod sample_point_completeness_tests {
1629    use super::*;
1630    use crate::kernel::Domain;
1631
1632    /// Build `Σ coeffs[i]·xⁱ`.
1633    fn poly(pool: &ExprPool, x: ExprId, coeffs: &[i64]) -> ExprId {
1634        let terms: Vec<ExprId> = coeffs
1635            .iter()
1636            .enumerate()
1637            .map(|(i, &c)| {
1638                let ci = pool.integer(c);
1639                if i == 0 {
1640                    ci
1641                } else {
1642                    pool.mul(vec![ci, pool.pow(x, pool.integer(i as i64))])
1643                }
1644            })
1645            .collect();
1646        pool.add(terms)
1647    }
1648
1649    fn forall(pool: &ExprPool, x: ExprId, body: Formula) -> Result<QeResult, CadError> {
1650        decide(
1651            &Formula::Forall {
1652                var: x,
1653                body: Box::new(body),
1654            },
1655            pool,
1656        )
1657    }
1658
1659    fn atom(kind: PredicateKind, lhs: ExprId, rhs: ExprId) -> Formula {
1660        Formula::Atom {
1661            kind,
1662            args: vec![lhs, rhs],
1663        }
1664    }
1665
1666    /// The three shapes that used to yield a proof of a false statement.
1667    ///
1668    /// - `x² > 0` / `x⁴ > 0`: satisfied-only-on-the-boundary negation, missed
1669    ///   because `Le`/`Ge` boundaries were not sampled.
1670    /// - `2x⁴+x³-4x²+3 ≥ 0`: false at `x = -6/5`, in a narrow cell between two
1671    ///   roots that no raw breakpoint or midpoint reaches.
1672    #[test]
1673    fn false_universals_are_not_proved() {
1674        let pool = ExprPool::new();
1675        let x = pool.symbol("x", Domain::Real);
1676        let zero = pool.integer(0_i32);
1677
1678        for (label, coeffs, kind) in [
1679            ("x^2 > 0", vec![0, 0, 1], PredicateKind::Gt),
1680            ("x^4 > 0", vec![0, 0, 0, 0, 1], PredicateKind::Gt),
1681            (
1682                "2x^4+x^3-4x^2+3 >= 0",
1683                vec![3, 0, -4, 1, 2],
1684                PredicateKind::Ge,
1685            ),
1686        ] {
1687            let p = poly(&pool, x, &coeffs);
1688            let got = forall(&pool, x, atom(kind, p, zero)).expect("decidable");
1689            assert!(
1690                !got.truth,
1691                "`forall x. {label}` is false but decide returned true"
1692            );
1693        }
1694    }
1695
1696    /// The fix must not flip true statements to false.
1697    #[test]
1698    fn true_universals_still_hold() {
1699        let pool = ExprPool::new();
1700        let x = pool.symbol("x", Domain::Real);
1701        let zero = pool.integer(0_i32);
1702
1703        for (label, coeffs, kind) in [
1704            ("x^2 >= 0", vec![0, 0, 1], PredicateKind::Ge),
1705            ("x^2 + 1 > 0", vec![1, 0, 1], PredicateKind::Gt),
1706            ("(x-1)^2 >= 0", vec![1, -2, 1], PredicateKind::Ge),
1707        ] {
1708            let p = poly(&pool, x, &coeffs);
1709            let got = forall(&pool, x, atom(kind, p, zero)).expect("decidable");
1710            assert!(
1711                got.truth,
1712                "`forall x. {label}` is true but decide returned false"
1713            );
1714        }
1715    }
1716
1717    /// Bisection must survive a bracket whose endpoint is a *different* root.
1718    ///
1719    /// `2x⁴+x³-4x²+3` isolates one root inside `(-2,-1)` while `-1` is itself a
1720    /// root, so neighbouring brackets share that endpoint. Steering the
1721    /// bisection by an endpoint that evaluates to zero collapsed the bracket
1722    /// onto it and discarded the root being isolated — which is precisely how
1723    /// the narrow negative cell stayed unreachable.
1724    #[test]
1725    fn refinement_survives_a_root_valued_endpoint() {
1726        let pool = ExprPool::new();
1727        let x = pool.symbol("x", Domain::Real);
1728        let p = poly(&pool, x, &[3, 0, -4, 1, 2]);
1729        let up = UniPoly::from_symbolic_clear_denoms(p, x, &pool).expect("polynomial");
1730        let master = combine_algebraic_master(x, &[up]);
1731
1732        let iv = RootInterval::new(rug::Rational::from(-2), rug::Rational::from(-1));
1733        let (lo, hi) = refine_isolating(&master, &iv);
1734
1735        assert!(lo <= hi, "refined bracket is inverted");
1736        assert!(
1737            hi < -1,
1738            "bracket collapsed onto the endpoint root -1 instead of isolating its own root"
1739        );
1740        assert!(lo > -2, "bracket did not tighten at all");
1741    }
1742
1743    // -----------------------------------------------------------------------
1744    // The same completeness gap, two variables up.
1745    //
1746    // `project_and_sample_x` flags an irrational projection root as untested,
1747    // but the flag only escalated to a refusal for `=` / `≠` atoms, so `≤`/`≥`
1748    // still reported an unsatisfiability that was never checked at the one
1749    // point that could have satisfied it.
1750    // -----------------------------------------------------------------------
1751
1752    /// `(x² − 2)² + y²`, non-negative and zero exactly at `(±√2, 0)`.
1753    fn touching_at_sqrt_two(pool: &ExprPool, x: ExprId, y: ExprId) -> ExprId {
1754        let inner = pool.add(vec![pool.pow(x, pool.integer(2_i32)), pool.integer(-2_i32)]);
1755        pool.add(vec![
1756            pool.pow(inner, pool.integer(2_i32)),
1757            pool.pow(y, pool.integer(2_i32)),
1758        ])
1759    }
1760
1761    /// `∃x∃y. (x²−2)² + y² ≤ 0` is **true** at `(√2, 0)`; no rational sample
1762    /// can exhibit it, so the only sound answers are `true` and a refusal.
1763    #[test]
1764    fn two_var_nonstrict_boundary_at_an_irrational_root_is_not_denied() {
1765        let pool = ExprPool::new();
1766        let x = pool.symbol("x", Domain::Real);
1767        let y = pool.symbol("y", Domain::Real);
1768        let lhs = touching_at_sqrt_two(&pool, x, y);
1769        let body = atom(PredicateKind::Le, lhs, pool.integer(0_i32));
1770        let f = Formula::Exists {
1771            var: x,
1772            body: Box::new(Formula::Exists {
1773                var: y,
1774                body: Box::new(body),
1775            }),
1776        };
1777        match decide(&f, &pool) {
1778            Ok(r) => assert!(
1779                r.truth,
1780                "`exists x exists y. (x^2-2)^2 + y^2 <= 0` is true at (sqrt 2, 0)"
1781            ),
1782            Err(e) => assert_eq!(e.code(), "E-CAD-001"),
1783        }
1784    }
1785
1786    /// The dual: `∀x∀y. (x²−2)² + y² > 0` is **false**, and a `true` here is a
1787    /// machine-checked-looking proof of a false theorem.
1788    #[test]
1789    fn two_var_universal_over_an_irrational_root_is_not_proved() {
1790        let pool = ExprPool::new();
1791        let x = pool.symbol("x", Domain::Real);
1792        let y = pool.symbol("y", Domain::Real);
1793        let lhs = touching_at_sqrt_two(&pool, x, y);
1794        let body = atom(PredicateKind::Gt, lhs, pool.integer(0_i32));
1795        let f = Formula::Forall {
1796            var: x,
1797            body: Box::new(Formula::Forall {
1798                var: y,
1799                body: Box::new(body),
1800            }),
1801        };
1802        match decide(&f, &pool) {
1803            Ok(r) => assert!(
1804                !r.truth,
1805                "`forall x forall y. (x^2-2)^2 + y^2 > 0` is false at (sqrt 2, 0)"
1806            ),
1807            Err(e) => assert_eq!(e.code(), "E-CAD-001"),
1808        }
1809    }
1810
1811    /// The control that the guard is not a blanket refusal of `≤`: the same
1812    /// polynomial shifted up by 1 is never `≤ 0`, and that `false` is sound
1813    /// because no boundary cell exists at all.
1814    #[test]
1815    fn two_var_nonstrict_unsatisfiable_still_decides_false() {
1816        let pool = ExprPool::new();
1817        let x = pool.symbol("x", Domain::Real);
1818        let y = pool.symbol("y", Domain::Real);
1819        let lhs = pool.add(vec![touching_at_sqrt_two(&pool, x, y), pool.integer(1_i32)]);
1820        let body = atom(PredicateKind::Le, lhs, pool.integer(0_i32));
1821        let f = Formula::Exists {
1822            var: x,
1823            body: Box::new(Formula::Exists {
1824                var: y,
1825                body: Box::new(body),
1826            }),
1827        };
1828        let r = decide(&f, &pool).expect("two squares plus one is decidable");
1829        assert!(!r.truth, "two squares plus 1 is never <= 0");
1830    }
1831
1832    /// The control that a *rational* boundary point is still found: the same
1833    /// shape with the double root at `x = 2/3` must come back `true`.
1834    #[test]
1835    fn two_var_nonstrict_boundary_at_a_rational_root_is_found() {
1836        let pool = ExprPool::new();
1837        let x = pool.symbol("x", Domain::Real);
1838        let y = pool.symbol("y", Domain::Real);
1839        let inner = pool.add(vec![
1840            pool.mul(vec![pool.integer(3_i32), x]),
1841            pool.integer(-2_i32),
1842        ]);
1843        let lhs = pool.add(vec![
1844            pool.pow(inner, pool.integer(2_i32)),
1845            pool.pow(y, pool.integer(2_i32)),
1846        ]);
1847        let body = atom(PredicateKind::Le, lhs, pool.integer(0_i32));
1848        let f = Formula::Exists {
1849            var: x,
1850            body: Box::new(Formula::Exists {
1851                var: y,
1852                body: Box::new(body),
1853            }),
1854        };
1855        let r = decide(&f, &pool).expect("a rational boundary point is reachable");
1856        assert!(r.truth, "(3x-2)^2 + y^2 <= 0 holds at (2/3, 0)");
1857    }
1858}