Skip to main content

symplex/api/
expr_algebraic_ext.rs

1//! Algebraic-number and polynomial-algebra conveniences on [`Ex`]:
2//! minimal polynomials, multivariate gcd/lcm, Gröbner bases, real roots
3//! as `RootOf`, factoring modulo a prime, symbolic resultants.  (0.9)
4//!
5//! Everything here is a thin bridge from `Ex` to the crate's exact
6//! polynomial engines (`poly::algebraic`, `poly::multipoly`,
7//! `poly::groebner`, `poly::factor_zassenhaus`, `poly::sturm`) and back.
8//! Methods that return `Option` yield `None` when the input is not of the
9//! required shape (Pattern 4: a query that cannot be answered); methods
10//! that validate caller-supplied structure (variable lists, moduli) return
11//! `Result` (Pattern 5).
12
13use num_bigint::BigInt;
14use num_rational::Ratio;
15use num_traits::{Signed, Zero};
16
17use crate::api::expr::{Ex, Expr, ExprType, Numeric};
18use crate::base::arena::Arena;
19use crate::base::errors::SymplexError;
20use crate::base::node::{ExprId, ExprNode};
21use crate::poly::Poly;
22use crate::poly::groebner::{groebner_basis, groebner_basis_lex};
23use crate::poly::multipoly::{GrevLex, Lex, MultiPoly};
24use crate::poly::polybridge::{expr_to_multipoly, expr_to_poly, multipoly_to_expr, poly_to_expr};
25use crate::poly::sturm::SturmChain;
26
27pub use crate::poly::multipoly::MonomialOrder;
28
29// ═══════════════════════════════════════════════════════════════════════════
30// Arena-level helpers
31// ═══════════════════════════════════════════════════════════════════════════
32
33fn invalid(operation: &'static str, reason: impl Into<String>) -> SymplexError {
34    SymplexError::invalid_argument(operation, reason)
35}
36
37/// The polynomial with integer coefficients, no common integer factor and
38/// positive leading coefficient that is a rational multiple of `p`
39/// (SymPy's normalisation of `minimal_polynomial`).
40fn integer_primitive(p: &Poly) -> Poly {
41    let prim = p.primitive_part();
42    match prim.leading_coeff() {
43        Some(lc) if lc.is_negative() => -&prim,
44        _ => prim,
45    }
46}
47
48/// Union of the free symbols of `exprs`, in the arena's canonical order.
49fn shared_generators(arena: &Arena, exprs: &[ExprId]) -> Vec<ExprId> {
50    let mut gens: Vec<ExprId> = Vec::new();
51    for &e in exprs {
52        for s in crate::base::walk::free_symbols(arena, e) {
53            if !gens.contains(&s) {
54                gens.push(s);
55            }
56        }
57    }
58    gens.sort_by(|a, b| {
59        arena
60            .sort_key(*a)
61            .cmp(arena.sort_key(*b))
62            .then_with(|| a.0.cmp(&b.0))
63    });
64    gens
65}
66
67/// Validate a variable list for the Gröbner API: non-empty, distinct
68/// symbols of the same context as `probe`.
69fn validate_vars(
70    probe: &Ex,
71    vars: &[Ex],
72    operation: &'static str,
73) -> Result<Vec<ExprId>, SymplexError> {
74    if vars.is_empty() {
75        return Err(invalid(operation, "at least one variable is required"));
76    }
77    let mut ids: Vec<ExprId> = Vec::with_capacity(vars.len());
78    for v in vars {
79        let id = probe.checked_id(v);
80        if v.expr_type() != ExprType::Symbol {
81            return Err(invalid(
82                operation,
83                format!("variable `{v}` is not a symbol"),
84            ));
85        }
86        if ids.contains(&id) {
87            return Err(invalid(operation, format!("duplicate variable `{v}`")));
88        }
89        ids.push(id);
90    }
91    Ok(ids)
92}
93
94/// Convert every expression to a `MultiPoly` over `vars`, naming the first
95/// one that is not a polynomial with rational coefficients.
96fn to_multipolys(
97    arena: &Arena,
98    exprs: &[ExprId],
99    vars: &[ExprId],
100    operation: &'static str,
101) -> Result<Vec<MultiPoly<GrevLex>>, SymplexError> {
102    exprs
103        .iter()
104        .map(|&e| {
105            expr_to_multipoly(arena, e, vars).ok_or_else(|| {
106                invalid(
107                    operation,
108                    format!(
109                        "`{}` is not a polynomial in the given variables with rational coefficients",
110                        arena.display(e)
111                    ),
112                )
113            })
114        })
115        .collect()
116}
117
118/// Reduced Gröbner basis of `polys` under `order`, returned in grevlex
119/// storage (the order only affects which basis is computed).
120fn groebner_in_order(
121    polys: &[MultiPoly<GrevLex>],
122    order: MonomialOrder,
123) -> Vec<MultiPoly<GrevLex>> {
124    match order {
125        MonomialOrder::GrevLex => groebner_basis(polys),
126        MonomialOrder::Lex => groebner_basis_lex(polys)
127            .iter()
128            .map(MultiPoly::convert_order)
129            .collect(),
130    }
131}
132
133/// Remainder of `f` on division by `basis` under `order`.
134fn reduce_in_order(
135    f: &MultiPoly<GrevLex>,
136    basis: &[MultiPoly<GrevLex>],
137    order: MonomialOrder,
138) -> MultiPoly<GrevLex> {
139    match order {
140        MonomialOrder::GrevLex => {
141            let refs: Vec<&MultiPoly<GrevLex>> = basis.iter().collect();
142            f.reduce(&refs)
143        }
144        MonomialOrder::Lex => {
145            let lex_basis: Vec<MultiPoly<Lex>> =
146                basis.iter().map(MultiPoly::convert_order).collect();
147            let refs: Vec<&MultiPoly<Lex>> = lex_basis.iter().collect();
148            f.convert_order::<Lex>().reduce(&refs).convert_order()
149        }
150    }
151}
152
153/// One distinct real root of a polynomial, before it is interned.
154enum RealRoot {
155    /// A rational root (of a linear factor).
156    Rational(Ratio<BigInt>),
157    /// `RootOf(g, k)`: the `k`-th root of the irreducible factor `g` in the
158    /// (re, im) order the `RootOf` evaluator uses.
159    RootOf { factor: usize, index: usize },
160}
161
162/// The distinct real roots of `f` (a non-constant polynomial with rational
163/// coefficients), ascending, together with the irreducible factors over ℤ
164/// that the `RootOf` entries refer to.  Rational roots are exact; every
165/// other root is named `RootOf(g, k)` with `k` from
166/// [`real_root_index`](crate::poly::roots::real_root_index), which
167/// verifies that the `k`-th computed root of `g` lies in the root's Sturm
168/// isolating interval and that the index is stable.
169///
170/// Returns `None` when the factorisation over ℤ is not certified complete
171/// (a `RootOf` must name an *irreducible* polynomial), when a root cannot
172/// be assigned to exactly one factor, or when its `RootOf` index cannot be
173/// verified.  These are internal limits, not a statement about `f`; the
174/// public wrapper folds them into its `None` alongside "not a polynomial"
175/// because its return type cannot distinguish the two in a patch release.
176fn real_roots_of(f: &Poly) -> Option<(Vec<Poly>, Vec<RealRoot>)> {
177    let (_content, parts, complete) = crate::poly::factor_zassenhaus::factor_zassenhaus_checked(f);
178    if !complete {
179        return None;
180    }
181    let mut factors: Vec<Poly> = Vec::with_capacity(parts.len());
182    let mut chains: Vec<SturmChain> = Vec::with_capacity(parts.len());
183    let mut square_free = Poly::from_int(1);
184    for (g, _mult) in &parts {
185        if g.degree()? == 0 {
186            continue;
187        }
188        square_free = &square_free * g;
189        chains.push(SturmChain::new(g));
190        factors.push(g.clone());
191    }
192    if square_free.degree().unwrap_or(0) == 0 {
193        return None;
194    }
195
196    // Isolating intervals of the product of the distinct factors are
197    // pairwise disjoint and sorted, so walking them in order visits every
198    // real root ascending; each one belongs to exactly one factor.
199    // Each interval is either the point `[r, r]` of an exact hit or a
200    // half-open `(lo, hi]` Sturm cell, so `count_roots_in` (also `(a, b]`)
201    // is the right membership test for the latter.
202    let intervals = SturmChain::new(&square_free).isolate_all_real_roots();
203    let mut out: Vec<RealRoot> = Vec::with_capacity(intervals.len());
204    for iv in intervals {
205        let owner = factors.iter().zip(&chains).position(|(g, chain)| {
206            if iv.is_point() {
207                g.eval(&iv.lower).is_zero()
208            } else {
209                chain.count_roots_in(&iv.lower, &iv.upper) == 1
210            }
211        })?;
212        let g = &factors[owner];
213        if g.degree() == Some(1) {
214            out.push(RealRoot::Rational(-(g.coeff(0) / g.coeff(1))));
215        } else {
216            // `real_root_index` treats the pair as closed `[lo, hi]`, a
217            // superset of `(lo, hi]`; the extra endpoint is not a root of
218            // `g` (an exact hit is reported as a point instead).
219            let index = crate::poly::roots::real_root_index(g, &iv.lower, &iv.upper)?;
220            out.push(RealRoot::RootOf {
221                factor: owner,
222                index,
223            });
224        }
225    }
226    Some((factors, out))
227}
228
229/// Determinant of the Sylvester matrix of two coefficient lists (highest
230/// degree first, both non-empty with non-zero leading coefficient), i.e.
231/// `res(f, g)`.  `f` of degree `m`, `g` of degree `n`: the matrix is
232/// `(m + n) × (m + n)` with `n` shifted copies of `f` above `m` shifted
233/// copies of `g`.
234fn sylvester_resultant(ctx: &crate::api::context::Context, f: &[Ex], g: &[Ex]) -> Option<Ex> {
235    let m = f.len().checked_sub(1)?;
236    let n = g.len().checked_sub(1)?;
237    if m == 0 && n == 0 {
238        return Some(ctx.one());
239    }
240    if m == 0 {
241        return Some(f[0].powi(n as i64));
242    }
243    if n == 0 {
244        return Some(g[0].powi(m as i64));
245    }
246    let size = m + n;
247    let zero = ctx.zero();
248    let mut rows: Vec<Vec<Ex>> = Vec::with_capacity(size);
249    for i in 0..n {
250        let mut row = vec![zero.clone(); size];
251        for (j, c) in f.iter().enumerate() {
252            row[i + j] = c.clone();
253        }
254        rows.push(row);
255    }
256    for i in 0..m {
257        let mut row = vec![zero.clone(); size];
258        for (j, c) in g.iter().enumerate() {
259            row[i + j] = c.clone();
260        }
261        rows.push(row);
262    }
263    // Invariant: `rows` is a non-empty square `size × size` matrix, so
264    // `Matrix::new` and `det` cannot fail.  Their `Err` is an internal
265    // error, not "not a polynomial"; the `Option` return of the public
266    // callers cannot express that in a patch release, so it is folded into
267    // `None` here (and asserted in debug builds).
268    let matrix = crate::domains::matrix::Matrix::new(rows);
269    debug_assert!(matrix.is_ok(), "Sylvester matrix is square by construction");
270    let det = matrix.ok()?.det();
271    debug_assert!(det.is_ok(), "determinant of a square matrix over Ex");
272    Some(det.ok()?.expand())
273}
274
275// ═══════════════════════════════════════════════════════════════════════════
276// impl Expr<Numeric> — algebraic numbers and polynomial algebra
277// ═══════════════════════════════════════════════════════════════════════════
278
279impl Expr<Numeric> {
280    // ── Minimal polynomial ─────────────────────────────────────────
281
282    /// Minimal polynomial over ℚ of an algebraic-number expression, as a
283    /// polynomial in `var` (SymPy `minimal_polynomial`).
284    ///
285    /// `self` must be a constant built from rational numbers, radicals
286    /// (`n^{p/q}`), `i`, `φ`, sums, products, negations, and integer or
287    /// rational powers of such numbers (`(1 + √2)⁻¹`, `√(3 + 2√2)`; a
288    /// fractional power needs a positive real base).  The result has
289    /// integer coefficients with no common factor and a positive leading
290    /// coefficient, so `3/4` gives `4·var − 3` and `√2 + √3` gives
291    /// `var⁴ − 10·var² + 1`.  Returns `None` for input that is not
292    /// recognised as algebraic — `π`, `e`, free symbols, transcendental
293    /// functions.
294    ///
295    /// The result is verified, never guessed: the irreducible factor of
296    /// each intermediate resultant is chosen by evaluating the number to
297    /// 320 bits and accepting the unique factor whose residual is
298    /// negligible, and the factorisation itself must be certified complete.
299    /// If either check fails the method returns `None` rather than a
300    /// polynomial that might be reducible or vanish at a different
301    /// conjugate.
302    ///
303    /// # Examples
304    ///
305    /// ```
306    /// use symplex::prelude::*;
307    ///
308    /// let ctx = Context::new();
309    /// let x = ctx.symbol("x");
310    /// let a = ctx.int(2).sqrt() + ctx.int(3).sqrt();
311    /// let m = a.minimal_polynomial(&x).unwrap();
312    /// assert_eq!(m, &x.powi(4) - &x.powi(2) * 10 + 1);
313    ///
314    /// let cbrt2 = ctx.int(2).pow(&ctx.rational(1, 3));
315    /// assert_eq!(cbrt2.minimal_polynomial(&x).unwrap(), &x.powi(3) - 2);
316    ///
317    /// assert!(ctx.pi().minimal_polynomial(&x).is_none());
318    /// ```
319    #[must_use]
320    pub fn minimal_polynomial(&self, var: &Ex) -> Option<Ex> {
321        use crate::poly::algebraic::{AlgExpr, minimal_polynomial_of};
322        let var_id = self.checked_id(var);
323        // Detach the expression under a read lock; the computation itself
324        // (resultants, factoring, 320-bit evaluation) runs unlocked.
325        let alg = {
326            let inner = self.inner.read();
327            AlgExpr::from_arena(&inner.arena, self.raw_id())
328        };
329        let mp = alg.and_then(|a| minimal_polynomial_of(&a)).or_else(|| {
330            // A product or power the structural recursion does not
331            // recognise may become a plain sum once expanded; try that
332            // form once before giving up.
333            let expanded = self.expand();
334            if expanded == *self {
335                return None;
336            }
337            let alg = {
338                let inner = self.inner.read();
339                AlgExpr::from_arena(&inner.arena, expanded.raw_id())
340            }?;
341            minimal_polynomial_of(&alg)
342        })?;
343        let id = {
344            let mut inner = self.inner.write();
345            poly_to_expr(&mut inner.arena, &integer_primitive(&mp), var_id)
346        };
347        Some(self.wrap(id))
348    }
349
350    // ── Multivariate gcd / lcm ─────────────────────────────────────
351
352    /// Polynomial greatest common divisor of `self` and `other` over ℚ in
353    /// all of their free symbols at once (SymPy `gcd(f, g)`).
354    ///
355    /// Both inputs must be polynomials with rational coefficients; anything
356    /// else (`sin(x)`, `1/x`, `π`) gives `None`.  The result is normalised
357    /// like [`MultiPoly::gcd`]: integer coefficients, positive leading
358    /// coefficient in graded reverse lexicographic order, and integer
359    /// content equal to the gcd of the inputs' integer contents once their
360    /// denominators are cleared — for polynomials over ℤ this is exactly
361    /// the gcd over ℤ (`gcd(2x, 4x) = 2x`).  `gcd(0, 0) = 0`; two rational
362    /// constants give their integer gcd.
363    ///
364    /// # Examples
365    ///
366    /// ```
367    /// use symplex::prelude::*;
368    ///
369    /// let ctx = Context::new();
370    /// let (x, y) = (ctx.symbol("x"), ctx.symbol("y"));
371    /// let f = &x.powi(2) - &y.powi(2);
372    /// let g = &x - &y;
373    /// assert_eq!(f.gcd_all(&g).unwrap(), g);
374    /// assert_eq!((&x * 2).gcd_all(&(&x * 4)).unwrap(), &x * 2);
375    /// assert!(x.sin().gcd_all(&x).is_none());
376    /// ```
377    #[must_use]
378    pub fn gcd_all(&self, other: &Ex) -> Option<Ex> {
379        self.multipoly_binary(other, MultiPoly::gcd)
380    }
381
382    /// Polynomial least common multiple of `self` and `other` over ℚ in
383    /// all of their free symbols (SymPy `lcm(f, g)`).
384    ///
385    /// Same preconditions and normalisation as [`gcd_all`](Self::gcd_all):
386    /// `lcm = a·b / gcd(a, b)` computed on the integer-normalised inputs,
387    /// so `lcm(2x, 4x) = 4x`; zero if either input is zero.
388    ///
389    /// # Examples
390    ///
391    /// ```
392    /// use symplex::prelude::*;
393    ///
394    /// let ctx = Context::new();
395    /// let (x, y) = (ctx.symbol("x"), ctx.symbol("y"));
396    /// let f = &x.powi(2) - &y.powi(2);
397    /// let g = &x - &y;
398    /// assert_eq!(f.lcm_all(&g).unwrap(), f);
399    /// assert_eq!((&x * &y).lcm_all(&y.powi(2)).unwrap(), &x * &y.powi(2));
400    /// ```
401    #[must_use]
402    pub fn lcm_all(&self, other: &Ex) -> Option<Ex> {
403        self.multipoly_binary(other, MultiPoly::lcm)
404    }
405
406    /// Shared driver of `gcd_all` / `lcm_all`.
407    fn multipoly_binary(
408        &self,
409        other: &Ex,
410        op: fn(&MultiPoly<GrevLex>, &MultiPoly<GrevLex>) -> MultiPoly<GrevLex>,
411    ) -> Option<Ex> {
412        let other_id = self.checked_id(other);
413        // Convert under a read lock, compute unlocked, intern under a write
414        // lock.
415        let (gens, a, b) = {
416            let inner = self.inner.read();
417            let arena = &inner.arena;
418            let gens = shared_generators(arena, &[self.raw_id(), other_id]);
419            let a = expr_to_multipoly(arena, self.raw_id(), &gens)?;
420            let b = expr_to_multipoly(arena, other_id, &gens)?;
421            (gens, a, b)
422        };
423        let result = op(&a, &b);
424        let id = {
425            let mut inner = self.inner.write();
426            multipoly_to_expr(&mut inner.arena, &result, &gens)
427        };
428        Some(self.wrap(id))
429    }
430
431    // ── Gröbner bases ──────────────────────────────────────────────
432
433    /// Reduced Gröbner basis of the ideal generated by `polys` in the
434    /// variables `vars` under the monomial order `order` (SymPy
435    /// `groebner(polys, *vars, order=…)`).
436    ///
437    /// Every element of the basis is monic; the list is sorted by leading
438    /// monomial, largest first.  An empty `polys` (or all zeros) gives an
439    /// empty basis; an ideal containing a non-zero constant gives `[1]`.
440    /// `MonomialOrder::GrevLex` runs Buchberger directly;
441    /// `MonomialOrder::Lex` computes in grevlex first and converts with
442    /// FGLM when the ideal is zero-dimensional, which is much faster than
443    /// lex Buchberger and yields the same (unique) reduced basis.
444    ///
445    /// # Errors
446    ///
447    /// `InvalidArgument` if `vars` is empty, contains duplicates or
448    /// non-symbols, or if some polynomial is not a polynomial in `vars`
449    /// with rational coefficients (other symbols count as non-rational
450    /// coefficients).
451    ///
452    /// # Examples
453    ///
454    /// ```
455    /// use symplex::prelude::*;
456    /// use symplex::multipoly::MonomialOrder;
457    ///
458    /// let ctx = Context::new();
459    /// let (x, y) = (ctx.symbol("x"), ctx.symbol("y"));
460    /// let f = &x.powi(2) + &y.powi(2) - 1;
461    /// let g = &x - &y;
462    /// let vars = [x.clone(), y.clone()];
463    /// let basis = Ex::groebner(&[f, g.clone()], &vars, MonomialOrder::Lex).unwrap();
464    /// assert_eq!(basis, vec![g, &y.powi(2) - ctx.rational(1, 2)]);
465    /// ```
466    pub fn groebner(
467        polys: &[Ex],
468        vars: &[Ex],
469        order: MonomialOrder,
470    ) -> Result<Vec<Ex>, SymplexError> {
471        const OP: &str = "groebner";
472        let probe = vars
473            .first()
474            .ok_or_else(|| invalid(OP, "at least one variable is required"))?;
475        let var_ids = validate_vars(probe, vars, OP)?;
476        let poly_ids: Vec<ExprId> = polys.iter().map(|p| probe.checked_id(p)).collect();
477        // Convert under a read lock, compute unlocked, intern under a write
478        // lock.
479        let mps = {
480            let inner = probe.inner.read();
481            to_multipolys(&inner.arena, &poly_ids, &var_ids, OP)?
482        };
483        let basis = groebner_in_order(&mps, order);
484        let ids: Vec<ExprId> = {
485            let mut inner = probe.inner.write();
486            let arena = &mut inner.arena;
487            basis
488                .iter()
489                .map(|g| multipoly_to_expr(arena, g, &var_ids))
490                .collect()
491        };
492        Ok(ids.into_iter().map(|id| probe.wrap(id)).collect())
493    }
494
495    /// Remainder of `self` on multivariate division by `basis` in the
496    /// variables `vars` under `order` (SymPy `reduced(f, G)[1]` /
497    /// `GroebnerBasis.reduce`).
498    ///
499    /// When `basis` is a Gröbner basis for `order` this is the unique
500    /// normal form of `self` modulo the ideal — zero exactly when `self`
501    /// lies in the ideal.  For an arbitrary `basis` it is *a* remainder,
502    /// which may depend on the order of the divisors.
503    ///
504    /// # Errors
505    ///
506    /// As [`groebner`](Self::groebner): invalid `vars`, or `self` or a
507    /// basis element that is not a polynomial in `vars` with rational
508    /// coefficients.
509    ///
510    /// # Examples
511    ///
512    /// ```
513    /// use symplex::prelude::*;
514    /// use symplex::multipoly::MonomialOrder;
515    ///
516    /// let ctx = Context::new();
517    /// let (x, y) = (ctx.symbol("x"), ctx.symbol("y"));
518    /// let vars = [x.clone(), y.clone()];
519    /// let basis = Ex::groebner(&[&x.powi(2) + &y.powi(2) - 1, &x - &y], &vars, MonomialOrder::Lex).unwrap();
520    /// // x² ≡ y² ≡ 1/2 modulo the ideal
521    /// let r = x.powi(2).reduce_modulo(&basis, &vars, MonomialOrder::Lex).unwrap();
522    /// assert_eq!(r, ctx.rational(1, 2));
523    /// assert!((&x.powi(2) - &y.powi(2)).reduce_modulo(&basis, &vars, MonomialOrder::Lex).unwrap().is_zero_structural());
524    /// ```
525    pub fn reduce_modulo(
526        &self,
527        basis: &[Ex],
528        vars: &[Ex],
529        order: MonomialOrder,
530    ) -> Result<Ex, SymplexError> {
531        const OP: &str = "reduce_modulo";
532        let var_ids = validate_vars(self, vars, OP)?;
533        let basis_ids: Vec<ExprId> = basis.iter().map(|b| self.checked_id(b)).collect();
534        // Convert under a read lock, compute unlocked, intern under a write
535        // lock.
536        let (f, divisors) = {
537            let inner = self.inner.read();
538            let arena = &inner.arena;
539            // `to_multipolys` returns one polynomial per input expression.
540            let Some(f) = to_multipolys(arena, &[self.raw_id()], &var_ids, OP)?
541                .into_iter()
542                .next()
543            else {
544                return Err(SymplexError::ComputationFailed {
545                    operation: OP,
546                    reason: "to_multipolys returned no polynomial for one input".into(),
547                });
548            };
549            (f, to_multipolys(arena, &basis_ids, &var_ids, OP)?)
550        };
551        let r = reduce_in_order(&f, &divisors, order);
552        let id = {
553            let mut inner = self.inner.write();
554            multipoly_to_expr(&mut inner.arena, &r, &var_ids)
555        };
556        Ok(self.wrap(id))
557    }
558
559    // ── Exact real roots ───────────────────────────────────────────
560
561    /// The distinct real roots of `self` as a polynomial in `var`, as
562    /// exact expressions in increasing order (SymPy `real_roots`, except
563    /// that a repeated root is listed once, as in
564    /// [`count_real_roots`](Self::count_real_roots)).
565    ///
566    /// Rational roots are returned as numbers.  Every other root is a
567    /// `RootOf(g, k)` node, where `g` is the irreducible factor over ℤ
568    /// that vanishes there and `k` indexes `g`'s roots sorted by real then
569    /// imaginary part — the same node `solve` produces for degree ≥ 5, so
570    /// it evaluates numerically (`eval_f64`) and prints as `RootOf(…)`.
571    /// The order is decided exactly with Sturm sequences.
572    ///
573    /// Returns `None` if `self` is not a polynomial in `var` with rational
574    /// coefficients or is constant; a polynomial without real roots gives
575    /// `Some(vec![])`.  `None` is also returned in the rare case that a
576    /// root cannot be *named* reliably — the factorisation over ℤ could
577    /// not be certified complete, or the `RootOf` index of a root is not
578    /// stable because another root of the same factor has the same real
579    /// part to within `2⁻⁶⁰` (the (re, im) order would then depend on
580    /// rounding).  Every `RootOf(g, k)` that is returned has been checked
581    /// to evaluate inside the root's exact isolating interval.
582    ///
583    /// # Examples
584    ///
585    /// ```
586    /// use symplex::prelude::*;
587    ///
588    /// let ctx = Context::new();
589    /// let x = ctx.symbol("x");
590    /// let roots = (&x.powi(3) - &x * 2).real_roots(&x).unwrap();   // −√2, 0, √2
591    /// assert_eq!(roots.len(), 3);
592    /// assert_eq!(roots[1], ctx.int(0));
593    /// assert!((roots[2].eval_f64().unwrap() - 2f64.sqrt()).abs() < 1e-12);
594    /// assert!((&x.powi(2) + 1).real_roots(&x).unwrap().is_empty());
595    /// ```
596    #[must_use]
597    pub fn real_roots(&self, var: &Ex) -> Option<Vec<Ex>> {
598        let var_id = self.checked_id(var);
599        // Convert under a read lock, compute unlocked, intern under a write
600        // lock.
601        let f = {
602            let inner = self.inner.read();
603            expr_to_poly(&inner.arena, self.raw_id(), var_id)?
604        };
605        if f.degree().unwrap_or(0) == 0 {
606            return None;
607        }
608        let (factors, roots) = real_roots_of(&f)?;
609        let ids: Vec<ExprId> = {
610            let mut inner = self.inner.write();
611            let arena = &mut inner.arena;
612            let mut factor_ids: Vec<Option<ExprId>> = vec![None; factors.len()];
613            roots
614                .into_iter()
615                .map(|root| match root {
616                    RealRoot::Rational(r) => arena.num_ratio(r),
617                    RealRoot::RootOf { factor, index } => {
618                        let g_expr = *factor_ids[factor]
619                            .get_or_insert_with(|| poly_to_expr(arena, &factors[factor], var_id));
620                        let idx = arena.int(index as i64);
621                        arena.intern(ExprNode::RootOf(g_expr, idx))
622                    }
623                })
624                .collect()
625        };
626        Some(ids.into_iter().map(|id| self.wrap(id)).collect())
627    }
628
629    /// The `index`-th distinct real root of `self` in `var`, counting from
630    /// the smallest (0-based) — `real_roots(var)[index]` (SymPy
631    /// `rootof(f, index)` for the real roots).
632    ///
633    /// `None` under the same conditions as [`real_roots`](Self::real_roots),
634    /// or if there are at most `index` real roots.
635    ///
636    /// # Examples
637    ///
638    /// ```
639    /// use symplex::prelude::*;
640    ///
641    /// let ctx = Context::new();
642    /// let x = ctx.symbol("x");
643    /// let f = &x.powi(5) - &x - 1;   // one real root ≈ 1.1673
644    /// let r = f.root_of(&x, 0).unwrap();
645    /// assert!((r.eval_f64().unwrap() - 1.1673039782614187).abs() < 1e-12);
646    /// assert!(f.root_of(&x, 1).is_none());
647    /// ```
648    #[must_use]
649    pub fn root_of(&self, var: &Ex, index: usize) -> Option<Ex> {
650        self.real_roots(var)?.into_iter().nth(index)
651    }
652
653    // ── Factoring modulo a prime ───────────────────────────────────
654
655    /// Factorisation of `self`, a polynomial in `var`, over the prime
656    /// field `GF(p)` (SymPy `factor_list(f, modulus=p)`).
657    ///
658    /// Returns `(lc, [(factor, multiplicity), …])` with `self ≡ lc · ∏
659    /// factorᵢ^multᵢ (mod p)`: `lc` is the leading coefficient reduced mod
660    /// `p`, each factor is monic and irreducible over `GF(p)` with
661    /// coefficients in `[0, p)`, and the list is sorted by degree then
662    /// coefficients.  Rational coefficients are reduced through the
663    /// inverse of their denominator.  A polynomial that vanishes
664    /// identically mod `p` gives `(0, [])`; a constant gives `(c mod p,
665    /// [])`.
666    ///
667    /// # Errors
668    ///
669    /// `InvalidArgument` if `p` is not prime, if `p` is `2` or at least
670    /// `2³¹` (the finite-field arithmetic supports odd primes below
671    /// [`factor_zassenhaus::MAX_PRIME`](crate::factor_zassenhaus::MAX_PRIME)),
672    /// if `self` is not a polynomial in `var` with rational coefficients,
673    /// or if some coefficient has a denominator divisible by `p`.
674    ///
675    /// # Examples
676    ///
677    /// ```
678    /// use symplex::prelude::*;
679    ///
680    /// let ctx = Context::new();
681    /// let x = ctx.symbol("x");
682    /// // x² + 1 ≡ (x + 2)(x + 3) (mod 5)
683    /// let (lc, factors) = (&x.powi(2) + 1).factor_mod(&x, 5).unwrap();
684    /// assert_eq!(lc, ctx.int(1));
685    /// assert_eq!(factors, vec![(&x + 2, 1), (&x + 3, 1)]);
686    /// // … but irreducible mod 3.
687    /// let (_, factors) = (&x.powi(2) + 1).factor_mod(&x, 3).unwrap();
688    /// assert_eq!(factors, vec![(&x.powi(2) + 1, 1)]);
689    /// assert!((&x.powi(2) + 1).factor_mod(&x, 6).is_err());
690    /// ```
691    pub fn factor_mod(&self, var: &Ex, p: u64) -> Result<(Ex, Vec<(Ex, u32)>), SymplexError> {
692        const OP: &str = "factor_mod";
693        let var_id = self.checked_id(var);
694        if !crate::domains::ntheory::isprime(p) {
695            return Err(invalid(OP, format!("modulus {p} is not prime")));
696        }
697        if p == 2 || p >= crate::poly::factor_zassenhaus::MAX_PRIME {
698            return Err(invalid(
699                OP,
700                format!(
701                    "modulus {p} is not supported: p must be an odd prime below {}",
702                    crate::poly::factor_zassenhaus::MAX_PRIME
703                ),
704            ));
705        }
706        // Convert under a read lock, compute unlocked, intern under a write
707        // lock.
708        let f = {
709            let inner = self.inner.read();
710            expr_to_poly(&inner.arena, self.raw_id(), var_id).ok_or_else(|| {
711                invalid(
712                    OP,
713                    "expression is not a polynomial in the given variable with rational coefficients",
714                )
715            })?
716        };
717        let pb = BigInt::from(p);
718        if f.coeffs().iter().any(|c| (c.denom() % &pb).is_zero()) {
719            return Err(invalid(
720                OP,
721                format!(
722                    "a coefficient has a denominator divisible by {p}, so it has no inverse mod {p}"
723                ),
724            ));
725        }
726        let (lc, factors) =
727            crate::poly::factor_zassenhaus::factor_mod_p(&f, p).ok_or_else(|| {
728                SymplexError::ComputationFailed {
729                    operation: OP,
730                    reason: "factor_mod_p rejected a valid modulus and polynomial".into(),
731                }
732            })?;
733        let (lc_id, factor_ids) = {
734            let mut inner = self.inner.write();
735            let arena = &mut inner.arena;
736            let lc_id = arena.int(lc as i64);
737            let factor_ids: Vec<(ExprId, u32)> = factors
738                .iter()
739                .map(|(coeffs, m)| {
740                    let g = Poly::from_coeffs(
741                        coeffs
742                            .iter()
743                            .map(|&c| Ratio::from_integer(BigInt::from(c)))
744                            .collect(),
745                    );
746                    (poly_to_expr(arena, &g, var_id), *m)
747                })
748                .collect();
749            (lc_id, factor_ids)
750        };
751        Ok((
752            self.wrap(lc_id),
753            factor_ids
754                .into_iter()
755                .map(|(id, m)| (self.wrap(id), m))
756                .collect(),
757        ))
758    }
759
760    // ── Symbolic resultant / discriminant ──────────────────────────
761
762    /// Resultant `res_var(self, other)` of two polynomials in `var` whose
763    /// coefficients may be symbolic (SymPy `resultant(f, g, var)`).
764    ///
765    /// Computed as the determinant of the Sylvester matrix over `Ex`
766    /// entries and expanded, so the result is a polynomial in the
767    /// parameters; [`resultant`](Self::resultant) is the faster exact
768    /// route when every coefficient is rational.  Returns `None` if either
769    /// expression is not polynomial in `var` (or `var` is not a symbol).
770    /// The resultant of two constants is `1`; if one polynomial is a
771    /// constant `c` and the other has degree `d`, the result is `c^d`.
772    ///
773    /// # Examples
774    ///
775    /// ```
776    /// use symplex::prelude::*;
777    ///
778    /// let ctx = Context::new();
779    /// let (x, a, b) = (ctx.symbol("x"), ctx.symbol("a"), ctx.symbol("b"));
780    /// // res(x − a, x − b) = g(a) = a − b
781    /// let r = (&x - &a).resultant_symbolic(&(&x - &b), &x).unwrap();
782    /// assert_eq!(r, &a - &b);
783    /// // res(x² + a, x + b) = a + b²
784    /// let r = (&x.powi(2) + &a).resultant_symbolic(&(&x + &b), &x).unwrap();
785    /// assert_eq!(r, &a + &b.powi(2));
786    /// ```
787    #[must_use]
788    pub fn resultant_symbolic(&self, other: &Ex, var: &Ex) -> Option<Ex> {
789        let _ = self.checked_id(other);
790        let _ = self.checked_id(var);
791        let f = crate::api::poly_ex::Poly::new(self, &[var])?;
792        let g = crate::api::poly_ex::Poly::new(other, &[var])?;
793        if f.is_zero() || g.is_zero() {
794            return Some(self.context().zero());
795        }
796        let fc = f.all_coeffs()?;
797        let gc = g.all_coeffs()?;
798        sylvester_resultant(&self.context(), &fc, &gc)
799    }
800
801    /// Discriminant of `self` as a polynomial in `var` with possibly
802    /// symbolic coefficients (SymPy `discriminant(f, var)`).
803    ///
804    /// `disc(f) = (−1)^{n(n−1)/2} · res(f, f′) / lc(f)`, evaluated
805    /// division-free: the leading coefficient is eliminated from the
806    /// Sylvester matrix of `f` and `f′` by one row operation before taking
807    /// the determinant, so the result is an expanded polynomial in the
808    /// parameters (`b² − 4ac` for `ax² + bx + c`).  Returns `None` for
809    /// non-polynomial or constant input; a linear polynomial has
810    /// discriminant `1`.
811    ///
812    /// # Examples
813    ///
814    /// ```
815    /// use symplex::prelude::*;
816    ///
817    /// let ctx = Context::new();
818    /// let (x, a, b, c) = (ctx.symbol("x"), ctx.symbol("a"), ctx.symbol("b"), ctx.symbol("c"));
819    /// let quad = &a * &x.powi(2) + &b * &x + &c;
820    /// assert_eq!(quad.discriminant_symbolic(&x).unwrap(), &b.powi(2) - &a * &c * 4);
821    /// // depressed cubic x³ + px + q: −4p³ − 27q²
822    /// let (p, q) = (ctx.symbol("p"), ctx.symbol("q"));
823    /// let cubic = &x.powi(3) + &p * &x + &q;
824    /// assert_eq!(cubic.discriminant_symbolic(&x).unwrap(), -(&p.powi(3) * 4) - &q.powi(2) * 27);
825    /// ```
826    #[must_use]
827    pub fn discriminant_symbolic(&self, var: &Ex) -> Option<Ex> {
828        let _ = self.checked_id(var);
829        let f = crate::api::poly_ex::Poly::new(self, &[var])?;
830        let coeffs = f.all_coeffs()?; // highest degree first
831        let n = coeffs.len().checked_sub(1)?;
832        if n == 0 {
833            return None;
834        }
835        let ctx = self.context();
836        if n == 1 {
837            return Some(ctx.one());
838        }
839        // Sylvester matrix of f (n + 1 coefficients) and f′ (n coefficients):
840        // (2n − 1) × (2n − 1), with n − 1 rows of f above n rows of f′.
841        // The first column is a_n at row 0 and n·a_n at row n − 1; the row
842        // operation R_{n−1} ← R_{n−1} − n·R_0 leaves a_n alone in the
843        // column, so det = a_n · det(minor) and the minor gives disc up to
844        // the sign (−1)^{n(n−1)/2}.
845        let deriv: Vec<Ex> = coeffs[..n]
846            .iter()
847            .enumerate()
848            .map(|(j, c)| c * ((n - j) as i64))
849            .collect();
850        let size = 2 * n - 1;
851        let zero = ctx.zero();
852        let mut rows: Vec<Vec<Ex>> = Vec::with_capacity(size);
853        for i in 0..n - 1 {
854            let mut row = vec![zero.clone(); size];
855            for (j, c) in coeffs.iter().enumerate() {
856                row[i + j] = c.clone();
857            }
858            rows.push(row);
859        }
860        for i in 0..n {
861            let mut row = vec![zero.clone(); size];
862            for (j, c) in deriv.iter().enumerate() {
863                row[i + j] = c.clone();
864            }
865            rows.push(row);
866        }
867        // R_{n−1} − n·R_0 = [0, −a_{n−1}, −2a_{n−2}, …, −n·a_0, 0, …]:
868        // entry j is −j · coeffs[j] for 1 ≤ j ≤ n.
869        for (j, (slot, c)) in rows[n - 1].iter_mut().zip(coeffs.iter()).enumerate() {
870            *slot = -&(c * (j as i64));
871        }
872        // Drop row 0 and column 0.
873        let minor: Vec<Vec<Ex>> = rows[1..].iter().map(|row| row[1..].to_vec()).collect();
874        // Invariant: `minor` is a square `(2n − 2) × (2n − 2)` matrix with
875        // `n ≥ 2`, so construction and `det` cannot fail; an `Err` would be
876        // an internal error, folded into `None` because the return type
877        // cannot carry it (see `sylvester_resultant`).
878        let matrix = crate::domains::matrix::Matrix::new(minor);
879        debug_assert!(
880            matrix.is_ok(),
881            "discriminant minor is square by construction"
882        );
883        let det = matrix.ok()?.det();
884        debug_assert!(det.is_ok(), "determinant of a square matrix over Ex");
885        let det = det.ok()?.expand();
886        Some(if (n * (n - 1) / 2) % 2 == 1 {
887            -det
888        } else {
889            det
890        })
891    }
892}