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). Currently a placeholder; full implementation
15//!   landing in 0.19.0.
16//!
17//! All algorithms produce a reduced Gröbner basis. [`Algorithm::Auto`]
18//! selects a backend by heuristic (currently F4).
19
20pub mod f4;
21pub mod f5;
22pub mod fglm;
23pub mod hilbert;
24
25use ocas_core::FastHashSet as HashSet;
26use ocas_domain::Domain;
27
28use crate::sparse::{
29    MonomialOrder, SparseMultivariatePolynomial, monomial_are_coprime, monomial_divides,
30};
31
32/// A Gröbner basis for a polynomial ideal.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct GroebnerBasis<D: Domain, O: MonomialOrder> {
35    /// The polynomials forming the basis.
36    pub basis: Vec<SparseMultivariatePolynomial<D, O>>,
37}
38
39impl<D: Domain, O: MonomialOrder> GroebnerBasis<D, O> {
40    /// Compute a Gröbner basis from a set of generators using Buchberger's algorithm.
41    ///
42    /// Requires that the coefficient domain supports exact division (i.e., is
43    /// effectively a field). The algorithm will panic if division fails.
44    ///
45    /// # Example
46    ///
47    /// ```
48    /// use ocas_domain::{RationalDomain, Rational};
49    /// use ocas_poly::sparse::Lex;
50    /// use ocas_poly::GroebnerBasis;
51    /// use ocas_poly::SparseMultivariatePolynomial;
52    ///
53    /// let d = RationalDomain;
54    /// // ideal: x + y, x - y
55    /// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
56    ///     (vec![1, 0], Rational::new(1, 1)),
57    ///     (vec![0, 1], Rational::new(1, 1)),
58    /// ]);
59    /// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
60    ///     (vec![1, 0], Rational::new(1, 1)),
61    ///     (vec![0, 1], Rational::new(-1, 1)),
62    /// ]);
63    /// let gb = GroebnerBasis::buchberger(&[f1, f2]);
64    /// assert!(gb.basis.len() >= 2);
65    /// ```
66    pub fn buchberger(ideal: &[SparseMultivariatePolynomial<D, O>]) -> Self {
67        // Filter out zero polynomials.
68        let mut basis: Vec<SparseMultivariatePolynomial<D, O>> =
69            ideal.iter().filter(|p| !p.is_zero()).cloned().collect();
70        if basis.is_empty() {
71            return Self { basis };
72        }
73
74        // Collect critical pairs: all unordered pairs (i, j) with i < j.
75        let mut pairs: HashSet<(usize, usize)> = HashSet::default();
76        for i in 0..basis.len() {
77            for j in i + 1..basis.len() {
78                pairs.insert((i, j));
79            }
80        }
81
82        let max_iter = 10000;
83
84        for _ in 0..max_iter {
85            if pairs.is_empty() {
86                break;
87            }
88            let (i, j) = *pairs.iter().next().unwrap();
89            pairs.remove(&(i, j));
90
91            // Buchberger's first criterion: if the leading monomials are
92            // coprime, the S-polynomial reduces to zero, so skip.
93            let lm_i = basis[i].leading_monomial();
94            let lm_j = basis[j].leading_monomial();
95            if let (Some(mi), Some(mj)) = (&lm_i, &lm_j)
96                && monomial_are_coprime(mi, mj)
97            {
98                continue;
99            }
100
101            // Compute S-polynomial and reduce by current basis.
102            let s = basis[i].spoly(&basis[j]);
103            let r = s.reduce(&basis);
104
105            if !r.is_zero() {
106                let new_idx = basis.len();
107                basis.push(r);
108                for k in 0..new_idx {
109                    pairs.insert((k, new_idx));
110                }
111            }
112        }
113
114        Self { basis }
115    }
116
117    /// Minimize the basis: remove polynomials whose leading monomial is
118    /// divisible by another element's leading monomial.
119    pub fn minimize(mut self) -> Self {
120        let lms: Vec<_> = self
121            .basis
122            .iter()
123            .filter_map(|p| p.leading_monomial().cloned())
124            .collect();
125
126        let mut keep = vec![true; self.basis.len()];
127        for i in 0..self.basis.len() {
128            for j in 0..self.basis.len() {
129                // Remove i if lms[j] divides lms[i] (i.e., lms[i] is a
130                // multiple of lms[j], making i redundant).
131                // monomial_divides(big, small) returns true when small divides big.
132                if i != j && keep[i] && keep[j] && monomial_divides(&lms[i], &lms[j]) {
133                    keep[i] = false;
134                    break;
135                }
136            }
137        }
138
139        self.basis = self
140            .basis
141            .into_iter()
142            .enumerate()
143            .filter(|(i, _)| keep[*i])
144            .map(|(_, p)| p)
145            .collect();
146
147        self
148    }
149
150    /// Inter-reduce the basis: reduce each element by the others and make
151    /// each polynomial monic.
152    ///
153    /// The algorithm processes elements in ascending order of leading
154    /// monomial. Each element is reduced by all elements with strictly
155    /// smaller leading monomials (those already in the result set).
156    /// This ensures the standard reduced Gröbner basis property:
157    /// no monomial of any basis element is divisible by the leading
158    /// monomial of any other basis element.
159    pub fn auto_reduce(mut self) -> Self {
160        let order = self
161            .basis
162            .first()
163            .map(|p| p.order.clone())
164            .unwrap_or_default();
165        // Sort basis in ascending order of leading monomial (smallest first).
166        self.basis
167            .sort_by(|a, b| match (a.leading_monomial(), b.leading_monomial()) {
168                (Some(ma), Some(mb)) => order.cmp(ma, mb),
169                (Some(_), None) => std::cmp::Ordering::Greater,
170                (None, Some(_)) => std::cmp::Ordering::Less,
171                (None, None) => std::cmp::Ordering::Equal,
172            });
173
174        let mut reduced: Vec<SparseMultivariatePolynomial<D, O>> = Vec::new();
175
176        for poly in &self.basis {
177            // Reduce `poly` by all elements already in `reduced`
178            // (which have smaller leading monomials).
179            let mut r = poly.reduce(&reduced);
180            if !r.is_zero() {
181                if let Some(lc) = r.leading_coeff().cloned()
182                    && let Some(inv) = r.domain().inv(&lc)
183                {
184                    r = r.mul_scalar(&inv);
185                }
186                reduced.push(r);
187            }
188        }
189
190        self.basis = reduced;
191        self
192    }
193
194    /// Verify that this is indeed a Gröbner basis by checking that all
195    /// S-polynomials reduce to zero.
196    pub fn is_groebner_basis(&self) -> bool {
197        for i in 0..self.basis.len() {
198            for j in i + 1..self.basis.len() {
199                let s = self.basis[i].spoly(&self.basis[j]);
200                let r = s.reduce(&self.basis);
201                if !r.is_zero() {
202                    return false;
203                }
204            }
205        }
206        true
207    }
208
209    /// Change the monomial order of this Gröbner basis.
210    ///
211    /// The polynomials are re-interpreted under the target order `O2`
212    /// and the F4 algorithm is re-run. This is the simple reorder path
213    /// (Symbolica's `reorder::<Order>()`). For zero-dimensional ideals,
214    /// use [`crate::groebner::fglm::fglm`] for a much faster conversion.
215    ///
216    /// # Example
217    ///
218    /// ```
219    /// use ocas_domain::{RationalDomain, Rational};
220    /// use ocas_poly::sparse::{Grevlex, Lex};
221    /// use ocas_poly::{GroebnerBasis, SparseMultivariatePolynomial, f4};
222    ///
223    /// let d = RationalDomain;
224    /// // ideal: x + y, x - y  → basis {y, x} under Lex
225    /// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
226    ///     (vec![1, 0], Rational::new(1, 1)),
227    ///     (vec![0, 1], Rational::new(1, 1)),
228    /// ]);
229    /// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
230    ///     (vec![1, 0], Rational::new(1, 1)),
231    ///     (vec![0, 1], Rational::new(-1, 1)),
232    /// ]);
233    /// let gb_lex = f4::f4(&[f1, f2]);
234    /// let gb_grevlex = gb_lex.reorder::<Grevlex>();
235    /// assert!(gb_grevlex.is_groebner_basis());
236    /// ```
237    pub fn reorder<O2: MonomialOrder>(&self) -> GroebnerBasis<D, O2>
238    where
239        D: 'static,
240    {
241        let converted: Vec<SparseMultivariatePolynomial<D, O2>> = self
242            .basis
243            .iter()
244            .map(|p| {
245                SparseMultivariatePolynomial::from_terms(
246                    p.domain().clone(),
247                    p.n_vars(),
248                    p.terms_ref()
249                        .iter()
250                        .map(|(e, c)| (e.to_vec(), c.clone()))
251                        .collect(),
252                )
253            })
254            .collect();
255        crate::groebner::f4::f4(&converted)
256    }
257}
258
259/// Convenience: compute a Gröbner basis and inter-reduce it.
260pub fn buchberger<D: Domain, O: MonomialOrder>(
261    ideal: &[SparseMultivariatePolynomial<D, O>],
262) -> GroebnerBasis<D, O> {
263    GroebnerBasis::buchberger(ideal).minimize().auto_reduce()
264}
265
266/// Algorithm selector for [`groebner_basis`].
267///
268/// `Auto` picks a backend based on the ideal's size and structure; the
269/// other variants force a specific algorithm. See [`groebner_basis`] for
270/// the unified entry point.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
272pub enum Algorithm {
273    /// Automatically select the most suitable algorithm based on ideal
274    /// size and structure (heuristic, calibrated from benchmarks).
275    /// Currently routes to F4; the crossover to F5 will be tuned from
276    /// cyclic-n benchmarks once the F5 core is complete.
277    #[default]
278    Auto,
279    /// Force the F4 matrix algorithm (Faugère 1999).
280    F4,
281    /// Force the F5 signature-based algorithm (Faugère 2002).
282    F5,
283    /// Force Buchberger's classic S-polynomial iteration.
284    Buchberger,
285}
286
287/// Compute a Gröbner basis using the requested [`Algorithm`].
288///
289/// This is the unified entry point for Gröbner basis computation. Zero
290/// polynomials in `ideal` are filtered internally by each backend.
291///
292/// [`Algorithm::Auto`] currently routes to F4; the crossover to F5 will
293/// be calibrated from cyclic-n benchmarks once the F5 core is complete.
294///
295/// # Example
296///
297/// ```
298/// use ocas_domain::{RationalDomain, Rational};
299/// use ocas_poly::sparse::Lex;
300/// use ocas_poly::{Algorithm, groebner_basis, SparseMultivariatePolynomial};
301///
302/// let d = RationalDomain;
303/// // ideal: x + y, x - y
304/// let f1 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
305///     (vec![1, 0], Rational::new(1, 1)),
306///     (vec![0, 1], Rational::new(1, 1)),
307/// ]);
308/// let f2 = SparseMultivariatePolynomial::<_, Lex>::from_terms(d, 2, vec![
309///     (vec![1, 0], Rational::new(1, 1)),
310///     (vec![0, 1], Rational::new(-1, 1)),
311/// ]);
312/// let gb = groebner_basis(&[f1, f2], Algorithm::Auto);
313/// assert!(gb.is_groebner_basis());
314/// ```
315pub fn groebner_basis<D: Domain + 'static, O: MonomialOrder>(
316    ideal: &[SparseMultivariatePolynomial<D, O>],
317    algo: Algorithm,
318) -> GroebnerBasis<D, O> {
319    match algo {
320        Algorithm::Auto | Algorithm::F4 => f4::f4(ideal),
321        Algorithm::F5 => f5::f5(ideal),
322        Algorithm::Buchberger => buchberger(ideal),
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::sparse::Lex;
330    use ocas_domain::{Rational, RationalDomain};
331
332    fn r(n: i64, d: i64) -> Rational {
333        Rational::new(n, d)
334    }
335
336    fn make_poly(
337        terms: Vec<(Vec<usize>, Rational)>,
338    ) -> SparseMultivariatePolynomial<RationalDomain, Lex> {
339        SparseMultivariatePolynomial::from_terms(RationalDomain, 2, terms)
340    }
341
342    #[test]
343    fn empty_ideal() {
344        let gb = buchberger::<RationalDomain, Lex>(&[]);
345        assert!(gb.basis.is_empty());
346    }
347
348    #[test]
349    fn single_polynomial() {
350        // f = x^2 - 1
351        let f = SparseMultivariatePolynomial::<_, Lex>::from_terms(
352            RationalDomain,
353            1,
354            vec![(vec![2], r(1, 1)), (vec![0], r(-1, 1))],
355        );
356        let gb = buchberger(&[f]);
357        assert_eq!(gb.basis.len(), 1);
358        assert!(gb.is_groebner_basis());
359    }
360
361    #[test]
362    fn linear_system() {
363        // x + y = 0, x - y = 0  →  basis = {x, y}
364        let f1 = make_poly(vec![(vec![1, 0], r(1, 1)), (vec![0, 1], r(1, 1))]);
365        let f2 = make_poly(vec![(vec![1, 0], r(1, 1)), (vec![0, 1], r(-1, 1))]);
366        let gb = buchberger(&[f1, f2]);
367        assert!(gb.is_groebner_basis());
368        // After auto-reduce, we expect {x, y} (monic leading terms)
369        assert!(gb.basis.len() >= 2);
370    }
371
372    #[test]
373    fn two_variable_ideal() {
374        // x^2 - y, x^3 - x  (elimination ideal: y = x^2, x^3 = x → x ∈ {0, ±1})
375        let f1 = make_poly(vec![(vec![2, 0], r(1, 1)), (vec![0, 1], r(-1, 1))]);
376        let f2 = make_poly(vec![(vec![3, 0], r(1, 1)), (vec![1, 0], r(-1, 1))]);
377        let gb = buchberger(&[f1, f2]);
378        assert!(gb.is_groebner_basis());
379        assert!(!gb.basis.is_empty());
380    }
381}