Skip to main content

ocas_py/
groebner.rs

1//! Python bindings for Gröbner basis computation and ideal operations.
2
3use ocas_domain::{Domain, Rational, RationalDomain};
4use ocas_poly::ideal::{self, PolynomialSystemSolution};
5use ocas_poly::sparse::Lex;
6use ocas_poly::{
7    Algorithm, GroebnerBasis, SparseMultivariatePolynomial, eliminate, groebner_basis,
8};
9use pyo3::exceptions::{PyTypeError, PyValueError};
10use pyo3::prelude::*;
11
12use crate::polynomial::PyPolynomial;
13
14/// A multivariate polynomial over $\mathbb{Q}$, constructed from a dictionary
15/// mapping exponent tuples to coefficients.
16///
17/// ```python
18/// from ocas import MultivariatePolynomial
19///
20/// # x² + y² - 1 in k[x,y]
21/// p = MultivariatePolynomial({(2, 0): 1, (0, 2): 1, (0, 0): -1}, n_vars=2)
22/// ```
23#[pyclass(name = "MultivariatePolynomial")]
24pub struct PyMultivariatePolynomial {
25    pub inner: SparseMultivariatePolynomial<RationalDomain, Lex>,
26}
27
28#[pymethods]
29impl PyMultivariatePolynomial {
30    #[new]
31    fn new(terms: &Bound<'_, PyAny>, n_vars: usize) -> PyResult<Self> {
32        let dict: &Bound<'_, pyo3::types::PyDict> = terms.cast().map_err(|_| {
33            PyTypeError::new_err("expected a dict mapping exponent tuples to coefficients")
34        })?;
35
36        let domain = RationalDomain;
37        let mut poly_terms: Vec<(Vec<usize>, Rational)> = Vec::new();
38
39        for (key, val) in dict.iter() {
40            let exp_tuple: &Bound<'_, pyo3::types::PyTuple> = key
41                .cast()
42                .map_err(|_| PyTypeError::new_err("exponent keys must be tuples of ints"))?;
43            let exp: Vec<usize> = exp_tuple
44                .iter()
45                .map(|x| x.extract::<usize>())
46                .collect::<PyResult<Vec<_>>>()?;
47            if exp.len() != n_vars {
48                return Err(PyValueError::new_err(format!(
49                    "exponent tuple length {} does not match n_vars={}",
50                    exp.len(),
51                    n_vars
52                )));
53            }
54
55            let coeff: Rational = if let Ok(i) = val.extract::<i64>() {
56                Rational::new(i, 1)
57            } else if let Ok((n, d)) = val.extract::<(i64, i64)>() {
58                Rational::new(n, d)
59            } else if let Ok(f) = val.extract::<f64>() {
60                // Approximate: convert float to rational via continued fractions.
61                let bits = 52u32; // f64 mantissa bits
62                let scaled = (f * (1i64 << bits) as f64).round() as i64;
63                Rational::new(scaled, 1i64 << bits)
64            } else {
65                return Err(PyTypeError::new_err("coefficients must be int or float"));
66            };
67
68            if !domain.is_zero(&coeff) {
69                poly_terms.push((exp, coeff));
70            }
71        }
72
73        Ok(Self {
74            inner: SparseMultivariatePolynomial::from_terms(domain, n_vars, poly_terms),
75        })
76    }
77
78    fn __repr__(&self) -> String {
79        format!("MultivariatePolynomial(n_vars={})", self.inner.n_vars())
80    }
81
82    fn __str__(&self) -> String {
83        format!("MultivariatePolynomial(n_vars={})", self.inner.n_vars())
84    }
85
86    fn n_vars(&self) -> usize {
87        self.inner.n_vars()
88    }
89}
90
91/// Convert a list of Python polynomial objects to Rust multivariate polynomials.
92///
93/// Accepts both `PyPolynomial` (univariate, mapped to variable 0) and
94/// `PyMultivariatePolynomial` (native multivariate). The `n_vars` parameter
95/// is only used for `PyPolynomial` items; `PyMultivariatePolynomial` carries
96/// its own `n_vars`.
97fn extract_multivariate_polys(
98    polys: &Bound<'_, PyAny>,
99    n_vars: usize,
100) -> PyResult<Vec<SparseMultivariatePolynomial<RationalDomain, Lex>>> {
101    let list: Vec<Bound<'_, PyAny>> = polys
102        .extract()
103        .map_err(|_| PyTypeError::new_err("expected a list of polynomials"))?;
104
105    if list.is_empty() {
106        return Ok(vec![]);
107    }
108
109    let mut result = Vec::with_capacity(list.len());
110    for item in &list {
111        // Try MultivariatePolynomial first (native multivariate).
112        if let Ok(mv_poly) = item.extract::<PyRef<'_, PyMultivariatePolynomial>>() {
113            result.push(mv_poly.inner.clone());
114            continue;
115        }
116        // Fall back to univariate Polynomial (mapped to variable 0).
117        if let Ok(py_poly) = item.extract::<PyRef<'_, PyPolynomial>>() {
118            let poly = &py_poly.inner;
119            match poly {
120                crate::polynomial::PolyErased::Rat(p) => {
121                    let terms: Vec<(Vec<usize>, Rational)> = p
122                        .coeffs()
123                        .iter()
124                        .enumerate()
125                        .filter(|(_, c)| !RationalDomain.is_zero(c))
126                        .map(|(i, c)| {
127                            let mut exp = vec![0usize; n_vars];
128                            exp[0] = i;
129                            (exp, c.clone())
130                        })
131                        .collect();
132                    result.push(SparseMultivariatePolynomial::from_terms(
133                        RationalDomain,
134                        n_vars,
135                        terms,
136                    ));
137                }
138                _ => {
139                    return Err(PyTypeError::new_err(
140                        "Gröbner basis operations require rational polynomials",
141                    ));
142                }
143            }
144        } else {
145            return Err(PyTypeError::new_err(
146                "expected Polynomial or MultivariatePolynomial objects in the generators list",
147            ));
148        }
149    }
150    Ok(result)
151}
152
153/// A Gröbner basis computation result.
154#[pyclass(name = "GroebnerBasis")]
155pub struct PyGroebnerBasis {
156    #[pyo3(get)]
157    pub n_vars: usize,
158    pub basis: Vec<SparseMultivariatePolynomial<RationalDomain, Lex>>,
159}
160
161#[pymethods]
162impl PyGroebnerBasis {
163    fn __len__(&self) -> usize {
164        self.basis.len()
165    }
166
167    fn __repr__(&self) -> String {
168        format!(
169            "GroebnerBasis({} elements, {} vars)",
170            self.basis.len(),
171            self.n_vars
172        )
173    }
174
175    fn is_groebner_basis(&self) -> bool {
176        GroebnerBasis {
177            basis: self.basis.clone(),
178        }
179        .is_groebner_basis()
180    }
181}
182
183/// A real solution to a polynomial system.
184#[pyclass(name = "RealSolution")]
185pub struct PyRealSolution {
186    #[pyo3(get)]
187    pub values: Vec<f64>,
188    #[pyo3(get)]
189    pub multiplicity: usize,
190}
191
192#[pymethods]
193impl PyRealSolution {
194    fn __repr__(&self) -> String {
195        let vals: Vec<String> = self.values.iter().map(|v| format!("{:.6}", v)).collect();
196        format!(
197            "RealSolution([{}], mult={})",
198            vals.join(", "),
199            self.multiplicity
200        )
201    }
202}
203
204/// Result of solving a polynomial system.
205#[pyclass(name = "PolynomialSystemSolution")]
206pub struct PyPolynomialSystemSolution {
207    pub inner: PolynomialSystemSolution,
208}
209
210#[pymethods]
211impl PyPolynomialSystemSolution {
212    #[getter]
213    fn kind(&self) -> &str {
214        match &self.inner {
215            PolynomialSystemSolution::ZeroDimensional(_) => "zero_dimensional",
216            PolynomialSystemSolution::PositiveDimensional(_) => "positive_dimensional",
217            PolynomialSystemSolution::Empty => "empty",
218        }
219    }
220
221    fn solutions(&self) -> Vec<PyRealSolution> {
222        match &self.inner {
223            PolynomialSystemSolution::ZeroDimensional(z) => z
224                .solutions
225                .iter()
226                .map(|s| PyRealSolution {
227                    values: s.values.clone(),
228                    multiplicity: s.multiplicity,
229                })
230                .collect(),
231            _ => vec![],
232        }
233    }
234
235    #[getter]
236    fn vector_space_dimension(&self) -> Option<usize> {
237        match &self.inner {
238            PolynomialSystemSolution::ZeroDimensional(z) => Some(z.vector_space_dimension),
239            _ => None,
240        }
241    }
242
243    fn __repr__(&self) -> String {
244        match &self.inner {
245            PolynomialSystemSolution::ZeroDimensional(z) => {
246                format!("Solution(zero_dim, {} solutions)", z.solutions.len())
247            }
248            PolynomialSystemSolution::PositiveDimensional(_) => {
249                "Solution(positive_dimensional)".to_string()
250            }
251            PolynomialSystemSolution::Empty => "Solution(empty)".to_string(),
252        }
253    }
254}
255
256/// A Hilbert series result.
257#[pyclass(name = "HilbertSeries")]
258pub struct PyHilbertSeries {
259    inner: ocas_poly::groebner::hilbert::HilbertSeries,
260}
261
262#[pymethods]
263impl PyHilbertSeries {
264    fn hilbert_function(&self, degree: usize) -> i64 {
265        self.inner.hilbert_function(degree)
266    }
267
268    #[getter]
269    fn dimension(&self) -> usize {
270        self.inner.dimension()
271    }
272
273    #[getter]
274    fn degree(&self) -> i64 {
275        self.inner.degree()
276    }
277
278    #[getter]
279    fn numerator(&self) -> Vec<i64> {
280        self.inner.numerator.clone()
281    }
282
283    fn __repr__(&self) -> String {
284        format!(
285            "HilbertSeries(dim={}, degree={}, numerator={:?})",
286            self.inner.dimension(),
287            self.inner.degree(),
288            self.inner.numerator
289        )
290    }
291}
292
293/// A primary decomposition component.
294#[pyclass(name = "PrimaryComponent")]
295pub struct PyPrimaryComponent {
296    #[pyo3(get)]
297    pub n_vars: usize,
298    pub primary: Vec<SparseMultivariatePolynomial<RationalDomain, Lex>>,
299    pub prime: Vec<SparseMultivariatePolynomial<RationalDomain, Lex>>,
300}
301
302#[pymethods]
303impl PyPrimaryComponent {
304    fn __repr__(&self) -> String {
305        format!(
306            "PrimaryComponent(primary={} gens, prime={} gens)",
307            self.primary.len(),
308            self.prime.len()
309        )
310    }
311}
312
313// ------------------------------------------------------------------
314//  Module functions
315// ------------------------------------------------------------------
316
317#[pyfunction]
318#[pyo3(signature = (generators, n_vars=1, algorithm="auto"))]
319pub fn py_groebner_basis(
320    generators: &Bound<'_, PyAny>,
321    n_vars: usize,
322    algorithm: &str,
323) -> PyResult<PyGroebnerBasis> {
324    let polys = extract_multivariate_polys(generators, n_vars)?;
325    let algo = parse_algorithm(algorithm)?;
326    let gb = groebner_basis(&polys, algo);
327    Ok(PyGroebnerBasis {
328        n_vars: polys.first().map(|p| p.n_vars()).unwrap_or(0),
329        basis: gb.basis,
330    })
331}
332
333#[pyfunction]
334#[pyo3(signature = (generators, f, n_vars=1, algorithm="auto"))]
335pub fn py_ideal_contains(
336    generators: &Bound<'_, PyAny>,
337    f: &Bound<'_, PyAny>,
338    n_vars: usize,
339    algorithm: &str,
340) -> PyResult<bool> {
341    let gens = extract_multivariate_polys(generators, n_vars)?;
342    let fs = extract_multivariate_polys(f, n_vars)?;
343    let poly = fs
344        .into_iter()
345        .next()
346        .ok_or_else(|| PyValueError::new_err("f must be a non-empty polynomial"))?;
347    let algo = parse_algorithm(algorithm)?;
348    Ok(ideal::ideal_contains(&gens, &poly, algo))
349}
350
351#[pyfunction]
352#[pyo3(signature = (equations, n_vars=1, algorithm="auto"))]
353pub fn py_solve_polynomial_system(
354    equations: &Bound<'_, PyAny>,
355    n_vars: usize,
356    algorithm: &str,
357) -> PyResult<PyPolynomialSystemSolution> {
358    let polys = extract_multivariate_polys(equations, n_vars)?;
359    let algo = parse_algorithm(algorithm)?;
360    let sol = ideal::solve_polynomial_system(&polys, algo);
361    Ok(PyPolynomialSystemSolution { inner: sol })
362}
363
364#[pyfunction]
365pub fn py_hilbert_series(gb: &PyGroebnerBasis) -> PyResult<PyHilbertSeries> {
366    let gb_struct = GroebnerBasis {
367        basis: gb.basis.clone(),
368    };
369    let hs = ocas_poly::groebner::hilbert::hilbert_series(&gb_struct);
370    Ok(PyHilbertSeries { inner: hs })
371}
372
373#[pyfunction]
374#[pyo3(signature = (generators, n_vars=1))]
375pub fn py_ideal_radical(generators: &Bound<'_, PyAny>, n_vars: usize) -> PyResult<PyGroebnerBasis> {
376    let gens = extract_multivariate_polys(generators, n_vars)?;
377    let rad = ideal::ideal_radical(&gens);
378    Ok(PyGroebnerBasis {
379        n_vars: gens.first().map(|p| p.n_vars()).unwrap_or(0),
380        basis: rad.basis,
381    })
382}
383
384#[pyfunction]
385#[pyo3(signature = (generators, n_vars=1))]
386pub fn py_primary_decomposition(
387    generators: &Bound<'_, PyAny>,
388    n_vars: usize,
389) -> PyResult<Vec<PyPrimaryComponent>> {
390    let gens = extract_multivariate_polys(generators, n_vars)?;
391    let decomp = ideal::primary_decomposition(&gens);
392    let n_vars = gens.first().map(|p| p.n_vars()).unwrap_or(0);
393    Ok(decomp
394        .into_iter()
395        .map(|comp| PyPrimaryComponent {
396            n_vars,
397            primary: comp.primary,
398            prime: comp.prime,
399        })
400        .collect())
401}
402
403#[pyfunction]
404pub fn py_is_zero_dimensional(gb: &PyGroebnerBasis) -> bool {
405    let gb_struct = GroebnerBasis {
406        basis: gb.basis.clone(),
407    };
408    ideal::is_zero_dimensional(&gb_struct)
409}
410
411#[pyfunction]
412#[pyo3(signature = (generators, elim_vars, n_vars=1, algorithm="auto"))]
413pub fn py_eliminate(
414    generators: &Bound<'_, PyAny>,
415    elim_vars: usize,
416    n_vars: usize,
417    algorithm: &str,
418) -> PyResult<PyGroebnerBasis> {
419    let polys = extract_multivariate_polys(generators, n_vars)?;
420    let algo = parse_algorithm(algorithm)?;
421    let result = eliminate(&polys, elim_vars, algo);
422    Ok(PyGroebnerBasis {
423        n_vars: polys.first().map(|p| p.n_vars()).unwrap_or(0),
424        basis: result.basis,
425    })
426}
427
428fn parse_algorithm(s: &str) -> PyResult<Algorithm> {
429    match s.to_lowercase().as_str() {
430        "auto" => Ok(Algorithm::Auto),
431        "f4" => Ok(Algorithm::F4),
432        "f5" => Ok(Algorithm::F5),
433        "buchberger" => Ok(Algorithm::Buchberger),
434        "multi_modular" | "multimodular" | "mm" => Ok(Algorithm::MultiModular),
435        _ => Err(PyValueError::new_err(format!(
436            "unknown algorithm '{}': expected auto, f4, f5, buchberger, or multi_modular",
437            s
438        ))),
439    }
440}