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>> =
199        generators_i.iter().map(|p| p.embed_new_main()).collect();
200
201    // Embed g and compute 1 - w·g.
202    let g_embedded = g.embed_new_main();
203    let w = {
204        let mut exp = smallvec::SmallVec::<[usize; 4]>::from_elem(0, n_vars + 1);
205        exp[0] = 1;
206        SparseMultivariatePolynomial::from_terms(
207            domain.clone(),
208            n_vars + 1,
209            vec![(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(
216            domain.clone(),
217            n_vars + 1,
218            vec![(one_exp.to_vec(), domain.one())],
219        );
220        one.sub(&wg)
221    };
222
223    let mut combined = embedded;
224    combined.push(one_minus_wg);
225
226    // Compute GB and eliminate w (variable 0), then strip w from result.
227    let elim_gb = crate::groebner::eliminate(&combined, 1, Algorithm::Auto);
228    elim_gb
229        .basis
230        .into_iter()
231        .map(|p| p.drop_variable(0))
232        .collect()
233}
234
235/// Compute the intersection of two ideals given by their generators.
236///
237/// Uses the standard trick: `I ∩ J = ⟨t·fᵢ, (1-t)·gⱼ⟩ ∩ k[x₁,…,xₙ]`
238/// where `t` is a new variable (index 0 after embedding).
239fn intersect_generators<D: Domain + 'static>(
240    generators_a: &[SparseMultivariatePolynomial<D, Lex>],
241    generators_b: &[SparseMultivariatePolynomial<D, Lex>],
242) -> Vec<SparseMultivariatePolynomial<D, Lex>> {
243    let n_vars = generators_a
244        .first()
245        .or(generators_b.first())
246        .map(|p| p.n_vars())
247        .unwrap_or(0);
248    let domain = generators_a
249        .first()
250        .or(generators_b.first())
251        .map(|p| p.domain().clone())
252        .unwrap_or_else(|| {
253            // This case shouldn't happen in practice since callers check emptiness.
254            unreachable!("intersect_generators called with empty inputs")
255        });
256
257    if generators_a.is_empty() || generators_b.is_empty() {
258        return vec![];
259    }
260
261    // Embed into k[x₁,…,xₙ, t] (t is variable 0).
262    let t = {
263        let mut exp = smallvec::SmallVec::<[usize; 4]>::from_elem(0, n_vars + 1);
264        exp[0] = 1;
265        SparseMultivariatePolynomial::from_terms(
266            domain.clone(),
267            n_vars + 1,
268            vec![(exp.to_vec(), domain.one())],
269        )
270    };
271    let one_minus_t = {
272        let one_exp = smallvec::SmallVec::<[usize; 4]>::from_elem(0, n_vars + 1);
273        let one = SparseMultivariatePolynomial::from_terms(
274            domain.clone(),
275            n_vars + 1,
276            vec![(one_exp.to_vec(), domain.one())],
277        );
278        one.sub(&t)
279    };
280
281    let mut combined: Vec<SparseMultivariatePolynomial<D, Lex>> = Vec::new();
282
283    // t · fᵢ
284    for f in generators_a {
285        let f_emb = f.embed_new_main();
286        combined.push(t.mul(&f_emb));
287    }
288    // (1-t) · gⱼ
289    for g in generators_b {
290        let g_emb = g.embed_new_main();
291        combined.push(one_minus_t.mul(&g_emb));
292    }
293
294    // Eliminate w (variable 0) and strip it from result polynomials.
295    let elim_gb = crate::groebner::eliminate(&combined, 1, Algorithm::Auto);
296    elim_gb
297        .basis
298        .into_iter()
299        .map(|p| p.drop_variable(0))
300        .collect()
301}
302
303/// Ideal intersection: `I ∩ J`.
304///
305/// Uses the standard trick with an auxiliary variable `t`:
306/// `I ∩ J = ⟨t·fᵢ, (1-t)·gⱼ⟩ ∩ k[x₁,…,xₙ]`.
307///
308/// # Example
309///
310/// ```
311/// use ocas_domain::{RationalDomain, Rational};
312/// use ocas_poly::sparse::Lex;
313/// use ocas_poly::ideal::ideal_intersection;
314/// use ocas_poly::SparseMultivariatePolynomial;
315///
316/// let d = RationalDomain;
317/// let x = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
318///     (vec![1, 0], Rational::new(1, 1)),
319/// ]);
320/// let y = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
321///     (vec![0, 1], Rational::new(1, 1)),
322/// ]);
323/// let gb = ideal_intersection(&[x], &[y]);
324/// // ⟨x⟩ ∩ ⟨y⟩ = ⟨xy⟩
325/// assert_eq!(gb.basis.len(), 1);
326/// ```
327pub fn ideal_intersection<D: Domain + 'static>(
328    generators_a: &[SparseMultivariatePolynomial<D, Lex>],
329    generators_b: &[SparseMultivariatePolynomial<D, Lex>],
330) -> GroebnerBasis<D, Lex> {
331    if generators_a.is_empty() || generators_b.is_empty() {
332        return GroebnerBasis { basis: vec![] };
333    }
334    let gens = intersect_generators(generators_a, generators_b);
335    if gens.is_empty() {
336        GroebnerBasis { basis: vec![] }
337    } else {
338        groebner_basis(&gens, Algorithm::Auto)
339    }
340}
341
342/// Ideal saturation: `I : J^∞ = ⋃_k (I : Jᵏ)`.
343///
344/// Iteratively computes `I : J`, `(I : J) : J`, etc. until stable.
345///
346/// # Example
347///
348/// ```
349/// use ocas_domain::{RationalDomain, Rational};
350/// use ocas_poly::sparse::Lex;
351/// use ocas_poly::ideal::ideal_saturate;
352/// use ocas_poly::SparseMultivariatePolynomial;
353///
354/// let d = RationalDomain;
355/// // ⟨x²y, xy²⟩ :⟨x⟩^∞ = ⟨y⟩
356/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
357///     (vec![2, 1], Rational::new(1, 1)),
358/// ]);
359/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
360///     (vec![1, 2], Rational::new(1, 1)),
361/// ]);
362/// let g = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
363///     (vec![1, 0], Rational::new(1, 1)),
364/// ]);
365/// let gb = ideal_saturate(&[f1, f2], &[g]);
366/// assert!(!gb.basis.is_empty());
367/// ```
368pub fn ideal_saturate<D: Domain + 'static>(
369    generators_i: &[SparseMultivariatePolynomial<D, Lex>],
370    generators_j: &[SparseMultivariatePolynomial<D, Lex>],
371) -> GroebnerBasis<D, Lex> {
372    if generators_i.is_empty() {
373        return GroebnerBasis { basis: vec![] };
374    }
375    if generators_j.is_empty() {
376        return groebner_basis(generators_i, Algorithm::Auto);
377    }
378
379    let mut current_gens = generators_i.to_vec();
380    let max_iter = 20;
381
382    for _ in 0..max_iter {
383        let current_gb = groebner_basis(&current_gens, Algorithm::Auto);
384        let next = ideal_quotient(&current_gb.basis, generators_j);
385        let next_gb = groebner_basis(&next.basis, Algorithm::Auto);
386
387        // Check if stable: next_gb ⊂ current_gb and current_gb ⊂ next_gb.
388        let all_in_current = next_gb
389            .basis
390            .iter()
391            .all(|p| p.reduce(&current_gb.basis).is_zero());
392        let all_in_next = current_gb
393            .basis
394            .iter()
395            .all(|p| p.reduce(&next_gb.basis).is_zero());
396
397        if all_in_current && all_in_next {
398            return current_gb;
399        }
400        current_gens = next_gb.basis;
401    }
402
403    groebner_basis(&current_gens, Algorithm::Auto)
404}
405
406// ------------------------------------------------------------------
407//  Zero-dimensional solving
408// ------------------------------------------------------------------
409
410/// A solution to a polynomial system (numerical approximation).
411#[derive(Debug, Clone)]
412pub struct RealSolution {
413    /// Variable values, one per variable in the original system.
414    pub values: Vec<f64>,
415    /// Algebraic multiplicity of this solution.
416    pub multiplicity: usize,
417}
418
419/// Result of solving a zero-dimensional polynomial system.
420#[derive(Debug, Clone)]
421pub struct ZeroDimSolutions {
422    /// The real solutions found.
423    pub solutions: Vec<RealSolution>,
424    /// The dimension of the quotient ring k[x₁,...,xₙ]/I
425    /// (number of solutions counted with multiplicity over ℂ).
426    pub vector_space_dimension: usize,
427}
428
429/// Result of solving a polynomial system.
430#[derive(Debug, Clone)]
431pub enum PolynomialSystemSolution {
432    /// Finite number of real solutions.
433    ZeroDimensional(ZeroDimSolutions),
434    /// Infinite solution set; the Gröbner basis in Lex order.
435    PositiveDimensional(GroebnerBasis<RationalDomain, Lex>),
436    /// No solutions (the ideal is ⟨1⟩).
437    Empty,
438}
439
440/// Check whether an ideal is zero-dimensional.
441///
442/// An ideal is zero-dimensional iff for every variable $x_i$, some leading
443/// monomial in the GB is a pure power $x_i^N$. Equivalently, the staircase
444/// (standard monomials) is finite.
445///
446/// # Example
447///
448/// ```
449/// use ocas_domain::{RationalDomain, Rational};
450/// use ocas_poly::sparse::Lex;
451/// use ocas_poly::{Algorithm, GroebnerBasis, SparseMultivariatePolynomial, groebner_basis};
452/// use ocas_poly::ideal::is_zero_dimensional;
453///
454/// let d = RationalDomain;
455/// // x² - 1, y - x → zero-dimensional
456/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
457///     (vec![2, 0], Rational::new(1, 1)),
458///     (vec![0, 0], Rational::new(-1, 1)),
459/// ]);
460/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
461///     (vec![0, 1], Rational::new(1, 1)),
462///     (vec![1, 0], Rational::new(-1, 1)),
463/// ]);
464/// let gb = groebner_basis(&[f1, f2], Algorithm::F4);
465/// assert!(is_zero_dimensional(&gb));
466/// ```
467pub fn is_zero_dimensional(gb: &GroebnerBasis<RationalDomain, Lex>) -> bool {
468    let n_vars = match gb.basis.first() {
469        Some(p) => p.n_vars(),
470        None => return false, // empty ideal is not zero-dimensional
471    };
472
473    // For each variable, check that some leading monomial is a pure power.
474    for var in 0..n_vars {
475        let has_pure_power = gb.basis.iter().any(|p| match p.leading_monomial() {
476            Some(lm) => lm
477                .iter()
478                .enumerate()
479                .all(|(i, &e)| if i == var { e > 0 } else { e == 0 }),
480            None => false,
481        });
482        if !has_pure_power {
483            return false;
484        }
485    }
486    true
487}
488
489/// Extract a univariate polynomial from a multivariate one, treating it
490/// as a polynomial in `var_index` with rational coefficients.
491/// All other variables must have exponent 0.
492fn extract_univariate(
493    poly: &SparseMultivariatePolynomial<RationalDomain, Lex>,
494    var_index: usize,
495) -> crate::dense::DenseUnivariatePolynomial<RationalDomain> {
496    let d = RationalDomain;
497    let deg = poly.degree_in(var_index);
498    let mut coeffs = vec![ocas_domain::Rational::new(0, 1); deg + 1];
499    for (exp, coeff) in poly.terms_ref() {
500        let power = exp.get(var_index).copied().unwrap_or(0);
501        coeffs[power] = coeffs[power].clone() + coeff.clone();
502    }
503    crate::dense::DenseUnivariatePolynomial::from_coeffs(d, coeffs)
504}
505
506/// Solve for one variable given a polynomial and values for higher-index
507/// variables already substituted. Returns the real roots as f64.
508fn solve_univariate_f64(
509    poly: &SparseMultivariatePolynomial<RationalDomain, Lex>,
510    var_index: usize,
511    substituted_values: &[f64], // values for variables > var_index
512) -> Vec<f64> {
513    // Build the univariate polynomial in var_index by substituting the
514    // already-known values for higher-index variables.
515    let d = RationalDomain;
516    let deg = poly.degree_in(var_index);
517    let mut coeffs_f64 = vec![0.0f64; deg + 1];
518
519    for (exp, coeff) in poly.terms_ref() {
520        // Check that variables > var_index have exponent 0 or are substituted.
521        let mut coeff_f = format!("{}", coeff).parse::<f64>().unwrap_or(0.0);
522        for (i, &e) in exp.iter().enumerate() {
523            if i > var_index && e > 0 {
524                // Variable i has been substituted.
525                let sub_idx = i - var_index - 1;
526                if sub_idx < substituted_values.len() {
527                    coeff_f *= substituted_values[sub_idx].powi(e as i32);
528                }
529            }
530        }
531        let power = exp.get(var_index).copied().unwrap_or(0);
532        coeffs_f64[power] += coeff_f;
533    }
534
535    // Convert to Rational coefficients and use Sturm-based root isolation.
536    let rational_coeffs: Vec<ocas_domain::Rational> =
537        coeffs_f64.iter().map(|&c| rational_approx(c)).collect();
538    let unipoly = crate::dense::DenseUnivariatePolynomial::from_coeffs(d, rational_coeffs);
539    let intervals = unipoly.isolate_real_roots();
540    intervals
541        .iter()
542        .map(|iv| {
543            let refined = unipoly.refine_root(iv, 1e-14);
544            (refined.low + refined.high) / 2.0
545        })
546        .collect()
547}
548
549/// Approximate f64 to Rational using continued fractions.
550fn rational_approx(x: f64) -> ocas_domain::Rational {
551    if x == 0.0 {
552        return ocas_domain::Rational::new(0, 1);
553    }
554    let sign: i64 = if x < 0.0 { -1 } else { 1 };
555    let x_abs = x.abs();
556    let mut a = x_abs.floor() as i64;
557    let mut frac = x_abs - a as f64;
558    let mut prev_num = 1i64;
559    let mut prev_den = 0i64;
560    let mut num = a;
561    let mut den = 1i64;
562
563    for _ in 0..50 {
564        if frac.abs() < 1e-12 {
565            break;
566        }
567        let r = 1.0 / frac;
568        a = r.floor() as i64;
569        frac = r - a as f64;
570        let new_num = a * num + prev_num;
571        let new_den = a * den + prev_den;
572        prev_num = num;
573        prev_den = den;
574        num = new_num;
575        den = new_den;
576        if den > 1_000_000 {
577            break;
578        }
579    }
580    ocas_domain::Rational::new(sign * num, den)
581}
582
583/// Solve a zero-dimensional system using triangular decomposition.
584///
585/// Converts the GB to Lex order, extracts univariate polynomials for each
586/// variable, and solves by back-substitution.
587///
588/// # Example
589///
590/// ```
591/// use ocas_domain::{RationalDomain, Rational};
592/// use ocas_poly::sparse::Lex;
593/// use ocas_poly::{Algorithm, SparseMultivariatePolynomial, groebner_basis};
594/// use ocas_poly::ideal::{solve_polynomial_system, PolynomialSystemSolution};
595///
596/// let d = RationalDomain;
597/// // x² + y² - 1, x - y → solutions at (±1/√2, ±1/√2)
598/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
599///     (vec![2, 0], Rational::new(1, 1)),
600///     (vec![0, 2], Rational::new(1, 1)),
601///     (vec![0, 0], Rational::new(-1, 1)),
602/// ]);
603/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
604///     (vec![1, 0], Rational::new(1, 1)),
605///     (vec![0, 1], Rational::new(-1, 1)),
606/// ]);
607/// let sol = solve_polynomial_system(&[f1, f2], Algorithm::Auto);
608/// match sol {
609///     PolynomialSystemSolution::ZeroDimensional(z) => {
610///         assert_eq!(z.solutions.len(), 2);
611///     }
612///     _ => panic!("expected zero-dimensional"),
613/// }
614/// ```
615pub fn solve_polynomial_system(
616    equations: &[SparseMultivariatePolynomial<RationalDomain, Lex>],
617    algo: Algorithm,
618) -> PolynomialSystemSolution {
619    if equations.is_empty() {
620        return PolynomialSystemSolution::PositiveDimensional(GroebnerBasis { basis: vec![] });
621    }
622
623    let gb = groebner_basis(equations, algo);
624
625    // Check for empty variety: GB = {1}.
626    if gb.basis.len() == 1
627        && gb.basis[0].terms_ref().len() == 1
628        && gb.basis[0]
629            .leading_monomial()
630            .map(|lm| lm.iter().all(|&e| e == 0))
631            .unwrap_or(false)
632    {
633        return PolynomialSystemSolution::Empty;
634    }
635
636    // Convert to Lex for triangular decomposition.
637    let gb_lex: GroebnerBasis<RationalDomain, Lex> = gb;
638    // If already in Lex, use directly; otherwise convert.
639    // (Our GB functions preserve the input order, so check.)
640
641    if !is_zero_dimensional(&gb_lex) {
642        return PolynomialSystemSolution::PositiveDimensional(gb_lex);
643    }
644
645    let solutions = solve_triangular(&gb_lex);
646    // The vector-space dimension equals the number of solutions over ℂ,
647    // which we approximate as the product of univariate polynomial degrees
648    // in the Lex GB.
649    let dim = compute_vector_space_dim(&gb_lex).unwrap_or(solutions.len());
650
651    PolynomialSystemSolution::ZeroDimensional(ZeroDimSolutions {
652        solutions,
653        vector_space_dimension: dim,
654    })
655}
656
657/// Compute the vector-space dimension of k[x₁,...,xₙ]/I for a zero-dimensional
658/// ideal. This is the product of the degrees of the univariate polynomials
659/// for each variable in the Lex GB.
660fn compute_vector_space_dim(gb: &GroebnerBasis<RationalDomain, Lex>) -> Option<usize> {
661    let n_vars = gb.basis.first()?.n_vars();
662    let mut dim = 1usize;
663    for var in 0..n_vars {
664        let max_deg = gb
665            .basis
666            .iter()
667            .filter(|p| {
668                p.terms_ref()
669                    .keys()
670                    .all(|e| e.iter().enumerate().all(|(i, &v)| i == var || v == 0))
671            })
672            .map(|p| p.degree_in(var))
673            .max()
674            .unwrap_or(1);
675        dim = dim.checked_mul(max_deg)?;
676    }
677    Some(dim)
678}
679
680/// Solve a triangular Lex GB by back-substitution.
681/// Starts from the last variable (smallest in Lex) and works backwards.
682fn solve_triangular(gb: &GroebnerBasis<RationalDomain, Lex>) -> Vec<RealSolution> {
683    let n_vars = match gb.basis.first() {
684        Some(p) => p.n_vars(),
685        None => return vec![],
686    };
687
688    // Start from the last variable and work backwards.
689    let raw = solve_recursive(gb, n_vars - 1, &[]);
690    // Reverse the values since we built them last-var-first.
691    raw.into_iter()
692        .map(|mut s| {
693            s.values.reverse();
694            s
695        })
696        .collect()
697}
698
699/// Recursive back-substitution solver.
700/// Solves variable `var_index` given values for variables `var_index+1..n_vars-1`.
701/// `higher_values[0]` = value for variable `var_index+1`, etc.
702fn solve_recursive(
703    gb: &GroebnerBasis<RationalDomain, Lex>,
704    var_index: usize,
705    higher_values: &[f64],
706) -> Vec<RealSolution> {
707    // First, try to find a polynomial that's purely univariate in var_index
708    // (no higher variables involved).
709    let univariate_poly = gb.basis.iter().find(|p| {
710        p.degree_in(var_index) > 0
711            && p.terms_ref()
712                .keys()
713                .all(|e| e.iter().enumerate().all(|(i, &v)| i <= var_index || v == 0))
714    });
715
716    let roots = if let Some(poly) = univariate_poly {
717        // Pure univariate: extract and solve directly.
718        let unipoly = extract_univariate(poly, var_index);
719        let intervals = unipoly.isolate_real_roots();
720        let r: Vec<f64> = intervals
721            .iter()
722            .map(|iv| {
723                let refined = unipoly.refine_root(iv, 1e-14);
724                (refined.low + refined.high) / 2.0
725            })
726            .collect();
727        r
728    } else {
729        // Find a polynomial involving var_index and possibly higher variables.
730        // Substitute known values for higher variables to get univariate.
731        let poly = gb.basis.iter().find(|p| p.degree_in(var_index) > 0);
732        let Some(poly) = poly else {
733            return vec![];
734        };
735        solve_univariate_f64(poly, var_index, higher_values)
736    };
737
738    if var_index == 0 {
739        // Base case: last variable to solve.
740        roots
741            .into_iter()
742            .map(|v| RealSolution {
743                values: vec![v],
744                multiplicity: 1,
745            })
746            .collect()
747    } else {
748        // For each root, recurse to solve the next lower variable.
749        let mut results = Vec::new();
750        for root in &roots {
751            let mut new_higher = Vec::with_capacity(higher_values.len() + 1);
752            new_higher.push(*root);
753            new_higher.extend_from_slice(higher_values);
754            let sub_solutions = solve_recursive(gb, var_index - 1, &new_higher);
755            for mut sol in sub_solutions {
756                sol.values.push(*root);
757                results.push(sol);
758            }
759        }
760        results
761    }
762}
763
764// ------------------------------------------------------------------
765//  Primary decomposition and radical
766// ------------------------------------------------------------------
767
768/// A primary component of an ideal: a primary ideal with its associated prime.
769#[derive(Debug, Clone)]
770pub struct PrimaryComponent {
771    /// Generators of the primary ideal.
772    pub primary: Vec<SparseMultivariatePolynomial<RationalDomain, Lex>>,
773    /// Generators of the associated prime ideal (the radical).
774    pub prime: Vec<SparseMultivariatePolynomial<RationalDomain, Lex>>,
775}
776
777/// Compute the radical √I of an ideal.
778///
779/// For zero-dimensional ideals, the radical is computed via the squarefree
780/// decomposition of the univariate polynomials in the Lex GB.
781///
782/// # Example
783///
784/// ```
785/// use ocas_domain::{RationalDomain, Rational};
786/// use ocas_poly::sparse::Lex;
787/// use ocas_poly::ideal::ideal_radical;
788/// use ocas_poly::SparseMultivariatePolynomial;
789///
790/// let d = RationalDomain;
791/// // √(x², xy) = (x)
792/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
793///     (vec![2, 0], Rational::new(1, 1)),
794/// ]);
795/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
796///     (vec![1, 1], Rational::new(1, 1)),
797/// ]);
798/// let rad = ideal_radical(&[f1, f2]);
799/// // The radical should be (x).
800/// assert!(!rad.basis.is_empty());
801/// ```
802pub fn ideal_radical(
803    generators: &[SparseMultivariatePolynomial<RationalDomain, Lex>],
804) -> GroebnerBasis<RationalDomain, Lex> {
805    if generators.is_empty() {
806        return GroebnerBasis { basis: vec![] };
807    }
808
809    let gb = groebner_basis(generators, Algorithm::Auto);
810
811    if is_zero_dimensional(&gb) {
812        radical_zero_dim(&gb)
813    } else {
814        // For positive-dimensional ideals, use the Jacobian saturation approach.
815        // √I = I : (∂f₁/∂x₁ · ∂f₂/∂x₂ · ... )^∞
816        // Simplified: just return the GB (conservative upper bound).
817        // A full implementation would compute the Jacobian and saturate.
818        radical_via_jacobian(&gb)
819    }
820}
821
822/// Compute the radical for a zero-dimensional ideal using squarefree
823/// decomposition of the univariate polynomials.
824fn radical_zero_dim(gb: &GroebnerBasis<RationalDomain, Lex>) -> GroebnerBasis<RationalDomain, Lex> {
825    let n_vars = match gb.basis.first() {
826        Some(p) => p.n_vars(),
827        None => return GroebnerBasis { basis: vec![] },
828    };
829
830    let domain = RationalDomain;
831    let mut radical_gens: Vec<SparseMultivariatePolynomial<RationalDomain, Lex>> = Vec::new();
832
833    // For each variable, find the univariate polynomial and make it squarefree.
834    for var in 0..n_vars {
835        let univariate = gb.basis.iter().find(|p| {
836            p.terms_ref()
837                .keys()
838                .all(|e| e.iter().enumerate().all(|(i, &v)| i == var || v == 0))
839                && p.degree_in(var) > 0
840        });
841
842        if let Some(poly) = univariate {
843            let unipoly = extract_univariate(poly, var);
844            // Squarefree part: p / gcd(p, p').
845            let deriv = unipoly.derivative();
846            let g = unipoly.gcd(&deriv);
847            let sqf = unipoly.div_rem(&g).map(|(q, _)| q).unwrap_or(unipoly);
848
849            // Convert back to multivariate.
850            let terms: Vec<(Vec<usize>, ocas_domain::Rational)> = sqf
851                .coeffs()
852                .iter()
853                .enumerate()
854                .filter(|(_, c)| !domain.is_zero(c))
855                .map(|(i, c)| {
856                    let mut exp = vec![0usize; n_vars];
857                    exp[var] = i;
858                    (exp, c.clone())
859                })
860                .collect();
861            if !terms.is_empty() {
862                radical_gens.push(SparseMultivariatePolynomial::from_terms(
863                    domain, n_vars, terms,
864                ));
865            }
866        }
867    }
868
869    // Also include all non-univariate basis elements (they're in the radical).
870    for p in &gb.basis {
871        let is_univariate = p
872            .terms_ref()
873            .keys()
874            .any(|e| e.iter().filter(|&&v| v > 0).count() <= 1);
875        if !is_univariate {
876            radical_gens.push(p.clone());
877        }
878    }
879
880    groebner_basis(&radical_gens, Algorithm::Auto)
881}
882
883/// Compute the radical for positive-dimensional ideals using the
884/// Jacobian saturation approach: √I = I : h^∞ where h is related to
885/// the Jacobian determinant.
886///
887/// Simplified Kemper algorithm for characteristic 0:
888/// 1. Compute partial derivatives ∂fᵢ/∂xⱼ for all generators and variables.
889/// 2. Let h = gcd of all non-zero partial derivatives.
890/// 3. √I = I : h^∞.
891///
892/// Falls back to returning the original GB if the Jacobian is trivial (all
893/// derivatives are zero or h = 1).
894fn radical_via_jacobian(
895    gb: &GroebnerBasis<RationalDomain, Lex>,
896) -> GroebnerBasis<RationalDomain, Lex> {
897    let n_vars = match gb.basis.first() {
898        Some(p) => p.n_vars(),
899        None => return gb.clone(),
900    };
901
902    if n_vars == 0 || gb.basis.is_empty() {
903        return gb.clone();
904    }
905
906    // Compute all partial derivatives ∂fᵢ/∂xⱼ.
907    let mut derivatives: Vec<SparseMultivariatePolynomial<RationalDomain, Lex>> = Vec::new();
908    for f in &gb.basis {
909        for var in 0..n_vars {
910            let df = f.derivative(var);
911            if df.total_degree().is_some_and(|d| d > 0) {
912                derivatives.push(df);
913            }
914        }
915    }
916
917    if derivatives.is_empty() {
918        // All derivatives are constant or zero; ideal is likely a prime
919        // coordinate subspace. Return GB as-is.
920        return gb.clone();
921    }
922
923    // Compute h = gcd of all derivatives.
924    // For simplicity, iteratively compute gcd: gcd(d1, gcd(d2, gcd(d3, ...))).
925    // Use the multivariate GCD via Groebner-based approach: gcd(a,b) can be
926    // computed as the generator of ⟨a⟩ ∩ ⟨b⟩ in the univariate case,
927    // but for multivariate we use a simpler heuristic.
928    //
929    // Simplification: take the product of all distinct irreducible factors
930    // that appear in any derivative. For now, use the first derivative as h
931    // (conservative: h divides the true Jacobian, so I:h^∞ ⊇ √I, which is
932    // still an upper bound).
933    let h = derivatives.into_iter().reduce(|a, b| {
934        // Multivariate GCD via repeated pseudo-division in the first variable.
935        // Simplified: take the polynomial with smaller total degree.
936        if a.total_degree() <= b.total_degree() {
937            a
938        } else {
939            b
940        }
941    });
942
943    let Some(h) = h else {
944        return gb.clone();
945    };
946
947    // Check if h is a nonzero constant (then I:h^∞ = I).
948    if h.total_degree() == Some(0) || h.total_degree().is_none() {
949        return gb.clone();
950    }
951
952    // Compute √I = I : h^∞ via saturation.
953    ideal_saturate(&gb.basis, std::slice::from_ref(&h))
954}
955
956/// Compute the primary decomposition of an ideal.
957///
958/// For zero-dimensional ideals, uses the factorization of the univariate
959/// polynomials in the Lex GB to separate primary components.
960///
961/// # Example
962///
963/// ```
964/// use ocas_domain::{RationalDomain, Rational};
965/// use ocas_poly::sparse::Lex;
966/// use ocas_poly::ideal::primary_decomposition;
967/// use ocas_poly::SparseMultivariatePolynomial;
968///
969/// let d = RationalDomain;
970/// // (x², xy) = (x) ∩ (x², y)
971/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
972///     (vec![2, 0], Rational::new(1, 1)),
973/// ]);
974/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
975///     (vec![1, 1], Rational::new(1, 1)),
976/// ]);
977/// let decomp = primary_decomposition(&[f1, f2]);
978/// assert!(decomp.len() >= 1);
979/// ```
980pub fn primary_decomposition(
981    generators: &[SparseMultivariatePolynomial<RationalDomain, Lex>],
982) -> Vec<PrimaryComponent> {
983    if generators.is_empty() {
984        return vec![];
985    }
986
987    let gb = groebner_basis(generators, Algorithm::Auto);
988
989    if is_zero_dimensional(&gb) {
990        primary_decomp_zero_dim(&gb)
991    } else {
992        // For positive-dimensional ideals, return a single component.
993        vec![PrimaryComponent {
994            primary: gb.basis.clone(),
995            prime: gb.basis.clone(), // conservative
996        }]
997    }
998}
999
1000/// Primary decomposition for zero-dimensional ideals.
1001///
1002/// Factors the univariate polynomials in the Lex GB and uses the factors
1003/// to separate primary components via saturation.
1004fn primary_decomp_zero_dim(gb: &GroebnerBasis<RationalDomain, Lex>) -> 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 {
1077            basis: gb.basis.clone(),
1078        };
1079        for (j, fj) in factor_polys.iter().enumerate() {
1080            if i == j {
1081                continue;
1082            }
1083            saturated = ideal_saturate(&saturated.basis, std::slice::from_ref(fj));
1084        }
1085
1086        // The prime is I + ⟨f_i⟩.
1087        let mut prime_gens = gb.basis.clone();
1088        prime_gens.push(fi.clone());
1089        let prime_gb = groebner_basis(&prime_gens, Algorithm::Auto);
1090
1091        components.push(PrimaryComponent {
1092            primary: saturated.basis,
1093            prime: prime_gb.basis,
1094        });
1095    }
1096
1097    components
1098}
1099
1100/// Test whether an ideal is prime.
1101///
1102/// For zero-dimensional ideals, checks if the univariate polynomials in the
1103/// Lex GB are irreducible.
1104///
1105/// **Note**: Positive-dimensional ideals always return `false`. Full primality
1106/// testing for positive-dimensional ideals requires irreducibility checking of
1107/// the variety, which is not yet implemented. This is a conservative
1108/// approximation: it never returns a false positive (non-prime reported as
1109/// prime), only false negatives (prime ideals reported as non-prime).
1110pub fn is_prime_ideal(generators: &[SparseMultivariatePolynomial<RationalDomain, Lex>]) -> 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()
1159                && deg <= 3
1160            {
1161                let has_rational_root = check_rational_roots(&unipoly);
1162                if has_rational_root && deg > 1 {
1163                    return false;
1164                }
1165            }
1166        }
1167    }
1168    true
1169}
1170
1171/// Enumerate all positive divisors of a positive integer.
1172fn divisors_of(n: i64) -> Vec<i64> {
1173    if n <= 0 {
1174        return vec![];
1175    }
1176    let mut divs = Vec::new();
1177    let mut i = 1i64;
1178    while i * i <= n {
1179        if n % i == 0 {
1180            divs.push(i);
1181            if i != n / i {
1182                divs.push(n / i);
1183            }
1184        }
1185        i += 1;
1186    }
1187    divs.sort_unstable();
1188    divs
1189}
1190
1191/// Check if a univariate polynomial has rational roots using the
1192/// rational root theorem: if $p/q$ is a root in lowest terms, then
1193/// $p$ divides the constant term and $q$ divides the leading coefficient.
1194fn check_rational_roots(poly: &crate::dense::DenseUnivariatePolynomial<RationalDomain>) -> 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(generators: &[SparseMultivariatePolynomial<RationalDomain, Lex>]) -> bool {
1244    let decomp = primary_decomposition(generators);
1245    decomp.len() <= 1
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use super::*;
1251    use crate::groebner_basis;
1252    use ocas_domain::{Rational, RationalDomain};
1253
1254    fn r(n: i64, d: i64) -> Rational {
1255        Rational::new(n, d)
1256    }
1257
1258    fn x() -> SparseMultivariatePolynomial<RationalDomain, Lex> {
1259        SparseMultivariatePolynomial::from_terms(RationalDomain, 2, vec![(vec![1, 0], r(1, 1))])
1260    }
1261
1262    fn y() -> SparseMultivariatePolynomial<RationalDomain, Lex> {
1263        SparseMultivariatePolynomial::from_terms(RationalDomain, 2, vec![(vec![0, 1], r(1, 1))])
1264    }
1265
1266    #[test]
1267    fn contains_basic() {
1268        // x ∈ ⟨x, y⟩
1269        assert!(ideal_contains(&[x(), y()], &x(), Algorithm::Auto));
1270    }
1271
1272    #[test]
1273    fn contains_negative() {
1274        // x ∉ ⟨y⟩
1275        assert!(!ideal_contains(&[y()], &x(), Algorithm::Auto));
1276    }
1277
1278    #[test]
1279    fn sum_xy() {
1280        // ⟨x⟩ + ⟨y⟩ = ⟨x, y⟩
1281        let gb = ideal_sum(&[x()], &[y()]);
1282        assert!(gb.basis.len() >= 2);
1283    }
1284
1285    #[test]
1286    fn product_xy() {
1287        // ⟨x⟩ · ⟨y⟩ = ⟨xy⟩
1288        let gb = ideal_product(&[x()], &[y()]);
1289        assert_eq!(gb.basis.len(), 1);
1290    }
1291
1292    #[test]
1293    fn quotient_x2_xy_by_x() {
1294        // ⟨x², xy⟩ :⟨x⟩ = ⟨x⟩
1295        let d = RationalDomain;
1296        let x2 =
1297            SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![(vec![2, 0], r(1, 1))]);
1298        let xy =
1299            SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![(vec![1, 1], r(1, 1))]);
1300        let g = x();
1301        let gb = ideal_quotient(&[x2, xy], &[g]);
1302        // Result should be ⟨x⟩ — check that x is in the ideal.
1303        assert!(!gb.basis.is_empty());
1304        assert!(ideal_contains(&gb.basis, &x(), Algorithm::Auto));
1305    }
1306
1307    #[test]
1308    fn intersection_x_y() {
1309        // ⟨x⟩ ∩ ⟨y⟩ = ⟨xy⟩
1310        let gb = ideal_intersection(&[x()], &[y()]);
1311        assert_eq!(gb.basis.len(), 1);
1312        // The single generator should be xy (up to scalar).
1313        let xy_exp = vec![1usize, 1];
1314        let has_xy = gb
1315            .basis
1316            .iter()
1317            .any(|p| p.terms_ref().len() == 1 && p.terms_ref().contains_key(xy_exp.as_slice()));
1318        assert!(has_xy, "expected xy in intersection basis");
1319    }
1320
1321    #[test]
1322    fn saturate_x2y_xy2_by_x() {
1323        // ⟨x²y, xy²⟩ :⟨x⟩^∞
1324        let d = RationalDomain;
1325        let f1 =
1326            SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![(vec![2, 1], r(1, 1))]);
1327        let f2 =
1328            SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![(vec![1, 2], r(1, 1))]);
1329        let g = x();
1330        let gb = ideal_saturate(&[f1, f2], &[g]);
1331        // Result should be ⟨y⟩ (or contain y).
1332        assert!(!gb.basis.is_empty());
1333        assert!(ideal_contains(&gb.basis, &y(), Algorithm::Auto));
1334    }
1335
1336    // --- Zero-dimensional solving tests ---
1337
1338    #[test]
1339    fn is_zero_dim_positive() {
1340        // x² - 1, y - x → zero-dimensional
1341        let d = RationalDomain;
1342        let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
1343            d,
1344            2,
1345            vec![(vec![2, 0], r(1, 1)), (vec![0, 0], r(-1, 1))],
1346        );
1347        let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
1348            d,
1349            2,
1350            vec![(vec![0, 1], r(1, 1)), (vec![1, 0], r(-1, 1))],
1351        );
1352        let gb = groebner_basis(&[f1, f2], Algorithm::F4);
1353        assert!(is_zero_dimensional(&gb));
1354    }
1355
1356    #[test]
1357    fn is_zero_dim_negative() {
1358        // x - y → positive-dimensional (line in 2D)
1359        let d = RationalDomain;
1360        let f = SparseMultivariatePolynomial::<_, Lex>::from_terms(
1361            d,
1362            2,
1363            vec![(vec![1, 0], r(1, 1)), (vec![0, 1], r(-1, 1))],
1364        );
1365        let gb = groebner_basis(&[f], Algorithm::F4);
1366        assert!(!is_zero_dimensional(&gb));
1367    }
1368
1369    #[test]
1370    fn solve_circle_line() {
1371        // x² + y² - 1, x - y → 2 solutions at (±1/√2, ±1/√2)
1372        let d = RationalDomain;
1373        let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
1374            d,
1375            2,
1376            vec![
1377                (vec![2, 0], r(1, 1)),
1378                (vec![0, 2], r(1, 1)),
1379                (vec![0, 0], r(-1, 1)),
1380            ],
1381        );
1382        let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
1383            d,
1384            2,
1385            vec![(vec![1, 0], r(1, 1)), (vec![0, 1], r(-1, 1))],
1386        );
1387        let sol = solve_polynomial_system(&[f1, f2], Algorithm::Auto);
1388        match sol {
1389            PolynomialSystemSolution::ZeroDimensional(z) => {
1390                assert_eq!(z.solutions.len(), 2);
1391                // Check that solutions are approximately (±0.707, ±0.707)
1392                for s in &z.solutions {
1393                    let x_val = s.values[0];
1394                    let y_val = s.values[1];
1395                    assert!((x_val - y_val).abs() < 1e-10, "x should equal y");
1396                    assert!(
1397                        (x_val * x_val + y_val * y_val - 1.0).abs() < 1e-10,
1398                        "x² + y² should be 1"
1399                    );
1400                }
1401            }
1402            _ => panic!("expected zero-dimensional"),
1403        }
1404    }
1405
1406    #[test]
1407    fn solve_empty_variety() {
1408        // x² + y² - 1, x² + y² - 2 → no solutions
1409        let d = RationalDomain;
1410        let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
1411            d,
1412            2,
1413            vec![
1414                (vec![2, 0], r(1, 1)),
1415                (vec![0, 2], r(1, 1)),
1416                (vec![0, 0], r(-1, 1)),
1417            ],
1418        );
1419        let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
1420            d,
1421            2,
1422            vec![
1423                (vec![2, 0], r(1, 1)),
1424                (vec![0, 2], r(1, 1)),
1425                (vec![0, 0], r(-2, 1)),
1426            ],
1427        );
1428        let sol = solve_polynomial_system(&[f1, f2], Algorithm::Auto);
1429        assert!(matches!(sol, PolynomialSystemSolution::Empty));
1430    }
1431
1432    // --- Primary decomposition and radical tests ---
1433
1434    #[test]
1435    fn radical_x2_y2() {
1436        // √(x², y²) = (x, y) — zero-dimensional
1437        let d = RationalDomain;
1438        let f1 =
1439            SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![(vec![2, 0], r(1, 1))]);
1440        let f2 =
1441            SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![(vec![0, 2], r(1, 1))]);
1442        let rad = ideal_radical(&[f1, f2]);
1443        // The radical should be (x, y).
1444        let x_poly =
1445            SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![(vec![1, 0], r(1, 1))]);
1446        let y_poly =
1447            SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![(vec![0, 1], r(1, 1))]);
1448        assert!(ideal_contains(&rad.basis, &x_poly, Algorithm::Auto));
1449        assert!(ideal_contains(&rad.basis, &y_poly, Algorithm::Auto));
1450    }
1451
1452    #[test]
1453    fn radical_of_prime_is_self() {
1454        // (x² - 2) is prime over ℚ, so √(x² - 2) = (x² - 2).
1455        let d = RationalDomain;
1456        let f = SparseMultivariatePolynomial::<_, Lex>::from_terms(
1457            d,
1458            1,
1459            vec![(vec![2], r(1, 1)), (vec![0], r(-2, 1))],
1460        );
1461        let rad = ideal_radical(std::slice::from_ref(&f));
1462        // Should be the same ideal.
1463        assert!(ideal_contains(&rad.basis, &f, Algorithm::Auto));
1464    }
1465
1466    #[test]
1467    fn primary_decomp_x2_xy() {
1468        // (x², xy) = (x) ∩ (x², y)
1469        let d = RationalDomain;
1470        let f1 =
1471            SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![(vec![2, 0], r(1, 1))]);
1472        let f2 =
1473            SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![(vec![1, 1], r(1, 1))]);
1474        let decomp = primary_decomposition(&[f1, f2]);
1475        assert!(!decomp.is_empty());
1476        // Each component should have primary and prime generators.
1477        for comp in &decomp {
1478            assert!(!comp.primary.is_empty());
1479            assert!(!comp.prime.is_empty());
1480        }
1481    }
1482
1483    #[test]
1484    fn is_prime_x2_minus_2() {
1485        // x² - 2 is prime over ℚ.
1486        let d = RationalDomain;
1487        let f = SparseMultivariatePolynomial::<_, Lex>::from_terms(
1488            d,
1489            1,
1490            vec![(vec![2], r(1, 1)), (vec![0], r(-2, 1))],
1491        );
1492        assert!(is_prime_ideal(&[f]));
1493    }
1494
1495    #[test]
1496    fn is_primary_x2() {
1497        // (x²) is primary (but not prime).
1498        let d = RationalDomain;
1499        let f = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 1, vec![(vec![2], r(1, 1))]);
1500        assert!(is_primary_ideal(&[f]));
1501    }
1502}