Skip to main content

symplex/domains/
certificates.rs

1//! Exact, machine-checkable certificates that a polynomial is non-negative
2//! on a box, on a half-line, or on a polyhedron whose facets depend on a
3//! parameter — each exportable as a Lean 4 / Mathlib proof.
4//!
5//! # Parametric polyhedra
6//!
7//! [`prove_nonnegative_on_polyhedron`] takes hypotheses `h₁, …, hₘ ∈
8//! ℚ[j, x]`, a goal `g ∈ ℚ[j, x]` and a parameter bound `j ≥ j₀`, and
9//! searches for the identity
10//!
11//! ```text
12//! λ(j) · g  =  Σₖ Σ_{a,b} μ_{k,a,b} · jᵃ (j − j₀)ᵇ · hₖ
13//!            + Σ_{k≤l} Σ_{a+b≤1} μ_{k,l,a,b} · jᵃ (j − j₀)ᵇ · hₖ hₗ      (optional)
14//!            + Σ_{a+b≥1} μ_{a,b} · jᵃ (j − j₀)ᵇ  +  μ₀ ,
15//! λ(j) = 1 + Σ_{a≥1} νₐ jᵃ ,        all μ, ν ≥ 0 .
16//! ```
17//!
18//! Every term on the right is non-negative wherever the hypotheses hold
19//! and `j ≥ j₀` (powers of `j` itself are only used when `j₀ ≥ 0`), and
20//! `λ(j) > 0`, so the identity proves `g ≥ 0` for **every** admissible
21//! `j`.  The polynomial multiplier `λ` on the goal is what makes the
22//! parametric case work: the Farkas multipliers of `j`-dependent facets
23//! are rational functions of `j`, and clearing their denominators puts a
24//! polynomial in front of `g`.  With the goal `−1`
25//! ([`prove_polyhedron_empty`]) the same identity proves the polyhedron
26//! **empty** for every `j ≥ j₀`.  The search is a sequence of exact LPs
27//! staged from the smallest basis upwards ([`PolyhedronOpts`]); every
28//! [`PolyhedronCertificate`] is re-verified with exact polynomial
29//! arithmetic, exports as a theorem ([`PolyhedronCertificate::to_lean`])
30//! or as bare proof steps for an existing skeleton
31//! ([`PolyhedronCertificate::lean_steps`]).
32//!
33//! # Sums of squares
34//!
35//! [`prove_sos`] proves `g ≥ 0` on all of ℝⁿ by an exact decomposition
36//! `g = Σ dₖ pₖ²` ([`SosCertificate`]): the Gram semidefinite program is
37//! solved by a built-in interior-point method, rounded, projected back onto
38//! the coefficient constraints exactly and checked with a rational `L·D·Lᵀ`;
39//! goals with real zeros are handled by numerically guided exact facial
40//! reduction.  This is the class the hypothesis-based certificates cannot
41//! reach (`(x − 1)² + (y − 1)²`, `x⁴ + y⁴ + z⁴ + 1 − 4xyz`).
42//!
43//! # Boxes
44//!
45//! [`prove_nonnegative_on_box`] searches for a **Handelman certificate**: a
46//! representation
47//!
48//! ```text
49//! goal(x) = Σₖ λₖ · Πᵢ (xᵢ − lᵢ)^{aₖᵢ} · (uᵢ − xᵢ)^{bₖᵢ},      λₖ ≥ 0
50//! ```
51//!
52//! over the box `lᵢ ≤ xᵢ ≤ uᵢ`, with all products of total degree at most
53//! `degree`.  Every factor is non-negative on the box, so the identity is a
54//! proof of `goal ≥ 0` there.  Handelman's theorem guarantees such a
55//! certificate exists for some degree whenever `goal` is strictly positive
56//! on the (compact) box; when `goal` touches zero in the interior none may
57//! exist, and the search reports that honestly.
58//!
59//! The search is an exact rational linear program ([`linprog`](crate::linprog)):
60//! the products are expanded with [`Poly`] arithmetic, their coefficient
61//! vectors form the columns of a matrix, and `nonneg_combination` finds
62//! `λ ≥ 0` exactly or returns a Farkas vector proving that no certificate of
63//! that degree exists.  Before a certificate is returned it is
64//! **re-verified** by recomputing `Σ λₖ·productₖ` with exact polynomial
65//! arithmetic and checking structural equality with `goal` — so a
66//! [`BoxCertificate`] can be trusted without trusting the LP solver.
67//!
68//! [`BoxCertificate::to_lean`] renders the result as a Lean 4 / Mathlib
69//! theorem whose proof is `nlinarith` fed with exactly the products that
70//! appear with non-zero weight (so it is a linear-arithmetic check, not a
71//! search).
72//!
73//! # Half-lines
74//!
75//! [`prove_nonnegative_on_halfline`] and [`prove_nonnegative_on_reals`]
76//! handle univariate goals on `x ≥ a` / `x ≤ a` / all of ℝ with a shift,
77//! a Pólya multiplier `(1 + k)ᴺ` and square factors for interior double
78//! zeros ([`HalfLineCertificate`], [`RealLineCertificate`]).
79//!
80//! # Outcomes and the `BoxCertificate` trait
81//!
82//! Every prover returns an [`Outcome`]`<C, U>` — `Proved(C)` with a
83//! re-verified certificate, `Refuted { point, value, .. }` with an exact
84//! counterexample, or `Unknown(U)` with a method-specific account of the
85//! search — under the aliases [`BoxOutcome`], [`HalfLineOutcome`],
86//! [`PolyhedronOutcome`] and [`SosOutcome`].  All certificate types
87//! implement the [`Certificate`] trait (`goal`, `verify`, `to_lean`,
88//! `to_json` / `from_json`) so generic code can treat them alike.
89//!
90//! # Example
91//!
92//! ```
93//! use symplex::prelude::*;
94//! use symplex::certificates::{prove_nonnegative_on_box, BoxBound, BoxOutcome};
95//!
96//! let ctx = Context::new();
97//! let (r, f) = (ctx.symbol("r"), ctx.symbol("f"));
98//! // 1/4 − (r − f/2)² ≥ 0 on [0, 1/2] × [0, 1]
99//! let goal = ctx.rational(1, 4) - (&r - &f / 2).powi(2);
100//! let bounds = [
101//!     BoxBound { var: r.clone(), lo: ctx.int(0), hi: ctx.rational(1, 2) },
102//!     BoxBound { var: f.clone(), lo: ctx.int(0), hi: ctx.int(1) },
103//! ];
104//! match prove_nonnegative_on_box(&goal, &bounds, 2).unwrap() {
105//!     BoxOutcome::Proved(cert) => {
106//!         assert!(cert.verify());
107//!         assert!(cert.to_lean("quarter_bound").unwrap().contains("nlinarith"));
108//!     }
109//!     other => panic!("expected a certificate, got {other:?}"),
110//! }
111//! ```
112
113use std::fmt;
114
115use num_bigint::BigInt;
116use num_traits::{One, Zero};
117
118use crate::api::context::Context;
119use crate::api::eq::Equation;
120use crate::api::expr::Ex;
121use crate::api::poly_ex::Poly;
122use crate::base::errors::SymplexError;
123use crate::base::interval::Interval;
124use crate::domains::linprog::{Feasibility, LpProblem, LpStatus, nonneg_combination};
125use crate::output::lean::{LeanOpts, MATHLIB_LINE_WIDTH, lean_ident, wrap_lean};
126
127mod outcome;
128mod polyhedron;
129mod sos;
130/// Which limit of a prover's budget ran out (see
131/// [`PolyhedronUnknown::budget_exhausted`]); defined by the LP layer.
132pub use crate::domains::linprog::BudgetHit;
133pub use outcome::{Certificate, Outcome};
134pub use polyhedron::{
135    ParamBound, ParamBoundTree, PolyhedronCertificate, PolyhedronCertificateData,
136    PolyhedronLeanNames, PolyhedronLeanSteps, PolyhedronOpts, PolyhedronOutcome, PolyhedronProver,
137    PolyhedronTerm, PolyhedronTermData, PolyhedronUnknown, prove_nonnegative_on_polyhedron,
138    prove_polyhedron_empty,
139};
140pub use sos::{
141    SosCertificate, SosCertificateData, SosOpts, SosOutcome, SosUnknown, is_sos, prove_sos,
142};
143
144/// Exact rationals as `"p/q"` strings for the serialisable certificate forms.
145pub(crate) mod serial {
146    use super::{BigInt, Q, SymplexError};
147
148    pub(crate) fn q_to_str(q: &Q) -> String {
149        format!("{}/{}", q.numer(), q.denom())
150    }
151
152    pub(crate) fn q_from_str(s: &str, operation: &'static str) -> Result<Q, SymplexError> {
153        let bad = || SymplexError::InvalidArgument {
154            operation,
155            reason: format!("malformed rational `{s}` (expected `p/q`)"),
156        };
157        let (n, d) = s.split_once('/').unwrap_or((s, "1"));
158        let n: BigInt = n.trim().parse().map_err(|_| bad())?;
159        let d: BigInt = d.trim().parse().map_err(|_| bad())?;
160        if d == BigInt::from(0) {
161            return Err(bad());
162        }
163        Ok(Q::new(n, d))
164    }
165}
166
167/// One side of the box in a [`BoxCertificateData`]: `lo ≤ var ≤ hi` as
168/// expression trees (the serialisable form of a [`BoxBound`]).
169#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
170pub struct BoxBoundTree {
171    /// The variable.
172    pub var: crate::output::tree::ExprTree,
173    /// Lower endpoint.
174    pub lo: crate::output::tree::ExprTree,
175    /// Upper endpoint.
176    pub hi: crate::output::tree::ExprTree,
177}
178
179/// One Handelman term in a [`BoxCertificateData`]:
180/// `weight · Π (xᵢ − lᵢ)^lower_powers[i] · Π (uᵢ − xᵢ)^upper_powers[i]`
181/// (the serialisable form of a [`HandelmanTerm`]).
182#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
183pub struct HandelmanTermData {
184    /// Exponent of `(xᵢ − lᵢ)`, one per variable.
185    pub lower_powers: Vec<u32>,
186    /// Exponent of `(uᵢ − xᵢ)`, one per variable.
187    pub upper_powers: Vec<u32>,
188    /// The positive rational weight as `"p/q"`.
189    pub weight: String,
190}
191
192/// Serialisable form of a [`BoxCertificate`] (see [`BoxCertificate::to_json`]).
193#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
194pub struct BoxCertificateData {
195    /// The goal (a polynomial in the box variables).
196    pub goal: crate::output::tree::ExprTree,
197    /// The box, one [`BoxBoundTree`] per variable.
198    pub bounds: Vec<BoxBoundTree>,
199    /// The terms.
200    pub terms: Vec<HandelmanTermData>,
201    /// The square factor `g`, if any.
202    pub square: Option<crate::output::tree::ExprTree>,
203}
204
205/// Serialisable form of a [`HalfLineCertificate`] (see
206/// [`HalfLineCertificate::to_json`]).
207#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
208pub struct HalfLineCertificateData {
209    /// The goal.
210    pub goal: crate::output::tree::ExprTree,
211    /// The variable.
212    pub var: crate::output::tree::ExprTree,
213    /// The endpoint `a`.
214    pub endpoint: crate::output::tree::ExprTree,
215    /// `"at_least"` (`x ≥ a`) or `"at_most"` (`x ≤ a`).
216    pub ray: String,
217    /// The Pólya power `N`.
218    pub polya_power: u32,
219    /// Coefficients of `(1 + k)ᴺ·goal / g²` in ascending powers of `k`, as `"p/q"`.
220    pub coefficients: Vec<String>,
221    /// The square factor `g`, if any.
222    pub square: Option<crate::output::tree::ExprTree>,
223}
224
225use crate::base::numeric::Q;
226
227fn invalid(reason: impl Into<String>) -> SymplexError {
228    SymplexError::invalid_argument("prove_nonnegative_on_box", reason)
229}
230
231/// One variable of the box: `lo ≤ var ≤ hi` with exact rational endpoints.
232#[derive(Clone, Debug)]
233pub struct BoxBound {
234    /// The variable.
235    pub var: Ex,
236    /// Lower endpoint (a rational literal).
237    pub lo: Ex,
238    /// Upper endpoint (a rational literal, `> lo`).
239    pub hi: Ex,
240}
241
242/// One product `Πᵢ (xᵢ − lᵢ)^{aᵢ} (uᵢ − xᵢ)^{bᵢ}` of a Handelman certificate.
243#[derive(Clone, Debug, PartialEq, Eq)]
244pub struct HandelmanTerm {
245    /// Exponents `aᵢ` of the lower-bound factors `xᵢ − lᵢ`, one per variable.
246    pub lower_powers: Vec<u32>,
247    /// Exponents `bᵢ` of the upper-bound factors `uᵢ − xᵢ`, one per variable.
248    pub upper_powers: Vec<u32>,
249    /// The weight `λₖ > 0`.
250    pub weight: Q,
251}
252
253impl HandelmanTerm {
254    /// Total degree of the product.
255    pub fn degree(&self) -> u32 {
256        self.lower_powers.iter().sum::<u32>() + self.upper_powers.iter().sum::<u32>()
257    }
258}
259
260/// A verified Handelman certificate: `goal = square² · Σ weightₖ · productₖ`
261/// on the box, every `weightₖ > 0`.  The square factor is `1` unless the
262/// goal had even-multiplicity zeros inside the box (see
263/// [`prove_nonnegative_on_box`]).
264#[derive(Clone, Debug)]
265pub struct BoxCertificate {
266    goal: Poly,
267    bounds: Vec<BoxBound>,
268    terms: Vec<HandelmanTerm>,
269    /// `g` with `goal = g² · Σ λₖ productₖ`; `None` when `g = 1`.
270    square: Option<Poly>,
271}
272
273impl BoxCertificate {
274    /// The polynomial that was proved non-negative.
275    pub fn goal(&self) -> &Poly {
276        &self.goal
277    }
278
279    /// The box.
280    pub fn bounds(&self) -> &[BoxBound] {
281        &self.bounds
282    }
283
284    /// The weighted products, in the order the search enumerated them.
285    pub fn terms(&self) -> &[HandelmanTerm] {
286        &self.terms
287    }
288
289    /// The square factor `g` in `goal = g² · Σ λₖ productₖ`, if any.  It
290    /// collects the even-multiplicity factors of the goal (`(x − 1)²·h`
291    /// gives `g = x − 1`), which is what lets a goal with interior zeros be
292    /// certified: `h` is strictly positive and gets the Handelman part.
293    pub fn square(&self) -> Option<&Poly> {
294        self.square.as_ref()
295    }
296
297    /// Largest total degree among the products.
298    pub fn degree(&self) -> u32 {
299        self.terms
300            .iter()
301            .map(HandelmanTerm::degree)
302            .max()
303            .unwrap_or(0)
304    }
305
306    /// The product `Πᵢ (xᵢ − lᵢ)^{aᵢ} (uᵢ − xᵢ)^{bᵢ}` of one term as a `Poly`.
307    pub fn product(&self, term: &HandelmanTerm) -> Poly {
308        let ctx = self.goal.context();
309        let gens: Vec<&Ex> = self.goal.gens().iter().collect();
310        let mut acc = Poly::one(&ctx, &gens).unwrap_or_else(|_| self.goal.clone());
311        for (i, b) in self.bounds.iter().enumerate() {
312            let lower = &b.var - &b.lo;
313            let upper = &b.hi - &b.var;
314            for _ in 0..term.lower_powers.get(i).copied().unwrap_or(0) {
315                if let Some(p) = Poly::new(&lower, &gens)
316                    && let Ok(m) = acc.mul(&p)
317                {
318                    acc = m;
319                }
320            }
321            for _ in 0..term.upper_powers.get(i).copied().unwrap_or(0) {
322                if let Some(p) = Poly::new(&upper, &gens)
323                    && let Ok(m) = acc.mul(&p)
324                {
325                    acc = m;
326                }
327            }
328        }
329        acc
330    }
331
332    /// Recompute `Σ weightₖ · productₖ` with exact polynomial arithmetic and
333    /// compare it structurally with the goal.  `prove_nonnegative_on_box`
334    /// only returns certificates for which this holds; call it again when
335    /// the certificate has crossed a trust boundary (serialisation, another
336    /// process).
337    pub fn verify(&self) -> bool {
338        let ctx = self.goal.context();
339        let gens: Vec<&Ex> = self.goal.gens().iter().collect();
340        let Ok(mut acc) = Poly::zero(&ctx, &gens) else {
341            return false;
342        };
343        for t in &self.terms {
344            if t.weight <= Q::zero() {
345                return false;
346            }
347            let w = ctx.from_ratio(t.weight.clone());
348            let Ok(scaled) = self.product(t).scale(&w) else {
349                return false;
350            };
351            let Ok(sum) = acc.add(&scaled) else {
352                return false;
353            };
354            acc = sum;
355        }
356        if let Some(g) = &self.square {
357            let Ok(g2) = g.mul(g) else {
358                return false;
359            };
360            let Ok(prod) = acc.mul(&g2) else {
361                return false;
362            };
363            acc = prod;
364        }
365        acc.equals(&self.goal)
366    }
367
368    /// The product of one term in factored form, `(x₁ − l₁)^a₁ · (u₁ − x₁)^b₁ · …`
369    /// (not expanded; see [`product`](Self::product) for the `Poly`).
370    pub fn product_expr(&self, term: &HandelmanTerm) -> Ex {
371        let ctx = self.goal.context();
372        let mut acc = ctx.one();
373        for (i, b) in self.bounds.iter().enumerate() {
374            let a = i64::from(term.lower_powers.get(i).copied().unwrap_or(0));
375            let e = i64::from(term.upper_powers.get(i).copied().unwrap_or(0));
376            if a > 0 {
377                acc *= (&b.var - &b.lo).powi(a);
378            }
379            if e > 0 {
380                acc *= (&b.hi - &b.var).powi(e);
381            }
382        }
383        acc
384    }
385
386    /// The certificate identity `goal = Σ λₖ·productₖ` as an [`Equation`],
387    /// with the products kept in factored form.
388    pub fn identity(&self) -> Equation {
389        let ctx = self.goal.context();
390        let mut rhs = ctx.zero();
391        for t in &self.terms {
392            rhs += ctx.from_ratio(t.weight.clone()) * self.product_expr(t);
393        }
394        if let Some(g) = &self.square {
395            rhs = g.to_ex().powi(2) * rhs;
396        }
397        Equation::new(self.goal.to_ex(), rhs)
398    }
399
400    /// A Lean 4 / Mathlib theorem proving `0 ≤ goal` on the box.
401    ///
402    /// The statement takes each variable as a real and each bound as a
403    /// hypothesis (`h_r_lo : 0 ≤ r`, `h_r_hi : r ≤ 1 / 2`, …); the proof is
404    /// `nlinarith` supplied with every product of the certificate as a
405    /// `mul_nonneg` hint, so the only search Lean performs is linear
406    /// arithmetic over the exact identity.  Degree-1 certificates (no
407    /// products) use `linarith`.
408    ///
409    /// # Errors
410    ///
411    /// [`SymplexError::NotImplemented`] if the goal cannot be rendered (see
412    /// [`Ex::to_lean`]).
413    pub fn to_lean(&self, theorem_name: &str) -> Result<String, SymplexError> {
414        self.to_lean_with(theorem_name, &LeanOpts::default())
415    }
416
417    /// [`to_lean`](Self::to_lean) with explicit rendering options.
418    pub fn to_lean_with(
419        &self,
420        theorem_name: &str,
421        opts: &LeanOpts,
422    ) -> Result<String, SymplexError> {
423        let real = &opts.real_type;
424        let vars: Vec<String> = self
425            .bounds
426            .iter()
427            .map(|b| lean_ident(&b.var.to_string()))
428            .collect();
429        // Which bounds the certificate actually uses (others are named with a
430        // leading underscore so Mathlib's unused-variable linter stays quiet).
431        let n = self.bounds.len();
432        let mut uses_lo = vec![false; n];
433        let mut uses_hi = vec![false; n];
434        for t in &self.terms {
435            for i in 0..n {
436                uses_lo[i] |= t.lower_powers.get(i).copied().unwrap_or(0) > 0;
437                uses_hi[i] |= t.upper_powers.get(i).copied().unwrap_or(0) > 0;
438            }
439        }
440        // Hypotheses.
441        let mut hyps: Vec<String> = Vec::new();
442        let mut lo_names: Vec<String> = Vec::new();
443        let mut hi_names: Vec<String> = Vec::new();
444        for (i, (b, v)) in self.bounds.iter().zip(&vars).enumerate() {
445            let lo = b.lo.to_lean_with(opts)?;
446            let hi = b.hi.to_lean_with(opts)?;
447            let base = v.trim_matches(['«', '»']);
448            let lo_name = format!("{}h_{base}_lo", if uses_lo[i] { "" } else { "_" });
449            let hi_name = format!("{}h_{base}_hi", if uses_hi[i] { "" } else { "_" });
450            hyps.push(format!("({lo_name} : {lo} ≤ {v})"));
451            hyps.push(format!("({hi_name} : {v} ≤ {hi})"));
452            lo_names.push(lo_name);
453            hi_names.push(hi_name);
454        }
455        let goal = self.goal.to_ex().to_lean_with(opts)?;
456        let square_hint = match &self.square {
457            Some(g) => Some(format!("sq_nonneg ({})", g.to_ex().to_lean_with(opts)?)),
458            None => None,
459        };
460
461        // Non-negativity of every product with non-zero weight, built from
462        // the bound hypotheses with `sub_nonneg.mpr` and `mul_nonneg`; with
463        // a square factor each hint becomes `mul_nonneg (sq_nonneg g) (…)`.
464        let mut hints: Vec<String> = Vec::new();
465        let mut max_factors = 0usize;
466        if let Some(sq) = &square_hint
467            && self.terms.iter().any(|t| t.degree() == 0)
468        {
469            // The constant term of Σ λₖ Pₖ contributes λ₀·g².
470            hints.push(sq.clone());
471            max_factors = max_factors.max(2);
472        }
473        for t in &self.terms {
474            let mut factors: Vec<String> = Vec::new();
475            for i in 0..n {
476                for _ in 0..t.lower_powers.get(i).copied().unwrap_or(0) {
477                    factors.push(format!("sub_nonneg.mpr {}", lo_names[i]));
478                }
479                for _ in 0..t.upper_powers.get(i).copied().unwrap_or(0) {
480                    factors.push(format!("sub_nonneg.mpr {}", hi_names[i]));
481                }
482            }
483            let Some((first, rest)) = factors.split_first() else {
484                continue; // the constant term needs no hint
485            };
486            let mut acc = first.clone();
487            for f in rest {
488                acc = format!("mul_nonneg ({acc}) ({f})");
489            }
490            if let Some(sq) = &square_hint {
491                acc = format!("mul_nonneg ({sq}) ({acc})");
492                max_factors = max_factors.max(factors.len() + 2);
493            } else {
494                max_factors = max_factors.max(factors.len());
495            }
496            if !hints.contains(&acc) {
497                hints.push(acc);
498            }
499        }
500
501        let sig = format!(
502            "theorem {} ({} : {real}) {} :\n    0 ≤ {goal} := by\n",
503            lean_ident(theorem_name),
504            vars.join(" "),
505            hyps.join(" ")
506        );
507        // With every product supplied as a hint, the remaining step is linear
508        // arithmetic over the exact identity; `nlinarith` is only needed to
509        // move the products' atoms into `linarith`'s view.
510        let tactic = if hints.is_empty() {
511            "  linarith".to_string()
512        } else if max_factors <= 1 {
513            format!("  linarith [{}]", hints.join(", "))
514        } else {
515            format!("  nlinarith [{}]", hints.join(", "))
516        };
517        Ok(wrap_lean(&format!("{sig}{tactic}\n"), MATHLIB_LINE_WIDTH))
518    }
519}
520
521impl BoxCertificate {
522    /// The certificate as plain data for serialisation with serde.
523    pub fn to_data(&self) -> BoxCertificateData {
524        BoxCertificateData {
525            goal: self.goal.to_ex().to_tree(),
526            bounds: self
527                .bounds
528                .iter()
529                .map(|b| BoxBoundTree {
530                    var: b.var.to_tree(),
531                    lo: b.lo.to_tree(),
532                    hi: b.hi.to_tree(),
533                })
534                .collect(),
535            terms: self
536                .terms
537                .iter()
538                .map(|t| HandelmanTermData {
539                    lower_powers: t.lower_powers.clone(),
540                    upper_powers: t.upper_powers.clone(),
541                    weight: serial::q_to_str(&t.weight),
542                })
543                .collect(),
544            square: self.square.as_ref().map(|g| g.to_ex().to_tree()),
545        }
546    }
547
548    /// Rebuild from data in `ctx` and **re-verify**; data that does not
549    /// verify is rejected.
550    ///
551    /// # Errors
552    ///
553    /// [`SymplexError::InvalidArgument`] for malformed data or a failed
554    /// verification.
555    pub fn from_data(ctx: &Context, data: &BoxCertificateData) -> Result<Self, SymplexError> {
556        const OP: &str = "BoxCertificate::from_data";
557        let bad = |reason: String| SymplexError::InvalidArgument {
558            operation: OP,
559            reason,
560        };
561        let bounds: Vec<BoxBound> = data
562            .bounds
563            .iter()
564            .map(|b| BoxBound {
565                var: ctx.from_tree(&b.var),
566                lo: ctx.from_tree(&b.lo),
567                hi: ctx.from_tree(&b.hi),
568            })
569            .collect();
570        if bounds.is_empty() {
571            return Err(bad(
572                "a certificate needs at least one bounded variable".into()
573            ));
574        }
575        let gens: Vec<&Ex> = bounds.iter().map(|b| &b.var).collect();
576        let goal_ex = ctx.from_tree(&data.goal);
577        let goal = Poly::new(&goal_ex, &gens).ok_or_else(|| {
578            bad(format!(
579                "goal `{goal_ex}` is not a polynomial in the box variables"
580            ))
581        })?;
582        let square = match &data.square {
583            Some(t) => {
584                let e = ctx.from_tree(t);
585                Some(
586                    Poly::new(&e, &gens)
587                        .ok_or_else(|| bad(format!("square factor `{e}` is not a polynomial")))?,
588                )
589            }
590            None => None,
591        };
592        let mut terms = Vec::with_capacity(data.terms.len());
593        for t in &data.terms {
594            if t.lower_powers.len() != bounds.len() || t.upper_powers.len() != bounds.len() {
595                return Err(bad(
596                    "a term's power vectors must have one entry per variable".into(),
597                ));
598            }
599            terms.push(HandelmanTerm {
600                lower_powers: t.lower_powers.clone(),
601                upper_powers: t.upper_powers.clone(),
602                weight: serial::q_from_str(&t.weight, OP)?,
603            });
604        }
605        let cert = BoxCertificate {
606            goal,
607            bounds,
608            terms,
609            square,
610        };
611        if !cert.verify() {
612            return Err(bad("the certificate data does not verify".into()));
613        }
614        Ok(cert)
615    }
616
617    /// JSON form of [`to_data`](Self::to_data), for crossing a trust
618    /// boundary: [`from_json`](Self::from_json) re-verifies before
619    /// accepting.
620    ///
621    /// ```
622    /// use symplex::prelude::*;
623    /// use symplex::certificates::{BoxBound, BoxCertificate, prove_nonnegative_on_box};
624    ///
625    /// let ctx = Context::new();
626    /// let x = ctx.symbol("x");
627    /// let unit = [BoxBound { var: x.clone(), lo: ctx.int(0), hi: ctx.int(1) }];
628    /// let out = prove_nonnegative_on_box(&(&x * (1 - &x)), &unit, 2).unwrap();
629    /// let json = out.certificate().unwrap().to_json().unwrap();
630    /// let back = BoxCertificate::from_json(&Context::new(), &json).unwrap();
631    /// assert!(back.verify());
632    /// assert_eq!(back.to_string(), out.certificate().unwrap().to_string());
633    /// ```
634    ///
635    /// # Errors
636    ///
637    /// [`SymplexError::ComputationFailed`] if serialisation fails.
638    pub fn to_json(&self) -> Result<String, SymplexError> {
639        serde_json::to_string(&self.to_data()).map_err(|e| SymplexError::ComputationFailed {
640            operation: "BoxCertificate::to_json",
641            reason: e.to_string(),
642        })
643    }
644
645    /// Parse [`to_json`](Self::to_json) output in `ctx` and re-verify it.
646    ///
647    /// # Errors
648    ///
649    /// [`SymplexError::InvalidArgument`] for malformed JSON or data that
650    /// does not verify.
651    pub fn from_json(ctx: &Context, json: &str) -> Result<Self, SymplexError> {
652        let data: BoxCertificateData =
653            serde_json::from_str(json).map_err(|e| SymplexError::InvalidArgument {
654                operation: "BoxCertificate::from_json",
655                reason: format!("malformed JSON: {e}"),
656            })?;
657        Self::from_data(ctx, &data)
658    }
659}
660
661impl fmt::Display for BoxCertificate {
662    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
663        let Equation { lhs, rhs } = self.identity();
664        write!(f, "{lhs} = {rhs}")?;
665        for b in &self.bounds {
666            write!(f, ", {} ≤ {} ≤ {}", b.lo, b.var, b.hi)?;
667        }
668        Ok(())
669    }
670}
671
672/// Result of [`prove_nonnegative_on_box`]: an [`Outcome`] with a
673/// [`BoxCertificate`] or a [`BoxUnknown`].
674pub type BoxOutcome = Outcome<BoxCertificate, BoxUnknown>;
675
676/// Why [`prove_nonnegative_on_box`] could not decide: no certificate of
677/// the requested degree exists and no counterexample was found on the
678/// sampled grid.  Try a higher `degree`; if the goal has a zero in the
679/// interior of the box, Handelman certificates may not exist at any degree.
680#[derive(Clone, Debug, PartialEq)]
681#[non_exhaustive]
682pub struct BoxUnknown {
683    /// The Farkas vector (one entry per monomial of the coefficient
684    /// system) proving that no certificate of this degree exists.
685    pub farkas: Option<Vec<Q>>,
686    /// The degree that was searched.
687    pub degree: u32,
688}
689
690impl fmt::Display for BoxUnknown {
691    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
692        write!(f, "no Handelman certificate of degree ≤ {}", self.degree)?;
693        if self.farkas.is_some() {
694            write!(f, " (Farkas vector available)")?;
695        }
696        Ok(())
697    }
698}
699
700/// `(variable, value)` pairs for a counterexample, in the prover's
701/// variable order.
702fn refutation_point(vars: &[&Ex], values: Vec<Q>) -> Vec<(Ex, Q)> {
703    vars.iter().map(|v| (*v).clone()).zip(values).collect()
704}
705
706/// Enumerate exponent vectors `(a₁..aₙ, b₁..bₙ)` with total degree ≤ `degree`.
707fn products_up_to(n: usize, degree: u32) -> Vec<(Vec<u32>, Vec<u32>)> {
708    let slots = 2 * n;
709    let mut out = Vec::new();
710    let mut current = vec![0u32; slots];
711    // Iterative odometer over compositions with bounded sum.
712    fn rec(
713        slot: usize,
714        remaining: u32,
715        current: &mut Vec<u32>,
716        n: usize,
717        out: &mut Vec<(Vec<u32>, Vec<u32>)>,
718    ) {
719        if slot == current.len() {
720            out.push((current[..n].to_vec(), current[n..].to_vec()));
721            return;
722        }
723        for e in 0..=remaining {
724            current[slot] = e;
725            rec(slot + 1, remaining - e, current, n, out);
726        }
727        current[slot] = 0;
728    }
729    rec(0, degree, &mut current, n, &mut out);
730    // Products containing both (x − l) and (u − x) for the same variable
731    // are kept: they are legitimate Handelman terms.
732    out
733}
734
735/// Exact rational value of `goal` at a point.
736fn value_at(goal: &Poly, point: &[Q]) -> Option<Q> {
737    let ctx = goal.context();
738    let vals: Vec<Ex> = point.iter().map(|q| ctx.from_ratio(q.clone())).collect();
739    let refs: Vec<&Ex> = vals.iter().collect();
740    goal.eval(&refs).ok()?.as_rational()
741}
742
743/// Search a grid of `steps + 1` points per axis (endpoints included) for a
744/// point where the goal is negative.
745fn find_counterexample(goal: &Poly, bounds: &[Interval<Q>], steps: u32) -> Option<(Vec<Q>, Q)> {
746    let n = bounds.len();
747    let total = (u64::from(steps) + 1).checked_pow(n as u32)?;
748    if total > 200_000 {
749        return None;
750    }
751    let mut idx = vec![0u32; n];
752    loop {
753        let point: Vec<Q> = idx
754            .iter()
755            .zip(bounds)
756            .map(|(&k, iv)| {
757                &iv.lower + (&iv.upper - &iv.lower) * Q::new(BigInt::from(k), BigInt::from(steps))
758            })
759            .collect();
760        if let Some(v) = value_at(goal, &point)
761            && v < Q::zero()
762        {
763            return Some((point, v));
764        }
765        // Increment the odometer.
766        let mut pos = 0;
767        loop {
768            if pos == n {
769                return None;
770            }
771            if idx[pos] < steps {
772                idx[pos] += 1;
773                break;
774            }
775            idx[pos] = 0;
776            pos += 1;
777        }
778    }
779}
780
781/// `nonneg_combination` with the objective `min Σ (1 + degₖ)·λₖ`, so the
782/// certificate uses as few and as low-degree products as the exact LP can
783/// find.  Falls back to plain feasibility if the weighted problem is
784/// (numerically impossible here, but defensively) not `Optimal`.
785fn sparse_nonneg_combination(
786    columns: &[Vec<Q>],
787    target: &[Q],
788    exponents: &[(Vec<u32>, Vec<u32>)],
789) -> Result<Feasibility, SymplexError> {
790    let m = target.len();
791    let cost: Vec<Q> = exponents
792        .iter()
793        .map(|(a, b)| {
794            let deg: u32 = a.iter().sum::<u32>() + b.iter().sum::<u32>();
795            Q::from_integer(BigInt::from(1 + u64::from(deg)))
796        })
797        .collect();
798    let mut lp = LpProblem::minimize(cost);
799    for i in 0..m {
800        let row: Vec<Q> = columns.iter().map(|c| c[i].clone()).collect();
801        lp = lp.eq(row, target[i].clone());
802    }
803    let sol = lp.solve()?;
804    match sol.status {
805        LpStatus::Optimal => Ok(Feasibility::Feasible(sol.x)),
806        LpStatus::Infeasible => Ok(Feasibility::Infeasible { farkas: sol.farkas }),
807        // No budget is set on this LP, so `BudgetExhausted` cannot occur;
808        // the plain feasibility question is the right fallback either way.
809        LpStatus::Unbounded | LpStatus::BudgetExhausted => nonneg_combination(columns, target),
810    }
811}
812
813/// Prove `goal ≥ 0` on the box `bounds` (one [`BoxBound`] per variable, with
814/// rational literal endpoints) by a Handelman certificate of total degree at
815/// most `degree`, or refute it with an exact counterexample.
816///
817/// `goal` must be a polynomial with rational coefficients in exactly the
818/// box variables.  The search cost grows with `C(2n + degree, degree)`
819/// products; `degree ≤ 4` with a handful of variables is instantaneous,
820/// `degree = 6` in three variables is a few hundred columns.
821///
822/// # Errors
823///
824/// [`SymplexError::InvalidArgument`] if `bounds` is empty, an endpoint is
825/// not a rational literal, `lo ≥ hi`, a variable repeats, or `goal` is not a
826/// rational-coefficient polynomial in the box variables.
827///
828/// # Examples
829///
830/// ```
831/// use symplex::prelude::*;
832/// use symplex::certificates::{prove_nonnegative_on_box, BoxBound, BoxOutcome};
833///
834/// let ctx = Context::new();
835/// let x = ctx.symbol("x");
836/// let bounds = [BoxBound { var: x.clone(), lo: ctx.int(0), hi: ctx.int(1) }];
837/// // x(1 − x) ≥ 0 on [0, 1]: the certificate is the single product itself.
838/// let out = prove_nonnegative_on_box(&(&x * (1 - &x)), &bounds, 2).unwrap();
839/// assert!(out.is_proved());
840/// // x − 1/2 is negative near 0: refuted with an exact witness.
841/// match prove_nonnegative_on_box(&(&x - ctx.rational(1, 2)), &bounds, 2).unwrap() {
842///     BoxOutcome::Refuted { value, .. } => assert!(value < num_rational::Ratio::from_integer(0.into())),
843///     other => panic!("{other:?}"),
844/// }
845/// ```
846pub fn prove_nonnegative_on_box(
847    goal: &Ex,
848    bounds: &[BoxBound],
849    degree: u32,
850) -> Result<BoxOutcome, SymplexError> {
851    if bounds.is_empty() {
852        return Err(invalid("at least one bounded variable is required"));
853    }
854    let mut vars: Vec<&Ex> = Vec::with_capacity(bounds.len());
855    let mut q_bounds: Vec<Interval<Q>> = Vec::with_capacity(bounds.len());
856    let mut box_bounds: Vec<BoxBound> = Vec::with_capacity(bounds.len());
857    for BoxBound { var, lo, hi } in bounds {
858        if vars.contains(&var) {
859            return Err(invalid(format!("variable `{var}` is bounded twice")));
860        }
861        let (Some(l), Some(h)) = (lo.eval().as_rational(), hi.eval().as_rational()) else {
862            return Err(invalid(format!(
863                "bounds of `{var}` must be rational literals, got [{lo}, {hi}]"
864            )));
865        };
866        if l >= h {
867            return Err(invalid(format!(
868                "bounds of `{var}` must satisfy lo < hi, got [{lo}, {hi}]"
869            )));
870        }
871        vars.push(var);
872        q_bounds.push(Interval::closed(l, h));
873        box_bounds.push(BoxBound {
874            var: var.clone(),
875            lo: lo.eval(),
876            hi: hi.eval(),
877        });
878    }
879    let goal_poly = Poly::new(goal, &vars).ok_or_else(|| {
880        invalid("goal must be a polynomial in the box variables (other symbols or non-polynomial operations found)")
881    })?;
882    if !goal_poly.has_rational_coeffs() {
883        return Err(invalid(
884            "goal must have rational coefficients (parameters are not supported)",
885        ));
886    }
887
888    // 1. Cheap exact refutation on a grid.
889    if let Some((point, value)) = find_counterexample(&goal_poly, &q_bounds, 8) {
890        return Ok(Outcome::Refuted {
891            point: refutation_point(&vars, point),
892            value,
893            param_value: None,
894        });
895    }
896
897    // 2. Plain Handelman search; on failure, split off the even-multiplicity
898    //    factors (`goal = g²·h`) and certify `h`, which has no interior
899    //    zeros of even order left.
900    match handelman_search(&goal_poly, &vars, &box_bounds, degree, None)? {
901        Ok(cert) => Ok(BoxOutcome::Proved(cert)),
902        Err(farkas) => {
903            if let Some((g, h)) = split_square_factor(&goal_poly, &vars)
904                && let Ok(cert) = handelman_search(&h, &vars, &box_bounds, degree, Some(&g))?
905            {
906                let cert = BoxCertificate {
907                    goal: goal_poly.clone(),
908                    ..cert
909                };
910                if cert.verify() {
911                    return Ok(BoxOutcome::Proved(cert));
912                }
913            }
914            // A finer grid before giving up.
915            if let Some((point, value)) = find_counterexample(&goal_poly, &q_bounds, 32) {
916                return Ok(Outcome::Refuted {
917                    point: refutation_point(&vars, point),
918                    value,
919                    param_value: None,
920                });
921            }
922            Ok(Outcome::Unknown(BoxUnknown { farkas, degree }))
923        }
924    }
925}
926
927/// `goal = g² · h` with `g` the product of the even-multiplicity factors
928/// (`f^(m div 2)` for each factor `f^m`) and `h` the remaining part
929/// (`content · Π f^(m mod 2)`), when the goal has at least one repeated
930/// factor.  Uses exact factoring over ℤ (univariate) or the multivariate
931/// factoring of `factor_list_all`.
932fn split_square_factor(goal: &Poly, vars: &[&Ex]) -> Option<(Poly, Poly)> {
933    let e = goal.to_ex();
934    let (content, factors) = if vars.len() == 1 {
935        e.factor_list(vars[0])
936    } else {
937        e.factor_list_all()
938    };
939    if factors.iter().all(|(_, m)| *m < 2) {
940        return None;
941    }
942    let ctx = goal.context();
943    let mut g = ctx.one();
944    let mut h = content;
945    for (f, m) in &factors {
946        if *m >= 2 {
947            g *= f.powi(i64::from(*m / 2));
948        }
949        if *m % 2 == 1 {
950            h *= f;
951        }
952    }
953    let g = Poly::new(&g, vars)?;
954    let h = Poly::new(&h, vars)?;
955    // Sanity: g²·h must reproduce the goal exactly.
956    let back = g.mul(&g).ok()?.mul(&h).ok()?;
957    if !back.equals(goal) {
958        return None;
959    }
960    Some((g, h))
961}
962
963/// The Handelman LP for `goal = Σ λₖ Pₖ` over `bounds` up to `degree`.
964/// `Ok(Ok(cert))` with a verified certificate (carrying `square`),
965/// `Ok(Err(farkas))` when no certificate of that degree exists.
966fn handelman_search(
967    goal_poly: &Poly,
968    vars: &[&Ex],
969    box_bounds: &[BoxBound],
970    degree: u32,
971    square: Option<&Poly>,
972) -> Result<Result<BoxCertificate, Option<Vec<Q>>>, SymplexError> {
973    let ctx = goal_poly.context();
974    let n = vars.len();
975    let lower: Vec<Poly> = box_bounds
976        .iter()
977        .map(|b| {
978            Poly::new(&(&b.var - &b.lo), vars).ok_or_else(|| invalid("internal: bound factor"))
979        })
980        .collect::<Result<_, _>>()?;
981    let upper: Vec<Poly> = box_bounds
982        .iter()
983        .map(|b| {
984            Poly::new(&(&b.hi - &b.var), vars).ok_or_else(|| invalid("internal: bound factor"))
985        })
986        .collect::<Result<_, _>>()?;
987    let exponents = products_up_to(n, degree);
988    let one = Poly::one(&ctx, vars)?;
989    let mut products: Vec<Poly> = Vec::with_capacity(exponents.len());
990    for (a, b) in &exponents {
991        let mut acc = one.clone();
992        for i in 0..n {
993            for _ in 0..a[i] {
994                acc = acc.mul(&lower[i])?;
995            }
996            for _ in 0..b[i] {
997                acc = acc.mul(&upper[i])?;
998            }
999        }
1000        products.push(acc);
1001    }
1002    let mut all: Vec<&Poly> = products.iter().collect();
1003    all.push(goal_poly);
1004    let monos = Poly::monomial_basis(&all)?;
1005    let coeff_vec = |p: &Poly| -> Result<Vec<Q>, SymplexError> {
1006        monos
1007            .iter()
1008            .map(|m| {
1009                p.coeff_monomial(m)?
1010                    .as_rational()
1011                    .ok_or_else(|| invalid("internal: non-rational coefficient"))
1012            })
1013            .collect()
1014    };
1015    let columns: Vec<Vec<Q>> = products.iter().map(coeff_vec).collect::<Result<_, _>>()?;
1016    let target = coeff_vec(goal_poly)?;
1017
1018    // Minimising Σ (1 + degₖ)·λₖ prefers sparse, low-degree certificates
1019    // (shorter Lean proofs) over an arbitrary feasible vertex.
1020    match sparse_nonneg_combination(&columns, &target, &exponents)? {
1021        Feasibility::Feasible(lambda) => {
1022            let terms: Vec<HandelmanTerm> = exponents
1023                .iter()
1024                .zip(&lambda)
1025                .filter(|(_, w)| **w > Q::zero())
1026                .map(|((a, b), w)| HandelmanTerm {
1027                    lower_powers: a.clone(),
1028                    upper_powers: b.clone(),
1029                    weight: w.clone(),
1030                })
1031                .collect();
1032            // With a square factor the certified polynomial is `square²·goal_poly`;
1033            // the caller substitutes the original goal.
1034            let certified = match square {
1035                Some(g) => g.mul(g)?.mul(goal_poly)?,
1036                None => goal_poly.clone(),
1037            };
1038            let cert = BoxCertificate {
1039                goal: certified,
1040                bounds: box_bounds.to_vec(),
1041                terms,
1042                square: square.cloned(),
1043            };
1044            if !cert.verify() {
1045                return Err(SymplexError::ComputationFailed {
1046                    operation: "prove_nonnegative_on_box",
1047                    reason:
1048                        "the LP solution did not reproduce the goal under exact re-verification"
1049                            .into(),
1050                });
1051            }
1052            Ok(Ok(cert))
1053        }
1054        Feasibility::Infeasible { farkas } => Ok(Err(farkas)),
1055    }
1056}
1057
1058/// Convenience: is `goal ≥ 0` on the box?  `Some(true)` with a verified
1059/// certificate up to `max_degree`, `Some(false)` with an exact
1060/// counterexample, `None` when undecided.
1061///
1062/// ```
1063/// use symplex::prelude::*;
1064/// use symplex::certificates::{is_nonnegative_on_box, BoxBound};
1065///
1066/// let ctx = Context::new();
1067/// let (x, y) = (ctx.symbol("x"), ctx.symbol("y"));
1068/// let bounds = [
1069///     BoxBound { var: x.clone(), lo: ctx.int(0), hi: ctx.int(1) },
1070///     BoxBound { var: y.clone(), lo: ctx.int(0), hi: ctx.int(1) },
1071/// ];
1072/// assert_eq!(is_nonnegative_on_box(&(1 - &x * &y), &bounds, 4), Some(true));
1073/// assert_eq!(is_nonnegative_on_box(&(&x * &y - 1), &bounds, 4), Some(false));
1074/// ```
1075pub fn is_nonnegative_on_box(goal: &Ex, bounds: &[BoxBound], max_degree: u32) -> Option<bool> {
1076    for d in 1..=max_degree.max(1) {
1077        match prove_nonnegative_on_box(goal, bounds, d) {
1078            Ok(Outcome::Proved(_)) => return Some(true),
1079            Ok(Outcome::Refuted { .. }) => return Some(false),
1080            Ok(Outcome::Unknown(_)) => continue,
1081            Err(_) => return None,
1082        }
1083    }
1084    None
1085}
1086
1087impl Poly {
1088    /// Express this polynomial as a non-negative combination `Σ λⱼ bⱼ` of
1089    /// `basis` (same generators), exactly: [`Feasibility::Feasible`] with the
1090    /// weights, or [`Feasibility::Infeasible`] with a Farkas vector over the
1091    /// monomials.  Coefficients must be rational.
1092    ///
1093    /// This is the linear-algebra core of a Positivstellensatz / Handelman
1094    /// search; [`prove_nonnegative_on_box`] builds the basis for you.
1095    ///
1096    /// # Errors
1097    ///
1098    /// `InvalidArgument` if `basis` is empty, generators differ, or a
1099    /// coefficient is not rational.
1100    ///
1101    /// ```
1102    /// use symplex::prelude::*;
1103    /// use symplex::linprog::Feasibility;
1104    ///
1105    /// let ctx = Context::new();
1106    /// let x = ctx.symbol("x");
1107    /// let goal = (&x + 1).powi(2).as_poly(&[&x]).unwrap();
1108    /// let b1 = (&x + 1).as_poly(&[&x]).unwrap();
1109    /// let b2 = (&x.powi(2) - 1).as_poly(&[&x]).unwrap();
1110    /// // (x + 1)² = 2(x + 1) + 1·(x² − 1)
1111    /// match goal.express_as_nonneg_combination(&[&b1, &b2]).unwrap() {
1112    ///     Feasibility::Feasible(w) => assert_eq!(w.iter().map(ToString::to_string).collect::<Vec<_>>(), ["2", "1"]),
1113    ///     other => panic!("{other:?}"),
1114    /// }
1115    /// ```
1116    pub fn express_as_nonneg_combination(
1117        &self,
1118        basis: &[&Poly],
1119    ) -> Result<Feasibility, SymplexError> {
1120        const OP: &str = "Poly::express_as_nonneg_combination";
1121        let bad = |reason: &str| SymplexError::InvalidArgument {
1122            operation: OP,
1123            reason: reason.into(),
1124        };
1125        if basis.is_empty() {
1126            return Err(bad("basis must not be empty"));
1127        }
1128        let mut all: Vec<&Poly> = basis.to_vec();
1129        all.push(self);
1130        let monos = Poly::monomial_basis(&all).map_err(|_| bad("generators differ"))?;
1131        let coeff_vec = |p: &Poly| -> Result<Vec<Q>, SymplexError> {
1132            monos
1133                .iter()
1134                .map(|m| {
1135                    p.coeff_monomial(m)?
1136                        .as_rational()
1137                        .ok_or_else(|| bad("coefficients must be rational"))
1138                })
1139                .collect()
1140        };
1141        let columns: Vec<Vec<Q>> = basis
1142            .iter()
1143            .map(|p| coeff_vec(p))
1144            .collect::<Result<_, _>>()?;
1145        let target = coeff_vec(self)?;
1146        nonneg_combination(&columns, &target)
1147    }
1148}
1149
1150impl Ex {
1151    /// [`prove_nonnegative_on_box`] as a method.
1152    pub fn prove_nonnegative_on_box(
1153        &self,
1154        bounds: &[BoxBound],
1155        degree: u32,
1156    ) -> Result<BoxOutcome, SymplexError> {
1157        prove_nonnegative_on_box(self, bounds, degree)
1158    }
1159}
1160
1161// ═══════════════════════════════════════════════════════════════════════════
1162// Half-lines and the real line (univariate)
1163// ═══════════════════════════════════════════════════════════════════════════
1164
1165/// Which unbounded domain a [`HalfLineCertificate`] covers.
1166#[derive(Clone, Debug, PartialEq, Eq)]
1167pub enum Ray {
1168    /// `x ≥ a`.
1169    AtLeast,
1170    /// `x ≤ a`.
1171    AtMost,
1172}
1173
1174/// A verified certificate that a univariate polynomial is non-negative on
1175/// a half-line.
1176///
1177/// With `k = x − a` (or `k = a − x` for [`Ray::AtMost`]) the identity is
1178///
1179/// ```text
1180/// (1 + k)^N · goal(x) = square(x)² · Σᵢ cᵢ kⁱ,      cᵢ ≥ 0,
1181/// ```
1182///
1183/// which proves `goal ≥ 0` for `k ≥ 0`.  `N = 0` is the plain
1184/// *shift-and-read-off-the-coefficients* certificate (the same sufficient
1185/// condition `linarith` re-derives in Lean); `N > 0` is a Pólya multiplier,
1186/// which by Pólya's theorem always exists when `goal` is strictly positive
1187/// on the closed half-line and has positive leading coefficient.  The
1188/// square factor collects even-multiplicity zeros, exactly as for the box
1189/// certificates.
1190#[derive(Clone, Debug)]
1191pub struct HalfLineCertificate {
1192    goal: Poly,
1193    var: Ex,
1194    endpoint: Ex,
1195    ray: Ray,
1196    polya_power: u32,
1197    coefficients: Vec<Q>,
1198    square: Option<Poly>,
1199}
1200
1201impl HalfLineCertificate {
1202    /// The polynomial that was proved non-negative.
1203    pub fn goal(&self) -> &Poly {
1204        &self.goal
1205    }
1206
1207    /// The variable.
1208    pub fn var(&self) -> &Ex {
1209        &self.var
1210    }
1211
1212    /// The finite endpoint `a`.
1213    pub fn endpoint(&self) -> &Ex {
1214        &self.endpoint
1215    }
1216
1217    /// Whether the domain is `x ≥ a` or `x ≤ a`.
1218    pub fn ray(&self) -> &Ray {
1219        &self.ray
1220    }
1221
1222    /// The Pólya exponent `N` (`0` for a pure shift certificate).
1223    pub fn polya_power(&self) -> u32 {
1224        self.polya_power
1225    }
1226
1227    /// The non-negative coefficients `cᵢ` of `(1 + k)^N · goal / square²` in
1228    /// powers of `k`, ascending.
1229    pub fn coefficients(&self) -> &[Q] {
1230        &self.coefficients
1231    }
1232
1233    /// The square factor `g`, if any.
1234    pub fn square(&self) -> Option<&Poly> {
1235        self.square.as_ref()
1236    }
1237
1238    /// `k` as an expression: `x − a` or `a − x`.
1239    pub fn shift_expr(&self) -> Ex {
1240        match self.ray {
1241            Ray::AtLeast => &self.var - &self.endpoint,
1242            Ray::AtMost => &self.endpoint - &self.var,
1243        }
1244    }
1245
1246    /// The certificate as plain data for serialisation with serde.
1247    pub fn to_data(&self) -> HalfLineCertificateData {
1248        HalfLineCertificateData {
1249            goal: self.goal.to_ex().to_tree(),
1250            var: self.var.to_tree(),
1251            endpoint: self.endpoint.to_tree(),
1252            ray: match self.ray {
1253                Ray::AtLeast => "at_least".to_string(),
1254                Ray::AtMost => "at_most".to_string(),
1255            },
1256            polya_power: self.polya_power,
1257            coefficients: self.coefficients.iter().map(serial::q_to_str).collect(),
1258            square: self.square.as_ref().map(|g| g.to_ex().to_tree()),
1259        }
1260    }
1261
1262    /// Rebuild from data in `ctx` and **re-verify**; data that does not
1263    /// verify is rejected.
1264    ///
1265    /// # Errors
1266    ///
1267    /// [`SymplexError::InvalidArgument`] for malformed data or a failed
1268    /// verification.
1269    pub fn from_data(ctx: &Context, data: &HalfLineCertificateData) -> Result<Self, SymplexError> {
1270        const OP: &str = "HalfLineCertificate::from_data";
1271        let bad = |reason: String| SymplexError::InvalidArgument {
1272            operation: OP,
1273            reason,
1274        };
1275        let var = ctx.from_tree(&data.var);
1276        let goal_ex = ctx.from_tree(&data.goal);
1277        let goal = Poly::new(&goal_ex, &[&var])
1278            .ok_or_else(|| bad(format!("goal `{goal_ex}` is not a polynomial in `{var}`")))?;
1279        let square = match &data.square {
1280            Some(t) => {
1281                let e = ctx.from_tree(t);
1282                Some(
1283                    Poly::new(&e, &[&var])
1284                        .ok_or_else(|| bad(format!("square factor `{e}` is not a polynomial")))?,
1285                )
1286            }
1287            None => None,
1288        };
1289        let ray = match data.ray.as_str() {
1290            "at_least" => Ray::AtLeast,
1291            "at_most" => Ray::AtMost,
1292            other => {
1293                return Err(bad(format!(
1294                    "unknown ray `{other}` (expected `at_least` or `at_most`)"
1295                )));
1296            }
1297        };
1298        let cert = HalfLineCertificate {
1299            goal,
1300            var,
1301            endpoint: ctx.from_tree(&data.endpoint).eval(),
1302            ray,
1303            polya_power: data.polya_power,
1304            coefficients: data
1305                .coefficients
1306                .iter()
1307                .map(|s| serial::q_from_str(s, OP))
1308                .collect::<Result<_, _>>()?,
1309            square,
1310        };
1311        if !cert.verify() {
1312            return Err(bad("the certificate data does not verify".into()));
1313        }
1314        Ok(cert)
1315    }
1316
1317    /// JSON form of [`to_data`](Self::to_data); [`from_json`](Self::from_json)
1318    /// re-verifies before accepting.
1319    ///
1320    /// ```
1321    /// use symplex::prelude::*;
1322    /// use symplex::certificates::{HalfLineCertificate, Ray, prove_nonnegative_on_halfline};
1323    ///
1324    /// let ctx = Context::new();
1325    /// let x = ctx.symbol("x");
1326    /// let out = prove_nonnegative_on_halfline(&(&x.powi(2) - &x + 1), &x, &ctx.int(0), Ray::AtLeast, 4).unwrap();
1327    /// let json = out.certificate().unwrap().to_json().unwrap();
1328    /// let back = HalfLineCertificate::from_json(&Context::new(), &json).unwrap();
1329    /// assert_eq!(back.polya_power(), out.certificate().unwrap().polya_power());
1330    /// assert!(back.verify());
1331    /// ```
1332    ///
1333    /// # Errors
1334    ///
1335    /// [`SymplexError::ComputationFailed`] if serialisation fails.
1336    pub fn to_json(&self) -> Result<String, SymplexError> {
1337        serde_json::to_string(&self.to_data()).map_err(|e| SymplexError::ComputationFailed {
1338            operation: "HalfLineCertificate::to_json",
1339            reason: e.to_string(),
1340        })
1341    }
1342
1343    /// Parse [`to_json`](Self::to_json) output in `ctx` and re-verify it.
1344    ///
1345    /// # Errors
1346    ///
1347    /// [`SymplexError::InvalidArgument`] for malformed JSON or data that
1348    /// does not verify.
1349    pub fn from_json(ctx: &Context, json: &str) -> Result<Self, SymplexError> {
1350        let data: HalfLineCertificateData =
1351            serde_json::from_str(json).map_err(|e| SymplexError::InvalidArgument {
1352                operation: "HalfLineCertificate::from_json",
1353                reason: format!("malformed JSON: {e}"),
1354            })?;
1355        Self::from_data(ctx, &data)
1356    }
1357
1358    /// Recompute `(1 + k)^N · goal` and `square² · Σ cᵢ kⁱ` exactly and
1359    /// compare them; also checks every `cᵢ ≥ 0`.
1360    pub fn verify(&self) -> bool {
1361        if self.coefficients.iter().any(|c| *c < Q::zero()) {
1362            return false;
1363        }
1364        let ctx = self.goal.context();
1365        let k = self.shift_expr();
1366        let mut rhs = ctx.zero();
1367        for (i, c) in self.coefficients.iter().enumerate() {
1368            rhs += ctx.from_ratio(c.clone()) * k.powi(i as i64);
1369        }
1370        if let Some(g) = &self.square {
1371            rhs = g.to_ex().powi(2) * rhs;
1372        }
1373        let lhs = (1 + &k).powi(i64::from(self.polya_power)) * self.goal.to_ex();
1374        let vars = [&self.var];
1375        match (Poly::new(&lhs, &vars), Poly::new(&rhs, &vars)) {
1376            (Some(l), Some(r)) => l.equals(&r),
1377            _ => false,
1378        }
1379    }
1380
1381    /// The identity `(1 + k)^N · goal = square² · Σ cᵢ kⁱ` as an
1382    /// [`Equation`].
1383    pub fn identity(&self) -> Equation {
1384        let ctx = self.goal.context();
1385        let k = self.shift_expr();
1386        let mut rhs = ctx.zero();
1387        for (i, c) in self.coefficients.iter().enumerate() {
1388            rhs += ctx.from_ratio(c.clone()) * k.powi(i as i64);
1389        }
1390        if let Some(g) = &self.square {
1391            rhs = g.to_ex().powi(2) * rhs;
1392        }
1393        let lhs = if self.polya_power == 0 {
1394            self.goal.to_ex()
1395        } else {
1396            (1 + &k).powi(i64::from(self.polya_power)) * self.goal.to_ex()
1397        };
1398        Equation::new(lhs, rhs)
1399    }
1400
1401    /// The hint terms of the Lean proof, for an existing proof skeleton:
1402    /// one entry per power of `k = x − a` (or `a − x`) with a positive
1403    /// coefficient, given the caller's name `hk` of the hypothesis
1404    /// `0 ≤ k` — `hk` itself for the first power, `pow_nonneg hk n` above,
1405    /// each wrapped in `mul_nonneg (sq_nonneg g) (…)` when the certificate
1406    /// has a square factor.  The constant term needs no hint.
1407    ///
1408    /// With a Pólya power `N > 0` the identity proves
1409    /// `0 ≤ (1 + k) ^ N * goal`; the caller then divides by
1410    /// `pow_pos (by linarith) N` as [`to_lean`](Self::to_lean) does.
1411    ///
1412    /// ```
1413    /// use symplex::prelude::*;
1414    /// use symplex::certificates::{prove_nonnegative_on_halfline, Ray};
1415    /// use symplex::lean::LeanOpts;
1416    ///
1417    /// let ctx = Context::new();
1418    /// let x = ctx.symbol("x");
1419    /// // x² − 2x + 3 = (x − 1)² + 2 on x ≥ 1: coefficients [2, 0, 1] in k = x − 1.
1420    /// let out = prove_nonnegative_on_halfline(&(&x.powi(2) - &x * 2 + 3), &x, &ctx.int(1), Ray::AtLeast, 0).unwrap();
1421    /// let cert = out.certificate().unwrap();
1422    /// assert_eq!(cert.lean_hints("hk", &LeanOpts::default()).unwrap(), vec!["pow_nonneg hk 2"]);
1423    /// ```
1424    ///
1425    /// # Errors
1426    ///
1427    /// [`SymplexError::NotImplemented`] if the square factor cannot be
1428    /// rendered.
1429    pub fn lean_hints(&self, hk: &str, opts: &LeanOpts) -> Result<Vec<String>, SymplexError> {
1430        let square_hint = match &self.square {
1431            Some(g) => Some(format!("sq_nonneg ({})", g.to_ex().to_lean_with(opts)?)),
1432            None => None,
1433        };
1434        let mut hints: Vec<String> = Vec::new();
1435        for (i, c) in self.coefficients.iter().enumerate() {
1436            if *c <= Q::zero() {
1437                continue;
1438            }
1439            let h = match i {
1440                0 => None,
1441                1 => Some(hk.to_string()),
1442                _ => Some(format!("pow_nonneg {hk} {i}")),
1443            };
1444            let h = match (&square_hint, h) {
1445                (Some(sq), Some(h)) => format!("mul_nonneg ({sq}) ({h})"),
1446                (Some(sq), None) => sq.clone(),
1447                (None, Some(h)) => h,
1448                (None, None) => continue,
1449            };
1450            hints.push(h);
1451        }
1452        Ok(hints)
1453    }
1454
1455    /// A Lean 4 / Mathlib theorem `0 ≤ goal` for `a ≤ x` (or `x ≤ a`).
1456    ///
1457    /// Shape for `N = 0`:
1458    /// ```text
1459    /// theorem name (x : ℝ) (h_x_lo : a ≤ x) : 0 ≤ goal := by
1460    ///   have hk : 0 ≤ x - a := sub_nonneg.mpr h_x_lo
1461    ///   nlinarith [pow_nonneg hk 2, pow_nonneg hk 3]
1462    /// ```
1463    /// and for a Pólya multiplier the product `(1 + (x - a)) ^ N * goal` is
1464    /// shown non-negative the same way and divided out with
1465    /// `nonneg_of_mul_nonneg_right`.  A square factor turns every hint into
1466    /// `mul_nonneg (sq_nonneg g) (…)`.
1467    pub fn to_lean(&self, theorem_name: &str) -> Result<String, SymplexError> {
1468        self.to_lean_with(theorem_name, &LeanOpts::default())
1469    }
1470
1471    /// [`to_lean`](Self::to_lean) with explicit rendering options.
1472    pub fn to_lean_with(
1473        &self,
1474        theorem_name: &str,
1475        opts: &LeanOpts,
1476    ) -> Result<String, SymplexError> {
1477        let real = &opts.real_type;
1478        let v = lean_ident(&self.var.to_string());
1479        let base = v.trim_matches(['«', '»']);
1480        let a = self.endpoint.to_lean_with(opts)?;
1481        let goal = self.goal.to_ex().to_lean_with(opts)?;
1482        let (hyp_name, hyp, k) = match self.ray {
1483            Ray::AtLeast => (
1484                format!("h_{base}_lo"),
1485                format!("{a} ≤ {v}"),
1486                format!("{v} - {a}"),
1487            ),
1488            Ray::AtMost => (
1489                format!("h_{base}_hi"),
1490                format!("{v} ≤ {a}"),
1491                format!("{a} - {v}"),
1492            ),
1493        };
1494        let square_hint = self.square.is_some();
1495        // One hint per power of k that carries a positive coefficient.
1496        let hints = self.lean_hints("hk", opts)?;
1497        let tactic_name = if square_hint || self.coefficients.len() > 2 {
1498            "nlinarith"
1499        } else {
1500            "linarith"
1501        };
1502        let hint_list = if hints.is_empty() {
1503            String::new()
1504        } else {
1505            format!(" [{}]", hints.join(", "))
1506        };
1507        let mut out = format!(
1508            "theorem {} ({v} : {real}) ({hyp_name} : {hyp}) :\n    0 ≤ {goal} := by\n  have hk : 0 ≤ {k} := sub_nonneg.mpr {hyp_name}\n",
1509            lean_ident(theorem_name),
1510        );
1511        if self.polya_power == 0 {
1512            out.push_str(&format!("  {tactic_name}{hint_list}\n"));
1513        } else {
1514            let n = self.polya_power;
1515            out.push_str(&format!(
1516                "  have hpos : 0 < (1 + ({k})) ^ {n} := pow_pos (by linarith) {n}\n  have hprod : 0 ≤ (1 + ({k})) ^ {n} * ({goal}) := by nlinarith{hint_list}\n  exact nonneg_of_mul_nonneg_right hprod hpos\n"
1517            ));
1518        }
1519        Ok(wrap_lean(&out, MATHLIB_LINE_WIDTH))
1520    }
1521}
1522
1523impl fmt::Display for HalfLineCertificate {
1524    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1525        let Equation { lhs, rhs } = self.identity();
1526        write!(f, "{lhs} = {rhs}")?;
1527        match self.ray {
1528            Ray::AtLeast => write!(f, ", {} ≥ {}", self.var, self.endpoint),
1529            Ray::AtMost => write!(f, ", {} ≤ {}", self.var, self.endpoint),
1530        }
1531    }
1532}
1533
1534/// Result of [`prove_nonnegative_on_halfline`]: an [`Outcome`] with a
1535/// [`HalfLineCertificate`] or a [`HalfLineUnknown`].  A refutation's
1536/// `point` has the single entry `(var, value)`.
1537pub type HalfLineOutcome = Outcome<HalfLineCertificate, HalfLineUnknown>;
1538
1539/// Why [`prove_nonnegative_on_halfline`] could not decide: the goal is
1540/// non-negative on the half-line (decided exactly by Sturm's theorem) but
1541/// no certificate was found within the Pólya budget — this happens when
1542/// the goal has an interior zero that is not an even-multiplicity factor
1543/// over ℚ (an irreducible sum of squares with an irrational double root).
1544#[derive(Clone, Debug, PartialEq, Eq)]
1545#[non_exhaustive]
1546pub struct HalfLineUnknown {
1547    /// The largest Pólya exponent tried.
1548    pub max_polya_power: u32,
1549}
1550
1551impl fmt::Display for HalfLineUnknown {
1552    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1553        write!(
1554            f,
1555            "non-negative by Sturm's theorem, but no certificate up to Pólya power {}",
1556            self.max_polya_power
1557        )
1558    }
1559}
1560
1561/// Coefficients (ascending in `k`) of `p(x)` rewritten in `k` where
1562/// `x = a + k` (`AtLeast`) or `x = a − k` (`AtMost`).
1563fn shifted_coefficients(p: &Poly, var: &Ex, a: &Ex, ray: &Ray) -> Option<Vec<Q>> {
1564    let ctx = p.context();
1565    let k = ctx.symbol("__k");
1566    let x_of_k = match ray {
1567        Ray::AtLeast => a + &k,
1568        Ray::AtMost => a - &k,
1569    };
1570    let q = p.to_ex().subs(var, &x_of_k);
1571    let qp = Poly::new(&q, &[&k])?;
1572    let coeffs = qp.all_coeffs()?; // highest first
1573    let mut asc: Vec<Q> = coeffs
1574        .iter()
1575        .rev()
1576        .map(|c| c.as_rational())
1577        .collect::<Option<_>>()?;
1578    while asc.len() > 1 && asc.last().is_some_and(Zero::is_zero) {
1579        asc.pop();
1580    }
1581    Some(asc)
1582}
1583
1584/// Multiply the coefficient list by `(1 + k)`.
1585fn times_one_plus_k(c: &[Q]) -> Vec<Q> {
1586    let mut out = vec![Q::zero(); c.len() + 1];
1587    for (i, ci) in c.iter().enumerate() {
1588        out[i] += ci;
1589        out[i + 1] += ci;
1590    }
1591    out
1592}
1593
1594/// Find a point of the half-line where `p < 0`, using the isolating
1595/// intervals of the real roots and the Cauchy bound.
1596fn halfline_counterexample(p: &Poly, var: &Ex, a: &Q, ray: &Ray) -> Option<(Q, Q)> {
1597    let ctx = p.context();
1598    let e = p.to_ex();
1599    let eval =
1600        |x: &Q| -> Option<Q> { e.subs(var, &ctx.from_ratio(x.clone())).eval().as_rational() };
1601    let one = Q::one();
1602    let inside = |x: &Q| match ray {
1603        Ray::AtLeast => x >= a,
1604        Ray::AtMost => x <= a,
1605    };
1606    // Candidate points: the endpoint, midpoints and outer points of the root
1607    // isolating intervals, and a point beyond all roots.
1608    let mut candidates: Vec<Q> = vec![a.clone()];
1609    let iv = e.real_roots_isolate(var);
1610    for interval in &iv {
1611        if let (Some(l), Some(h)) = (interval.lower.as_rational(), interval.upper.as_rational()) {
1612            candidates.push((&l + &h) / Q::from_integer(BigInt::from(2)));
1613            candidates.push(&l - &one);
1614            candidates.push(&h + &one);
1615            candidates.push((&l + a) / Q::from_integer(BigInt::from(2)));
1616        }
1617    }
1618    let far = match ray {
1619        Ray::AtLeast => a + Q::from_integer(BigInt::from(1000)),
1620        Ray::AtMost => a - Q::from_integer(BigInt::from(1000)),
1621    };
1622    candidates.push(far);
1623    for x in candidates {
1624        if inside(&x)
1625            && let Some(v) = eval(&x)
1626            && v < Q::zero()
1627        {
1628            return Some((x, v));
1629        }
1630    }
1631    None
1632}
1633
1634/// Prove `goal ≥ 0` for `var ≥ a` ([`Ray::AtLeast`]) or `var ≤ a`
1635/// ([`Ray::AtMost`]) with a [`HalfLineCertificate`], or refute it exactly.
1636///
1637/// The search: shift to `k ≥ 0`; if every coefficient of the shifted
1638/// polynomial is non-negative that is the certificate (`N = 0`); otherwise
1639/// multiply by `(1 + k)` up to `max_polya_power` times; if that fails, split
1640/// off even-multiplicity factors (`goal = g²·h`) and retry on `h`.  A goal
1641/// that is negative somewhere on the half-line is refuted with an exact
1642/// point; a goal that is non-negative (by Sturm's theorem) but has no
1643/// certificate of this form is reported as [`HalfLineOutcome::Unknown`].
1644///
1645/// # Errors
1646///
1647/// [`SymplexError::InvalidArgument`] if `a` is not a rational literal, or
1648/// `goal` is not a univariate polynomial in `var` with rational
1649/// coefficients.
1650///
1651/// # Examples
1652///
1653/// ```
1654/// use symplex::prelude::*;
1655/// use symplex::certificates::{prove_nonnegative_on_halfline, HalfLineOutcome, Ray};
1656///
1657/// let ctx = Context::new();
1658/// let j = ctx.symbol("j");
1659/// // (j − 1)(j − 3) ≥ 0 for j ≥ 3:  p(3 + k) = k² + 2k, all coefficients ≥ 0.
1660/// let p = (&j - 1) * (&j - 3);
1661/// let out = prove_nonnegative_on_halfline(&p, &j, &ctx.int(3), Ray::AtLeast, 10).unwrap();
1662/// let cert = out.certificate().unwrap();
1663/// assert_eq!(cert.polya_power(), 0);
1664/// assert!(cert.verify());
1665/// // For j ≥ 2 the claim is false (p(5/2) < 0).
1666/// assert!(matches!(
1667///     prove_nonnegative_on_halfline(&p, &j, &ctx.int(2), Ray::AtLeast, 10).unwrap(),
1668///     HalfLineOutcome::Refuted { .. }
1669/// ));
1670/// ```
1671pub fn prove_nonnegative_on_halfline(
1672    goal: &Ex,
1673    var: &Ex,
1674    a: &Ex,
1675    ray: Ray,
1676    max_polya_power: u32,
1677) -> Result<HalfLineOutcome, SymplexError> {
1678    let bad = |reason: &str| SymplexError::InvalidArgument {
1679        operation: "prove_nonnegative_on_halfline",
1680        reason: reason.into(),
1681    };
1682    let a_ex = a.eval();
1683    let a_q = a_ex
1684        .as_rational()
1685        .ok_or_else(|| bad("the endpoint must be a rational literal"))?;
1686    let goal_poly =
1687        Poly::new(goal, &[var]).ok_or_else(|| bad("goal must be a polynomial in the variable"))?;
1688    if !goal_poly.has_rational_coeffs() {
1689        return Err(bad("goal must have rational coefficients"));
1690    }
1691    let ctx: Context = goal.context();
1692
1693    // Exact decision first, so a false claim is refuted with a point and a
1694    // true one never gets a spurious Unknown from a failed search.
1695    let (lo, hi) = match ray {
1696        Ray::AtLeast => (a_ex.clone(), ctx.infinity()),
1697        Ray::AtMost => (ctx.neg_infinity(), a_ex.clone()),
1698    };
1699    if goal_poly.is_nonnegative_on(&lo, &hi) == Some(false)
1700        && let Some((point, value)) = halfline_counterexample(&goal_poly, var, &a_q, &ray)
1701    {
1702        return Ok(Outcome::Refuted {
1703            point: vec![(var.clone(), point)],
1704            value,
1705            param_value: None,
1706        });
1707    }
1708
1709    let try_certify = |p: &Poly, square: Option<&Poly>| -> Option<HalfLineCertificate> {
1710        let mut coeffs = shifted_coefficients(p, var, &a_ex, &ray)?;
1711        for n in 0..=max_polya_power {
1712            if coeffs.iter().all(|c| *c >= Q::zero()) {
1713                let cert = HalfLineCertificate {
1714                    goal: goal_poly.clone(),
1715                    var: var.clone(),
1716                    endpoint: a_ex.clone(),
1717                    ray: ray.clone(),
1718                    polya_power: n,
1719                    coefficients: coeffs,
1720                    square: square.cloned(),
1721                };
1722                return cert.verify().then_some(cert);
1723            }
1724            coeffs = times_one_plus_k(&coeffs);
1725        }
1726        None
1727    };
1728
1729    if let Some(c) = try_certify(&goal_poly, None) {
1730        return Ok(HalfLineOutcome::Proved(c));
1731    }
1732    if let Some((g, h)) = split_square_factor(&goal_poly, &[var])
1733        && let Some(c) = try_certify(&h, Some(&g))
1734    {
1735        return Ok(HalfLineOutcome::Proved(c));
1736    }
1737    if let Some((point, value)) = halfline_counterexample(&goal_poly, var, &a_q, &ray) {
1738        return Ok(Outcome::Refuted {
1739            point: vec![(var.clone(), point)],
1740            value,
1741            param_value: None,
1742        });
1743    }
1744    Ok(Outcome::Unknown(HalfLineUnknown { max_polya_power }))
1745}
1746
1747/// A verified proof that a univariate polynomial is non-negative on all of
1748/// ℝ: a pair of half-line certificates meeting at `split`.
1749#[derive(Clone, Debug)]
1750pub struct RealLineCertificate {
1751    /// Certificate for `x ≥ split`.
1752    pub upper: HalfLineCertificate,
1753    /// Certificate for `x ≤ split`.
1754    pub lower: HalfLineCertificate,
1755}
1756
1757/// Serialisable form of a [`RealLineCertificate`]: its two halves.
1758#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
1759pub struct RealLineCertificateData {
1760    /// The `x ≥ split` half.
1761    pub upper: HalfLineCertificateData,
1762    /// The `x ≤ split` half.
1763    pub lower: HalfLineCertificateData,
1764}
1765
1766impl RealLineCertificate {
1767    /// The goal (shared by both halves).
1768    pub fn goal(&self) -> &Poly {
1769        &self.upper.goal
1770    }
1771
1772    /// Both halves re-verified.
1773    pub fn verify(&self) -> bool {
1774        self.upper.verify() && self.lower.verify() && self.upper.endpoint == self.lower.endpoint
1775    }
1776
1777    /// The serialisable form.
1778    pub fn to_data(&self) -> RealLineCertificateData {
1779        RealLineCertificateData {
1780            upper: self.upper.to_data(),
1781            lower: self.lower.to_data(),
1782        }
1783    }
1784
1785    /// Rebuild from the serialisable form into `ctx`, re-verifying.
1786    ///
1787    /// # Errors
1788    ///
1789    /// As [`HalfLineCertificate::from_data`]; also if the halves do not
1790    /// meet at the same point.
1791    pub fn from_data(ctx: &Context, data: &RealLineCertificateData) -> Result<Self, SymplexError> {
1792        let cert = RealLineCertificate {
1793            upper: HalfLineCertificate::from_data(ctx, &data.upper)?,
1794            lower: HalfLineCertificate::from_data(ctx, &data.lower)?,
1795        };
1796        if !cert.verify() {
1797            return Err(SymplexError::InvalidArgument {
1798                operation: "RealLineCertificate::from_data",
1799                reason: "the two halves do not verify as one certificate".into(),
1800            });
1801        }
1802        Ok(cert)
1803    }
1804
1805    /// The certificate as JSON.
1806    ///
1807    /// # Errors
1808    ///
1809    /// [`SymplexError::ComputationFailed`] if serialisation fails.
1810    pub fn to_json(&self) -> Result<String, SymplexError> {
1811        serde_json::to_string_pretty(&self.to_data()).map_err(|e| SymplexError::ComputationFailed {
1812            operation: "RealLineCertificate::to_json",
1813            reason: e.to_string(),
1814        })
1815    }
1816
1817    /// Parse [`to_json`](Self::to_json) output into `ctx`, re-verifying.
1818    ///
1819    /// # Errors
1820    ///
1821    /// As [`from_data`](Self::from_data); `InvalidArgument` for malformed JSON.
1822    pub fn from_json(ctx: &Context, json: &str) -> Result<Self, SymplexError> {
1823        let data: RealLineCertificateData =
1824            serde_json::from_str(json).map_err(|e| SymplexError::InvalidArgument {
1825                operation: "RealLineCertificate::from_json",
1826                reason: e.to_string(),
1827            })?;
1828        Self::from_data(ctx, &data)
1829    }
1830
1831    /// A Lean theorem with no hypotheses, by cases on `le_total split x`.
1832    pub fn to_lean(&self, theorem_name: &str) -> Result<String, SymplexError> {
1833        self.to_lean_with(theorem_name, &LeanOpts::default())
1834    }
1835
1836    /// [`to_lean`](Self::to_lean) with explicit rendering options.
1837    pub fn to_lean_with(
1838        &self,
1839        theorem_name: &str,
1840        opts: &LeanOpts,
1841    ) -> Result<String, SymplexError> {
1842        let up = self.upper.to_lean_with("_", opts)?;
1843        let lo = self.lower.to_lean_with("_", opts)?;
1844        // Take the tactic blocks (everything after the `:= by` line).
1845        let body = |text: &str| -> String {
1846            text.split_once(":= by\n")
1847                .map(|(_, b)| b.to_string())
1848                .unwrap_or_default()
1849        };
1850        let v = lean_ident(&self.upper.var.to_string());
1851        let base = v.trim_matches(['«', '»']).to_string();
1852        let a = self.upper.endpoint.to_lean_with(opts)?;
1853        let goal = self.upper.goal.to_ex().to_lean_with(opts)?;
1854        let indent = |b: String| -> String {
1855            b.lines()
1856                .map(|l| format!("  {l}"))
1857                .collect::<Vec<_>>()
1858                .join("\n")
1859        };
1860        let text = format!(
1861            "theorem {} ({v} : {}) : 0 ≤ {goal} := by\n  rcases le_total {a} {v} with h_{base}_lo | h_{base}_hi\n  · -- {a} ≤ {v}\n{}\n  · -- {v} ≤ {a}\n{}\n",
1862            lean_ident(theorem_name),
1863            opts.real_type,
1864            indent(body(&up)),
1865            indent(body(&lo)),
1866        );
1867        Ok(wrap_lean(&text, MATHLIB_LINE_WIDTH))
1868    }
1869}
1870
1871impl fmt::Display for RealLineCertificate {
1872    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1873        write!(f, "{}; {}", self.upper, self.lower)
1874    }
1875}
1876
1877// ── The `Certificate` trait, delegating to the inherent methods ────────────
1878
1879impl Certificate for BoxCertificate {
1880    fn goal(&self) -> &Poly {
1881        BoxCertificate::goal(self)
1882    }
1883    fn verify(&self) -> bool {
1884        BoxCertificate::verify(self)
1885    }
1886    fn to_lean_with(&self, theorem_name: &str, opts: &LeanOpts) -> Result<String, SymplexError> {
1887        BoxCertificate::to_lean_with(self, theorem_name, opts)
1888    }
1889    fn to_json(&self) -> Result<String, SymplexError> {
1890        BoxCertificate::to_json(self)
1891    }
1892    fn from_json(ctx: &Context, json: &str) -> Result<Self, SymplexError> {
1893        BoxCertificate::from_json(ctx, json)
1894    }
1895}
1896
1897impl Certificate for HalfLineCertificate {
1898    fn goal(&self) -> &Poly {
1899        HalfLineCertificate::goal(self)
1900    }
1901    fn verify(&self) -> bool {
1902        HalfLineCertificate::verify(self)
1903    }
1904    fn to_lean_with(&self, theorem_name: &str, opts: &LeanOpts) -> Result<String, SymplexError> {
1905        HalfLineCertificate::to_lean_with(self, theorem_name, opts)
1906    }
1907    fn to_json(&self) -> Result<String, SymplexError> {
1908        HalfLineCertificate::to_json(self)
1909    }
1910    fn from_json(ctx: &Context, json: &str) -> Result<Self, SymplexError> {
1911        HalfLineCertificate::from_json(ctx, json)
1912    }
1913}
1914
1915impl Certificate for RealLineCertificate {
1916    fn goal(&self) -> &Poly {
1917        RealLineCertificate::goal(self)
1918    }
1919    fn verify(&self) -> bool {
1920        RealLineCertificate::verify(self)
1921    }
1922    fn to_lean_with(&self, theorem_name: &str, opts: &LeanOpts) -> Result<String, SymplexError> {
1923        RealLineCertificate::to_lean_with(self, theorem_name, opts)
1924    }
1925    fn to_json(&self) -> Result<String, SymplexError> {
1926        RealLineCertificate::to_json(self)
1927    }
1928    fn from_json(ctx: &Context, json: &str) -> Result<Self, SymplexError> {
1929        RealLineCertificate::from_json(ctx, json)
1930    }
1931}
1932
1933/// Prove `goal ≥ 0` on all of ℝ (univariate) by splitting at `split` into
1934/// two half-line certificates.
1935///
1936/// Returns `Ok(None)` when one side could not be certified (the goal is
1937/// negative somewhere, or has a zero that is not an even-multiplicity
1938/// rational factor).
1939///
1940/// ```
1941/// use symplex::prelude::*;
1942/// use symplex::certificates::prove_nonnegative_on_reals;
1943///
1944/// let ctx = Context::new();
1945/// let x = ctx.symbol("x");
1946/// let cert = prove_nonnegative_on_reals(&(&x.powi(2) - &x + 1), &x, &ctx.int(0), 10).unwrap().unwrap();
1947/// assert!(cert.verify());
1948/// assert!(cert.to_lean("pos_quadratic").unwrap().contains("rcases le_total"));
1949/// ```
1950pub fn prove_nonnegative_on_reals(
1951    goal: &Ex,
1952    var: &Ex,
1953    split: &Ex,
1954    max_polya_power: u32,
1955) -> Result<Option<RealLineCertificate>, SymplexError> {
1956    let upper = prove_nonnegative_on_halfline(goal, var, split, Ray::AtLeast, max_polya_power)?;
1957    let lower = prove_nonnegative_on_halfline(goal, var, split, Ray::AtMost, max_polya_power)?;
1958    match (upper, lower) {
1959        (HalfLineOutcome::Proved(upper), HalfLineOutcome::Proved(lower)) => {
1960            Ok(Some(RealLineCertificate { upper, lower }))
1961        }
1962        _ => Ok(None),
1963    }
1964}