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