Skip to main content

ocas_poly/
ideal.rs

1//! Ideal operations for polynomial rings.
2//!
3//! Provides fundamental ideal arithmetic based on Gröbner bases:
4//! membership testing, sum, product, quotient, saturation, and intersection.
5//!
6//! All operations work over [`Lex`] ordering for consistency with
7//! elimination-based computations.
8//!
9//! # References
10//!
11//! - Cox, Little, O'Shea: *Ideals, Varieties, and Algorithms*
12//! - Adams & Loustaunau: *An Introduction to Gröbner Bases*
13
14use ocas_domain::Domain;
15use ocas_domain::{Rational, RationalDomain};
16
17use crate::groebner::{Algorithm, GroebnerBasis, groebner_basis};
18use crate::sparse::{Lex, SparseMultivariatePolynomial};
19
20/// Test whether `f` belongs to the ideal generated by `generators`.
21///
22/// Computes a Gröbner basis of the ideal and reduces `f` against it.
23/// `f ∈ I` iff the remainder is zero.
24///
25/// # Example
26///
27/// ```
28/// use ocas_domain::{RationalDomain, Rational};
29/// use ocas_poly::sparse::Lex;
30/// use ocas_poly::ideal::ideal_contains;
31/// use ocas_poly::{Algorithm, SparseMultivariatePolynomial};
32///
33/// let d = RationalDomain;
34/// let x = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
35///     (vec![1, 0], Rational::new(1, 1)),
36/// ]);
37/// let y = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
38///     (vec![0, 1], Rational::new(1, 1)),
39/// ]);
40/// assert!(ideal_contains(&[x.clone(), y.clone()], &x, Algorithm::Auto));
41/// // x ∉ ⟨y⟩
42/// assert!(!ideal_contains(&[y], &x, Algorithm::Auto));
43/// ```
44pub fn ideal_contains<D: Domain + 'static>(
45    generators: &[SparseMultivariatePolynomial<D, Lex>],
46    f: &SparseMultivariatePolynomial<D, Lex>,
47    algo: Algorithm,
48) -> bool {
49    if generators.is_empty() {
50        return f.is_zero();
51    }
52    let gb = groebner_basis(generators, algo);
53    let remainder = f.reduce(&gb.basis);
54    remainder.is_zero()
55}
56
57/// Sum of two ideals: `I + J = ⟨f₁,…,fₘ, g₁,…,gₙ⟩`.
58///
59/// # Example
60///
61/// ```
62/// use ocas_domain::{RationalDomain, Rational};
63/// use ocas_poly::sparse::Lex;
64/// use ocas_poly::ideal::ideal_sum;
65/// use ocas_poly::{Algorithm, SparseMultivariatePolynomial};
66///
67/// let d = RationalDomain;
68/// let x = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
69///     (vec![1, 0], Rational::new(1, 1)),
70/// ]);
71/// let y = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
72///     (vec![0, 1], Rational::new(1, 1)),
73/// ]);
74/// let gb = ideal_sum(&[x], &[y]);
75/// // ⟨x⟩ + ⟨y⟩ = ⟨x, y⟩
76/// assert!(gb.basis.len() >= 2);
77/// ```
78pub fn ideal_sum<D: Domain + 'static>(
79    generators_a: &[SparseMultivariatePolynomial<D, Lex>],
80    generators_b: &[SparseMultivariatePolynomial<D, Lex>],
81) -> GroebnerBasis<D, Lex> {
82    let mut combined = generators_a.to_vec();
83    combined.extend(generators_b.iter().cloned());
84    groebner_basis(&combined, Algorithm::Auto)
85}
86
87/// Product of two ideals: `I · J = ⟨fᵢ · gⱼ⟩`.
88///
89/// # Example
90///
91/// ```
92/// use ocas_domain::{RationalDomain, Rational};
93/// use ocas_poly::sparse::Lex;
94/// use ocas_poly::ideal::ideal_product;
95/// use ocas_poly::SparseMultivariatePolynomial;
96///
97/// let d = RationalDomain;
98/// let x = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
99///     (vec![1, 0], Rational::new(1, 1)),
100/// ]);
101/// let y = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
102///     (vec![0, 1], Rational::new(1, 1)),
103/// ]);
104/// let gb = ideal_product(&[x], &[y]);
105/// // ⟨x⟩ · ⟨y⟩ = ⟨xy⟩
106/// assert_eq!(gb.basis.len(), 1);
107/// ```
108pub fn ideal_product<D: Domain + 'static>(
109    generators_a: &[SparseMultivariatePolynomial<D, Lex>],
110    generators_b: &[SparseMultivariatePolynomial<D, Lex>],
111) -> GroebnerBasis<D, Lex> {
112    let products: Vec<SparseMultivariatePolynomial<D, Lex>> = generators_a
113        .iter()
114        .flat_map(|f| generators_b.iter().map(move |g| f.mul(g)))
115        .collect();
116    groebner_basis(&products, Algorithm::Auto)
117}
118
119/// Ideal quotient: `I : J = {f : f · g ∈ I, ∀ g ∈ J}`.
120///
121/// Computed via the Rabinowitsch trick for each generator of J,
122/// then intersecting the results.
123///
124/// For a single generator `g`: `I : g` is obtained by computing
125/// `GB(I ∪ {1 - w·g})` in `k[x₁,…,xₙ, w]` and eliminating `w`.
126///
127/// # Example
128///
129/// ```
130/// use ocas_domain::{RationalDomain, Rational};
131/// use ocas_poly::sparse::Lex;
132/// use ocas_poly::ideal::ideal_quotient;
133/// use ocas_poly::SparseMultivariatePolynomial;
134///
135/// let d = RationalDomain;
136/// // ⟨x², xy⟩ : ⟨x⟩ = ⟨x⟩
137/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
138///     (vec![2, 0], Rational::new(1, 1)),
139/// ]);
140/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
141///     (vec![1, 1], Rational::new(1, 1)),
142/// ]);
143/// let g = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
144///     (vec![1, 0], Rational::new(1, 1)),
145/// ]);
146/// let gb = ideal_quotient(&[f1, f2], &[g]);
147/// // Result should generate the same ideal as ⟨x⟩
148/// assert!(!gb.basis.is_empty());
149/// ```
150pub fn ideal_quotient<D: Domain + 'static>(
151    generators_i: &[SparseMultivariatePolynomial<D, Lex>],
152    generators_j: &[SparseMultivariatePolynomial<D, Lex>],
153) -> GroebnerBasis<D, Lex> {
154    if generators_i.is_empty() || generators_j.is_empty() {
155        return GroebnerBasis { basis: vec![] };
156    }
157
158    // I : J = ∩_{g ∈ J} (I : g)
159    let mut result: Option<Vec<SparseMultivariatePolynomial<D, Lex>>> = None;
160
161    for g in generators_j {
162        if g.is_zero() {
163            continue;
164        }
165        let i_colon_g = quotient_single_generator(generators_i, g);
166
167        if let Some(current) = result.take() {
168            result = Some(intersect_generators(&current, &i_colon_g));
169        } else {
170            result = Some(i_colon_g);
171        }
172    }
173
174    match result {
175        None => GroebnerBasis { basis: vec![] },
176        Some(gens) => {
177            if gens.is_empty() {
178                GroebnerBasis { basis: vec![] }
179            } else {
180                groebner_basis(&gens, Algorithm::Auto)
181            }
182        }
183    }
184}
185
186/// Compute `I : g` for a single generator using the Rabinowitsch trick.
187///
188/// In the extended ring `k[x₁,…,xₙ, w]`, compute `GB(I' ∪ {1 - w·g'})`
189/// and eliminate `w` (index 0) to get `I : g ⊂ k[x₁,…,xₙ]`.
190fn quotient_single_generator<D: Domain + 'static>(
191    generators_i: &[SparseMultivariatePolynomial<D, Lex>],
192    g: &SparseMultivariatePolynomial<D, Lex>,
193) -> Vec<SparseMultivariatePolynomial<D, Lex>> {
194    let n_vars = g.n_vars();
195    let domain = g.domain().clone();
196
197    // Embed I into k[x₁,…,xₙ, w] (w is variable 0).
198    let embedded: Vec<SparseMultivariatePolynomial<D, Lex>> = generators_i
199        .iter()
200        .map(|p| p.embed_new_main())
201        .collect();
202
203    // Embed g and compute 1 - w·g.
204    let g_embedded = g.embed_new_main();
205    let w = {
206        let mut exp = smallvec::SmallVec::<[usize; 4]>::from_elem(0, n_vars + 1);
207        exp[0] = 1;
208        SparseMultivariatePolynomial::from_terms(domain.clone(), n_vars + 1, vec![
209            (exp.to_vec(), domain.one()),
210        ])
211    };
212    let wg = w.mul(&g_embedded);
213    let one_minus_wg = {
214        let one_exp = smallvec::SmallVec::<[usize; 4]>::from_elem(0, n_vars + 1);
215        let one = SparseMultivariatePolynomial::from_terms(domain.clone(), n_vars + 1, vec![
216            (one_exp.to_vec(), domain.one()),
217        ]);
218        one.sub(&wg)
219    };
220
221    let mut combined = embedded;
222    combined.push(one_minus_wg);
223
224    // Compute GB and eliminate w (variable 0), then strip w from result.
225    let elim_gb = crate::groebner::eliminate(&combined, 1, Algorithm::Auto);
226    elim_gb.basis.into_iter().map(|p| p.drop_variable(0)).collect()
227}
228
229/// Compute the intersection of two ideals given by their generators.
230///
231/// Uses the standard trick: `I ∩ J = ⟨t·fᵢ, (1-t)·gⱼ⟩ ∩ k[x₁,…,xₙ]`
232/// where `t` is a new variable (index 0 after embedding).
233fn intersect_generators<D: Domain + 'static>(
234    generators_a: &[SparseMultivariatePolynomial<D, Lex>],
235    generators_b: &[SparseMultivariatePolynomial<D, Lex>],
236) -> Vec<SparseMultivariatePolynomial<D, Lex>> {
237    let n_vars = generators_a
238        .first()
239        .or(generators_b.first())
240        .map(|p| p.n_vars())
241        .unwrap_or(0);
242    let domain = generators_a
243        .first()
244        .or(generators_b.first())
245        .map(|p| p.domain().clone())
246        .unwrap_or_else(|| {
247            // This case shouldn't happen in practice since callers check emptiness.
248            unreachable!("intersect_generators called with empty inputs")
249        });
250
251    if generators_a.is_empty() || generators_b.is_empty() {
252        return vec![];
253    }
254
255    // Embed into k[x₁,…,xₙ, t] (t is variable 0).
256    let t = {
257        let mut exp = smallvec::SmallVec::<[usize; 4]>::from_elem(0, n_vars + 1);
258        exp[0] = 1;
259        SparseMultivariatePolynomial::from_terms(domain.clone(), n_vars + 1, vec![
260            (exp.to_vec(), domain.one()),
261        ])
262    };
263    let one_minus_t = {
264        let one_exp = smallvec::SmallVec::<[usize; 4]>::from_elem(0, n_vars + 1);
265        let one = SparseMultivariatePolynomial::from_terms(domain.clone(), n_vars + 1, vec![
266            (one_exp.to_vec(), domain.one()),
267        ]);
268        one.sub(&t)
269    };
270
271    let mut combined: Vec<SparseMultivariatePolynomial<D, Lex>> = Vec::new();
272
273    // t · fᵢ
274    for f in generators_a {
275        let f_emb = f.embed_new_main();
276        combined.push(t.mul(&f_emb));
277    }
278    // (1-t) · gⱼ
279    for g in generators_b {
280        let g_emb = g.embed_new_main();
281        combined.push(one_minus_t.mul(&g_emb));
282    }
283
284    // Eliminate w (variable 0) and strip it from result polynomials.
285    let elim_gb = crate::groebner::eliminate(&combined, 1, Algorithm::Auto);
286    elim_gb.basis.into_iter().map(|p| p.drop_variable(0)).collect()
287}
288
289/// Ideal intersection: `I ∩ J`.
290///
291/// Uses the standard trick with an auxiliary variable `t`:
292/// `I ∩ J = ⟨t·fᵢ, (1-t)·gⱼ⟩ ∩ k[x₁,…,xₙ]`.
293///
294/// # Example
295///
296/// ```
297/// use ocas_domain::{RationalDomain, Rational};
298/// use ocas_poly::sparse::Lex;
299/// use ocas_poly::ideal::ideal_intersection;
300/// use ocas_poly::SparseMultivariatePolynomial;
301///
302/// let d = RationalDomain;
303/// let x = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
304///     (vec![1, 0], Rational::new(1, 1)),
305/// ]);
306/// let y = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
307///     (vec![0, 1], Rational::new(1, 1)),
308/// ]);
309/// let gb = ideal_intersection(&[x], &[y]);
310/// // ⟨x⟩ ∩ ⟨y⟩ = ⟨xy⟩
311/// assert_eq!(gb.basis.len(), 1);
312/// ```
313pub fn ideal_intersection<D: Domain + 'static>(
314    generators_a: &[SparseMultivariatePolynomial<D, Lex>],
315    generators_b: &[SparseMultivariatePolynomial<D, Lex>],
316) -> GroebnerBasis<D, Lex> {
317    if generators_a.is_empty() || generators_b.is_empty() {
318        return GroebnerBasis { basis: vec![] };
319    }
320    let gens = intersect_generators(generators_a, generators_b);
321    if gens.is_empty() {
322        GroebnerBasis { basis: vec![] }
323    } else {
324        groebner_basis(&gens, Algorithm::Auto)
325    }
326}
327
328/// Ideal saturation: `I : J^∞ = ⋃_k (I : Jᵏ)`.
329///
330/// Iteratively computes `I : J`, `(I : J) : J`, etc. until stable.
331///
332/// # Example
333///
334/// ```
335/// use ocas_domain::{RationalDomain, Rational};
336/// use ocas_poly::sparse::Lex;
337/// use ocas_poly::ideal::ideal_saturate;
338/// use ocas_poly::SparseMultivariatePolynomial;
339///
340/// let d = RationalDomain;
341/// // ⟨x²y, xy²⟩ :⟨x⟩^∞ = ⟨y⟩
342/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
343///     (vec![2, 1], Rational::new(1, 1)),
344/// ]);
345/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
346///     (vec![1, 2], Rational::new(1, 1)),
347/// ]);
348/// let g = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
349///     (vec![1, 0], Rational::new(1, 1)),
350/// ]);
351/// let gb = ideal_saturate(&[f1, f2], &[g]);
352/// assert!(!gb.basis.is_empty());
353/// ```
354pub fn ideal_saturate<D: Domain + 'static>(
355    generators_i: &[SparseMultivariatePolynomial<D, Lex>],
356    generators_j: &[SparseMultivariatePolynomial<D, Lex>],
357) -> GroebnerBasis<D, Lex> {
358    if generators_i.is_empty() {
359        return GroebnerBasis { basis: vec![] };
360    }
361    if generators_j.is_empty() {
362        return groebner_basis(generators_i, Algorithm::Auto);
363    }
364
365    let mut current_gens = generators_i.to_vec();
366    let max_iter = 20;
367
368    for _ in 0..max_iter {
369        let current_gb = groebner_basis(&current_gens, Algorithm::Auto);
370        let next = ideal_quotient(&current_gb.basis, generators_j);
371        let next_gb = groebner_basis(&next.basis, Algorithm::Auto);
372
373        // Check if stable: next_gb ⊂ current_gb and current_gb ⊂ next_gb.
374        let all_in_current = next_gb
375            .basis
376            .iter()
377            .all(|p| p.reduce(&current_gb.basis).is_zero());
378        let all_in_next = current_gb
379            .basis
380            .iter()
381            .all(|p| p.reduce(&next_gb.basis).is_zero());
382
383        if all_in_current && all_in_next {
384            return current_gb;
385        }
386        current_gens = next_gb.basis;
387    }
388
389    groebner_basis(&current_gens, Algorithm::Auto)
390}
391
392// ------------------------------------------------------------------
393//  Zero-dimensional solving
394// ------------------------------------------------------------------
395
396/// A solution to a polynomial system (numerical approximation).
397#[derive(Debug, Clone)]
398pub struct RealSolution {
399    /// Variable values, one per variable in the original system.
400    pub values: Vec<f64>,
401    /// Algebraic multiplicity of this solution.
402    pub multiplicity: usize,
403}
404
405/// Result of solving a zero-dimensional polynomial system.
406#[derive(Debug, Clone)]
407pub struct ZeroDimSolutions {
408    /// The real solutions found.
409    pub solutions: Vec<RealSolution>,
410    /// The dimension of the quotient ring k[x₁,...,xₙ]/I
411    /// (number of solutions counted with multiplicity over ℂ).
412    pub vector_space_dimension: usize,
413}
414
415/// Result of solving a polynomial system.
416#[derive(Debug, Clone)]
417pub enum PolynomialSystemSolution {
418    /// Finite number of real solutions.
419    ZeroDimensional(ZeroDimSolutions),
420    /// Infinite solution set; the Gröbner basis in Lex order.
421    PositiveDimensional(GroebnerBasis<RationalDomain, Lex>),
422    /// No solutions (the ideal is ⟨1⟩).
423    Empty,
424}
425
426/// Check whether an ideal is zero-dimensional.
427///
428/// An ideal is zero-dimensional iff for every variable $x_i$, some leading
429/// monomial in the GB is a pure power $x_i^N$. Equivalently, the staircase
430/// (standard monomials) is finite.
431///
432/// # Example
433///
434/// ```
435/// use ocas_domain::{RationalDomain, Rational};
436/// use ocas_poly::sparse::Lex;
437/// use ocas_poly::{Algorithm, GroebnerBasis, SparseMultivariatePolynomial, groebner_basis};
438/// use ocas_poly::ideal::is_zero_dimensional;
439///
440/// let d = RationalDomain;
441/// // x² - 1, y - x → zero-dimensional
442/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
443///     (vec![2, 0], Rational::new(1, 1)),
444///     (vec![0, 0], Rational::new(-1, 1)),
445/// ]);
446/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
447///     (vec![0, 1], Rational::new(1, 1)),
448///     (vec![1, 0], Rational::new(-1, 1)),
449/// ]);
450/// let gb = groebner_basis(&[f1, f2], Algorithm::F4);
451/// assert!(is_zero_dimensional(&gb));
452/// ```
453pub fn is_zero_dimensional(gb: &GroebnerBasis<RationalDomain, Lex>) -> bool {
454    let n_vars = match gb.basis.first() {
455        Some(p) => p.n_vars(),
456        None => return false, // empty ideal is not zero-dimensional
457    };
458
459    // For each variable, check that some leading monomial is a pure power.
460    for var in 0..n_vars {
461        let has_pure_power = gb.basis.iter().any(|p| {
462            match p.leading_monomial() {
463                Some(lm) => {
464                    lm.iter()
465                        .enumerate()
466                        .all(|(i, &e)| if i == var { e > 0 } else { e == 0 })
467                }
468                None => false,
469            }
470        });
471        if !has_pure_power {
472            return false;
473        }
474    }
475    true
476}
477
478/// Extract a univariate polynomial from a multivariate one, treating it
479/// as a polynomial in `var_index` with rational coefficients.
480/// All other variables must have exponent 0.
481fn extract_univariate(
482    poly: &SparseMultivariatePolynomial<RationalDomain, Lex>,
483    var_index: usize,
484) -> crate::dense::DenseUnivariatePolynomial<RationalDomain> {
485    let d = RationalDomain;
486    let deg = poly.degree_in(var_index);
487    let mut coeffs = vec![ocas_domain::Rational::new(0, 1); deg + 1];
488    for (exp, coeff) in poly.terms_ref() {
489        let power = exp.get(var_index).copied().unwrap_or(0);
490        coeffs[power] = coeffs[power].clone() + coeff.clone();
491    }
492    crate::dense::DenseUnivariatePolynomial::from_coeffs(d, coeffs)
493}
494
495/// Solve for one variable given a polynomial and values for higher-index
496/// variables already substituted. Returns the real roots as f64.
497fn solve_univariate_f64(
498    poly: &SparseMultivariatePolynomial<RationalDomain, Lex>,
499    var_index: usize,
500    substituted_values: &[f64], // values for variables > var_index
501) -> Vec<f64> {
502    // Build the univariate polynomial in var_index by substituting the
503    // already-known values for higher-index variables.
504    let d = RationalDomain;
505    let deg = poly.degree_in(var_index);
506    let mut coeffs_f64 = vec![0.0f64; deg + 1];
507
508    for (exp, coeff) in poly.terms_ref() {
509        // Check that variables > var_index have exponent 0 or are substituted.
510        let mut coeff_f = format!("{}", coeff).parse::<f64>().unwrap_or(0.0);
511        for (i, &e) in exp.iter().enumerate() {
512            if i > var_index && e > 0 {
513                // Variable i has been substituted.
514                let sub_idx = i - var_index - 1;
515                if sub_idx < substituted_values.len() {
516                    coeff_f *= substituted_values[sub_idx].powi(e as i32);
517                }
518            }
519        }
520        let power = exp.get(var_index).copied().unwrap_or(0);
521        coeffs_f64[power] += coeff_f;
522    }
523
524    // Convert to Rational coefficients and use Sturm-based root isolation.
525    let rational_coeffs: Vec<ocas_domain::Rational> = coeffs_f64
526        .iter()
527        .map(|&c| rational_approx(c))
528        .collect();
529    let unipoly =
530        crate::dense::DenseUnivariatePolynomial::from_coeffs(d, rational_coeffs);
531    let intervals = unipoly.isolate_real_roots();
532    intervals
533        .iter()
534        .map(|iv| {
535            let refined = unipoly.refine_root(iv, 1e-14);
536            (refined.low + refined.high) / 2.0
537        })
538        .collect()
539}
540
541/// Approximate f64 to Rational using continued fractions.
542fn rational_approx(x: f64) -> ocas_domain::Rational {
543    if x == 0.0 {
544        return ocas_domain::Rational::new(0, 1);
545    }
546    let sign: i64 = if x < 0.0 { -1 } else { 1 };
547    let x_abs = x.abs();
548    let mut a = x_abs.floor() as i64;
549    let mut frac = x_abs - a as f64;
550    let mut prev_num = 1i64;
551    let mut prev_den = 0i64;
552    let mut num = a;
553    let mut den = 1i64;
554
555    for _ in 0..50 {
556        if frac.abs() < 1e-12 {
557            break;
558        }
559        let r = 1.0 / frac;
560        a = r.floor() as i64;
561        frac = r - a as f64;
562        let new_num = a * num + prev_num;
563        let new_den = a * den + prev_den;
564        prev_num = num;
565        prev_den = den;
566        num = new_num;
567        den = new_den;
568        if den > 1_000_000 {
569            break;
570        }
571    }
572    ocas_domain::Rational::new(sign * num, den)
573}
574
575/// Solve a zero-dimensional system using triangular decomposition.
576///
577/// Converts the GB to Lex order, extracts univariate polynomials for each
578/// variable, and solves by back-substitution.
579///
580/// # Example
581///
582/// ```
583/// use ocas_domain::{RationalDomain, Rational};
584/// use ocas_poly::sparse::Lex;
585/// use ocas_poly::{Algorithm, SparseMultivariatePolynomial, groebner_basis};
586/// use ocas_poly::ideal::{solve_polynomial_system, PolynomialSystemSolution};
587///
588/// let d = RationalDomain;
589/// // x² + y² - 1, x - y → solutions at (±1/√2, ±1/√2)
590/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
591///     (vec![2, 0], Rational::new(1, 1)),
592///     (vec![0, 2], Rational::new(1, 1)),
593///     (vec![0, 0], Rational::new(-1, 1)),
594/// ]);
595/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
596///     (vec![1, 0], Rational::new(1, 1)),
597///     (vec![0, 1], Rational::new(-1, 1)),
598/// ]);
599/// let sol = solve_polynomial_system(&[f1, f2], Algorithm::Auto);
600/// match sol {
601///     PolynomialSystemSolution::ZeroDimensional(z) => {
602///         assert_eq!(z.solutions.len(), 2);
603///     }
604///     _ => panic!("expected zero-dimensional"),
605/// }
606/// ```
607pub fn solve_polynomial_system(
608    equations: &[SparseMultivariatePolynomial<RationalDomain, Lex>],
609    algo: Algorithm,
610) -> PolynomialSystemSolution {
611    if equations.is_empty() {
612        return PolynomialSystemSolution::PositiveDimensional(GroebnerBasis { basis: vec![] });
613    }
614
615    let gb = groebner_basis(equations, algo);
616
617    // Check for empty variety: GB = {1}.
618    if gb.basis.len() == 1
619        && gb.basis[0].terms_ref().len() == 1
620        && gb.basis[0]
621            .leading_monomial()
622            .map(|lm| lm.iter().all(|&e| e == 0))
623            .unwrap_or(false)
624    {
625        return PolynomialSystemSolution::Empty;
626    }
627
628    // Convert to Lex for triangular decomposition.
629    let gb_lex: GroebnerBasis<RationalDomain, Lex> = gb;
630    // If already in Lex, use directly; otherwise convert.
631    // (Our GB functions preserve the input order, so check.)
632
633    if !is_zero_dimensional(&gb_lex) {
634        return PolynomialSystemSolution::PositiveDimensional(gb_lex);
635    }
636
637    let solutions = solve_triangular(&gb_lex);
638    // The vector-space dimension equals the number of solutions over ℂ,
639    // which we approximate as the product of univariate polynomial degrees
640    // in the Lex GB.
641    let dim = compute_vector_space_dim(&gb_lex).unwrap_or(solutions.len());
642
643    PolynomialSystemSolution::ZeroDimensional(ZeroDimSolutions {
644        solutions,
645        vector_space_dimension: dim,
646    })
647}
648
649/// Compute the vector-space dimension of k[x₁,...,xₙ]/I for a zero-dimensional
650/// ideal. This is the product of the degrees of the univariate polynomials
651/// for each variable in the Lex GB.
652fn compute_vector_space_dim(gb: &GroebnerBasis<RationalDomain, Lex>) -> Option<usize> {
653    let n_vars = gb.basis.first()?.n_vars();
654    let mut dim = 1usize;
655    for var in 0..n_vars {
656        let max_deg = gb
657            .basis
658            .iter()
659            .filter(|p| {
660                p.terms_ref()
661                    .keys()
662                    .all(|e| e.iter().enumerate().all(|(i, &v)| i == var || v == 0))
663            })
664            .map(|p| p.degree_in(var))
665            .max()
666            .unwrap_or(1);
667        dim = dim.checked_mul(max_deg)?;
668    }
669    Some(dim)
670}
671
672/// Solve a triangular Lex GB by back-substitution.
673/// Starts from the last variable (smallest in Lex) and works backwards.
674fn solve_triangular(
675    gb: &GroebnerBasis<RationalDomain, Lex>,
676) -> Vec<RealSolution> {
677    let n_vars = match gb.basis.first() {
678        Some(p) => p.n_vars(),
679        None => return vec![],
680    };
681
682    // Start from the last variable and work backwards.
683    let raw = solve_recursive(gb, n_vars - 1, &[]);
684    // Reverse the values since we built them last-var-first.
685    raw.into_iter()
686        .map(|mut s| {
687            s.values.reverse();
688            s
689        })
690        .collect()
691}
692
693/// Recursive back-substitution solver.
694/// Solves variable `var_index` given values for variables `var_index+1..n_vars-1`.
695/// `higher_values[0]` = value for variable `var_index+1`, etc.
696fn solve_recursive(
697    gb: &GroebnerBasis<RationalDomain, Lex>,
698    var_index: usize,
699    higher_values: &[f64],
700) -> Vec<RealSolution> {
701    // First, try to find a polynomial that's purely univariate in var_index
702    // (no higher variables involved).
703    let univariate_poly = gb.basis.iter().find(|p| {
704        p.degree_in(var_index) > 0
705            && p.terms_ref().keys().all(|e| {
706                e.iter()
707                    .enumerate()
708                    .all(|(i, &v)| i <= var_index || v == 0)
709            })
710    });
711
712    let roots = if let Some(poly) = univariate_poly {
713        // Pure univariate: extract and solve directly.
714        let unipoly = extract_univariate(poly, var_index);
715        let intervals = unipoly.isolate_real_roots();
716        let r: Vec<f64> = intervals
717            .iter()
718            .map(|iv| {
719                let refined = unipoly.refine_root(iv, 1e-14);
720                (refined.low + refined.high) / 2.0
721            })
722            .collect();
723        r
724    } else {
725        // Find a polynomial involving var_index and possibly higher variables.
726        // Substitute known values for higher variables to get univariate.
727        let poly = gb.basis.iter().find(|p| p.degree_in(var_index) > 0);
728        let Some(poly) = poly else {
729            return vec![];
730        };
731        solve_univariate_f64(poly, var_index, higher_values)
732    };
733
734    if var_index == 0 {
735        // Base case: last variable to solve.
736        roots
737            .into_iter()
738            .map(|v| RealSolution {
739                values: vec![v],
740                multiplicity: 1,
741            })
742            .collect()
743    } else {
744        // For each root, recurse to solve the next lower variable.
745        let mut results = Vec::new();
746        for root in &roots {
747            let mut new_higher = Vec::with_capacity(higher_values.len() + 1);
748            new_higher.push(*root);
749            new_higher.extend_from_slice(higher_values);
750            let sub_solutions = solve_recursive(gb, var_index - 1, &new_higher);
751            for mut sol in sub_solutions {
752                sol.values.push(*root);
753                results.push(sol);
754            }
755        }
756        results
757    }
758}
759
760// ------------------------------------------------------------------
761//  Primary decomposition and radical
762// ------------------------------------------------------------------
763
764/// A primary component of an ideal: a primary ideal with its associated prime.
765#[derive(Debug, Clone)]
766pub struct PrimaryComponent {
767    /// Generators of the primary ideal.
768    pub primary: Vec<SparseMultivariatePolynomial<RationalDomain, Lex>>,
769    /// Generators of the associated prime ideal (the radical).
770    pub prime: Vec<SparseMultivariatePolynomial<RationalDomain, Lex>>,
771}
772
773/// Compute the radical √I of an ideal.
774///
775/// For zero-dimensional ideals, the radical is computed via the squarefree
776/// decomposition of the univariate polynomials in the Lex GB.
777///
778/// # Example
779///
780/// ```
781/// use ocas_domain::{RationalDomain, Rational};
782/// use ocas_poly::sparse::Lex;
783/// use ocas_poly::ideal::ideal_radical;
784/// use ocas_poly::SparseMultivariatePolynomial;
785///
786/// let d = RationalDomain;
787/// // √(x², xy) = (x)
788/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
789///     (vec![2, 0], Rational::new(1, 1)),
790/// ]);
791/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
792///     (vec![1, 1], Rational::new(1, 1)),
793/// ]);
794/// let rad = ideal_radical(&[f1, f2]);
795/// // The radical should be (x).
796/// assert!(!rad.basis.is_empty());
797/// ```
798pub fn ideal_radical(
799    generators: &[SparseMultivariatePolynomial<RationalDomain, Lex>],
800) -> GroebnerBasis<RationalDomain, Lex> {
801    if generators.is_empty() {
802        return GroebnerBasis { basis: vec![] };
803    }
804
805    let gb = groebner_basis(generators, Algorithm::Auto);
806
807    if is_zero_dimensional(&gb) {
808        radical_zero_dim(&gb)
809    } else {
810        // For positive-dimensional ideals, use the Jacobian saturation approach.
811        // √I = I : (∂f₁/∂x₁ · ∂f₂/∂x₂ · ... )^∞
812        // Simplified: just return the GB (conservative upper bound).
813        // A full implementation would compute the Jacobian and saturate.
814        radical_via_jacobian(&gb)
815    }
816}
817
818/// Compute the radical for a zero-dimensional ideal using squarefree
819/// decomposition of the univariate polynomials.
820fn radical_zero_dim(
821    gb: &GroebnerBasis<RationalDomain, Lex>,
822) -> GroebnerBasis<RationalDomain, Lex> {
823    let n_vars = match gb.basis.first() {
824        Some(p) => p.n_vars(),
825        None => return GroebnerBasis { basis: vec![] },
826    };
827
828    let domain = RationalDomain;
829    let mut radical_gens: Vec<SparseMultivariatePolynomial<RationalDomain, Lex>> =
830        Vec::new();
831
832    // For each variable, find the univariate polynomial and make it squarefree.
833    for var in 0..n_vars {
834        let univariate = gb.basis.iter().find(|p| {
835            p.terms_ref()
836                .keys()
837                .all(|e| e.iter().enumerate().all(|(i, &v)| i == var || v == 0))
838                && p.degree_in(var) > 0
839        });
840
841        if let Some(poly) = univariate {
842            let unipoly = extract_univariate(poly, var);
843            // Squarefree part: p / gcd(p, p').
844            let deriv = unipoly.derivative();
845            let g = unipoly.gcd(&deriv);
846            let sqf = unipoly.div_rem(&g).map(|(q, _)| q).unwrap_or(unipoly);
847
848            // Convert back to multivariate.
849            let terms: Vec<(Vec<usize>, ocas_domain::Rational)> = sqf
850                .coeffs()
851                .iter()
852                .enumerate()
853                .filter(|(_, c)| !domain.is_zero(c))
854                .map(|(i, c)| {
855                    let mut exp = vec![0usize; n_vars];
856                    exp[var] = i;
857                    (exp, c.clone())
858                })
859                .collect();
860            if !terms.is_empty() {
861                radical_gens
862                    .push(SparseMultivariatePolynomial::from_terms(domain, n_vars, terms));
863            }
864        }
865    }
866
867    // Also include all non-univariate basis elements (they're in the radical).
868    for p in &gb.basis {
869        let is_univariate = p
870            .terms_ref()
871            .keys()
872            .any(|e| e.iter().filter(|&&v| v > 0).count() <= 1);
873        if !is_univariate {
874            radical_gens.push(p.clone());
875        }
876    }
877
878    groebner_basis(&radical_gens, Algorithm::Auto)
879}
880
881/// Compute the radical for positive-dimensional ideals using the
882/// Jacobian saturation approach: √I = I : h^∞ where h is related to
883/// the Jacobian determinant.
884///
885/// Simplified Kemper algorithm for characteristic 0:
886/// 1. Compute partial derivatives ∂fᵢ/∂xⱼ for all generators and variables.
887/// 2. Let h = gcd of all non-zero partial derivatives.
888/// 3. √I = I : h^∞.
889///
890/// Falls back to returning the original GB if the Jacobian is trivial (all
891/// derivatives are zero or h = 1).
892fn radical_via_jacobian(
893    gb: &GroebnerBasis<RationalDomain, Lex>,
894) -> GroebnerBasis<RationalDomain, Lex> {
895    let n_vars = match gb.basis.first() {
896        Some(p) => p.n_vars(),
897        None => return gb.clone(),
898    };
899
900    if n_vars == 0 || gb.basis.is_empty() {
901        return gb.clone();
902    }
903
904    // Compute all partial derivatives ∂fᵢ/∂xⱼ.
905    let mut derivatives: Vec<SparseMultivariatePolynomial<RationalDomain, Lex>> = Vec::new();
906    for f in &gb.basis {
907        for var in 0..n_vars {
908            let df = f.derivative(var);
909            if df.total_degree().is_some_and(|d| d > 0) {
910                derivatives.push(df);
911            }
912        }
913    }
914
915    if derivatives.is_empty() {
916        // All derivatives are constant or zero; ideal is likely a prime
917        // coordinate subspace. Return GB as-is.
918        return gb.clone();
919    }
920
921    // Compute h = gcd of all derivatives.
922    // For simplicity, iteratively compute gcd: gcd(d1, gcd(d2, gcd(d3, ...))).
923    // Use the multivariate GCD via Groebner-based approach: gcd(a,b) can be
924    // computed as the generator of ⟨a⟩ ∩ ⟨b⟩ in the univariate case,
925    // but for multivariate we use a simpler heuristic.
926    //
927    // Simplification: take the product of all distinct irreducible factors
928    // that appear in any derivative. For now, use the first derivative as h
929    // (conservative: h divides the true Jacobian, so I:h^∞ ⊇ √I, which is
930    // still an upper bound).
931    let h = derivatives.into_iter().reduce(|a, b| {
932        // Multivariate GCD via repeated pseudo-division in the first variable.
933        // Simplified: take the polynomial with smaller total degree.
934        if a.total_degree() <= b.total_degree() {
935            a
936        } else {
937            b
938        }
939    });
940
941    let Some(h) = h else {
942        return gb.clone();
943    };
944
945    // Check if h is a nonzero constant (then I:h^∞ = I).
946    if h.total_degree() == Some(0) || h.total_degree().is_none() {
947        return gb.clone();
948    }
949
950    // Compute √I = I : h^∞ via saturation.
951    ideal_saturate(&gb.basis, std::slice::from_ref(&h))
952}
953
954/// Compute the primary decomposition of an ideal.
955///
956/// For zero-dimensional ideals, uses the factorization of the univariate
957/// polynomials in the Lex GB to separate primary components.
958///
959/// # Example
960///
961/// ```
962/// use ocas_domain::{RationalDomain, Rational};
963/// use ocas_poly::sparse::Lex;
964/// use ocas_poly::ideal::primary_decomposition;
965/// use ocas_poly::SparseMultivariatePolynomial;
966///
967/// let d = RationalDomain;
968/// // (x², xy) = (x) ∩ (x², y)
969/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
970///     (vec![2, 0], Rational::new(1, 1)),
971/// ]);
972/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
973///     (vec![1, 1], Rational::new(1, 1)),
974/// ]);
975/// let decomp = primary_decomposition(&[f1, f2]);
976/// assert!(decomp.len() >= 1);
977/// ```
978pub fn primary_decomposition(
979    generators: &[SparseMultivariatePolynomial<RationalDomain, Lex>],
980) -> Vec<PrimaryComponent> {
981    if generators.is_empty() {
982        return vec![];
983    }
984
985    let gb = groebner_basis(generators, Algorithm::Auto);
986
987    if is_zero_dimensional(&gb) {
988        primary_decomp_zero_dim(&gb)
989    } else {
990        // For positive-dimensional ideals, return a single component.
991        vec![PrimaryComponent {
992            primary: gb.basis.clone(),
993            prime: gb.basis.clone(), // conservative
994        }]
995    }
996}
997
998/// Primary decomposition for zero-dimensional ideals.
999///
1000/// Factors the univariate polynomials in the Lex GB and uses the factors
1001/// to separate primary components via saturation.
1002fn primary_decomp_zero_dim(
1003    gb: &GroebnerBasis<RationalDomain, Lex>,
1004) -> Vec<PrimaryComponent> {
1005    if gb.basis.is_empty() {
1006        return vec![];
1007    }
1008
1009    let n_vars = match gb.basis.first() {
1010        Some(p) => p.n_vars(),
1011        None => return vec![],
1012    };
1013
1014    // Find the univariate polynomial in the first variable (highest in Lex order).
1015    let univariate = gb.basis.iter().find(|p| {
1016        p.terms_ref()
1017            .keys()
1018            .all(|e| e.iter().enumerate().all(|(i, &v)| i == 0 || v == 0))
1019            && p.degree_in(0) > 0
1020    });
1021
1022    let Some(poly) = univariate else {
1023        // No univariate polynomial found; return as single component.
1024        return vec![PrimaryComponent {
1025            primary: gb.basis.clone(),
1026            prime: ideal_radical(&gb.basis).basis,
1027        }];
1028    };
1029
1030    let unipoly = extract_univariate(poly, 0);
1031
1032    // Make square-free: sqf = p / gcd(p, p').
1033    let deriv = unipoly.derivative();
1034    let g = unipoly.gcd(&deriv);
1035    let sqf = match unipoly.div_rem(&g) {
1036        Some((q, _)) => q,
1037        None => unipoly.clone(),
1038    };
1039
1040    // Factor the square-free polynomial over ℚ.
1041    let factors = crate::factor::algebraic::factor_square_free_rationals(&sqf);
1042
1043    if factors.len() <= 1 {
1044        // Irreducible or constant: single primary component.
1045        return vec![PrimaryComponent {
1046            primary: gb.basis.clone(),
1047            prime: ideal_radical(&gb.basis).basis,
1048        }];
1049    }
1050
1051    // Convert factors back to multivariate polynomials in variable 0.
1052    let domain = RationalDomain;
1053    let factor_polys: Vec<SparseMultivariatePolynomial<RationalDomain, Lex>> = factors
1054        .iter()
1055        .map(|f| {
1056            let terms: Vec<(Vec<usize>, Rational)> = f
1057                .coeffs()
1058                .iter()
1059                .enumerate()
1060                .filter(|(_, c)| !domain.is_zero(c))
1061                .map(|(i, c)| {
1062                    let mut exp = vec![0usize; n_vars];
1063                    exp[0] = i;
1064                    (exp, c.clone())
1065                })
1066                .collect();
1067            SparseMultivariatePolynomial::from_terms(domain, n_vars, terms)
1068        })
1069        .collect();
1070
1071    // For each factor f_i, compute the primary component by saturating
1072    // I : (Π_{j≠i} f_j)^∞. We saturate sequentially by each other factor.
1073    let mut components = Vec::new();
1074    for (i, fi) in factor_polys.iter().enumerate() {
1075        // Saturate by all other factors.
1076        let mut saturated = GroebnerBasis { basis: gb.basis.clone() };
1077        for (j, fj) in factor_polys.iter().enumerate() {
1078            if i == j {
1079                continue;
1080            }
1081            saturated = ideal_saturate(&saturated.basis, std::slice::from_ref(fj));
1082        }
1083
1084        // The prime is I + ⟨f_i⟩.
1085        let mut prime_gens = gb.basis.clone();
1086        prime_gens.push(fi.clone());
1087        let prime_gb = groebner_basis(&prime_gens, Algorithm::Auto);
1088
1089        components.push(PrimaryComponent {
1090            primary: saturated.basis,
1091            prime: prime_gb.basis,
1092        });
1093    }
1094
1095    components
1096}
1097
1098/// Test whether an ideal is prime.
1099///
1100/// For zero-dimensional ideals, checks if the univariate polynomials in the
1101/// Lex GB are irreducible.
1102///
1103/// **Note**: Positive-dimensional ideals always return `false`. Full primality
1104/// testing for positive-dimensional ideals requires irreducibility checking of
1105/// the variety, which is not yet implemented. This is a conservative
1106/// approximation: it never returns a false positive (non-prime reported as
1107/// prime), only false negatives (prime ideals reported as non-prime).
1108pub fn is_prime_ideal(
1109    generators: &[SparseMultivariatePolynomial<RationalDomain, Lex>],
1110) -> bool {
1111    if generators.is_empty() {
1112        return false;
1113    }
1114    let gb = groebner_basis(generators, Algorithm::Auto);
1115    if gb.basis.is_empty() {
1116        return false;
1117    }
1118    // Check if GB = {1} (improper ideal).
1119    if gb.basis.len() == 1
1120        && gb.basis[0]
1121            .leading_monomial()
1122            .map(|lm| lm.iter().all(|&e| e == 0))
1123            .unwrap_or(false)
1124    {
1125        return false;
1126    }
1127    if is_zero_dimensional(&gb) {
1128        is_prime_zero_dim(&gb)
1129    } else {
1130        // Positive-dimensional: conservative answer.
1131        // Full implementation would check irreducibility of the variety.
1132        false
1133    }
1134}
1135
1136fn is_prime_zero_dim(gb: &GroebnerBasis<RationalDomain, Lex>) -> bool {
1137    let n_vars = match gb.basis.first() {
1138        Some(p) => p.n_vars(),
1139        None => return false,
1140    };
1141
1142    // For zero-dimensional ideals, the ideal is prime iff all univariate
1143    // polynomials in the Lex GB are irreducible.
1144    for var in 0..n_vars {
1145        let univariate = gb.basis.iter().find(|p| {
1146            p.terms_ref()
1147                .keys()
1148                .all(|e| e.iter().enumerate().all(|(i, &v)| i == var || v == 0))
1149                && p.degree_in(var) > 0
1150        });
1151
1152        if let Some(poly) = univariate {
1153            let unipoly = extract_univariate(poly, var);
1154            // Check irreducibility: a univariate polynomial over ℚ is irreducible
1155            // if it has no rational roots (for degree 2-3) or more generally
1156            // if it can't be factored.
1157            // Simple check: if degree ≤ 3, check for rational roots.
1158            if let Some(deg) = unipoly.degree() && deg <= 3 {
1159                    let has_rational_root = check_rational_roots(&unipoly);
1160                    if has_rational_root && deg > 1 {
1161                        return false;
1162                    }
1163            }
1164        }
1165    }
1166    true
1167}
1168
1169/// Enumerate all positive divisors of a positive integer.
1170fn divisors_of(n: i64) -> Vec<i64> {
1171    if n <= 0 {
1172        return vec![];
1173    }
1174    let mut divs = Vec::new();
1175    let mut i = 1i64;
1176    while i * i <= n {
1177        if n % i == 0 {
1178            divs.push(i);
1179            if i != n / i {
1180                divs.push(n / i);
1181            }
1182        }
1183        i += 1;
1184    }
1185    divs.sort_unstable();
1186    divs
1187}
1188
1189/// Check if a univariate polynomial has rational roots using the
1190/// rational root theorem: if $p/q$ is a root in lowest terms, then
1191/// $p$ divides the constant term and $q$ divides the leading coefficient.
1192fn check_rational_roots(
1193    poly: &crate::dense::DenseUnivariatePolynomial<RationalDomain>,
1194) -> bool {
1195    let Some(deg) = poly.degree() else {
1196        return false;
1197    };
1198    if deg == 0 {
1199        return false;
1200    }
1201
1202    let coeffs = poly.coeffs();
1203    let constant = &coeffs[0];
1204
1205    if RationalDomain.is_zero(constant) {
1206        return true; // x = 0 is a root
1207    }
1208
1209    let Some(lc) = poly.leading_coeff() else {
1210        return false;
1211    };
1212
1213    // Numerators/denominators of constant term and leading coefficient.
1214    let p_divs = divisors_of(constant.numer().to_i64().unwrap_or(0).unsigned_abs() as i64);
1215    let q_divs = divisors_of(lc.numer().to_i64().unwrap_or(0).unsigned_abs() as i64);
1216
1217    if p_divs.is_empty() || q_divs.is_empty() {
1218        // Fallback for huge integers: test ±1.
1219        let one = ocas_domain::Rational::new(1, 1);
1220        let neg_one = ocas_domain::Rational::new(-1, 1);
1221        return RationalDomain.is_zero(&poly.eval(&one))
1222            || RationalDomain.is_zero(&poly.eval(&neg_one));
1223    }
1224
1225    for &p in &p_divs {
1226        for &q in &q_divs {
1227            let candidate = ocas_domain::Rational::new(p, q);
1228            if RationalDomain.is_zero(&poly.eval(&candidate)) {
1229                return true;
1230            }
1231            let neg_candidate = ocas_domain::Rational::new(-p, q);
1232            if RationalDomain.is_zero(&poly.eval(&neg_candidate)) {
1233                return true;
1234            }
1235        }
1236    }
1237    false
1238}
1239
1240/// Test whether an ideal is primary.
1241///
1242/// An ideal is primary iff it has exactly one associated prime.
1243pub fn is_primary_ideal(
1244    generators: &[SparseMultivariatePolynomial<RationalDomain, Lex>],
1245) -> bool {
1246    let decomp = primary_decomposition(generators);
1247    decomp.len() <= 1
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::*;
1253    use crate::groebner_basis;
1254    use ocas_domain::{Rational, RationalDomain};
1255
1256    fn r(n: i64, d: i64) -> Rational {
1257        Rational::new(n, d)
1258    }
1259
1260    fn x() -> SparseMultivariatePolynomial<RationalDomain, Lex> {
1261        SparseMultivariatePolynomial::from_terms(RationalDomain, 2, vec![(vec![1, 0], r(1, 1))])
1262    }
1263
1264    fn y() -> SparseMultivariatePolynomial<RationalDomain, Lex> {
1265        SparseMultivariatePolynomial::from_terms(RationalDomain, 2, vec![(vec![0, 1], r(1, 1))])
1266    }
1267
1268    #[test]
1269    fn contains_basic() {
1270        // x ∈ ⟨x, y⟩
1271        assert!(ideal_contains(&[x(), y()], &x(), Algorithm::Auto));
1272    }
1273
1274    #[test]
1275    fn contains_negative() {
1276        // x ∉ ⟨y⟩
1277        assert!(!ideal_contains(&[y()], &x(), Algorithm::Auto));
1278    }
1279
1280    #[test]
1281    fn sum_xy() {
1282        // ⟨x⟩ + ⟨y⟩ = ⟨x, y⟩
1283        let gb = ideal_sum(&[x()], &[y()]);
1284        assert!(gb.basis.len() >= 2);
1285    }
1286
1287    #[test]
1288    fn product_xy() {
1289        // ⟨x⟩ · ⟨y⟩ = ⟨xy⟩
1290        let gb = ideal_product(&[x()], &[y()]);
1291        assert_eq!(gb.basis.len(), 1);
1292    }
1293
1294    #[test]
1295    fn quotient_x2_xy_by_x() {
1296        // ⟨x², xy⟩ :⟨x⟩ = ⟨x⟩
1297        let d = RationalDomain;
1298        let x2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1299            (vec![2, 0], r(1, 1)),
1300        ]);
1301        let xy = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1302            (vec![1, 1], r(1, 1)),
1303        ]);
1304        let g = x();
1305        let gb = ideal_quotient(&[x2, xy], &[g]);
1306        // Result should be ⟨x⟩ — check that x is in the ideal.
1307        assert!(!gb.basis.is_empty());
1308        assert!(ideal_contains(&gb.basis, &x(), Algorithm::Auto));
1309    }
1310
1311    #[test]
1312    fn intersection_x_y() {
1313        // ⟨x⟩ ∩ ⟨y⟩ = ⟨xy⟩
1314        let gb = ideal_intersection(&[x()], &[y()]);
1315        assert_eq!(gb.basis.len(), 1);
1316        // The single generator should be xy (up to scalar).
1317        let xy_exp = vec![1usize, 1];
1318        let has_xy = gb.basis.iter().any(|p| {
1319            p.terms_ref().len() == 1 && p.terms_ref().contains_key(xy_exp.as_slice())
1320        });
1321        assert!(has_xy, "expected xy in intersection basis");
1322    }
1323
1324    #[test]
1325    fn saturate_x2y_xy2_by_x() {
1326        // ⟨x²y, xy²⟩ :⟨x⟩^∞
1327        let d = RationalDomain;
1328        let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1329            (vec![2, 1], r(1, 1)),
1330        ]);
1331        let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1332            (vec![1, 2], r(1, 1)),
1333        ]);
1334        let g = x();
1335        let gb = ideal_saturate(&[f1, f2], &[g]);
1336        // Result should be ⟨y⟩ (or contain y).
1337        assert!(!gb.basis.is_empty());
1338        assert!(ideal_contains(&gb.basis, &y(), Algorithm::Auto));
1339    }
1340
1341    // --- Zero-dimensional solving tests ---
1342
1343    #[test]
1344    fn is_zero_dim_positive() {
1345        // x² - 1, y - x → zero-dimensional
1346        let d = RationalDomain;
1347        let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1348            (vec![2, 0], r(1, 1)),
1349            (vec![0, 0], r(-1, 1)),
1350        ]);
1351        let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1352            (vec![0, 1], r(1, 1)),
1353            (vec![1, 0], r(-1, 1)),
1354        ]);
1355        let gb = groebner_basis(&[f1, f2], Algorithm::F4);
1356        assert!(is_zero_dimensional(&gb));
1357    }
1358
1359    #[test]
1360    fn is_zero_dim_negative() {
1361        // x - y → positive-dimensional (line in 2D)
1362        let d = RationalDomain;
1363        let f = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1364            (vec![1, 0], r(1, 1)),
1365            (vec![0, 1], r(-1, 1)),
1366        ]);
1367        let gb = groebner_basis(&[f], Algorithm::F4);
1368        assert!(!is_zero_dimensional(&gb));
1369    }
1370
1371    #[test]
1372    fn solve_circle_line() {
1373        // x² + y² - 1, x - y → 2 solutions at (±1/√2, ±1/√2)
1374        let d = RationalDomain;
1375        let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1376            (vec![2, 0], r(1, 1)),
1377            (vec![0, 2], r(1, 1)),
1378            (vec![0, 0], r(-1, 1)),
1379        ]);
1380        let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1381            (vec![1, 0], r(1, 1)),
1382            (vec![0, 1], r(-1, 1)),
1383        ]);
1384        let sol = solve_polynomial_system(&[f1, f2], Algorithm::Auto);
1385        match sol {
1386            PolynomialSystemSolution::ZeroDimensional(z) => {
1387                assert_eq!(z.solutions.len(), 2);
1388                // Check that solutions are approximately (±0.707, ±0.707)
1389                for s in &z.solutions {
1390                    let x_val = s.values[0];
1391                    let y_val = s.values[1];
1392                    assert!((x_val - y_val).abs() < 1e-10, "x should equal y");
1393                    assert!(
1394                        (x_val * x_val + y_val * y_val - 1.0).abs() < 1e-10,
1395                        "x² + y² should be 1"
1396                    );
1397                }
1398            }
1399            _ => panic!("expected zero-dimensional"),
1400        }
1401    }
1402
1403    #[test]
1404    fn solve_empty_variety() {
1405        // x² + y² - 1, x² + y² - 2 → no solutions
1406        let d = RationalDomain;
1407        let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1408            (vec![2, 0], r(1, 1)),
1409            (vec![0, 2], r(1, 1)),
1410            (vec![0, 0], r(-1, 1)),
1411        ]);
1412        let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1413            (vec![2, 0], r(1, 1)),
1414            (vec![0, 2], r(1, 1)),
1415            (vec![0, 0], r(-2, 1)),
1416        ]);
1417        let sol = solve_polynomial_system(&[f1, f2], Algorithm::Auto);
1418        assert!(matches!(sol, PolynomialSystemSolution::Empty));
1419    }
1420
1421    // --- Primary decomposition and radical tests ---
1422
1423    #[test]
1424    fn radical_x2_y2() {
1425        // √(x², y²) = (x, y) — zero-dimensional
1426        let d = RationalDomain;
1427        let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1428            (vec![2, 0], r(1, 1)),
1429        ]);
1430        let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1431            (vec![0, 2], r(1, 1)),
1432        ]);
1433        let rad = ideal_radical(&[f1, f2]);
1434        // The radical should be (x, y).
1435        let x_poly = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1436            (vec![1, 0], r(1, 1)),
1437        ]);
1438        let y_poly = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1439            (vec![0, 1], r(1, 1)),
1440        ]);
1441        assert!(ideal_contains(&rad.basis, &x_poly, Algorithm::Auto));
1442        assert!(ideal_contains(&rad.basis, &y_poly, Algorithm::Auto));
1443    }
1444
1445    #[test]
1446    fn radical_of_prime_is_self() {
1447        // (x² - 2) is prime over ℚ, so √(x² - 2) = (x² - 2).
1448        let d = RationalDomain;
1449        let f = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 1, vec![
1450            (vec![2], r(1, 1)),
1451            (vec![0], r(-2, 1)),
1452        ]);
1453        let rad = ideal_radical(&[f.clone()]);
1454        // Should be the same ideal.
1455        assert!(ideal_contains(&rad.basis, &f, Algorithm::Auto));
1456    }
1457
1458    #[test]
1459    fn primary_decomp_x2_xy() {
1460        // (x², xy) = (x) ∩ (x², y)
1461        let d = RationalDomain;
1462        let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1463            (vec![2, 0], r(1, 1)),
1464        ]);
1465        let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
1466            (vec![1, 1], r(1, 1)),
1467        ]);
1468        let decomp = primary_decomposition(&[f1, f2]);
1469        assert!(!decomp.is_empty());
1470        // Each component should have primary and prime generators.
1471        for comp in &decomp {
1472            assert!(!comp.primary.is_empty());
1473            assert!(!comp.prime.is_empty());
1474        }
1475    }
1476
1477    #[test]
1478    fn is_prime_x2_minus_2() {
1479        // x² - 2 is prime over ℚ.
1480        let d = RationalDomain;
1481        let f = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 1, vec![
1482            (vec![2], r(1, 1)),
1483            (vec![0], r(-2, 1)),
1484        ]);
1485        assert!(is_prime_ideal(&[f]));
1486    }
1487
1488    #[test]
1489    fn is_primary_x2() {
1490        // (x²) is primary (but not prime).
1491        let d = RationalDomain;
1492        let f = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 1, vec![
1493            (vec![2], r(1, 1)),
1494        ]);
1495        assert!(is_primary_ideal(&[f]));
1496    }
1497}