From 53ebf1b81d71acd0a649bb184cb9ddbca1d8fe95 Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Thu, 9 Jul 2026 11:44:47 -0700 Subject: [PATCH 1/4] Add ext_py matrices and vectors Adds a basic Python project scafold including basic vector bindings and tests, and CI integration. --- .github/workflows/ext_py.yaml | 43 + ext/crates/fp/src/field/dyn_field.rs | 139 +++ ext/crates/fp/src/field/mod.rs | 2 + ext/crates/fp/src/matrix/matrix_inner.rs | 4 + ext_py/.gitignore | 2 + ext_py/Cargo.toml | 19 + ext_py/pyproject.toml | 24 + ext_py/python/ext/__init__.py | 1 + ext_py/src/fp_mod.rs | 494 +++++++++ ext_py/src/fp_mod/matrices.rs | 1296 ++++++++++++++++++++++ ext_py/src/fp_mod/vectors.rs | 701 ++++++++++++ ext_py/src/lib.rs | 12 + ext_py/tests/test_affine_subspace.py | 181 +++ ext_py/tests/test_augmented_matrix.py | 249 +++++ ext_py/tests/test_fp_vector_slices.py | 329 ++++++ ext_py/tests/test_matrix.py | 386 +++++++ ext_py/tests/test_matrix_slice_mut.py | 196 ++++ ext_py/tests/test_quasi_inverse.py | 251 +++++ ext_py/tests/test_subquotient.py | 228 ++++ ext_py/tests/test_subspace.py | 173 +++ 20 files changed, 4730 insertions(+) create mode 100644 .github/workflows/ext_py.yaml create mode 100644 ext/crates/fp/src/field/dyn_field.rs create mode 100644 ext_py/.gitignore create mode 100644 ext_py/Cargo.toml create mode 100644 ext_py/pyproject.toml create mode 100644 ext_py/python/ext/__init__.py create mode 100644 ext_py/src/fp_mod.rs create mode 100644 ext_py/src/fp_mod/matrices.rs create mode 100644 ext_py/src/fp_mod/vectors.rs create mode 100644 ext_py/src/lib.rs create mode 100644 ext_py/tests/test_affine_subspace.py create mode 100644 ext_py/tests/test_augmented_matrix.py create mode 100644 ext_py/tests/test_fp_vector_slices.py create mode 100644 ext_py/tests/test_matrix.py create mode 100644 ext_py/tests/test_matrix_slice_mut.py create mode 100644 ext_py/tests/test_quasi_inverse.py create mode 100644 ext_py/tests/test_subquotient.py create mode 100644 ext_py/tests/test_subspace.py diff --git a/.github/workflows/ext_py.yaml b/.github/workflows/ext_py.yaml new file mode 100644 index 0000000000..11ef35878e --- /dev/null +++ b/.github/workflows/ext_py.yaml @@ -0,0 +1,43 @@ +name: Test ext_py + +on: [push, pull_request] + +jobs: + test: + if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository }} + runs-on: ubuntu-latest + env: + RUST_BACKTRACE: 1 + RUSTFLAGS: "-D warnings" + + steps: + - uses: actions/checkout@v5 + + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Install rustup + id: rustup + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + + - name: Cache files + uses: actions/cache@v5 + with: + path: | + ~/.cargo + /usr/share/rust/.cargo + **/target + key: ext-py-${{ steps.rustup.outputs.rustc_hash }}-${{ hashFiles('**/Cargo.*') }} + + - name: Build wheel + run: cd ext_py && pip install --group dev && maturin build --out dist + + - name: Install wheel and run tests + run: | + cd ext_py + pip install --force-reinstall dist/*.whl + pytest tests diff --git a/ext/crates/fp/src/field/dyn_field.rs b/ext/crates/fp/src/field/dyn_field.rs new file mode 100644 index 0000000000..0f8e5f1c0c --- /dev/null +++ b/ext/crates/fp/src/field/dyn_field.rs @@ -0,0 +1,139 @@ +use std::{ + fmt, + ops::{Add, Mul, Neg, Sub}, +}; + +use super::{Fp, SmallFq, element::FieldElement}; +use crate::prime::ValidPrime; + +/// A field element whose field type has been erased to one of the two concrete kinds the public +/// API exposes: a prime field [`Fp`] or a small extension field [`SmallFq`], each over a dynamic +/// [`ValidPrime`]. +/// +/// This lets callers that cannot be generic over the [`Field`](super::Field) trait — most notably +/// the Python bindings, where a `pyclass` cannot carry generic parameters — manipulate field +/// elements of either kind through a single type, with the arithmetic operators implemented once +/// here rather than re-matched at every call site. +/// +/// Combining two elements that do not live in the same field (a different kind, or the same kind +/// over a different prime/degree) is a genuine failure, not a value: the binary operators report it +/// as [`None`]/[`Err`] rather than silently coercing one operand or returning a default element. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum DynFieldElement { + Fp(FieldElement>), + SmallFq(FieldElement>), +} + +/// Why [`DynFieldElement::try_div`] could not divide. +/// +/// Division has two distinct failure modes that callers may want to surface differently (the Python +/// bindings map them to `ValueError` and `ZeroDivisionError` respectively), so unlike the additive +/// operators this is reported with a dedicated enum rather than a bare [`Option`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DivError { + /// The two operands do not live in the same field. + MismatchedField, + /// The divisor is the zero element. + DivisionByZero, +} + +impl fmt::Display for DivError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MismatchedField => f.write_str("cannot combine elements from different fields"), + Self::DivisionByZero => f.write_str("division by zero"), + } + } +} + +impl std::error::Error for DivError {} + +/// Implement an additive/multiplicative operator once, returning `None` when the operands are not +/// in the same field. The field-equality guard is necessary: [`FieldElement`]'s own operators use +/// the left operand's field unconditionally, so without it a mismatch would silently compute a +/// bogus result instead of signalling failure. +macro_rules! impl_binop { + ($trait:ident, $method:ident) => { + impl $trait for DynFieldElement { + type Output = Option; + + fn $method(self, rhs: Self) -> Self::Output { + match (self, rhs) { + (Self::Fp(a), Self::Fp(b)) if a.field() == b.field() => { + Some(Self::Fp(a.$method(b))) + } + (Self::SmallFq(a), Self::SmallFq(b)) if a.field() == b.field() => { + Some(Self::SmallFq(a.$method(b))) + } + _ => None, + } + } + } + }; +} + +impl_binop!(Add, add); +impl_binop!(Sub, sub); +impl_binop!(Mul, mul); + +impl Neg for DynFieldElement { + type Output = Self; + + fn neg(self) -> Self::Output { + match self { + Self::Fp(x) => Self::Fp(-x), + Self::SmallFq(x) => Self::SmallFq(-x), + } + } +} + +impl DynFieldElement { + /// Divide, distinguishing a mismatched-field failure from division by zero. Returns + /// [`DivError::MismatchedField`] when the operands are not in the same field and + /// [`DivError::DivisionByZero`] when `rhs` is the zero element. + pub fn try_div(self, rhs: Self) -> Result { + match (self, rhs) { + (Self::Fp(a), Self::Fp(b)) if a.field() == b.field() => { + (a / b).map(Self::Fp).ok_or(DivError::DivisionByZero) + } + (Self::SmallFq(a), Self::SmallFq(b)) if a.field() == b.field() => { + (a / b).map(Self::SmallFq).ok_or(DivError::DivisionByZero) + } + _ => Err(DivError::MismatchedField), + } + } + + /// The multiplicative inverse, or [`None`] for the zero element. + pub fn inv(self) -> Option { + match self { + Self::Fp(x) => x.inv().map(Self::Fp), + Self::SmallFq(x) => x.inv().map(Self::SmallFq), + } + } + + /// The Frobenius endomorphism `x -> x^p`. + pub fn frobenius(self) -> Self { + match self { + Self::Fp(x) => Self::Fp(x.frobenius()), + Self::SmallFq(x) => Self::SmallFq(x.frobenius()), + } + } + + /// The canonical `u32` value of a prime-field element, or [`None`] for a [`SmallFq`] element, + /// which has no canonical integer representative. + pub fn try_as_u32(self) -> Option { + match self { + Self::Fp(x) => Some(*x), + Self::SmallFq(_) => None, + } + } +} + +impl fmt::Display for DynFieldElement { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Fp(x) => write!(f, "{x}"), + Self::SmallFq(x) => write!(f, "{x}"), + } + } +} diff --git a/ext/crates/fp/src/field/mod.rs b/ext/crates/fp/src/field/mod.rs index 714a9e735d..1d5d4e08c5 100644 --- a/ext/crates/fp/src/field/mod.rs +++ b/ext/crates/fp/src/field/mod.rs @@ -1,12 +1,14 @@ use self::field_internal::FieldInternal; use crate::prime::Prime; +pub mod dyn_field; pub mod element; pub(crate) mod field_internal; pub mod fp; pub mod smallfq; +pub use dyn_field::{DivError, DynFieldElement}; use element::FieldElement; pub use fp::Fp; pub use smallfq::SmallFq; diff --git a/ext/crates/fp/src/matrix/matrix_inner.rs b/ext/crates/fp/src/matrix/matrix_inner.rs index 54b90924ad..023894db48 100644 --- a/ext/crates/fp/src/matrix/matrix_inner.rs +++ b/ext/crates/fp/src/matrix/matrix_inner.rs @@ -1558,6 +1558,10 @@ impl<'a> MatrixSliceMut<'a> { l.add_masked(r, 1, mask); } } + + pub fn to_vec(&self) -> Vec> { + self.iter().map(|row| row.iter().collect()).collect() + } } #[cfg(test)] diff --git a/ext_py/.gitignore b/ext_py/.gitignore new file mode 100644 index 0000000000..5ddc4ed4e8 --- /dev/null +++ b/ext_py/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +*.so diff --git a/ext_py/Cargo.toml b/ext_py/Cargo.toml new file mode 100644 index 0000000000..5853af808d --- /dev/null +++ b/ext_py/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ext_py" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "ext_py" +crate-type = ["cdylib"] + +[dependencies] +anyhow = "1.0" +fp = { path = "../ext/crates/fp" } + +pyo3 = "0.29.0" +serde_json = "1.0.141" + +[dev-dependencies] +tempfile = "3.20.0" diff --git a/ext_py/pyproject.toml b/ext_py/pyproject.toml new file mode 100644 index 0000000000..a4e3a72daa --- /dev/null +++ b/ext_py/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["maturin>=1.8"] +build-backend = "maturin" + +[project] +name = "sseq_ext" +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] + +[tool.maturin] +features = ["pyo3/extension-module"] +module-name = "ext" +python-source = "python" + +[dependency-groups] +dev = [ + "maturin>=1.8", + "pytest", +] diff --git a/ext_py/python/ext/__init__.py b/ext_py/python/ext/__init__.py new file mode 100644 index 0000000000..ec66790593 --- /dev/null +++ b/ext_py/python/ext/__init__.py @@ -0,0 +1 @@ +from .ext import * diff --git a/ext_py/src/fp_mod.rs b/ext_py/src/fp_mod.rs new file mode 100644 index 0000000000..fb62c387f5 --- /dev/null +++ b/ext_py/src/fp_mod.rs @@ -0,0 +1,494 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; + +use fp::{ + field::{DivError, DynFieldElement, Field, Fp as RustFp, SmallFq as RustSmallFq}, + matrix::Matrix as RustMatrix, + prime::{self, Prime}, +}; +use pyo3::{ + basic::CompareOp, + exceptions::{PyIndexError, PyRuntimeError, PyValueError, PyZeroDivisionError}, + prelude::*, + types::PyBytes, + PyResult, +}; + +mod matrices; +mod vectors; + +const MAX_VALID_PRIME: u32 = 1 << 31; + +type DynFp = RustFp; +type DynSmallFq = RustSmallFq; + +fn valid_prime(p: u32) -> PyResult { + if !(2..MAX_VALID_PRIME).contains(&p) { + return Err(PyValueError::new_err(format!("{p} is not prime"))); + } + prime::ValidPrime::try_from(p).map_err(|_| PyValueError::new_err(format!("{p} is not prime"))) +} + +/// Build a `SmallFq` from a Python-supplied prime and degree. +fn small_fq(p: u32, degree: u32) -> PyResult { + let p = valid_prime(p)?; + Ok(DynSmallFq::new(p, degree)) +} + +fn py_hash(value: &T) -> isize { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + match hasher.finish() as isize { + -1 => -2, + hash => hash, + } +} + +fn checked_range(start: usize, end: usize, len: usize) -> PyResult<()> { + if start <= end && end <= len { + Ok(()) + } else { + Err(PyIndexError::new_err(format!( + "range {start}..{end} out of range for vector of length {len}" + ))) + } +} + +pub(crate) fn checked_row(row: usize, rows: usize) -> PyResult { + if row < rows { + Ok(row) + } else { + Err(PyIndexError::new_err(format!( + "row {row} out of range for matrix with {rows} rows" + ))) + } +} + +fn borrow_error(err: impl ToString) -> PyErr { + PyRuntimeError::new_err(err.to_string()) +} + +/// Map any stringifiable error (e.g. `std::io::Error` from +/// (de)serialization) into the `RuntimeError` used uniformly across the +/// `to_bytes`/`from_bytes` methods. +fn io_err(e: impl ToString) -> PyErr { + PyRuntimeError::new_err(e.to_string()) +} + +/// Run a `to_bytes`-style writer into a fresh buffer and wrap the result as +/// `PyBytes`, mapping I/O errors through [`io_err`]. +fn serialize_to_pybytes<'py>( + py: Python<'py>, + f: impl FnOnce(&mut Vec) -> std::io::Result<()>, +) -> PyResult> { + let mut buffer = Vec::new(); + f(&mut buffer).map_err(io_err)?; + Ok(PyBytes::new(py, &buffer)) +} + +/// Uniform error for using a value that has been moved out (consumed) by a +/// consuming method. Mirrors `borrow_error` for the move-and-invalidate +/// pyclasses (e.g. the augmented matrices). +fn consumed_error(label: &str) -> PyErr { + PyRuntimeError::new_err(format!("{label} has been consumed")) +} + +/// A value that a consuming method can `take()` out, after which any further +/// access raises `RuntimeError("