Skip to main content

ocas_poly/groebner/
mod.rs

1//! Gröbner basis computation for multivariate polynomial ideals.
2//!
3//! Provides three algorithms, all reachable through the unified
4//! [`groebner_basis`] entry point with an [`Algorithm`] selector:
5//!
6//! - **Buchberger** ([`buchberger`]) — classic S-polynomial iteration with
7//!   Gebauer-Moeller optimization. Suitable for small ideals.
8//! - **F4** ([`f4::f4`]) — matrix-based algorithm from Faugère (1999).
9//!   Dramatically faster for larger ideals by batching S-polynomial
10//!   reductions into sparse matrix row operations.
11//! - **F5** ([`f5::f5`]) — signature-based algorithm from Faugère (2002).
12//!   Rejects zero-reducers *before* matrix construction via syzygy
13//!   criteria, targeting order-of-magnitude speedups on difficult ideals
14//!   (e.g. cyclic-n). Production-grade since 0.19.0.
15//!
16//! All algorithms produce a reduced Gröbner basis. [`Algorithm::Auto`]
17//! selects a backend by heuristic (currently F4).
18
19pub mod f4;
20pub mod f5;
21pub mod fglm;
22pub mod hilbert;
23
24use ocas_core::FastHashSet as HashSet;
25use ocas_domain::Domain;
26
27use crate::sparse::{
28    MonomialOrder, SparseMultivariatePolynomial, monomial_are_coprime, monomial_divides,
29};
30
31/// A Gröbner basis for a polynomial ideal.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct GroebnerBasis<D: Domain, O: MonomialOrder> {
34    /// The polynomials forming the basis.
35    pub basis: Vec<SparseMultivariatePolynomial<D, O>>,
36}
37
38impl<D: Domain, O: MonomialOrder> GroebnerBasis<D, O> {
39    /// Compute a Gröbner basis from a set of generators using Buchberger's algorithm.
40    ///
41    /// Requires that the coefficient domain supports exact division (i.e., is
42    /// effectively a field). The algorithm will panic if division fails.
43    ///
44    /// # Example
45    ///
46    /// ```
47    /// use ocas_domain::{RationalDomain, Rational};
48    /// use ocas_poly::sparse::Lex;
49    /// use ocas_poly::GroebnerBasis;
50    /// use ocas_poly::SparseMultivariatePolynomial;
51    ///
52    /// let d = RationalDomain;
53    /// // ideal: x + y, x - y
54    /// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
55    ///     (vec![1, 0], Rational::new(1, 1)),
56    ///     (vec![0, 1], Rational::new(1, 1)),
57    /// ]);
58    /// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
59    ///     (vec![1, 0], Rational::new(1, 1)),
60    ///     (vec![0, 1], Rational::new(-1, 1)),
61    /// ]);
62    /// let gb = GroebnerBasis::buchberger(&[f1, f2]);
63    /// assert!(gb.basis.len() >= 2);
64    /// ```
65    pub fn buchberger(ideal: &[SparseMultivariatePolynomial<D, O>]) -> Self {
66        // Filter out zero polynomials.
67        let mut basis: Vec<SparseMultivariatePolynomial<D, O>> =
68            ideal.iter().filter(|p| !p.is_zero()).cloned().collect();
69        if basis.is_empty() {
70            return Self { basis };
71        }
72
73        // Collect critical pairs: all unordered pairs (i, j) with i < j.
74        let mut pairs: HashSet<(usize, usize)> = HashSet::default();
75        for i in 0..basis.len() {
76            for j in i + 1..basis.len() {
77                pairs.insert((i, j));
78            }
79        }
80
81        let max_iter = 10000;
82
83        for _ in 0..max_iter {
84            if pairs.is_empty() {
85                break;
86            }
87            let (i, j) = *pairs.iter().next().unwrap();
88            pairs.remove(&(i, j));
89
90            // Buchberger's first criterion: if the leading monomials are
91            // coprime, the S-polynomial reduces to zero, so skip.
92            let lm_i = basis[i].leading_monomial();
93            let lm_j = basis[j].leading_monomial();
94            if let (Some(mi), Some(mj)) = (&lm_i, &lm_j)
95                && monomial_are_coprime(mi, mj)
96            {
97                continue;
98            }
99
100            // Compute S-polynomial and reduce by current basis.
101            let s = basis[i].spoly(&basis[j]);
102            let r = s.reduce(&basis);
103
104            if !r.is_zero() {
105                let new_idx = basis.len();
106                basis.push(r);
107                for k in 0..new_idx {
108                    pairs.insert((k, new_idx));
109                }
110            }
111        }
112
113        Self { basis }
114    }
115
116    /// Minimize the basis: remove polynomials whose leading monomial is
117    /// divisible by another element's leading monomial.
118    pub fn minimize(mut self) -> Self {
119        let lms: Vec<_> = self
120            .basis
121            .iter()
122            .filter_map(|p| p.leading_monomial().cloned())
123            .collect();
124
125        let mut keep = vec![true; self.basis.len()];
126        for i in 0..self.basis.len() {
127            for j in 0..self.basis.len() {
128                // Remove i if lms[j] divides lms[i] (i.e., lms[i] is a
129                // multiple of lms[j], making i redundant).
130                // monomial_divides(big, small) returns true when small divides big.
131                if i != j && keep[i] && keep[j] && monomial_divides(&lms[i], &lms[j]) {
132                    keep[i] = false;
133                    break;
134                }
135            }
136        }
137
138        self.basis = self
139            .basis
140            .into_iter()
141            .enumerate()
142            .filter(|(i, _)| keep[*i])
143            .map(|(_, p)| p)
144            .collect();
145
146        self
147    }
148
149    /// Inter-reduce the basis: reduce each element by the others and make
150    /// each polynomial monic.
151    ///
152    /// The algorithm processes elements in ascending order of leading
153    /// monomial. Each element is reduced by all elements with strictly
154    /// smaller leading monomials (those already in the result set).
155    /// This ensures the standard reduced Gröbner basis property:
156    /// no monomial of any basis element is divisible by the leading
157    /// monomial of any other basis element.
158    pub fn auto_reduce(mut self) -> Self {
159        let order = self
160            .basis
161            .first()
162            .map(|p| p.order.clone())
163            .unwrap_or_default();
164        // Sort basis in ascending order of leading monomial (smallest first).
165        self.basis
166            .sort_by(|a, b| match (a.leading_monomial(), b.leading_monomial()) {
167                (Some(ma), Some(mb)) => order.cmp(ma, mb),
168                (Some(_), None) => std::cmp::Ordering::Greater,
169                (None, Some(_)) => std::cmp::Ordering::Less,
170                (None, None) => std::cmp::Ordering::Equal,
171            });
172
173        let mut reduced: Vec<SparseMultivariatePolynomial<D, O>> = Vec::new();
174
175        for poly in &self.basis {
176            // Reduce `poly` by all elements already in `reduced`
177            // (which have smaller leading monomials).
178            let mut r = poly.reduce(&reduced);
179            if !r.is_zero() {
180                if let Some(lc) = r.leading_coeff().cloned()
181                    && let Some(inv) = r.domain().inv(&lc)
182                {
183                    r = r.mul_scalar(&inv);
184                }
185                reduced.push(r);
186            }
187        }
188
189        self.basis = reduced;
190        self
191    }
192
193    /// Verify that this is indeed a Gröbner basis by checking that all
194    /// S-polynomials reduce to zero.
195    pub fn is_groebner_basis(&self) -> bool {
196        for i in 0..self.basis.len() {
197            for j in i + 1..self.basis.len() {
198                let s = self.basis[i].spoly(&self.basis[j]);
199                let r = s.reduce(&self.basis);
200                if !r.is_zero() {
201                    return false;
202                }
203            }
204        }
205        true
206    }
207
208    /// Change the monomial order of this Gröbner basis.
209    ///
210    /// The polynomials are re-interpreted under the target order `O2`
211    /// and the F4 algorithm is re-run. This is the simple reorder path
212    /// (Symbolica's `reorder::<Order>()`). For zero-dimensional ideals,
213    /// use [`crate::groebner::fglm::fglm`] for a much faster conversion.
214    ///
215    /// # Example
216    ///
217    /// ```
218    /// use ocas_domain::{RationalDomain, Rational};
219    /// use ocas_poly::sparse::{Grevlex, Lex};
220    /// use ocas_poly::{GroebnerBasis, SparseMultivariatePolynomial, f4};
221    ///
222    /// let d = RationalDomain;
223    /// // ideal: x + y, x - y  → basis {y, x} under Lex
224    /// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
225    ///     (vec![1, 0], Rational::new(1, 1)),
226    ///     (vec![0, 1], Rational::new(1, 1)),
227    /// ]);
228    /// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
229    ///     (vec![1, 0], Rational::new(1, 1)),
230    ///     (vec![0, 1], Rational::new(-1, 1)),
231    /// ]);
232    /// let gb_lex = f4::f4(&[f1, f2]);
233    /// let gb_grevlex = gb_lex.reorder::<Grevlex>();
234    /// assert!(gb_grevlex.is_groebner_basis());
235    /// ```
236    pub fn reorder<O2: MonomialOrder>(&self) -> GroebnerBasis<D, O2>
237    where
238        D: 'static,
239    {
240        let converted: Vec<SparseMultivariatePolynomial<D, O2>> = self
241            .basis
242            .iter()
243            .map(|p| {
244                SparseMultivariatePolynomial::from_terms(
245                    p.domain().clone(),
246                    p.n_vars(),
247                    p.terms_ref()
248                        .iter()
249                        .map(|(e, c)| (e.to_vec(), c.clone()))
250                        .collect(),
251                )
252            })
253            .collect();
254        crate::groebner::f4::f4(&converted)
255    }
256}
257
258/// Convenience: compute a Gröbner basis and inter-reduce it.
259pub fn buchberger<D: Domain, O: MonomialOrder>(
260    ideal: &[SparseMultivariatePolynomial<D, O>],
261) -> GroebnerBasis<D, O> {
262    GroebnerBasis::buchberger(ideal).minimize().auto_reduce()
263}
264
265/// Algorithm selector for [`groebner_basis`].
266///
267/// `Auto` picks a backend based on the ideal's size and structure; the
268/// other variants force a specific algorithm. See [`groebner_basis`] for
269/// the unified entry point.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
271pub enum Algorithm {
272    /// Automatically select the most suitable algorithm based on ideal
273    /// size and structure (heuristic, calibrated from benchmarks).
274    /// Currently routes to F4; the crossover to F5 will be tuned from
275    /// cyclic-n benchmarks once the F5 core is complete.
276    #[default]
277    Auto,
278    /// Force the F4 matrix algorithm (Faugère 1999).
279    F4,
280    /// Force the F5 signature-based algorithm (Faugère 2002).
281    F5,
282    /// Force Buchberger's classic S-polynomial iteration.
283    Buchberger,
284}
285
286/// Compute a Gröbner basis using the requested [`Algorithm`].
287///
288/// This is the unified entry point for Gröbner basis computation. Zero
289/// polynomials in `ideal` are filtered internally by each backend.
290///
291/// [`Algorithm::Auto`] currently routes to F4; the crossover to F5 will
292/// be calibrated from cyclic-n benchmarks once the F5 core is complete.
293///
294/// # Example
295///
296/// ```
297/// use ocas_domain::{RationalDomain, Rational};
298/// use ocas_poly::sparse::Lex;
299/// use ocas_poly::{Algorithm, groebner_basis, SparseMultivariatePolynomial};
300///
301/// let d = RationalDomain;
302/// // ideal: x + y, x - y
303/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
304///     (vec![1, 0], Rational::new(1, 1)),
305///     (vec![0, 1], Rational::new(1, 1)),
306/// ]);
307/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
308///     (vec![1, 0], Rational::new(1, 1)),
309///     (vec![0, 1], Rational::new(-1, 1)),
310/// ]);
311/// let gb = groebner_basis(&[f1, f2], Algorithm::Auto);
312/// assert!(gb.is_groebner_basis());
313/// ```
314pub fn groebner_basis<D: Domain + 'static, O: MonomialOrder>(
315    ideal: &[SparseMultivariatePolynomial<D, O>],
316    algo: Algorithm,
317) -> GroebnerBasis<D, O> {
318    match algo {
319        Algorithm::Auto | Algorithm::F4 => f4::f4(ideal),
320        Algorithm::F5 => f5::f5(ideal),
321        Algorithm::Buchberger => buchberger(ideal),
322    }
323}
324
325/// Eliminate variables from an ideal.
326///
327/// Returns the Gröbner basis of `I ∩ k[x_{elim_vars}, ..., x_{n-1}]`, i.e.,
328/// the polynomials in the basis that do not involve the first `elim_vars`
329/// variables. Uses Lex ordering which is a natural elimination order:
330/// under Lex, the reduced GB of an ideal automatically contains the
331/// elimination ideal's generators.
332///
333/// # Example
334///
335/// ```
336/// use ocas_domain::{RationalDomain, Rational};
337/// use ocas_poly::sparse::Lex;
338/// use ocas_poly::{SparseMultivariatePolynomial, eliminate, Algorithm};
339///
340/// let d = RationalDomain;
341/// // Ideal: x + y + z, x*y + x*z in k[x,y,z]; eliminate x.
342/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 3, vec![
343///     (vec![1, 0, 0], Rational::new(1, 1)),
344///     (vec![0, 1, 0], Rational::new(1, 1)),
345///     (vec![0, 0, 1], Rational::new(1, 1)),
346/// ]);
347/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 3, vec![
348///     (vec![1, 1, 0], Rational::new(1, 1)),
349///     (vec![1, 0, 1], Rational::new(1, 1)),
350/// ]);
351/// let elim = eliminate(&[f1, f2], 1, Algorithm::Auto);
352/// // Result should be in k[y,z]
353/// for p in &elim.basis {
354///     assert!(p.degree_in(0) == 0, "eliminated variable x should not appear");
355/// }
356/// ```
357pub fn eliminate<D: Domain + 'static>(
358    ideal: &[SparseMultivariatePolynomial<D, crate::sparse::Lex>],
359    elim_vars: usize,
360    algo: Algorithm,
361) -> GroebnerBasis<D, crate::sparse::Lex> {
362    let n_vars = ideal.first().map(|p| p.n_vars()).unwrap_or(0);
363    assert!(
364        elim_vars <= n_vars,
365        "elim_vars ({elim_vars}) must be <= n_vars ({n_vars})"
366    );
367    if ideal.is_empty() {
368        return GroebnerBasis { basis: vec![] };
369    }
370
371    // Compute Gröbner basis under Lex ordering.
372    // Lex is a natural elimination order: polynomials in the GB that
373    // don't involve x_0,...,x_{s-1} form a GB of the elimination ideal.
374    let gb = groebner_basis(ideal, algo);
375
376    // Filter: keep only polynomials that don't involve the eliminated variables.
377    let filtered: Vec<SparseMultivariatePolynomial<D, crate::sparse::Lex>> = gb
378        .basis
379        .into_iter()
380        .filter(|p| {
381            p.terms_ref()
382                .keys()
383                .all(|exp| exp.iter().take(elim_vars).all(|&e| e == 0))
384        })
385        .collect();
386
387    GroebnerBasis { basis: filtered }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::sparse::Lex;
394    use ocas_domain::{Rational, RationalDomain};
395
396    fn r(n: i64, d: i64) -> Rational {
397        Rational::new(n, d)
398    }
399
400    fn make_poly(
401        terms: Vec<(Vec<usize>, Rational)>,
402    ) -> SparseMultivariatePolynomial<RationalDomain, Lex> {
403        SparseMultivariatePolynomial::from_terms(RationalDomain, 2, terms)
404    }
405
406    #[test]
407    fn empty_ideal() {
408        let gb = buchberger::<RationalDomain, Lex>(&[]);
409        assert!(gb.basis.is_empty());
410    }
411
412    #[test]
413    fn single_polynomial() {
414        // f = x^2 - 1
415        let f = SparseMultivariatePolynomial::<_, Lex>::from_terms(
416            RationalDomain,
417            1,
418            vec![(vec![2], r(1, 1)), (vec![0], r(-1, 1))],
419        );
420        let gb = buchberger(&[f]);
421        assert_eq!(gb.basis.len(), 1);
422        assert!(gb.is_groebner_basis());
423    }
424
425    #[test]
426    fn linear_system() {
427        // x + y = 0, x - y = 0  →  basis = {x, y}
428        let f1 = make_poly(vec![(vec![1, 0], r(1, 1)), (vec![0, 1], r(1, 1))]);
429        let f2 = make_poly(vec![(vec![1, 0], r(1, 1)), (vec![0, 1], r(-1, 1))]);
430        let gb = buchberger(&[f1, f2]);
431        assert!(gb.is_groebner_basis());
432        // After auto-reduce, we expect {x, y} (monic leading terms)
433        assert!(gb.basis.len() >= 2);
434    }
435
436    #[test]
437    fn two_variable_ideal() {
438        // x^2 - y, x^3 - x  (elimination ideal: y = x^2, x^3 = x → x ∈ {0, ±1})
439        let f1 = make_poly(vec![(vec![2, 0], r(1, 1)), (vec![0, 1], r(-1, 1))]);
440        let f2 = make_poly(vec![(vec![3, 0], r(1, 1)), (vec![1, 0], r(-1, 1))]);
441        let gb = buchberger(&[f1, f2]);
442        assert!(gb.is_groebner_basis());
443        assert!(!gb.basis.is_empty());
444    }
445
446    // --- Step 1a: Lex order verification ---
447
448    #[test]
449    fn lex_cyclic_3() {
450        // Cyclic-3: x+y+z, xy+yz+zx, xyz-1 under Lex.
451        let d = RationalDomain;
452        let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
453            d,
454            3,
455            vec![
456                (vec![1, 0, 0], r(1, 1)),
457                (vec![0, 1, 0], r(1, 1)),
458                (vec![0, 0, 1], r(1, 1)),
459            ],
460        );
461        let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
462            d,
463            3,
464            vec![
465                (vec![1, 1, 0], r(1, 1)),
466                (vec![0, 1, 1], r(1, 1)),
467                (vec![1, 0, 1], r(1, 1)),
468            ],
469        );
470        let f3 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
471            d,
472            3,
473            vec![(vec![1, 1, 1], r(1, 1)), (vec![0, 0, 0], r(-1, 1))],
474        );
475        let gb = groebner_basis(&[f1, f2, f3], Algorithm::F4);
476        assert!(gb.is_groebner_basis());
477        // Print basis for debugging.
478        for (i, p) in gb.basis.iter().enumerate() {
479            eprintln!("lex_cyclic_3 gb[{i}]: {p:?}");
480        }
481        // Under Lex, the GB should be triangular (each poly introduces
482        // one fewer variable). The smallest variable (z) should appear
483        // in a univariate polynomial.
484        let has_univariate_in_z = gb
485            .basis
486            .iter()
487            .any(|p| p.terms_ref().keys().all(|e| e[0] == 0 && e[1] == 0));
488        assert!(
489            has_univariate_in_z,
490            "Lex GB should contain a univariate poly in z"
491        );
492    }
493
494    #[test]
495    fn lex_two_variable_elimination() {
496        // Ideal: x^2 - y, x^3 - x in k[x,y] under Lex.
497        // Lex GB should eliminate x: expect y^2 - y, xy - x, x^2 - y.
498        let d = RationalDomain;
499        let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
500            d,
501            2,
502            vec![(vec![2, 0], r(1, 1)), (vec![0, 1], r(-1, 1))],
503        );
504        let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
505            d,
506            2,
507            vec![(vec![3, 0], r(1, 1)), (vec![1, 0], r(-1, 1))],
508        );
509        let gb = groebner_basis(&[f1, f2], Algorithm::F4);
510        assert!(gb.is_groebner_basis());
511        // The GB should be triangular: first poly in y only, then xy, then x^2.
512        // Find the univariate poly in y.
513        let y_poly = gb
514            .basis
515            .iter()
516            .find(|p| p.terms_ref().keys().all(|e| e[0] == 0));
517        assert!(
518            y_poly.is_some(),
519            "Lex GB should contain a univariate poly in y"
520        );
521    }
522
523    // --- Step 1c: eliminate() tests ---
524
525    #[test]
526    fn eliminate_simple() {
527        // Eliminate x from {x + y, x - y} in k[x,y].
528        // x + y = 0 and x - y = 0 ⟹ x = 0, y = 0.
529        // Eliminating x should give {y} (or just y = 0).
530        let d = RationalDomain;
531        let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
532            d,
533            2,
534            vec![(vec![1, 0], r(1, 1)), (vec![0, 1], r(1, 1))],
535        );
536        let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
537            d,
538            2,
539            vec![(vec![1, 0], r(1, 1)), (vec![0, 1], r(-1, 1))],
540        );
541        let elim = eliminate(&[f1, f2], 1, Algorithm::F4);
542        assert!(!elim.basis.is_empty());
543        // All result polynomials should be in y only.
544        for p in &elim.basis {
545            assert_eq!(p.degree_in(0), 0, "eliminated var x should not appear");
546        }
547    }
548
549    #[test]
550    fn eliminate_cox_little_oshea() {
551        // Cox-Little-O'Shea §3.1: eliminate x from {x+y+z-1, xy+xz, xyz}.
552        let d = RationalDomain;
553        let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
554            d,
555            3,
556            vec![
557                (vec![1, 0, 0], r(1, 1)),
558                (vec![0, 1, 0], r(1, 1)),
559                (vec![0, 0, 1], r(1, 1)),
560                (vec![0, 0, 0], r(-1, 1)),
561            ],
562        );
563        let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
564            d,
565            3,
566            vec![(vec![1, 1, 0], r(1, 1)), (vec![1, 0, 1], r(1, 1))],
567        );
568        let f3 = SparseMultivariatePolynomial::<_, Lex>::from_terms(
569            d,
570            3,
571            vec![(vec![1, 1, 1], r(1, 1))],
572        );
573        let elim = eliminate(&[f1, f2, f3], 1, Algorithm::F4);
574        // All result polynomials should be in y, z only.
575        for p in &elim.basis {
576            assert_eq!(p.degree_in(0), 0, "x should be eliminated");
577        }
578        // Should contain y^2 + z^2 - y - z and yz + z^2 - z (or equivalent).
579        assert!(
580            elim.basis.len() >= 2,
581            "expected at least 2 generators, got {}",
582            elim.basis.len()
583        );
584    }
585}