Skip to main content

ocas_py/
polynomial.rs

1//! Python `Polynomial` class — dense univariate polynomials over ℤ, ℚ, or GF(p).
2//!
3//! Wraps [`ocas_poly::DenseUnivariatePolynomial`] with an enum-erasure
4//! strategy so that a single Python class supports three coefficient domains.
5
6use ocas_domain::{FiniteField, Integer, IntegerDomain, Rational, RationalDomain};
7use ocas_poly::DenseUnivariatePolynomial;
8use pyo3::exceptions::{PyTypeError, PyValueError};
9use pyo3::prelude::*;
10
11use crate::domain::DomainKind;
12
13/// Type-erased polynomial over one of the three supported domains.
14#[derive(Clone)]
15pub(crate) enum PolyErased {
16    Int(DenseUnivariatePolynomial<IntegerDomain>),
17    Rat(DenseUnivariatePolynomial<RationalDomain>),
18    Fq(DenseUnivariatePolynomial<FiniteField>),
19}
20
21/// A dense univariate polynomial.
22///
23/// The coefficient domain is selected by the `domain` argument: one of
24/// the strings `"integer"` (default), `"rational"`, or a `FiniteField`
25/// instance.
26///
27/// ```python
28/// from ocas import Polynomial
29///
30/// # x^2 + 2x + 1 over the integers
31/// p = Polynomial([1, 2, 1])
32/// print(p.degree())       # 2
33/// print(p.eval(2))        # 9
34/// print((p * p).coeffs()) # [1, 4, 6, 4, 1]
35///
36/// # over GF(5)
37/// from ocas import FiniteField
38/// q = Polynomial([1, 2, 1], domain=FiniteField(5))
39/// ```
40#[pyclass(name = "Polynomial", skip_from_py_object)]
41#[derive(Clone)]
42pub struct PyPolynomial {
43    pub(crate) inner: PolyErased,
44}
45
46/// A single (polynomial, multiplicity) factor returned by factorization.
47#[pyclass(name = "PolynomialFactor", skip_from_py_object)]
48pub struct PyPolynomialFactor {
49    #[pyo3(get)]
50    pub factor: PyPolynomial,
51    #[pyo3(get)]
52    pub multiplicity: usize,
53}
54
55/// Extract integer-domain coefficients from a Python iterable of ints.
56fn extract_int_coeffs(obj: &Bound<'_, PyAny>) -> PyResult<Vec<Integer>> {
57    let ints: Vec<i64> = obj
58        .extract()
59        .map_err(|_| PyTypeError::new_err("integer coefficients must be ints"))?;
60    Ok(ints.into_iter().map(Integer::from).collect())
61}
62
63/// Extract rational-domain coefficients. Each element is either an int
64/// (denominator 1) or a `(numerator, denominator)` tuple; the whole list
65/// must be uniformly one form.
66fn extract_rat_coeffs(obj: &Bound<'_, PyAny>) -> PyResult<Vec<Rational>> {
67    if let Ok(ints) = obj.extract::<Vec<i64>>() {
68        Ok(ints.into_iter().map(|n| Rational::new(n, 1)).collect())
69    } else if let Ok(pairs) = obj.extract::<Vec<(i64, i64)>>() {
70        pairs
71            .into_iter()
72            .map(|(num, den)| {
73                if den == 0 {
74                    Err(PyValueError::new_err("rational denominator cannot be zero"))
75                } else {
76                    Ok(Rational::new(num, den))
77                }
78            })
79            .collect()
80    } else {
81        Err(PyTypeError::new_err(
82            "rational coefficients must be ints or (num, denom) tuples",
83        ))
84    }
85}
86
87/// Build a `PyPolynomial` from a Python iterable of coefficients and a domain kind.
88pub(crate) fn build_polynomial(
89    coeffs: &Bound<'_, PyAny>,
90    domain: &DomainKind,
91) -> PyResult<PyPolynomial> {
92    let inner = match domain {
93        DomainKind::Integer => {
94            let c = extract_int_coeffs(coeffs)?;
95            PolyErased::Int(DenseUnivariatePolynomial::from_coeffs(IntegerDomain, c))
96        }
97        DomainKind::Rational => {
98            let c = extract_rat_coeffs(coeffs)?;
99            PolyErased::Rat(DenseUnivariatePolynomial::from_coeffs(RationalDomain, c))
100        }
101        DomainKind::FiniteField(p) => {
102            let field = FiniteField::new(p.clone());
103            let ints: Vec<i64> = coeffs
104                .extract()
105                .map_err(|_| PyTypeError::new_err("finite-field coefficients must be ints"))?;
106            let c: Vec<_> = ints.into_iter().map(|v| field.element(v)).collect();
107            PolyErased::Fq(DenseUnivariatePolynomial::from_coeffs(field, c))
108        }
109    };
110    Ok(PyPolynomial { inner })
111}
112
113#[pymethods]
114impl PyPolynomial {
115    /// Create a polynomial from coefficients (constant term first).
116    ///
117    /// `domain` selects the coefficient ring: `"integer"` (default),
118    /// `"rational"`, or a `FiniteField` instance.
119    #[new]
120    #[pyo3(signature = (coeffs, domain=None))]
121    fn new(coeffs: &Bound<'_, PyAny>, domain: Option<&Bound<'_, PyAny>>) -> PyResult<Self> {
122        let kind = match domain {
123            Some(d) => DomainKind::from_py(d)?,
124            None => DomainKind::Integer,
125        };
126        build_polynomial(coeffs, &kind)
127    }
128
129    /// Return the coefficients as a list of decimal strings (constant term
130    /// first). String form preserves arbitrary precision across the
131    /// gmp/non-gmp builds; wrap each entry in `int(...)` to obtain a Python
132    /// integer. Rational entries are rendered as `n/d`.
133    fn coeffs(&self) -> Vec<String> {
134        match &self.inner {
135            PolyErased::Int(p) => p.coeffs().iter().map(|c| c.to_string()).collect(),
136            PolyErased::Rat(p) => p.coeffs().iter().map(|c| c.to_string()).collect(),
137            PolyErased::Fq(p) => p.coeffs().iter().map(|c| c.value().to_string()).collect(),
138        }
139    }
140
141    /// Return the degree, or `None` for the zero polynomial.
142    fn degree(&self) -> Option<usize> {
143        match &self.inner {
144            PolyErased::Int(p) => p.degree(),
145            PolyErased::Rat(p) => p.degree(),
146            PolyErased::Fq(p) => p.degree(),
147        }
148    }
149
150    /// Return the number of stored coefficients.
151    fn len(&self) -> usize {
152        match &self.inner {
153            PolyErased::Int(p) => p.coeffs().len(),
154            PolyErased::Rat(p) => p.coeffs().len(),
155            PolyErased::Fq(p) => p.coeffs().len(),
156        }
157    }
158
159    /// Return `True` if this is the zero polynomial.
160    fn is_zero(&self) -> bool {
161        self.len() == 0
162    }
163
164    /// Evaluate the polynomial at `x` and return the result as a decimal
165    /// string (rational results are rendered as `n/d`).
166    ///
167    /// For integer/finite-field domains, `x` is an int. For the rational
168    /// domain, `x` may be an int or a `(num, denom)` tuple.
169    fn eval(&self, x: &Bound<'_, PyAny>) -> PyResult<String> {
170        match &self.inner {
171            PolyErased::Int(p) => {
172                let v = x
173                    .extract::<i64>()
174                    .map_err(|_| PyTypeError::new_err("x must be an int"))?;
175                Ok(p.eval(&Integer::from(v)).to_string())
176            }
177            PolyErased::Rat(p) => {
178                let v = if let Ok(n) = x.extract::<i64>() {
179                    Rational::new(n, 1)
180                } else if let Ok((num, den)) = x.extract::<(i64, i64)>() {
181                    Rational::new(num, den)
182                } else {
183                    return Err(PyTypeError::new_err(
184                        "x must be an int or (num, denom) tuple",
185                    ));
186                };
187                Ok(p.eval(&v).to_string())
188            }
189            PolyErased::Fq(p) => {
190                let field = p.domain();
191                let v = x
192                    .extract::<i64>()
193                    .map_err(|_| PyTypeError::new_err("x must be an int"))?;
194                Ok(p.eval(&field.element(v)).value().to_string())
195            }
196        }
197    }
198
199    /// Return the formal derivative.
200    fn derivative(&self) -> PyPolynomial {
201        match &self.inner {
202            PolyErased::Int(p) => PyPolynomial {
203                inner: PolyErased::Int(p.derivative()),
204            },
205            PolyErased::Rat(p) => PyPolynomial {
206                inner: PolyErased::Rat(p.derivative()),
207            },
208            PolyErased::Fq(p) => PyPolynomial {
209                inner: PolyErased::Fq(p.derivative()),
210            },
211        }
212    }
213
214    /// Return the formal integral with constant term zero.
215    fn integral(&self) -> PyPolynomial {
216        match &self.inner {
217            PolyErased::Int(p) => PyPolynomial {
218                inner: PolyErased::Int(p.integral()),
219            },
220            PolyErased::Rat(p) => PyPolynomial {
221                inner: PolyErased::Rat(p.integral()),
222            },
223            PolyErased::Fq(p) => PyPolynomial {
224                inner: PolyErased::Fq(p.integral()),
225            },
226        }
227    }
228
229    /// Return the primitive part (content stripped) for integer polynomials.
230    fn primitive_part(&self) -> PyResult<PyPolynomial> {
231        match &self.inner {
232            PolyErased::Int(p) => Ok(PyPolynomial {
233                inner: PolyErased::Int(p.primitive_part()),
234            }),
235            _ => Err(PyValueError::new_err(
236                "primitive_part is only defined over the integers",
237            )),
238        }
239    }
240
241    /// Return the list of irreducible factors with multiplicities.
242    ///
243    /// Over the integers each factor is primitive; over a finite field they
244    /// are monic.
245    fn factor(&self) -> PyResult<Vec<PyPolynomialFactor>> {
246        let factors: Vec<_> = match &self.inner {
247            PolyErased::Int(p) => p
248                .factor()
249                .into_iter()
250                .map(|(f, m)| PyPolynomialFactor {
251                    factor: PyPolynomial {
252                        inner: PolyErased::Int(f),
253                    },
254                    multiplicity: m,
255                })
256                .collect(),
257            PolyErased::Fq(p) => p
258                .factor()
259                .into_iter()
260                .map(|(f, m)| PyPolynomialFactor {
261                    factor: PyPolynomial {
262                        inner: PolyErased::Fq(f),
263                    },
264                    multiplicity: m,
265                })
266                .collect(),
267            PolyErased::Rat(_p) => {
268                return Err(PyValueError::new_err(
269                    "factor is not implemented over the rationals; use the integer primitive part",
270                ));
271            }
272        };
273        Ok(factors)
274    }
275
276    /// Return the square-free factorization as a list of `(factor, multiplicity)`.
277    fn square_free_factorization(&self) -> PyResult<Vec<PyPolynomialFactor>> {
278        let factors: Vec<_> = match &self.inner {
279            PolyErased::Int(p) => p
280                .square_free_factorization()
281                .into_iter()
282                .map(|(f, m)| PyPolynomialFactor {
283                    factor: PyPolynomial {
284                        inner: PolyErased::Int(f),
285                    },
286                    multiplicity: m,
287                })
288                .collect(),
289            PolyErased::Rat(p) => p
290                .square_free_factorization()
291                .into_iter()
292                .map(|(f, m)| PyPolynomialFactor {
293                    factor: PyPolynomial {
294                        inner: PolyErased::Rat(f),
295                    },
296                    multiplicity: m,
297                })
298                .collect(),
299            PolyErased::Fq(p) => p
300                .square_free_factorization()
301                .into_iter()
302                .map(|(f, m)| PyPolynomialFactor {
303                    factor: PyPolynomial {
304                        inner: PolyErased::Fq(f),
305                    },
306                    multiplicity: m,
307                })
308                .collect(),
309        };
310        Ok(factors)
311    }
312
313    /// Return `True` if the polynomial has no repeated factors.
314    fn is_square_free(&self) -> bool {
315        match &self.inner {
316            PolyErased::Int(p) => p.is_square_free(),
317            PolyErased::Rat(p) => p.is_square_free(),
318            PolyErased::Fq(p) => p.is_square_free(),
319        }
320    }
321
322    /// Return the greatest common divisor with `other`.
323    ///
324    /// Both polynomials must share the same coefficient domain.
325    fn gcd(&self, other: &PyPolynomial) -> PyResult<PyPolynomial> {
326        match (&self.inner, &other.inner) {
327            (PolyErased::Int(a), PolyErased::Int(b)) => Ok(PyPolynomial {
328                inner: PolyErased::Int(a.gcd(b)),
329            }),
330            (PolyErased::Rat(a), PolyErased::Rat(b)) => Ok(PyPolynomial {
331                inner: PolyErased::Rat(a.gcd(b)),
332            }),
333            (PolyErased::Fq(a), PolyErased::Fq(b)) => Ok(PyPolynomial {
334                inner: PolyErased::Fq(a.gcd(b)),
335            }),
336            _ => Err(PyTypeError::new_err(
337                "gcd requires both polynomials to share the same coefficient domain",
338            )),
339        }
340    }
341
342    /// Divide by `other`, returning `(quotient, remainder)`, or `None` if
343    /// `other` is zero.
344    fn div_rem(&self, other: &PyPolynomial) -> PyResult<Option<(PyPolynomial, PyPolynomial)>> {
345        match (&self.inner, &other.inner) {
346            (PolyErased::Int(a), PolyErased::Int(b)) => Ok(a.div_rem(b).map(|(q, r)| {
347                (
348                    PyPolynomial {
349                        inner: PolyErased::Int(q),
350                    },
351                    PyPolynomial {
352                        inner: PolyErased::Int(r),
353                    },
354                )
355            })),
356            (PolyErased::Rat(a), PolyErased::Rat(b)) => Ok(a.div_rem(b).map(|(q, r)| {
357                (
358                    PyPolynomial {
359                        inner: PolyErased::Rat(q),
360                    },
361                    PyPolynomial {
362                        inner: PolyErased::Rat(r),
363                    },
364                )
365            })),
366            (PolyErased::Fq(a), PolyErased::Fq(b)) => Ok(a.div_rem(b).map(|(q, r)| {
367                (
368                    PyPolynomial {
369                        inner: PolyErased::Fq(q),
370                    },
371                    PyPolynomial {
372                        inner: PolyErased::Fq(r),
373                    },
374                )
375            })),
376            _ => Err(PyTypeError::new_err(
377                "div_rem requires both polynomials to share the same coefficient domain",
378            )),
379        }
380    }
381
382    /// Return `self + other`.
383    fn __add__(&self, other: &PyPolynomial) -> PyResult<PyPolynomial> {
384        match (&self.inner, &other.inner) {
385            (PolyErased::Int(a), PolyErased::Int(b)) => Ok(PyPolynomial {
386                inner: PolyErased::Int(a.add(b)),
387            }),
388            (PolyErased::Rat(a), PolyErased::Rat(b)) => Ok(PyPolynomial {
389                inner: PolyErased::Rat(a.add(b)),
390            }),
391            (PolyErased::Fq(a), PolyErased::Fq(b)) => Ok(PyPolynomial {
392                inner: PolyErased::Fq(a.add(b)),
393            }),
394            _ => Err(PyTypeError::new_err(
395                "+ requires both polynomials to share the same coefficient domain",
396            )),
397        }
398    }
399
400    /// Return `self - other`.
401    fn __sub__(&self, other: &PyPolynomial) -> PyResult<PyPolynomial> {
402        match (&self.inner, &other.inner) {
403            (PolyErased::Int(a), PolyErased::Int(b)) => Ok(PyPolynomial {
404                inner: PolyErased::Int(a.sub(b)),
405            }),
406            (PolyErased::Rat(a), PolyErased::Rat(b)) => Ok(PyPolynomial {
407                inner: PolyErased::Rat(a.sub(b)),
408            }),
409            (PolyErased::Fq(a), PolyErased::Fq(b)) => Ok(PyPolynomial {
410                inner: PolyErased::Fq(a.sub(b)),
411            }),
412            _ => Err(PyTypeError::new_err(
413                "- requires both polynomials to share the same coefficient domain",
414            )),
415        }
416    }
417
418    /// Return `self * other`.
419    fn __mul__(&self, other: &PyPolynomial) -> PyResult<PyPolynomial> {
420        match (&self.inner, &other.inner) {
421            (PolyErased::Int(a), PolyErased::Int(b)) => Ok(PyPolynomial {
422                inner: PolyErased::Int(a.mul(b)),
423            }),
424            (PolyErased::Rat(a), PolyErased::Rat(b)) => Ok(PyPolynomial {
425                inner: PolyErased::Rat(a.mul(b)),
426            }),
427            (PolyErased::Fq(a), PolyErased::Fq(b)) => Ok(PyPolynomial {
428                inner: PolyErased::Fq(a.mul(b)),
429            }),
430            _ => Err(PyTypeError::new_err(
431                "* requires both polynomials to share the same coefficient domain",
432            )),
433        }
434    }
435
436    /// Return `-self`.
437    fn __neg__(&self) -> PyPolynomial {
438        match &self.inner {
439            PolyErased::Int(p) => PyPolynomial {
440                inner: PolyErased::Int(p.mul_scalar(&Integer::from(-1))),
441            },
442            PolyErased::Rat(p) => PyPolynomial {
443                inner: PolyErased::Rat(p.mul_scalar(&Rational::new(-1, 1))),
444            },
445            PolyErased::Fq(p) => {
446                let field = p.domain();
447                PyPolynomial {
448                    inner: PolyErased::Fq(p.mul_scalar(&field.element(-1))),
449                }
450            }
451        }
452    }
453
454    /// Return `True` if the normalized coefficient vectors match.
455    fn __eq__(&self, other: &PyPolynomial) -> bool {
456        match (&self.inner, &other.inner) {
457            (PolyErased::Int(a), PolyErased::Int(b)) => a == b,
458            (PolyErased::Rat(a), PolyErased::Rat(b)) => a == b,
459            (PolyErased::Fq(a), PolyErased::Fq(b)) => a == b,
460            _ => false,
461        }
462    }
463
464    fn __repr__(&self) -> String {
465        match &self.inner {
466            PolyErased::Int(p) => {
467                format!("Polynomial([{}], 'integer')", fmt_poly_coeffs(p))
468            }
469            PolyErased::Rat(p) => {
470                format!("Polynomial([{}], 'rational')", fmt_poly_coeffs(p))
471            }
472            PolyErased::Fq(p) => format!(
473                "Polynomial([{}], domain=FiniteField({}))",
474                fmt_poly_coeffs(p),
475                p.domain().prime()
476            ),
477        }
478    }
479}
480
481/// Format polynomial coefficients as a comma-separated list of stringified
482/// values, used by `__repr__`.
483fn fmt_poly_coeffs<D: ocas_domain::Domain>(p: &DenseUnivariatePolynomial<D>) -> String
484where
485    D::Element: std::fmt::Display,
486{
487    p.coeffs()
488        .iter()
489        .map(|c| c.to_string())
490        .collect::<Vec<_>>()
491        .join(", ")
492}