diff --git a/.github/workflows/ext.yaml b/.github/workflows/ext.yaml index 3ea2204896..d3026ac60c 100644 --- a/.github/workflows/ext.yaml +++ b/.github/workflows/ext.yaml @@ -169,9 +169,6 @@ jobs: - name: Lint sseq_gui wasm run: make -C web_ext/sseq_gui lint-wasm - - name: Test worker panic handling - run: make -C web_ext/sseq_gui test-wasm-js - - name: Build wasm run: make -C web_ext/sseq_gui wasm diff --git a/ext/Cargo.toml b/ext/Cargo.toml index 8b3919aba8..2ff468dfc1 100644 --- a/ext/Cargo.toml +++ b/ext/Cargo.toml @@ -35,7 +35,9 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } zstd = { version = "0.13.3", optional = true } -[target.'cfg(unix)'.dependencies] +# Emscripten reports `target_family = "unix"` but lacks the signal APIs +# (`pthread_sigmask`, …) that `ctrlc`/`nix` require, so exclude wasm targets. +[target.'cfg(all(unix, not(target_arch = "wasm32")))'.dependencies] ctrlc = { version = "3", features = ["termination"] } [dev-dependencies] diff --git a/ext/Makefile b/ext/Makefile index 963bc530a6..1d5a042f4f 100644 --- a/ext/Makefile +++ b/ext/Makefile @@ -11,10 +11,10 @@ test: lint: cargo fmt --all -- --check - cargo clippy --workspace --no-default-features --profile test - cargo clippy --workspace --all-targets --profile test - cargo check --workspace --no-default-features --profile test - cargo check --workspace --all-targets --all-features --profile test + cargo clippy --workspace --no-default-features --profile test --fix + cargo clippy --workspace --all-targets --profile test --fix + cargo check --workspace --no-default-features --profile test --fix + cargo check --workspace --all-targets --all-features --profile test --fix docs: # Prevent the cached crates.js from confusing the current run diff --git a/ext/crates/algebra/src/algebra/adem_algebra.rs b/ext/crates/algebra/src/algebra/adem_algebra.rs index af4678030d..18febc2a5f 100644 --- a/ext/crates/algebra/src/algebra/adem_algebra.rs +++ b/ext/crates/algebra/src/algebra/adem_algebra.rs @@ -16,7 +16,7 @@ use rustc_hash::FxHashMap as HashMap; #[cfg(doc)] use crate::algebra::SteenrodAlgebra; use crate::algebra::{ - Algebra, Bialgebra, GeneratedAlgebra, UnstableAlgebra, + Algebra, Bialgebra, CoproductError, GeneratedAlgebra, UnstableAlgebra, combinatorics::{self, MAX_XI_TAU}, }; @@ -1391,6 +1391,34 @@ impl Bialgebra for AdemAlgebra { } } + fn try_coproduct( + &self, + op_deg: i32, + op_idx: usize, + ) -> Result, CoproductError> { + // Guards the `assert_eq!(op_deg % q, 0)` (generic) and `assert_eq!(op_idx, 0)` + // (p = 2) preconditions below, plus the out-of-range basis lookup, so + // `coproduct` never panics. + if op_deg < 0 { + return Err(CoproductError::OutOfRange); + } + self.compute_basis(op_deg); + if op_idx >= self.dimension(op_deg) { + return Err(CoproductError::OutOfRange); + } + if self.generic { + if op_deg != 1 { + let q = self.prime() * 2 - 2; + if !(op_deg as u32).is_multiple_of(q) { + return Err(CoproductError::IndivisibleDegree { q, degree: op_deg }); + } + } + } else if op_idx != 0 { + return Err(CoproductError::NonzeroIndex); + } + Ok(self.coproduct(op_deg, op_idx)) + } + fn coproduct(&self, op_deg: i32, op_idx: usize) -> Vec<(i32, usize, i32, usize)> { if self.generic { if op_deg == 1 { diff --git a/ext/crates/algebra/src/algebra/algebra_trait.rs b/ext/crates/algebra/src/algebra/algebra_trait.rs index cad1ac0f5f..7270d66b45 100644 --- a/ext/crates/algebra/src/algebra/algebra_trait.rs +++ b/ext/crates/algebra/src/algebra/algebra_trait.rs @@ -494,6 +494,29 @@ impl MuAlgebra for A { } } +/// Why [`GeneratedAlgebra::try_decompose_basis_element`] could not decompose a basis element. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DecomposeError { + /// `degree` is negative, or `idx` is not a valid basis index in that degree. + OutOfRange, + /// The basis element exists but has no decomposition: the degree-0 unit, or an + /// algebra generator. + Indecomposable, +} + +impl std::fmt::Display for DecomposeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::OutOfRange => f.write_str("basis element index out of range"), + Self::Indecomposable => { + f.write_str("the unit and algebra generators are indecomposable") + } + } + } +} + +impl std::error::Error for DecomposeError {} + /// An [`Algebra`] equipped with a distinguished presentation. /// /// These data can be used to specify finite modules as the actions of the distinguished generators. @@ -542,6 +565,33 @@ pub trait GeneratedAlgebra: Algebra { idx: usize, ) -> Vec<(u32, (i32, usize), (i32, usize))>; + /// Non-panicking variant of [`Self::decompose_basis_element`], distinguishing the two + /// reasons it can fail: [`DecomposeError::OutOfRange`] when `degree`/`idx` do not name a + /// basis element, and [`DecomposeError::Indecomposable`] when they name the degree-0 unit + /// or an algebra generator. Computes the basis up to `degree` first. + /// + /// These are exactly the inputs for which [`Self::decompose_basis_element`] is invalid and + /// would panic, so the delegation below never reaches the panicking path. An implementation + /// of [`Self::decompose_basis_element`] that can fail for other reasons should override both + /// that method and this one, keeping them consistent. + fn try_decompose_basis_element( + &self, + degree: i32, + idx: usize, + ) -> Result, DecomposeError> { + if degree < 0 { + return Err(DecomposeError::OutOfRange); + } + self.compute_basis(degree); + if idx >= self.dimension(degree) { + return Err(DecomposeError::OutOfRange); + } + if degree == 0 || self.generators(degree).contains(&idx) { + return Err(DecomposeError::Indecomposable); + } + Ok(self.decompose_basis_element(degree, idx)) + } + /// Returns relations that the algebra wants checked to ensure the consistency of module. /// /// Relations are encoded as general multi-degree elements which are killed in the quotient: diff --git a/ext/crates/algebra/src/algebra/bialgebra_trait.rs b/ext/crates/algebra/src/algebra/bialgebra_trait.rs index 0b0d4bc45f..790c77c607 100644 --- a/ext/crates/algebra/src/algebra/bialgebra_trait.rs +++ b/ext/crates/algebra/src/algebra/bialgebra_trait.rs @@ -1,5 +1,42 @@ use crate::algebra::Algebra; +/// Why [`Bialgebra::try_coproduct`] could not compute a coproduct. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoproductError { + /// `op_deg` is negative, or `op_idx` is not a valid basis index in that degree. + OutOfRange, + /// The Milnor coproduct is only implemented at the prime 2. + OddPrimeUnsupported, + /// The generic Adem coproduct is only defined when the degree is divisible by `q = 2p - 2` + /// (the degree-1 Bockstein aside). + IndivisibleDegree { + /// The modulus `q = 2p - 2` the degree must be divisible by. + q: u32, + /// The offending degree. + degree: i32, + }, + /// The Adem coproduct at the prime 2 is only defined for index 0. + NonzeroIndex, +} + +impl std::fmt::Display for CoproductError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::OutOfRange => f.write_str("basis element index out of range"), + Self::OddPrimeUnsupported => f.write_str("coproduct is only supported at p = 2"), + Self::IndivisibleDegree { q, degree } => { + write!( + f, + "coproduct expects a degree divisible by {q}, got {degree}" + ) + } + Self::NonzeroIndex => f.write_str("at p = 2 the coproduct expects index 0"), + } + } +} + +impl std::error::Error for CoproductError {} + /// An [`Algebra`] equipped with a coproduct operation that makes it into a /// bialgebra. #[enum_dispatch::enum_dispatch] @@ -13,6 +50,30 @@ pub trait Bialgebra: Algebra { /// `x` must have been returned by [`Bialgebra::decompose()`]. fn coproduct(&self, op_deg: i32, op_idx: usize) -> Vec<(i32, usize, i32, usize)>; + /// Non-panicking variant of [`Self::coproduct`]. Computes the basis up to `op_deg` first and + /// returns [`CoproductError::OutOfRange`] when `op_deg`/`op_idx` do not name a basis element. + /// + /// This default only guards the out-of-range preconditions shared by every implementation, so + /// it is correct for [`Self::coproduct`] implementations that are total on valid basis + /// elements (such as [`crate::algebra::Field`]). Implementations whose `coproduct` is only + /// partially defined — e.g. [`crate::algebra::MilnorAlgebra`] (prime 2 only) and + /// [`crate::algebra::AdemAlgebra`] (degree/index restrictions) — override this method to guard + /// their extra preconditions, so the delegation below never reaches the panicking path. + fn try_coproduct( + &self, + op_deg: i32, + op_idx: usize, + ) -> Result, CoproductError> { + if op_deg < 0 { + return Err(CoproductError::OutOfRange); + } + self.compute_basis(op_deg); + if op_idx >= self.dimension(op_deg) { + return Err(CoproductError::OutOfRange); + } + Ok(self.coproduct(op_deg, op_idx)) + } + /// Decomposes an element of the algebra into a product of elements, each of /// which we can compute a coproduct on efficiently. /// diff --git a/ext/crates/algebra/src/algebra/milnor_algebra.rs b/ext/crates/algebra/src/algebra/milnor_algebra.rs index 0efdc51a64..91c3bdcbdb 100644 --- a/ext/crates/algebra/src/algebra/milnor_algebra.rs +++ b/ext/crates/algebra/src/algebra/milnor_algebra.rs @@ -9,13 +9,15 @@ use once::OnceVec; use rustc_hash::FxHashMap as HashMap; use serde::{Deserialize, Serialize}; -use crate::algebra::{Algebra, Bialgebra, GeneratedAlgebra, UnstableAlgebra, combinatorics}; +use crate::algebra::{ + Algebra, Bialgebra, CoproductError, GeneratedAlgebra, UnstableAlgebra, combinatorics, +}; fn q_part_default() -> u32 { !0 } -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MilnorProfile { /// If `true`, unspecified p_part entries will be 0. Otherwise they will be infinity. pub truncated: bool, @@ -1722,6 +1724,26 @@ impl MilnorAlgebra { } impl Bialgebra for MilnorAlgebra { + fn try_coproduct( + &self, + op_deg: i32, + op_idx: usize, + ) -> Result, CoproductError> { + // Guards the `assert_eq!(self.prime(), 2, ...)` below and the out-of-range + // `basis_element_from_index` lookup, so `coproduct` never panics. + if self.prime() != 2 { + return Err(CoproductError::OddPrimeUnsupported); + } + if op_deg < 0 { + return Err(CoproductError::OutOfRange); + } + self.compute_basis(op_deg); + if op_idx >= self.dimension(op_deg) { + return Err(CoproductError::OutOfRange); + } + Ok(self.coproduct(op_deg, op_idx)) + } + fn coproduct(&self, op_deg: i32, op_idx: usize) -> Vec<(i32, usize, i32, usize)> { assert_eq!(self.prime(), 2, "Coproduct at odd primes not supported"); if op_deg == 0 { diff --git a/ext/crates/algebra/src/algebra/mod.rs b/ext/crates/algebra/src/algebra/mod.rs index 67baed2b04..5abad179a6 100644 --- a/ext/crates/algebra/src/algebra/mod.rs +++ b/ext/crates/algebra/src/algebra/mod.rs @@ -5,10 +5,10 @@ pub mod adem_algebra; pub use adem_algebra::AdemAlgebra; mod algebra_trait; -pub use algebra_trait::{Algebra, GeneratedAlgebra, MuAlgebra, UnstableAlgebra}; +pub use algebra_trait::{Algebra, DecomposeError, GeneratedAlgebra, MuAlgebra, UnstableAlgebra}; mod bialgebra_trait; -pub use bialgebra_trait::Bialgebra; +pub use bialgebra_trait::{Bialgebra, CoproductError}; pub mod combinatorics; diff --git a/ext/crates/algebra/src/algebra/steenrod_algebra.rs b/ext/crates/algebra/src/algebra/steenrod_algebra.rs index 04c65a7e4e..4735e9800f 100644 --- a/ext/crates/algebra/src/algebra/steenrod_algebra.rs +++ b/ext/crates/algebra/src/algebra/steenrod_algebra.rs @@ -9,7 +9,10 @@ use serde::Deserialize; use serde_json::Value; use crate::{ - algebra::{AdemAlgebra, Algebra, Bialgebra, GeneratedAlgebra, MilnorAlgebra, UnstableAlgebra}, + algebra::{ + AdemAlgebra, Algebra, Bialgebra, CoproductError, DecomposeError, GeneratedAlgebra, + MilnorAlgebra, UnstableAlgebra, + }, pair_algebra::PairAlgebra, }; diff --git a/ext/crates/algebra/src/lib.rs b/ext/crates/algebra/src/lib.rs index 3cb1c0cf2f..0ab2d51ea5 100644 --- a/ext/crates/algebra/src/lib.rs +++ b/ext/crates/algebra/src/lib.rs @@ -7,13 +7,13 @@ pub mod module; pub mod steenrod_evaluator; -pub(crate) mod steenrod_parser; +pub mod steenrod_parser; mod algebra; pub use crate::algebra::*; -pub(crate) fn module_gens_from_json( +pub fn module_gens_from_json( gens: &serde_json::Value, ) -> ( bivec::BiVec, 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..ac87840f76 100644 --- a/ext/crates/fp/src/field/mod.rs +++ b/ext/crates/fp/src/field/mod.rs @@ -1,15 +1,17 @@ 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; +pub use smallfq::{SmallFq, SmallFqError}; pub trait Field: FieldInternal + Sized { type Characteristic: Prime; diff --git a/ext/crates/fp/src/field/smallfq.rs b/ext/crates/fp/src/field/smallfq.rs index b1aa80115c..773c07b5c3 100644 --- a/ext/crates/fp/src/field/smallfq.rs +++ b/ext/crates/fp/src/field/smallfq.rs @@ -110,6 +110,31 @@ pub struct SmallFq

{ table: ZechTable, } +/// Why [`SmallFq::try_new`] rejected a `(p, d)` pair. +/// +/// [`SmallFq::new`] `assert!`s that `d > 1` (prime fields should use [`Fp`]) and that the field +/// order `q = p^d` is representable (`q < 2^16`, so the Zech-logarithm table fits). The variants +/// below name those two rejection modes for callers (such as the Python bindings) handling +/// untrusted input that must not panic. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SmallFqError { + /// The requested degree `d` is `<= 1`; use [`Fp`] for prime fields instead. + DegreeTooSmall { d: u32 }, + /// The field order `q = p^d` is `>= 2^16` (or `p^d` overflows), too large to represent. + FieldTooLarge { p: u32, d: u32 }, +} + +impl std::fmt::Display for SmallFqError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DegreeTooSmall { .. } => write!(f, "degree must be greater than 1"), + Self::FieldTooLarge { .. } => write!(f, "field is too large"), + } + } +} + +impl std::error::Error for SmallFqError {} + impl SmallFq

{ pub fn new(p: P, d: u32) -> Self { assert!(d > 1, "Use Fp for prime fields"); @@ -123,6 +148,24 @@ impl SmallFq

{ } } + /// Construct a `SmallFq`, validating that `(p, d)` describes a representable extension field. + /// + /// Unlike [`Self::new`], which `assert!`s (and whose `p.pow(d)` can overflow for large `d`), + /// this checks the same conditions without panicking: the degree must satisfy `d > 1` and the + /// field order `q = p^d` must be representable (`q < 2^16`), returning the matching + /// [`SmallFqError`] otherwise. Intended for callers handling untrusted input, such as the + /// Python bindings. + pub fn try_new(p: P, d: u32) -> Result { + if d <= 1 { + return Err(SmallFqError::DegreeTooSmall { d }); + } + let too_large = d > 16 || p.as_u32().checked_pow(d).is_none_or(|q| q >= 1 << 16); + if too_large { + return Err(SmallFqError::FieldTooLarge { p: p.as_u32(), d }); + } + Ok(Self::new(p, d)) + } + /// Return the element `-1`. If `p = 2`, this is `a^0 = 1`. Otherwise, it is `a^((q - 1) / 2)`. pub fn negative_one(self) -> FieldElement { let e = if self.p == 2 { 0 } else { (self.q() - 1) / 2 }; diff --git a/ext/crates/fp/src/matrix/affine.rs b/ext/crates/fp/src/matrix/affine.rs index 415012c6d6..fd198687b9 100644 --- a/ext/crates/fp/src/matrix/affine.rs +++ b/ext/crates/fp/src/matrix/affine.rs @@ -1,5 +1,8 @@ use super::Subspace; -use crate::vector::{FpSlice, FpVector}; +use crate::{ + prime::Prime, + vector::{FpSlice, FpVector}, +}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct AffineSubspace { @@ -7,6 +10,37 @@ pub struct AffineSubspace { linear_part: Subspace, } +/// Why [`AffineSubspace::try_new`] rejected an `(offset, linear_part)` pair. +/// +/// [`AffineSubspace::new`] `assert_eq!`s that `offset.len()` matches `linear_part`'s ambient +/// dimension and then reduces `offset` against `linear_part`, which additionally requires the two +/// to share a prime (otherwise the reduction's vector addition panics). The variants below name +/// those two rejection modes for callers (such as the Python bindings) handling untrusted input +/// that must not panic. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AffineSubspaceError { + /// `offset` and `linear_part` are defined over different primes. + PrimeMismatch { offset: u32, linear_part: u32 }, + /// `offset.len()` does not match `linear_part`'s ambient dimension. + LengthMismatch { offset: usize, ambient: usize }, +} + +impl std::fmt::Display for AffineSubspaceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PrimeMismatch { + offset, + linear_part, + } => write!(f, "prime mismatch: {offset} != {linear_part}"), + Self::LengthMismatch { offset, ambient } => { + write!(f, "length mismatch: {offset} != {ambient}") + } + } + } +} + +impl std::error::Error for AffineSubspaceError {} + impl AffineSubspace { pub fn new(mut offset: FpVector, linear_part: Subspace) -> Self { assert_eq!(offset.len(), linear_part.ambient_dimension()); @@ -17,6 +51,31 @@ impl AffineSubspace { } } + /// Construct an affine subspace `offset + linear_part`, validating compatibility. + /// + /// Unlike [`Self::new`], which `assert_eq!`s on the ambient dimension (and can panic inside + /// the offset reduction when the operands disagree on the prime), this checks both conditions + /// without panicking: `offset` and `linear_part` must share a prime and `offset.len()` must + /// equal `linear_part`'s ambient dimension, returning the matching [`AffineSubspaceError`] + /// otherwise. Intended for callers handling untrusted input, such as the Python bindings. + pub fn try_new(offset: FpVector, linear_part: Subspace) -> Result { + let offset_prime = offset.prime().as_u32(); + let linear_prime = linear_part.prime().as_u32(); + if offset_prime != linear_prime { + return Err(AffineSubspaceError::PrimeMismatch { + offset: offset_prime, + linear_part: linear_prime, + }); + } + if offset.len() != linear_part.ambient_dimension() { + return Err(AffineSubspaceError::LengthMismatch { + offset: offset.len(), + ambient: linear_part.ambient_dimension(), + }); + } + Ok(Self::new(offset, linear_part)) + } + pub fn offset(&self) -> &FpVector { &self.offset } diff --git a/ext/crates/fp/src/matrix/matrix_inner.rs b/ext/crates/fp/src/matrix/matrix_inner.rs index 8c6dd93b38..43bff02130 100644 --- a/ext/crates/fp/src/matrix/matrix_inner.rs +++ b/ext/crates/fp/src/matrix/matrix_inner.rs @@ -352,6 +352,11 @@ impl Matrix { /// assert_eq!(Matrix::from_vec(TWO, &matrix_vec).to_vec(), matrix_vec); /// ``` pub fn to_vec(&self) -> Vec> { + // A matrix with zero columns has `stride == 0`, and `itertools::chunks` + // panics on a chunk size of 0. Such a matrix is `rows` empty rows. + if self.columns() == 0 { + return (0..self.rows()).map(|_| Vec::new()).collect(); + } self.data .iter() .chunks(self.stride) @@ -920,6 +925,58 @@ impl Matrix { Subspace::from_matrix(kernel) } + /// Whether the pivot vector has been sized to match the columns, i.e. the + /// matrix has been through [`Self::initialize_pivots`] (as + /// [`Self::row_reduce`] does). The `compute_*` methods below read the + /// pivots — directly and via [`Self::find_first_row_in_block`] — and would + /// slice the empty pivot vector out of bounds if called on a matrix that + /// has never been row reduced. This is the precondition the `try_compute_*` + /// variants check before delegating. + fn pivots_initialized(&self) -> bool { + self.pivots().len() == self.columns() + } + + /// Non-panicking variant of [`Self::compute_quasi_inverse`]. + /// + /// Returns `None` exactly when the matrix has not been row reduced (its + /// pivots are uninitialized), the state in which `compute_quasi_inverse` + /// would slice `pivots` out of bounds and panic. Otherwise returns `Some` + /// of the same result. + pub fn try_compute_quasi_inverse( + &self, + last_target_col: usize, + first_source_col: usize, + ) -> Option { + self.pivots_initialized() + .then(|| self.compute_quasi_inverse(last_target_col, first_source_col)) + } + + /// Non-panicking variant of [`Self::compute_image`]. + /// + /// Returns `None` exactly when the matrix has not been row reduced (its + /// pivots are uninitialized), the state in which `compute_image` would + /// slice `pivots` out of bounds and panic. Otherwise returns `Some` of the + /// same result. + pub fn try_compute_image( + &self, + last_target_col: usize, + first_source_col: usize, + ) -> Option { + self.pivots_initialized() + .then(|| self.compute_image(last_target_col, first_source_col)) + } + + /// Non-panicking variant of [`Self::compute_kernel`]. + /// + /// Returns `None` exactly when the matrix has not been row reduced (its + /// pivots are uninitialized), the state in which `compute_kernel` would + /// slice `pivots` out of bounds and panic. Otherwise returns `Some` of the + /// same result. + pub fn try_compute_kernel(&self, first_source_column: usize) -> Option { + self.pivots_initialized() + .then(|| self.compute_kernel(first_source_column)) + } + pub fn extend_column_dimension(&mut self, columns: usize) { if columns > self.columns { self.extend_column_capacity(columns); @@ -1080,6 +1137,14 @@ impl Matrix { } } + /// Trim the matrix to rows `row_start..row_end` and columns `col_start..`. + /// + /// When `keep_pivots` is `true` the existing pivot table is carried over to + /// the trimmed matrix. This is only valid when `col_start == 0`: a nonzero + /// `col_start` shifts every column index, so the old pivots would point at + /// the wrong columns (and the pivot table length would no longer match the + /// trimmed column count). Passing `keep_pivots == true` with `col_start != 0` + /// is therefore forbidden. pub fn trim(&mut self, row_start: usize, row_end: usize, col_start: usize, keep_pivots: bool) { assert!( !keep_pivots || col_start == 0, @@ -1364,6 +1429,12 @@ impl AugmentedMatrix { self.inner.compute_kernel(self.start[N - 1]) } + /// Non-panicking variant of [`Self::compute_kernel`]. Returns `None` when + /// the matrix has not been row reduced (see [`Matrix::try_compute_kernel`]). + pub fn try_compute_kernel(&self) -> Option { + self.inner.try_compute_kernel(self.start[N - 1]) + } + pub fn extend_column_dimension(&mut self, columns: usize) { if columns > self.columns { self.end[N - 1] += columns - self.columns; @@ -1391,9 +1462,23 @@ impl AugmentedMatrix<2> { self.inner.compute_image(self.end[0], self.start[1]) } + /// Non-panicking variant of [`Self::compute_image`]. Returns `None` when + /// the matrix has not been row reduced (see [`Matrix::try_compute_image`]). + pub fn try_compute_image(&self) -> Option { + self.inner.try_compute_image(self.end[0], self.start[1]) + } + pub fn compute_quasi_inverse(&self) -> QuasiInverse { self.inner.compute_quasi_inverse(self.end[0], self.start[1]) } + + /// Non-panicking variant of [`Self::compute_quasi_inverse`]. Returns `None` + /// when the matrix has not been row reduced (see + /// [`Matrix::try_compute_quasi_inverse`]). + pub fn try_compute_quasi_inverse(&self) -> Option { + self.inner + .try_compute_quasi_inverse(self.end[0], self.start[1]) + } } impl AugmentedMatrix<3> { @@ -1416,6 +1501,21 @@ impl AugmentedMatrix<3> { /// /// This takes ownership of the matrix since it heavily modifies the matrix. This is not /// strictly necessary but is fine in most applications. + /// Non-panicking variant of [`Self::compute_quasi_inverses`]. + /// + /// `compute_quasi_inverses` consumes the matrix and slices its pivots, so it + /// would panic if called before row reduction. This variant checks that the + /// pivots are initialized first; if they are not, it returns the matrix back + /// unconsumed as `Err(self)` so the caller can recover (e.g. row reduce and + /// retry). Otherwise it returns `Ok` of the same result. + pub fn try_compute_quasi_inverses(self) -> Result<(QuasiInverse, QuasiInverse), Self> { + if self.pivots_initialized() { + Ok(self.compute_quasi_inverses()) + } else { + Err(self) + } + } + pub fn compute_quasi_inverses(mut self) -> (QuasiInverse, QuasiInverse) { let p = self.prime(); let stride = self.stride; @@ -1622,6 +1722,13 @@ mod tests { } } + #[test] + fn test_to_vec_zero_columns() { + // A matrix with zero columns is `rows` empty rows, not zero rows. + let m = Matrix::new(ValidPrime::new(2), 3, 0); + assert_eq!(m.to_vec(), vec![Vec::::new(); 3]); + } + proptest! { // Test that `arbitrary_rref` generates matrices in rref. #[test] diff --git a/ext/crates/fp/src/matrix/mod.rs b/ext/crates/fp/src/matrix/mod.rs index 1ff0fda7b1..62e620ffb2 100644 --- a/ext/crates/fp/src/matrix/mod.rs +++ b/ext/crates/fp/src/matrix/mod.rs @@ -15,8 +15,8 @@ pub mod arbitrary { } // pub use basis::Basis; -pub use affine::AffineSubspace; +pub use affine::{AffineSubspace, AffineSubspaceError}; pub use matrix_inner::{AugmentedMatrix, Matrix, MatrixSliceMut}; -pub use quasi_inverse::QuasiInverse; +pub use quasi_inverse::{QuasiInverse, QuasiInverseError}; pub use subquotient::Subquotient; pub use subspace::Subspace; diff --git a/ext/crates/fp/src/matrix/quasi_inverse.rs b/ext/crates/fp/src/matrix/quasi_inverse.rs index 833f3ef9f8..9c1d4886c9 100644 --- a/ext/crates/fp/src/matrix/quasi_inverse.rs +++ b/ext/crates/fp/src/matrix/quasi_inverse.rs @@ -10,6 +10,38 @@ use crate::{ vector::{FpSlice, FpSliceMut, FpVector}, }; +/// Why [`QuasiInverse::try_new`] rejected an `(image, preimage)` pair as inconsistent. +/// +/// [`QuasiInverse::apply`] (and [`QuasiInverse::stream_quasi_inverse`]) consume one row of +/// `preimage`, addressed by a running counter, for every non-negative pivot in `image`. The +/// variants below name the two ways that walk could index `preimage` out of bounds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QuasiInverseError { + /// A non-negative pivot is not a valid row index into `preimage`, i.e. it is `>= rows`. + PivotOutOfRange { pivot: isize, rows: usize }, + /// `image` has more non-negative pivots than `preimage` has rows. + TooManyPivots { nonneg: usize, rows: usize }, +} + +impl std::fmt::Display for QuasiInverseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PivotOutOfRange { pivot, rows } => write!( + f, + "inconsistent QuasiInverse: pivot {pivot} is out of range for a preimage with \ + {rows} rows" + ), + Self::TooManyPivots { nonneg, rows } => write!( + f, + "inconsistent QuasiInverse: image has {nonneg} non-negative pivots but preimage \ + only has {rows} rows" + ), + } + } +} + +impl std::error::Error for QuasiInverseError {} + /// Given a matrix M, a quasi-inverse Q is a map from the co-domain to the domain such that xQM = x /// for all x in the image (recall our matrices act on the right). /// @@ -25,10 +57,42 @@ pub struct QuasiInverse { } impl QuasiInverse { + /// Construct a `QuasiInverse` without validating that `image` and `preimage` are consistent. + /// + /// This is the fast path for trusted internal callers that build `image`/`preimage` together + /// (e.g. row reduction), where the invariant holds by construction. Callers handling + /// untrusted data (such as the Python bindings) should use [`Self::try_new`] instead. pub fn new(image: Option>, preimage: Matrix) -> Self { Self { image, preimage } } + /// Construct a `QuasiInverse`, validating that `image` is consistent with `preimage`. + /// + /// When `image` is `Some`, [`Self::apply`] walks the pivots and consumes one row of + /// `preimage` per non-negative pivot, so a `QuasiInverse` is only safe to `apply` when every + /// non-negative pivot is a valid `preimage` row index and there are no more non-negative + /// pivots than `preimage` has rows. This checks exactly those conditions, returning the + /// matching [`QuasiInverseError`] otherwise. When `image` is `None` the image is the standard + /// basis and no validation is needed. + pub fn try_new(image: Option>, preimage: Matrix) -> Result { + if let Some(pivots) = image.as_ref() { + let rows = preimage.rows(); + let mut nonneg = 0usize; + for &pivot in pivots { + if pivot >= 0 { + nonneg += 1; + if (pivot as usize) >= rows { + return Err(QuasiInverseError::PivotOutOfRange { pivot, rows }); + } + } + } + if nonneg > rows { + return Err(QuasiInverseError::TooManyPivots { nonneg, rows }); + } + } + Ok(Self { image, preimage }) + } + pub fn image_dimension(&self) -> usize { self.preimage.rows() } diff --git a/ext/crates/sseq/src/differential.rs b/ext/crates/sseq/src/differential.rs index 94d9cb1eb6..26b2e2d3d4 100644 --- a/ext/crates/sseq/src/differential.rs +++ b/ext/crates/sseq/src/differential.rs @@ -4,6 +4,7 @@ use fp::{ vector::{FpSlice, FpSliceMut, FpVector}, }; +#[derive(Clone)] pub struct Differential { pub matrix: Matrix, first_empty_row: usize, @@ -24,6 +25,16 @@ impl Differential { } } + /// The dimension of the source space of the differential. + pub fn source_dim(&self) -> usize { + self.source_dim + } + + /// The dimension of the target space of the differential. + pub fn target_dim(&self) -> usize { + self.target_dim + } + pub fn set_to_zero(&mut self) { self.matrix.set_to_zero(); self.first_empty_row = 0; diff --git a/ext/src/chain_complex/chain_homotopy.rs b/ext/src/chain_complex/chain_homotopy.rs index 7bf90a2df5..1f6ede14ab 100644 --- a/ext/src/chain_complex/chain_homotopy.rs +++ b/ext/src/chain_complex/chain_homotopy.rs @@ -280,6 +280,29 @@ impl< Arc::clone(&self.homotopies[source_s]) } + /// Like [`homotopy`](Self::homotopy), but returns `None` for any homological degree outside the + /// currently defined range (see [`defined_range`](Self::defined_range)) instead of panicking. + /// + /// [`homotopy`](Self::homotopy) indexes the internal `homotopies` `OnceBiVec` and panics out of + /// range; this is the non-panicking sibling used to guard such an access (e.g. from the Python + /// bindings). + pub fn try_homotopy(&self, source_s: i32) -> Option>> { + let range = self.defined_range(); + if source_s < range.start || source_s >= range.end { + None + } else { + Some(self.homotopy(source_s)) + } + } + + /// The range of homological degrees `s` for which [`Self::homotopy`] is + /// currently defined (i.e. the populated range of the internal homotopy + /// table). Used by external callers (e.g. the Python bindings) to guard + /// [`Self::homotopy`] against an out-of-range index without panicking. + pub fn defined_range(&self) -> std::ops::Range { + self.homotopies.range() + } + pub fn save_dir(&self) -> &SaveDirectory { &self.save_dir } diff --git a/ext/src/chain_complex/mod.rs b/ext/src/chain_complex/mod.rs index 2cafb4f671..9d25871264 100644 --- a/ext/src/chain_complex/mod.rs +++ b/ext/src/chain_complex/mod.rs @@ -163,12 +163,49 @@ where self.module(b.s()).number_of_gens_in_degree(b.t()) } + /// Like [`number_of_gens_in_bidegree`](Self::number_of_gens_in_bidegree), but returns `None` + /// for any bidegree outside the currently computed range instead of panicking. + /// + /// `number_of_gens_in_bidegree` panics out of range on either axis: `module(b.s())` indexes the + /// homological degrees (panicking unless `0 <= b.s() < next_homological_degree()`), and the + /// module's `number_of_gens_in_degree(b.t())` indexes its computed degrees (panicking for + /// `b.t() > max_computed_degree()`; it returns 0 below `min_degree()`). `None` distinguishes + /// "not computed" from a computed bidegree that genuinely has 0 generators (`Some(0)`). + fn try_number_of_gens_in_bidegree(&self, b: Bidegree) -> Option { + if b.s() < 0 || b.s() >= self.next_homological_degree() { + return None; + } + let m = self.module(b.s()); + if b.t() < m.min_degree() || b.t() > m.max_computed_degree() { + return None; + } + Some(m.number_of_gens_in_degree(b.t())) + } + /// Iterate through all nonzero bidegrees in increasing order of stem. fn iter_nonzero_stem(&self) -> impl Iterator + '_ { self.iter_stem() .filter(move |&b| self.number_of_gens_in_bidegree(b) > 0) } + /// Like [`iter_nonzero_stem`](Self::iter_nonzero_stem), but the returned iterator owns a + /// shared handle (`Arc`) to the chain complex instead of borrowing it. + /// + /// This is the owning analogue of [`iter_nonzero_stem`]: it walks the same bidegrees as + /// [`iter_stem_owned`](ChainComplex::iter_stem_owned) and keeps only those with a nonzero + /// number of generators. The filter uses + /// [`try_number_of_gens_in_bidegree`](Self::try_number_of_gens_in_bidegree) (mapping the + /// out-of-range `None` to 0); every bidegree the stem walk yields is in the computed range, + /// so this matches `number_of_gens_in_bidegree(b) > 0` exactly. + fn iter_nonzero_stem_owned(self: Arc) -> impl Iterator + where + Self: Sized, + { + let cc = Arc::clone(&self); + self.iter_stem_owned() + .filter(move |&b| cc.try_number_of_gens_in_bidegree(b).unwrap_or(0) > 0) + } + /// Get a string representation of d(gen), where d is the differential of the resolution. fn boundary_string(&self, g: BidegreeGenerator) -> String { let d = self.differential(g.s()); @@ -220,6 +257,34 @@ pub trait ChainComplex: Send + Sync { /// The first s such that `self.module(s)` is not defined. fn next_homological_degree(&self) -> i32; + /// Like [`module`](Self::module), but returns `None` for any homological degree outside the + /// defined range `[0, next_homological_degree())` instead of panicking. + /// + /// The resolution backends index their internal module table (an `OnceBiVec`) here, panicking + /// unless `0 <= s < next_homological_degree()`; this is the non-panicking sibling used to guard + /// such an access (e.g. from the Python bindings). + fn try_module(&self, s: i32) -> Option> { + if s < 0 || s >= self.next_homological_degree() { + None + } else { + Some(self.module(s)) + } + } + + /// Like [`differential`](Self::differential), but returns `None` for any homological degree + /// outside the defined range `[0, next_homological_degree())` instead of panicking. + /// + /// The resolution backends index their internal differential table (an `OnceVec`) here, + /// panicking unless `0 <= s < next_homological_degree()`; this is the non-panicking sibling used + /// to guard such an access. + fn try_differential(&self, s: i32) -> Option> { + if s < 0 || s >= self.next_homological_degree() { + None + } else { + Some(self.differential(s)) + } + } + /// Iterate through all defined bidegrees in increasing order of stem. fn iter_stem(&self) -> StemIterator<'_, Self> { StemIterator { @@ -229,6 +294,26 @@ pub trait ChainComplex: Send + Sync { } } + /// Like [`iter_stem`](Self::iter_stem), but the returned iterator owns a shared handle + /// (`Arc`) to the chain complex instead of borrowing it. + /// + /// This yields exactly the same bidegrees, in the same order, with the same bounds as + /// [`iter_stem`] (both delegate to the shared [`stem_step`] cursor logic). Because the + /// iterator does not borrow `self`, it is `'static` (when `Self: 'static`) and can be stored + /// in long-lived owners such as FFI handles. + fn iter_stem_owned(self: Arc) -> OwnedStemIterator + where + Self: Sized, + { + let current = Bidegree::n_s(self.min_degree(), 0); + let max_s = self.next_homological_degree(); + OwnedStemIterator { + cc: self, + current, + max_s, + } + } + /// Apply the quasi-inverse of the (s, t)th differential to the list of inputs and results. /// This defaults to applying `self.differentials(s).quasi_inverse(t)`, but in some cases /// the quasi-inverse might be stored separately on disk. @@ -278,6 +363,41 @@ pub trait ChainComplex: Send + Sync { } } +/// Advance a stem-walk cursor by one step, shared by [`StemIterator`] (borrowing) and +/// [`OwnedStemIterator`] (owning) so the two stay in lockstep. +/// +/// `cc` is the chain complex being walked, `current` the mutable cursor (next bidegree to +/// consider), and `max_s` the exclusive homological-degree bound (`next_homological_degree()` at +/// the time the iterator was created). Returns the next defined bidegree in increasing order of +/// stem, or `None` once the walk is exhausted. +fn stem_step( + cc: &CC, + current: &mut Bidegree, + max_s: i32, +) -> Option { + loop { + if max_s == 0 { + return None; + } + let cur = *current; + + if cur.s() == max_s { + *current = Bidegree::n_s(cur.n() + 1, 0); + continue; + } + if cur.t() > cc.module(cur.s()).max_computed_degree() { + if cur.s() == 0 { + return None; + } else { + *current = Bidegree::n_s(cur.n() + 1, 0); + continue; + } + } + *current = cur + Bidegree::n_s(0, 1); + return Some(cur); + } +} + /// An iterator returned by [`ChainComplex::iter_stem`] pub struct StemIterator<'a, CC: ?Sized> { cc: &'a CC, @@ -289,25 +409,25 @@ impl Iterator for StemIterator<'_, CC> { type Item = Bidegree; fn next(&mut self) -> Option { - if self.max_s == 0 { - return None; - } - let cur = self.current; + stem_step(self.cc, &mut self.current, self.max_s) + } +} - if cur.s() == self.max_s { - self.current = Bidegree::n_s(cur.n() + 1, 0); - return self.next(); - } - if cur.t() > self.cc.module(cur.s()).max_computed_degree() { - if cur.s() == 0 { - return None; - } else { - self.current = Bidegree::n_s(cur.n() + 1, 0); - return self.next(); - } - } - self.current = cur + Bidegree::n_s(0, 1); - Some(cur) +/// The owning analogue of [`StemIterator`], returned by [`ChainComplex::iter_stem_owned`]. It +/// holds a shared handle (`Arc`) to the chain complex rather than a borrow, so it can outlive any +/// particular reference and be stored in long-lived owners. It yields the same bidegrees, in the +/// same order, as [`StemIterator`] (both delegate to [`stem_step`]). +pub struct OwnedStemIterator { + cc: Arc, + current: Bidegree, + max_s: i32, +} + +impl Iterator for OwnedStemIterator { + type Item = Bidegree; + + fn next(&mut self) -> Option { + stem_step(&*self.cc, &mut self.current, self.max_s) } } @@ -322,6 +442,24 @@ pub trait AugmentedChainComplex: ChainComplex { fn target(&self) -> Arc; fn chain_map(&self, s: i32) -> Arc; + + /// Like [`chain_map`](Self::chain_map), but returns `None` for any homological degree outside + /// the bounded range `[0, max_s())` instead of panicking. + /// + /// [`chain_map`](Self::chain_map) indexes the internal `chain_maps` `Vec` and panics out of + /// range; for a complex augmented from a [`FiniteChainComplex`] there is one chain map per + /// module, so `max_s()` (the number of nonzero modules) is the defined upper bound. This is the + /// non-panicking sibling used to guard such an access. + fn try_chain_map(&self, s: i32) -> Option> + where + Self: BoundedChainComplex, + { + if s < 0 || s >= self.max_s() { + None + } else { + Some(self.chain_map(s)) + } + } } /// A bounded chain complex is a chain complex C for which C_s = 0 for all s >= max_s diff --git a/ext/src/resolution_homomorphism.rs b/ext/src/resolution_homomorphism.rs index 905bf6020a..2ea7f2624a 100644 --- a/ext/src/resolution_homomorphism.rs +++ b/ext/src/resolution_homomorphism.rs @@ -99,6 +99,23 @@ where Arc::clone(&self.maps[input_s]) } + /// Like [`get_map`](Self::get_map), but returns `None` for any homological degree outside the + /// defined range `[shift.s(), next_homological_degree())` instead of panicking. + /// + /// [`get_map`](Self::get_map) indexes the internal `maps` `OnceBiVec` (which starts at + /// `shift.s()`), panicking unless `shift.s() <= input_s < next_homological_degree()`. This is + /// the non-panicking sibling used to guard such an access (e.g. from the Python bindings). + pub fn try_get_map( + &self, + input_s: i32, + ) -> Option>> { + if input_s < self.shift.s() || input_s >= self.next_homological_degree() { + None + } else { + Some(self.get_map(input_s)) + } + } + pub fn save_dir(&self) -> &SaveDirectory { &self.save_dir } diff --git a/ext/src/save.rs b/ext/src/save.rs index 536f0b3d23..422f05f8c5 100644 --- a/ext/src/save.rs +++ b/ext/src/save.rs @@ -74,7 +74,7 @@ impl From> for SaveDirectory { /// exit. fn open_files() -> &'static Mutex> { static OPEN_FILES: LazyLock>> = LazyLock::new(|| { - #[cfg(unix)] + #[cfg(all(unix, not(target_arch = "wasm32")))] ctrlc::set_handler(move || { tracing::warn!("Ctrl-C detected. Deleting open files and exiting."); let files = open_files().lock().unwrap(); diff --git a/ext_py/.envrc b/ext_py/.envrc new file mode 100644 index 0000000000..3550a30f2d --- /dev/null +++ b/ext_py/.envrc @@ -0,0 +1 @@ +use flake diff --git a/ext_py/.gitignore b/ext_py/.gitignore new file mode 100644 index 0000000000..031c523ff1 --- /dev/null +++ b/ext_py/.gitignore @@ -0,0 +1,78 @@ +/target + +# Local emscripten SDK cloned by build-pyodide.sh +.emsdk/ + +# Scratch API-ordering note: must never be staged. +API_BIND_ORDER.md + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version diff --git a/ext_py/API_PROPOSAL.md b/ext_py/API_PROPOSAL.md new file mode 100644 index 0000000000..d73fc2445f --- /dev/null +++ b/ext_py/API_PROPOSAL.md @@ -0,0 +1,586 @@ +# Python API Proposal for `ext` + +This document specifies the Python API for `ext`, the PyO3 bindings to the `ext` workspace. + +## Guiding principle + +Two rules drive every decision below: + +1. **Completeness.** Every `pub` item in the four bound crates — `fp`, `algebra`, `sseq`, and + `ext` — gets a Python binding. The Rust public surface *is* the Python surface. +2. **Lightness.** Every binding is 1–3 lines of glue: unwrap the Python arguments into Rust + types, call the one underlying Rust function, wrap the result back up for Python. No logic + lives in the binding layer. Multi-step workflows (resolving then charting, building a Massey + product, …) are *not* bindings — they are Python scripts that compose the bound primitives, just + as the current `examples/*.rs` compose the Rust API. Orchestration lives in Python `examples/`, + the per-`pub`-item glue lives in Rust. + +The bindings are therefore *mechanical*. The only real design work is (a) choosing the concrete +monomorphizations of the generic Rust types, and (b) fixing a handful of wrapping conventions so +the glue stays uniform. Everything after that is enumeration. + +--- + +## 1. Monomorphization strategy + +The Rust crates are generic over the prime `P`, the algebra `A`, the module `M`, the chain +complex `CC`, and a const `U: bool` (stable vs. unstable). Python has no monomorphization, and the +project's standing preference is to **reduce generics and use dynamic dispatch where performance is +not on the critical path** (the heavy lifting is already inside `fp`/`algebra`). So the bindings +commit to one concrete instantiation of each generic parameter and bind *that*: + +| Rust generic parameter | Bound instantiation | Rationale | +|---|---|---| +| Prime `P: Prime` | `ValidPrime` (runtime prime) | One vector/matrix type covers all primes; `P2`/`P3`… are an internal perf detail. | +| Algebra `A: Algebra` | `SteenrodAlgebra` (the `enum_dispatch` union) | Already a runtime union of Adem/Milnor; the natural dynamic type. | +| Module `M: Module` | `SteenrodModule = Box>` | The crate already provides this trait object; concrete module types coerce into it. | +| Chain complex `CC` | `CCC = FiniteChainComplex` | The crate's own default alias; what `utils::construct` returns. | +| Sseq `Sseq` | `Sseq<2, Adams>` | The only spectral sequence the examples use. | +| Const `U: bool` | bound twice: `false` and `true` | Stable and unstable resolutions become two distinct Python classes. | + +Consequences: + +- Concrete module types (`FDModuleBuilder`, `FreeModule`, `TensorModule`, …) are bound over + `SteenrodAlgebra`, and each exposes a conversion into the dynamic `SteenrodModule` accepted + everywhere downstream (`FreeModule`/`TensorModule` via `.into_steenrod_module()`; the + finite-dimensional builder via `FDModuleBuilder.build()`). +- The `MuResolution` / `MuResolutionHomomorphism` families bind as `Resolution` / + `UnstableResolution` and `ResolutionHomomorphism` / `UnstableResolutionHomomorphism`. +- Trait methods (`Algebra`, `Module`, `ChainComplex`, `ModuleHomomorphism`, …) are bound as + inherent methods on each concrete `#[pyclass]`. There is no Python-visible trait hierarchy; + Python's duck typing stands in for the Rust traits. + +--- + +## 2. Binding conventions + +These are the templates. Every item in §4–§7 is one of these shapes. + +### 2.1 Newtype wrapper + +Every Rust type is wrapped in a tuple struct `#[pyclass]`. Add `From` impls in both directions so +argument-unwrapping and return-wrapping are a single `.into()`. + +```rust +#[pyclass] +pub struct MilnorAlgebra(::algebra::MilnorAlgebra); + +impl From<::algebra::MilnorAlgebra> for MilnorAlgebra { + fn from(x: ::algebra::MilnorAlgebra) -> Self { MilnorAlgebra(x) } +} +``` + +### 2.2 Shared / immutable objects: `frozen` + `Arc` + +Resolutions, algebras, modules and homomorphisms are shared by `Arc` in Rust and are interior-mutable +(they compute into `OnceVec`s). Bind them as `#[pyclass(frozen)]` holding an `Arc`, so `&self` +methods suffice and Python can hold many references cheaply. This matches the existing `Resolution` +and `SecondaryResolution` bindings. + +```rust +#[pyclass(frozen)] +#[derive(Clone)] +pub struct Resolution(Arc>); +``` + +`Clone` on the wrapper is an `Arc::clone`, so passing a resolution into another constructor is free. + +### 2.3 Method body: unwrap → call → wrap + +```rust +#[pymethods] +impl Resolution { + fn module(&self, s: i32) -> FreeModule { + self.0.module(s).into() // call Rust, wrap result + } + fn compute_through_stem(&self, max: Bidegree) { + self.0.compute_through_stem(max.0) // unwrap arg, call Rust + } +} +``` + +### 2.4 Errors: `anyhow`/`Result` → `PyRuntimeError` + +Anything returning `anyhow::Result` or another `Result` maps the error through `to_string`, exactly +as the current `query_module` binding does: + +```rust +fn parse_module_name(name: &str) -> PyResult { + ext::utils::parse_module_name(name) + .map(json_to_py) + .map_err(|e| PyRuntimeError::new_err(e.to_string())) +} +``` + +A `panic!`-ing Rust API (e.g. `Module::dimension` out of range) is left to PyO3's panic-to-exception +machinery; we do not add guard code in the binding. + +### 2.5 Enums + +C-like Rust enums (`AlgebraType`, `Orientation`) bind directly as `#[pyclass]` enums with a `From` +to the Rust enum, as `AlgebraType` already does. + +### 2.6 `serde_json::Value` ↔ Python + +Rust APIs that speak `serde_json::Value` (module JSON, `to_json`, `parse_module_name`) convert to and +from native Python `dict`/`list` via a single `json_to_py` / `py_to_json` helper pair (using +`pythonize`, or a hand-rolled walk). At the call sites this is one `.into()`-style call, keeping the +binding thin while giving Python users plain dicts rather than opaque handles. + +### 2.7 Slices and lifetimes: owning handle + range + +`FpSlice<'a>` / `FpSliceMut<'a>` / `MatrixSliceMut<'a>` are borrowed, lifetime-bearing views and so +cannot be stored in a `#[pyclass]` directly. They are bound by **owning the parent and remembering +the range**: the slice pyclass holds a `Py` (a reference-counted handle to the parent +vector, keeping it alive) together with the `(start, end)` it covers, and reconstructs a real Rust +slice on demand at each call: + +```rust +#[pyclass] +pub struct FpSlice { parent: Py, start: usize, end: usize } + +#[pymethods] +impl FpSlice { + fn entry(&self, py: Python, i: usize) -> u32 { + self.parent.borrow(py).0.slice(self.start, self.end).entry(i) // rebuild slice, call + } +} +``` + +`FpSliceMut` is the same, calling `borrow_mut(py)` and `.slice_mut(..)`; Python's borrow rules give +the exclusive access the mutable slice needs (a `RuntimeError` if aliased, which is the correct +behaviour). `MatrixSliceMut` holds a `Py` plus a row/column rectangle. This keeps the +binding thin (rebuild-then-call is one line) and **zero-copy**, and the slice types stay faithful +members of the public surface rather than being collapsed to owned copies. Methods that *return* a +slice return one of these handle pyclasses pointing at the parent the caller already passed in; +methods that *take* a slice accept either the slice pyclass or a plain `FpVector` (whose +`.as_slice()` is used). + +### 2.8 Iterators + +Rust methods returning `impl Iterator` bind as a method that `.collect()`s into a Python `list` (the +simple, eager choice), unless the iterator is large/lazy by nature (`iter_nonzero_stem`, +`iter_degrees`), in which case we wrap it in a small `#[pyclass]` implementing `__iter__`/`__next__`. +The examples only ever `for`-loop these, so either is observationally identical. + +### 2.9 Module layout + +The Python package mirrors the crates, reusing the submodule names already present in `src/`: + +```python +import ext +from ext import fp, algebra, sseq +# ext-crate items (Resolution, query_module, …) live at the top level of ext +``` + +`fp`, `algebra`, `sseq` are PyO3 submodules; the `ext` crate's own surface sits directly in +`ext`. Below, each section header notes its Python home. + +--- + +## 3. Naming map + +Rust → Python name conventions, applied uniformly: + +- snake_case methods are kept as-is (already Python style). +- Constructors `T::new(..)` → `T(..)` (`#[new]`); other associated fns → `@staticmethod` + (`Bidegree::s_t` → `Bidegree.s_t`), matching the existing `Bidegree` binding. +- Trait methods are flattened onto the concrete pyclass. +- Type aliases keep the short name (`FDModule`, `FPModule`, `CCC` → `ChainComplex`). +- `Mu*` → base name; `Mu*` → `Unstable*`. + +--- + +## 4. `fp` crate → `ext.fp` + +The numeric foundation. `u32` ↔ Python `int`; `Vec` ↔ `list[int]`. + +### 4.1 `fp::prime` + +| Rust item | Python binding | +|---|---| +| `ValidPrime::new(p) -> Self` (panicking) | `ValidPrime(p)` | +| `ValidPrime::new_unchecked(p)` | `ValidPrime.new_unchecked(p)` (staticmethod) | +| `ValidPrime` as int-like | `__int__`, `__index__`, `__eq__`, `__hash__`, `__repr__` via `as_u32`/`Display` | +| `Prime::{as_i32, as_u32, as_usize}` | folded into `__int__` (bind on `ValidPrime`) | +| `Prime::sum(a, b)` / `product` / `inverse` / `pow` / `pow_mod` | methods on `ValidPrime` | +| `power_mod(p, b, e)` | `fp.power_mod(p, b, e)` | +| `log2(n)`, `logp(p, n)`, `factor_pk(p, n)`, `inverse(p, k)`, `minus_one_to_the_n(p, i)`, `is_prime(p)` | module-level `fp.*` functions | +| `Binomial::{binomial, multinomial, binomial_odd_is_zero, …}` | module-level `fp.binomial(p, n, k)`, `fp.multinomial(p, list)`, … (take prime + ints) | +| `TWO` constant | `fp.TWO` | +| `PRIMES`, `NUM_PRIMES`, `PRIME_TO_INDEX_MAP`, `MAX_MULTINOMIAL_LEN`, `ODD_PRIMES` | module attributes | + +`Prime` trait itself and the static `P2/P3/P5/P7` types are **not** bound — they are the +monomorphization detail §1 collapses into `ValidPrime`. (Internal; see §8.) + +### 4.2 `fp::field` + +| Rust item | Python binding | +|---|---| +| `Fp::new(p)` | `Fp(p)` | +| `Fp::{characteristic, degree, zero, one, element}` | methods on `Fp` | +| `SmallFq::new(p, degree)` | `SmallFq(p, degree)` | +| `SmallFq::{p, degree, a, q, zero, one}` | methods on `SmallFq` | +| `FieldElement::{inv, frobenius, field}` + arithmetic | `FieldElement` pyclass with `__add__`/`__sub__`/`__mul__`/`__truediv__`/`__neg__`, `inv`, `frobenius`; `__int__` via `Deref` | +| `F2` (and `F3/F5/F7` under `odd-primes`) | `fp.F2`, … | + +The `Field` trait is flattened onto `Fp` and `SmallFq`. + +### 4.3 `fp::vector` + +`FpVector` is the owned vector; `FpSlice`/`FpSliceMut` are bound as handle+range pyclasses (§2.7). + +| Rust item | Python binding | +|---|---| +| `FpVector::new(p, len)` | `FpVector(p, len)` | +| `new_with_capacity`, `from_slice(p, &[u32])`, `from_bytes` | `@staticmethod` constructors; `from_slice(p, list)` | +| `prime, len, is_empty, entry, density, is_zero, first_nonzero` | query methods; `len` also `__len__`, `entry` also `__getitem__` | +| `set_entry, scale, set_to_zero, add_basis_element, copy_from_slice, assign, extend_len, set_scratch_vector_size` | mutators; `set_entry` also `__setitem__` | +| `add(other, c)`, `add_offset`, `add_truncate`, `add_carry` | methods; `add(other, 1)` backs `__add__`/`__iadd__` | +| `sign_rule(other)` | method | +| `iter` / `iter_nonzero` | `__iter__` / `iter_nonzero()` | +| `to_bytes` / `update_from_bytes` | `to_bytes() -> bytes` / `update_from_bytes(bytes)` | +| `padded_len`, `num_limbs` | `@staticmethod` | +| `FpVectorIterator`, `FpVectorNonZeroIterator` | small `#[pyclass]` iterator wrappers | +| `FpSlice` — `prime, len, is_empty, entry, is_zero, first_nonzero, restrict, iter, iter_nonzero, to_owned` | `FpSlice` pyclass = `Py` + `(start, end)` (§2.7); `slice(v, a, b)` / `v.slice(a, b)` build it | +| `FpSliceMut` — `prime, set_entry, set_to_zero, scale, add_basis_element, add, add_offset, add_masked, add_unmasked, add_tensor, assign, slice_mut, as_slice` | `FpSliceMut` pyclass = `Py` + range (§2.7), `borrow_mut` on each call | + +### 4.4 `fp::matrix` + +| Rust item | Python binding | +|---|---| +| `Matrix::new(p, rows, cols)` | `Matrix(p, rows, cols)` | +| `from_rows, from_row, from_vec, from_data, from_bytes, identity, augmented_from_vec` | `@staticmethod` constructors | +| `prime, rows, columns, pivots, is_zero, to_vec, to_bytes` | queries / conversions | +| `row(i)` → `FpSlice`, `row_mut(i)` → `FpSliceMut`, `slice_mut(...)` → `MatrixSliceMut` | handle+range pyclasses per §2.7 (zero-copy) | +| `set_to_zero, assign, swap_rows, initialize_pivots, extend_column_dimension, extend_column_capacity, add_row, safe_row_op, trim, rotate_down` | mutators | +| `row_reduce` → `int` (rank), `find_pivots_permutation` | methods | +| `compute_kernel, compute_image, compute_quasi_inverse` | return `Subspace`/`QuasiInverse` | +| `apply(result, coeff, input)` | `apply(result_slice, coeff, input_slice)` — takes `FpSliceMut`/`FpSlice` or `FpVector` | +| `MatrixSliceMut` — `prime, rows, columns, row, row_mut, row_slice, iter, iter_mut, add_identity, add_masked` | `MatrixSliceMut` pyclass = `Py` + rectangle (§2.7) | +| `naive_mul` and `Mul` | `__matmul__` / `__mul__` | +| `iter` / `iter_mut` | `__iter__` yielding owned rows | +| `Subspace` — `new, from_matrix, entire_space, dimension, ambient_dimension, contains, contains_space, add_vector, reduce, sum, iter, iter_all_vectors, set_to_zero, set_to_entire, to_bytes, from_bytes` | full `Subspace` pyclass | +| `Subquotient` — `new, new_full, dimension, ambient_dimension, gens, zeros, reduce, quotient, add_gen, clear_gens, …` | full `Subquotient` pyclass | +| `QuasiInverse` — `new, image_dimension, source_dimension, target_dimension, to_bytes, from_bytes` + `apply` | `QuasiInverse` pyclass | +| `AffineSubspace` — `new, offset, linear_part, contains, contains_space, sum` | pyclass | +| `AugmentedMatrix` | bind `N=2` and `N=3` as `AugmentedMatrix2` / `AugmentedMatrix3` (const-generic → two classes), with `segment`, `row_segment`, `compute_kernel/image/quasi_inverse` (N=3), `into_matrix`, `add_identity` | + +The BLAS variants (`fast_mul_*`, `blas`) fold behind `__mul__` and are not individually exposed. + +--- + +## 5. `algebra` crate → `ext.algebra` + +### 5.1 The `Algebra` surface + +The `Algebra` trait (and `GeneratedAlgebra`, `Bialgebra`, `UnstableAlgebra`) is flattened onto each +concrete algebra pyclass. The bound method set, per algebra: + +`prime, compute_basis, dimension, basis_element_to_string, basis_element_from_string, +element_to_string, multiply_basis_elements, multiply_basis_element_by_element, +multiply_element_by_basis_element, multiply_element_by_element, default_filtration_one_products, +generators, generator_to_string, decompose_basis_element, generating_relations, coproduct, decompose`. + +The `multiply_*` family's `result: FpSliceMut` / input `FpSlice` arguments accept the slice +handle pyclasses (or a plain `FpVector`) per §2.7; the binding rebuilds the Rust slice and calls +through. + +### 5.2 Concrete algebras + +| Rust item | Python binding | +|---|---| +| `SteenrodAlgebra` (enum union) | primary `SteenrodAlgebra` pyclass | +| `SteenrodAlgebra::from_json(value, ty, unstable)` | `SteenrodAlgebra.from_json(dict, ty, unstable)` (staticmethod) | +| convenience constructors | `SteenrodAlgebra.adem(p, unstable=False)`, `SteenrodAlgebra.milnor(p, unstable=False)` | +| `MilnorAlgebra::new(p, unstable)` | `MilnorAlgebra(p, unstable_enabled=False)` (already drafted) | +| `MilnorAlgebra::{new_with_profile, generic, q, profile, basis_element_from_index, basis_element_to_index, try_basis_element_to_index, ppart_table, beps_pn, multiply}` | methods | +| `MilnorProfile`, `MilnorBasisElement` | small pyclasses (fields + `compute_degree`) | +| `AdemAlgebra::new(p, unstable)` | `AdemAlgebra(p, unstable_enabled=False)` | +| `AdemAlgebra::{generic, q, basis_element_from_index, basis_element_to_index, beps_pn}` | methods | +| `AdemBasisElement`, `PorBockstein` | pyclass / enum | +| `Field::new(p)` | `algebra.Field(p)` (the trivial 1-dim algebra; distinct from `fp.Fp`) | +| `AlgebraType` enum | already bound; keep `MILNOR`/`ADEM` | +| `module_gens_from_json(value)` | `algebra.module_gens_from_json(dict)` → `(graded_dims, names)` (the returned closure is dropped; not bindable, see §8) | +| `combinatorics::{adem_relation_coefficient, inadmissible_pairs, tau_degrees, xi_degrees}` | module-level functions | +| `combinatorics::DualpairsIndexer` | pyclass | + +`PPartAllocation`/`PPartMultiplier` are perf-workspace types — internal (§8). + +### 5.3 Modules (`algebra::module`) → `algebra` + +All concrete module types are bound over `SteenrodAlgebra`, each with the flattened `Module` method +set (`algebra, min_degree, max_computed_degree, dimension, compute_basis, act_on_basis, act, +act_by_element, basis_element_to_string, element_to_string, is_unit, prime, max_degree, +total_dimension`) plus `.into_steenrod_module()`. + +| Rust item | Python binding | +|---|---| +| `SteenrodModule = Box>` | `SteenrodModule` pyclass (the dynamic module §1) | +| `from_json(algebra, value)` (steenrod_module) | `algebra.steenrod_module_from_json(algebra, dict)` | +| `FDModule::new(algebra, name, graded_dims)` | `FDModuleBuilder(algebra, name, graded_dims, min_degree=0)` (call `.build()` for the `SteenrodModule`) | +| `FDModule::{set_basis_element_name, add_generator, set_action, action, extend_actions, check_validity, parse_action, string_to_basis_element, from_json, to_json, test_equal}` | methods (`to_json` → dict; `set_action` takes `FpVector`) | +| `FreeModule::new(algebra, name, min_degree)` | `FreeModule(algebra, name, min_degree)` | +| `FreeModule::{add_generators, number_of_gens_in_degree, gen_names, generator_offset, internal_generator_offset, operation_generator_to_index, index_to_op_gen, extend_by_zero, iter_gens}` | methods | +| `OperationGeneratorPair` | small pyclass (4 int fields) | +| `FinitelyPresentedModule::new` (`FPModule`) | `FPModule(algebra, name, min_degree)` | +| `FPModule::{generators, add_generators, add_relations, gen_idx_to_fp_idx, fp_idx_to_gen_idx, from_json}` | methods | +| `TensorModule::new(left, right)` | `TensorModule(left, right)` (`+ seek_module_num, offset`) | +| `SuspensionModule::new(inner, shift)` | `SuspensionModule(inner, shift)` | +| `QuotientModule::new(module, truncation)` | `QuotientModule(module, truncation)` (`+ quotient*, reduce, old_basis_to_new`) | +| `HomModule::new(source, target)` | `HomModule(source, target)` (`+ source, target`) | +| `RealProjectiveSpace::new(algebra, min, max, clear_bottom)` | `RealProjectiveSpace(algebra, min, max, clear_bottom)` (`+ from_json/to_json`) | +| `ZeroModule` | `ZeroModule(algebra, min_degree)` | +| `BlockStructure`, `GeneratorBasisEltPair` | pyclasses | +| `FDModule::from_tensor_module` (used by `tensor.py`) | provide as `FDModuleBuilder.from_module(steenrod_module)` — thin wrapper over the existing `From`/bounded-module conversion (not yet bound) | + +### 5.4 Module homomorphisms (`algebra::module::homomorphism`) → `algebra` + +`ModuleHomomorphism` flattened method set on each: `source, target, degree_shift, apply, +apply_to_basis_element, kernel, image, quasi_inverse, compute_auxiliary_data_through_degree, +get_partial_matrix, apply_quasi_inverse, min_degree, prime`. + +| Rust item | Python binding | +|---|---| +| `FreeModuleHomomorphism::new(source, target, degree_shift)` | `FreeModuleHomomorphism(source, target, degree_shift)` | +| `FreeModuleHomomorphism::{output, next_degree, extend_by_zero, add_generators_from_rows, add_generators_from_matrix_rows, apply_to_generator, hom_k, set_image/kernel/quasi_inverse, differential_density}` | methods | +| `FullModuleHomomorphism::{new, from_matrices, from, replace_source, replace_target}` | `FullModuleHomomorphism` pyclass | +| `QuotientHomomorphism`, `QuotientHomomorphismSource` | pyclasses | +| `GenericZeroHomomorphism::new` | pyclass | +| `HomPullback::new` | pyclass | +| `ZeroHomomorphism`/`IdentityHomomorphism` traits | bound as `@staticmethod` `zero(s, t, shift)` / `identity(s)` constructors on the relevant pyclasses | + +The `UnstableFreeModuleHomomorphism` (`U=true`) binds as `UnstableFreeModuleHomomorphism`. + +### 5.5 Steenrod evaluator / parser → `algebra` + +| Rust item | Python binding | +|---|---| +| `SteenrodEvaluator::new(p)` | `SteenrodEvaluator(p)` | +| `evaluate_algebra_adem(str)` → `(i32, FpVector)` | `evaluate_algebra_adem(s) -> (deg, FpVector)` | +| `evaluate_algebra_milnor(str)` | likewise | +| `evaluate_module_adem(str)` → `BTreeMap` | returns `dict[str, (int, FpVector)]` | +| `adem_to_milnor(result, coeff, deg, input)` / `milnor_to_adem` | `adem_to_milnor(deg, vec) -> FpVector` (own output) | +| `adem_element_to_string` / `milnor_element_to_string` | via the algebra's `element_to_string` | +| `parse_algebra(str)`, `parse_module(str)` | `algebra.parse_algebra`, `parse_module` returning the parse-tree pyclasses | +| `AlgebraNode, AlgebraBasisElt, ModuleNode, BocksteinOrSq` | enum/pyclass bindings (needed for `parse_*` returns) | + +`PairAlgebra` trait + `pair_algebra` element type: bound only as far as `SecondaryResolution` needs +(see §7.4); the associated `Element` type is opaque (`#[pyclass]` with `element_is_zero`, +`to/from_bytes`). Marked low priority. + +--- + +## 6. `sseq` crate → `ext.sseq` + +### 6.1 `sseq::coordinates` + +Bind the `N=2` aliases as first-class; the general `MultiDegree` is not exposed (the bidegree case +is the only one used). + +| Rust item | Python binding | +|---|---| +| `Bidegree::{s_t, n_s, x_y}` | already drafted: `Bidegree.s_t`, `Bidegree.n_s`, `Bidegree.x_y` | +| `Bidegree::{n, s, t, x, y, coords}` | properties / methods | +| `Bidegree` `Add`/`Sub`/`Display`/`Eq`/`Hash` | `__add__`, `__sub__`, `__str__`, `__eq__`, `__hash__` | +| `BidegreeElement::new(degree, vec)` | `BidegreeElement(degree, vec)` | +| `BidegreeElement::{degree, n, s, t, x, y, vec, into_vec, to_basis_string}` + `Display` | methods (`vec` → owned `FpVector`) | +| `BidegreeGenerator::{new, s_t, n_s}` | `BidegreeGenerator(degree, idx)`, `.s_t`, `.n_s` | +| `BidegreeGenerator::{degree, idx, n, s, t, x, y, into_element}` + `Display` | methods | +| `BidegreeRange::{new, s, t, restrict}` | `BidegreeRange` pyclass (mainly an argument carrier) | +| `iter_s_t(f, min, max)` | `sseq.iter_s_t(callback, min, max)` | + +The `ordered` submodule (`ByStem`, `ByInternalDegree`, `OrderedMultiDegree`, …) is sorting +machinery — internal (§8). + +### 6.2 `Sseq` (bound as `Sseq<2, Adams>`) + +| Rust item | Python binding | +|---|---| +| `Sseq::new(p)` | `Sseq(p)` | +| `set_dimension, dimension, get_dimension, clear` | methods | +| `min, max, defined, iter_degrees` | methods (`iter_degrees` → iterator) | +| `add_permanent_class, permanent_classes` | methods (`permanent_classes` → `Subspace`) | +| `add_differential(r, source, target_vec)` | method | +| `differentials, differentials_hitting` | return `Differential` / iterator | +| `page_data, invalid, update, update_degree, complete, inconsistent` | methods | +| `multiply(elem, product)`, `leibniz(...)` | methods | +| `write_to_graph(backend, *, page, differentials=False, products=[], header=None)` | the charting entry point used by `chart.py` | +| `Adams`, `SseqProfile`, `Product<2>` | `Adams` marker exposed as default; `Product` pyclass (`b`, `left`, `matrices`) | +| `Differential::{new, add, set_to_zero, prime, inconsistent, get_source_target_pairs, evaluate, quasi_inverse}` | `Differential` pyclass (slice args per §2.7) | + +### 6.3 `sseq::charting` + +| Rust item | Python binding | +|---|---| +| `SvgBackend::new(writer)` | `SvgBackend(file)` — accepts any Python file-like / `sys.stdout`; the binding wraps it in a Rust `io::Write` adapter | +| `SvgBackend::legend(writer)` | `SvgBackend.legend(file)` (staticmethod) | +| `TikzBackend::new(writer)` | `TikzBackend(file)` | +| `Orientation` enum | `sseq.Orientation.{Left, Right, Above, Below}` | +| `Backend` trait methods (`header, line, text, node, structline, init, structline_matrix`) | flattened onto `SvgBackend`/`TikzBackend` (so charts can be driven manually from Python too) | + +Note: `write_to_graph` is generic over `T: Backend`; the binding accepts the concrete +`SvgBackend`/`TikzBackend` pyclasses (an enum-dispatch over the two bound backends), keeping the call +monomorphic. + +--- + +## 7. `ext` crate → `ext` (top level) + +### 7.1 `ext::chain_complex` + +`ChainComplex` + `FreeChainComplex` + `AugmentedChainComplex` + `BoundedChainComplex` flattened onto +each concrete complex pyclass: + +`algebra, module, differential, min_degree, zero_module, has_computed_bidegree, +compute_through_bidegree, next_homological_degree, prime, iter_stem, save_dir` and (free) +`graded_dimension_string, to_sseq, filtration_one_products, filtration_one_product, +number_of_gens_in_bidegree, iter_nonzero_stem, boundary_string` and (augmented) `target, chain_map` +and (bounded) `max_s, euler_characteristic`. + +| Rust item | Python binding | +|---|---| +| `CCC = FiniteChainComplex` | `ChainComplex` pyclass | +| `FiniteChainComplex::{new, ccdz, pop, map}` | constructors / methods (`map` over modules → not bound; see §8) | +| `FiniteAugmentedChainComplex::{augment, map}` | `augment` bound; constructed mainly via `utils` | +| `ChainHomotopy::{new, extend, extend_all, shift, left, right, homotopy, prime}` | `ChainHomotopy` pyclass (used to build Massey products — §7.6) | +| `StemIterator` | iterator pyclass backing `iter_stem` | + +### 7.2 `ext::resolution` + +| Rust item | Python binding | +|---|---| +| `Resolution` (= `MuResolution`) | `Resolution` pyclass (already partly bound) | +| `Resolution::{new, new_with_save, set_name, name}` | `Resolution(chain_complex)`, `Resolution.with_save(cc, dir)`, `name` property | +| `compute_through_bidegree(_with_callback)`, `compute_through_stem(_with_callback)` | methods (callback = Python callable) | +| all `ChainComplex`/`FreeChainComplex`/`AugmentedChainComplex` methods | per §7.1 — incl. `to_sseq`, `graded_dimension_string`, `filtration_one_products`, `module`, `iter_nonzero_stem`, `number_of_gens_in_bidegree` | +| `UnstableResolution` (`U=true`) | `UnstableResolution` pyclass, incl. `to_unstable_sseq` (= `to_sseq` on the unstable complex) | + +### 7.3 `ext::resolution_homomorphism` + +| Rust item | Python binding | +|---|---| +| `ResolutionHomomorphism::{new, from_class, from_module_homomorphism}` | `ResolutionHomomorphism(name, source, target, shift)`, `.from_class(...)`, `.from_module_homomorphism(...)` | +| fields `source, target, shift` | read-only properties | +| `{name, algebra, next_homological_degree, get_map, extend, extend_through_stem, extend_all, extend_step, act, save_dir}` | methods (`act` writes into an `FpVector`) | +| `UnstableResolutionHomomorphism` | bound analogously | + +### 7.4 `ext::secondary` + +| Rust item | Python binding | +|---|---| +| `SecondaryResolution::new(resolution)` | already drafted: `SecondaryResolution(resolution)` | +| `SecondaryLift` flattened: `underlying, algebra, prime, source, target, shift, max, homotopy, homotopies, intermediates, compute_partial, compute_intermediates, compute_homotopy_step, extend_all, extend_through_stem, compute_through_bidegree, initialize_homotopies, compute_composites` | methods on `SecondaryResolution` | +| `SecondaryResolution::e3_page()` | `e3_page() -> Sseq` | +| `SecondaryResolutionHomomorphism::{new, name, homotopy, hom_k, hom_k_with, product_nullhomotopy}` | pyclass | +| `SecondaryHomotopy::{new, add_composite, act, composite}` | pyclass | +| `SecondaryComposite::{new, finalize, add_composite, act, to_bytes, from_bytes}` | pyclass | +| `SecondaryChainHomotopy` | pyclass | +| `LAMBDA_BIDEGREE` constant | `ext.LAMBDA_BIDEGREE` | + +### 7.5 `ext::yoneda` + +| Rust item | Python binding | +|---|---| +| `yoneda_representative_element(cc, b, class)` | `ext.yoneda_representative_element(resolution, bidegree, class)` (used by `steenrod.py`) | +| `yoneda_representative(cc, map)` | `ext.yoneda_representative(resolution, chain_map)` | +| `yoneda_representative_with_strategy(cc, map, strategy)` | with a Python `strategy` callable | +| `Yoneda` alias (a `FiniteAugmentedChainComplex`) | returned as the bound augmented-complex pyclass | + +### 7.6 `ext::nassau` + +| Rust item | Python binding | +|---|---| +| `nassau::Resolution::{new, new_with_save, name, set_name, compute_through_stem}` + `ChainComplex` | `NassauResolution(module)` pyclass (Milnor-only; feature-gated mirror of `Resolution`) | + +### 7.7 `ext::utils` + +| Rust item | Python binding | +|---|---| +| `query_module(save_dir, load_quasi_inverse)` | already bound (`query_module(algebra_type, save)` — reconcile signature: real Rust takes `Option` + bool) | +| `query_module_only(prompt, algebra, load_quasi_inverse)` | already bound | +| `query_unstable_module(load_quasi_inverse)` / `query_unstable_module_only()` | `query_unstable_module`, `query_unstable_module_only` | +| `construct(spec, save_dir)` | `ext.construct(spec, save_dir=None)` (spec = dict or `"S_2"` string) | +| `construct_standard`, `construct_nassau` | `construct_standard`, `construct_nassau` | +| `parse_module_name(name)` | `ext.parse_module_name(name) -> dict` | +| `load_module_json(name)` | `ext.load_module_json(name) -> dict` | +| `get_unit(resolution)` | `ext.get_unit(resolution) -> (is_unit, unit_resolution)` (used by `massey.py`) | +| `unicode_num(n)`, `secondary_job()` | module-level helpers | +| `init_logging()` | already called in `#[pymodule_init]`; also expose `ext.init_logging()` | +| `Config` struct | `ext.Config(module, algebra)` pyclass | +| `LogWriter`, `ext_tracing_subscriber` | internal (§8) | + +### 7.8 `ext::save` + +Save-directory plumbing (`SaveDirectory`, `SaveKind`, …). Bound minimally: a `SaveDirectory` pyclass +constructible from a path string (`SaveDirectory(path)`), accepted anywhere a `save_dir` argument +appears. The rest of `save` is internal serialization detail (§8). + +--- + +## 8. Deliberately not bound + +These `pub` items are excluded, with reason — the only carve-outs from rule (1): + +- **Monomorphization-collapsed types:** `Prime` trait, `P2/P3/P5/P7`, `Fp

` for static `P`, + `MultiDegree`/`MultiDegreeElement`/`MultiDegreeGenerator` for `N≠2`, the `Mu*` generic + forms. Their bound `ValidPrime` / `Bidegree` / `Resolution` instantiations stand in for them (§1). +- **Performance workspaces:** `PPartAllocation`, `PPartMultiplier`, BLAS `fast_mul_*`/`blas`. Hidden + behind the operations that use them. +- **Closures returned from Rust:** the name-lookup closure from `module_gens_from_json`, the + `strategy`/`callback`/`header` higher-order *returns*. Higher-order *arguments* are bound (Python + callables); returned Rust closures cannot be wrapped thinly and are dropped. +- **Iteration/ordering helpers:** `coordinates::ordered::*`, `StemIterator` internals, + `BiVec`/`OnceVec`/`MultiIndexed` (these surface only as Python lists/iterators). +- **Threading & serialization internals:** `SenderData`, `LogWriter`, `ext_tracing_subscriber`, the + byte-level `save` machinery beyond `SaveDirectory`. + +Anything in this list that a user later needs is promoted by exposing the capability upstream, not by +thickening the binding. + +--- + +## 9. Reconciling the examples + +The example scripts in `examples/` were drafted against an aspirational API; several names do not +exist in Rust. The bindings above follow Rust, so the examples must be adjusted: + +- **`massey.py`** references `MasseyProductComputer` / `compute_massey_product`. No such type exists. + The real computation (see `examples/massey.rs`) is assembled from `get_unit`, + `ResolutionHomomorphism::{new, from_class, extend_through_stem, act}`, `ChainHomotopy::{new, + extend, homotopy}`, and `AugmentedMatrix` row-reduction. All of those primitives are bound (§7.1, + §7.3, §4.4), so `massey.py` is **rewritten in Python to compose them**, line-for-line mirroring + `examples/massey.rs`. This is the general policy: multi-step workflows that exceed a single binding + call live as Python example scripts (like everything in `examples/`), not as thick bindings or new + upstream helpers. The binding layer stays at 1–3 lines per `pub` item; orchestration is Python. +- **`chart.py`** matches Rust once `write_to_graph` and `SvgBackend` are bound as in §6.2–§6.3 + (`products` is an iterator of `(name, Product)` pairs). +- **`define_module.py`** uses `module.to_json()`, `algebra.generators`, `algebra.basis_element_to_string`, + `SteenrodEvaluator` — all bound (§5). +- **`tensor.py`** uses `parse_module_name`, `steenrod_module_from_json`, `TensorModule`, + `FDModuleBuilder.from_module` — all bound (§5.3, §7.7). +- **`secondary.py`** uses `SecondaryResolution`, `compute_partial`, `extend_all`, `homotopy`, + `underlying`, `iter_nonzero_stem`, `BidegreeGenerator` — all bound (§7.4). + +`unstable_chart.py`, `resolve.py`, `steenrod.py`, `algebra_dim.py` map directly onto §7.2, §7.5, +§5.1. + +--- + +## 10. Implementation priority + +The bindings are independent and mechanical, so order is driven by example coverage: + +1. `fp`: `ValidPrime`, `FpVector`, `Matrix`, `Subspace` (foundation for everything). +2. `algebra`: `SteenrodAlgebra`, `MilnorAlgebra`, `AdemAlgebra`, the `Algebra` method set, + `SteenrodModule` + `FDModuleBuilder`/`FreeModule`/`TensorModule`, `steenrod_module_from_json`. +3. `sseq`: `Bidegree`, `BidegreeGenerator`, `BidegreeElement`, `Sseq`, `SvgBackend`. +4. `ext` top level: `Resolution`, `construct`/`query_module*`, `ChainComplex` method set, + `to_sseq`, `filtration_one_products`. +5. `ResolutionHomomorphism`, `ChainHomotopy`, `get_unit`, `yoneda_representative_element`. +6. `SecondaryResolution` and the secondary family; `UnstableResolution`; `NassauResolution`. +7. The Steenrod evaluator/parser, finitely-presented modules, remaining module constructions. +8. The long tail of `Algebra`/`Module`/`Matrix` queries needed only for completeness (rule 1). + +Each item is one wrapper of the shape in §2; "done" means the corresponding `pub` Rust item has a +Python name that unwraps, calls, and wraps. diff --git a/ext_py/Cargo.toml b/ext_py/Cargo.toml new file mode 100644 index 0000000000..a962c819ca --- /dev/null +++ b/ext_py/Cargo.toml @@ -0,0 +1,25 @@ +[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" +algebra = { path = "../ext/crates/algebra" } +bivec = { path = "../ext/crates/bivec" } +fp = { path = "../ext/crates/fp" } +once = { path = "../ext/crates/once" } +sseq = { path = "../ext/crates/sseq" } + +ext = { path = "../ext", features = ["logging"] } + +pyo3 = "0.29.0" +serde_json = "1.0.141" + +[dev-dependencies] +tempfile = "3.20.0" diff --git a/ext_py/build-pyodide.sh b/ext_py/build-pyodide.sh new file mode 100755 index 0000000000..d41e409a0d --- /dev/null +++ b/ext_py/build-pyodide.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# +# Build the `ext` Python extension as a WebAssembly wheel for Pyodide. +# +# This cross-compiles the Rust/pyo3 extension to `wasm32-unknown-emscripten` +# against the CPython ABI shipped by a specific Pyodide release. The emscripten +# version MUST match the one the target Pyodide was built with, so both are +# pinned below. +# +# The crate already builds threadless: it depends on `ext`/`algebra`/`fp`/`sseq` +# without the `concurrent` (rayon) feature, so no native-only cargo features +# need to be disabled for the wasm build. +# +# Usage: +# ./build-pyodide.sh +# +# Output: +# dist/*-wasm32.whl (install in Pyodide via micropip) +# +set -euo pipefail + +# --- Pinned versions (keep EMSCRIPTEN_VERSION in sync with PYODIDE_VERSION) --- +# Pyodide 314.0.0 -> CPython 3.14.2, emscripten 5.0.3 +PYODIDE_VERSION="${PYODIDE_VERSION:-314.0.0}" +EMSCRIPTEN_VERSION="${EMSCRIPTEN_VERSION:-5.0.3}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EMSDK_DIR="$SCRIPT_DIR/.emsdk" + +cd "$SCRIPT_DIR" + +# --- 1. Emscripten SDK ------------------------------------------------------- +if [[ ! -d "$EMSDK_DIR" ]]; then + echo ">> Cloning emsdk into $EMSDK_DIR" + git clone --depth 1 https://github.com/emscripten-core/emsdk.git "$EMSDK_DIR" +fi + +echo ">> Installing/activating emscripten $EMSCRIPTEN_VERSION" +"$EMSDK_DIR/emsdk" install "$EMSCRIPTEN_VERSION" +"$EMSDK_DIR/emsdk" activate "$EMSCRIPTEN_VERSION" +# shellcheck disable=SC1091 +source "$EMSDK_DIR/emsdk_env.sh" + +# --- 2. pyodide-build + cross-build environment ------------------------------ +if ! command -v pyodide >/dev/null 2>&1; then + echo "!! 'pyodide' not found on PATH." + echo "!! Activate the venv (e.g. 'source .venv/bin/activate') and install it:" + echo "!! uv pip install pyodide-build" + exit 1 +fi + +echo ">> Ensuring Pyodide cross-build environment $PYODIDE_VERSION is installed" +pyodide xbuildenv install "$PYODIDE_VERSION" + +# --- 3. Build the wheel ------------------------------------------------------ +echo ">> Building Pyodide wheel" +pyodide build + +echo +echo ">> Done. Wheel(s):" +ls -1 dist/*wasm32.whl diff --git a/ext_py/examples/_query.py b/ext_py/examples/_query.py new file mode 100644 index 0000000000..73b2612647 --- /dev/null +++ b/ext_py/examples/_query.py @@ -0,0 +1,17 @@ +"""Backwards-compatible shim: the query I/O machinery now lives in the package. + +Historically this file held the Python mirror of the Rust ``query`` crate plus +the ``query_*`` helpers. That code has moved INTO the installed package +(``ext._query`` and ``ext.utils``) as part of the maturin mixed layout. This +shim re-exports those names so existing examples that do +``import _query as query`` keep working unchanged. +""" + +from ext import query_n_s, query_resolution # noqa: F401 +from ext._query import ( # noqa: F401 + optional, + raw, + vector, + with_default, + yes_no, +) diff --git a/ext_py/examples/algebra_dim.py b/ext_py/examples/algebra_dim.py new file mode 100644 index 0000000000..6aabb4c1d7 --- /dev/null +++ b/ext_py/examples/algebra_dim.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +""" +Compute dimensions of the Milnor algebra A_n for n = 0 to 125. +Python translation of algebra_dim.rs example. +""" + +from ext import algebra + + +def main(): + # Create Milnor algebra over F_2 + alg = algebra.MilnorAlgebra(p=2, unstable_enabled=False) + + # Compute basis up to degree 125 + alg.compute_basis(125) + + # Print dimensions + for n in range(126): + print(f"dim A_{n} = {alg.dimension(n)}") + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/bruner.py b/ext_py/examples/bruner.py new file mode 100644 index 0000000000..a6bffa58d9 --- /dev/null +++ b/ext_py/examples/bruner.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Convert between our basis and Bruner's basis (sphere only, mostly hardcoded). + +Python port of ext/examples/bruner.rs. +""" + +import os +import sys + +import _query as query +from ext import Resolution, algebra, fp, sseq +from ext.algebra import MilnorAlgebra, MilnorBasisElement, SteenrodAlgebra +# NOTE: depends on FreeModule, FreeModuleHomomorphism (algebra, API_PROPOSAL §5.3/§5.4), +# FiniteChainComplex (ext top level, API_PROPOSAL §7.1), ResolutionHomomorphism +# (ext top level, API_PROPOSAL §7.3), construct (ext, API_PROPOSAL §7.7). +from ext import FiniteChainComplex, ResolutionHomomorphism +# NOTE: depends on Matrix.from_vec (fp, API_PROPOSAL §4.4) and the TWO constant +# (fp, API_PROPOSAL §4.1). +from ext.fp import FpVector, Matrix + +TWO = fp.TWO + + +def read_line(data): + """Read the first non-empty line of ``data``; return it without the newline. + + Returns ``None`` at end of file, mirroring the Rust ``read_line`` helper. + """ + buf = "" + while buf == "": + line = data.readline() + if line == "": + return None + # Remove newline character (Rust pops the trailing byte unconditionally). + buf = line[:-1] + return buf + + +def entry(x): + """Take the first whitespace-delimited item of ``x``; return ``(rest, item)``.""" + x = x.strip() + k = x.find(" ") + if k == -1: + return ("", x) + return (x[k:], x[:k]) + + +def get_algebra_element(a, input_str): + """Parse an algebra element ``$op_deg _ $op``; yield indices whose sum is it.""" + input_str, t = entry(input_str) + t = int(t) + input_str, _ = entry(input_str) + + input_str = input_str.strip() + assert input_str[0:1] == "i" + + # Remove the i + input_str = input_str[1:] + # Remove the trailing ). + input_str = input_str[: len(input_str) - 2] + + for piece in input_str.split(")"): + piece = piece[1:] + elt = MilnorBasisElement( + [int(x) for x in piece.split(",")], + 0, + t, + ) + yield a.basis_element_to_index(elt) + + +def get_element(a, m, input_data): + """Read a generator block; return ``(degree, FpVector)`` or ``None`` at EOF.""" + buf = read_line(input_data) + if buf is None: + return None + degree = int(buf.strip()) + a.compute_basis(degree) + m.compute_basis(degree) + + buf = read_line(input_data) + num_lines = int(buf.strip()) + + result = FpVector(TWO, m.dimension(degree)) + + for _ in range(num_lines): + buf = read_line(input_data) + rem, gen_idx = entry(buf) + gen_idx = int(gen_idx) + offset = m.internal_generator_offset(degree, gen_idx) + for op in get_algebra_element(a, rem[1:]): + result.add_basis_element(offset + op, 1) + return (degree, result) + + +def create_chain_complex(num_s): + """Create a ``FiniteChainComplex`` with ``num_s`` non-zero modules.""" + alg = SteenrodAlgebra.milnor(int(TWO), False) + + modules = [] + differentials = [] + for _ in range(num_s): + modules.append(algebra.FreeModule(alg, "", 0)) + for s in range(1, num_s): + differentials.append( + algebra.FreeModuleHomomorphism(modules[s], modules[s - 1], 0) + ) + return FiniteChainComplex(modules, differentials) + + +def read_bruner_resolution(data_dir, max_n): + """Read the ``hDiff.N`` files in ``data_dir`` into a chain complex. + + Returns ``(max_s, FiniteChainComplex)``. + """ + num_s = len(os.listdir(data_dir)) + + cc = create_chain_complex(num_s) + # Rust does `cc.algebra().as_ref().try_into()` to view the chain complex's + # SteenrodAlgebra as a &MilnorAlgebra. No SteenrodAlgebra -> MilnorAlgebra + # conversion is bound, so we rebuild the same Milnor algebra here. + # NOTE: depends on SteenrodAlgebra -> MilnorAlgebra conversion (API_PROPOSAL §5.2); + # using a fresh MilnorAlgebra(2, False) as a faithful stand-in. + alg = MilnorAlgebra(int(TWO), False) + + s = num_s - 1 + + alg.compute_basis(max_n + s + 1) + # Handle s = 0 + # TODO: actually parse file + m = cc.module(0) + m.add_generators(0, 1, None) + m.extend_by_zero(max_n + 1) + + for s in range(1, num_s): + m = cc.module(s) + d = cc.differential(s) + + with open(os.path.join(data_dir, f"hDiff.{s}")) as f: + read_line(f) + + entries = [] + cur_degree = 0 + + while True: + element = get_element(alg, cc.module(s - 1), f) + if element is None: + break + t, g = element + if t == cur_degree: + entries.append(g) + else: + m.add_generators(cur_degree, len(entries), None) + d.add_generators_from_rows(cur_degree, entries) + + m.extend_by_zero(t - 1) + d.extend_by_zero(t - 1) + + entries = [g] + cur_degree = t + + m.add_generators(cur_degree, len(entries), None) + d.add_generators_from_rows(cur_degree, entries) + + m.extend_by_zero(max_n + s + 1) + d.extend_by_zero(max_n + s) + + return (s, cc) + + +def main(): + data_dir = os.path.join(os.path.dirname(__file__), "bruner_data") + max_n = query.with_default("Max n", "20", int) + + # Read in Bruner's resolution + max_s, cc = read_bruner_resolution(data_dir, max_n) + max = sseq.Bidegree.n_s(max_n, max_s) + + save_dir = query.optional("Save directory", str) + + resolution = Resolution.construct("S_2@milnor", save_dir) + + resolution.compute_through_stem(max) + + # Create a ResolutionHomomorphism object + # NOTE: Bidegree::zero() is not bound (API_PROPOSAL §6.1 lists only s_t/n_s/x_y); + # Bidegree.n_s(0, 0) is the faithful equivalent. + hom = ResolutionHomomorphism("", cc, resolution, sseq.Bidegree.n_s(0, 0)) + + # We have to explicitly tell it what to do at (0, 0) + hom.extend_step(sseq.Bidegree.n_s(0, 0), Matrix.from_vec(TWO, [[1]])) + hom.extend_all() + + # Now print the results + print("sseq_basis | bruner_basis") + for b in hom.target.iter_stem(): + matrix = hom.get_map(b.s).hom_k(b.t) + + for i, row in enumerate(matrix): + g = sseq.BidegreeGenerator(b, i) + print(f"x_{g:#} = {list(row)}") + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/chart.py b/ext_py/examples/chart.py new file mode 100755 index 0000000000..c749b6e12a --- /dev/null +++ b/ext_py/examples/chart.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Write an SVG chart of the spectral sequence of a resolution to stdout. + +Python port of ext/examples/chart.rs. +""" + +import sys + +import _query as query +from ext import sseq + + +def main(): + resolution = query.query_resolution() + resolution.compute_through_stem(query.query_n_s()) + + ss = resolution.to_sseq() + products = [ + (name, resolution.filtration_one_products(op_deg, op_idx)) + for (name, op_deg, op_idx) in resolution.algebra.default_filtration_one_products + ] + + ss.write_to_graph( + sseq.SvgBackend(sys.stdout), + page=2, + products=products, + ) + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/d2_charts.py b/ext_py/examples/d2_charts.py new file mode 100644 index 0000000000..49ff23c85e --- /dev/null +++ b/ext_py/examples/d2_charts.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Write E2/E3 page charts (with and without d2) of a resolution to TikZ files. + +Python port of ext/examples/d2_charts.rs. +""" + +import _query as query +import ext +from ext import algebra, sseq + + +def main(): + # standard backend: this example uses SecondaryResolution, unavailable on Nassau + resolution = query.query_resolution(alg=algebra.AlgebraType.Milnor, algorithm="standard") + resolution.compute_through_stem(query.query_n_s()) + + lift = ext.SecondaryResolution(resolution) + lift.extend_all() + + ss = lift.e3_page + products = [ + (name, resolution.filtration_one_products(op_deg, op_idx)) + for (name, op_deg, op_idx) in resolution.algebra.default_filtration_one_products + ] + + def write(path, page, diff, prod): + # NOTE: depends on TikzBackend.EXT and Resolution.name (API_PROPOSAL §6.3, §7.4). + suffix = sseq.TikzBackend.EXT + backend = sseq.TikzBackend( + open(f"{path}_{resolution.name}.{suffix}", "w") + ) + ss.write_to_graph( + backend, page=page, differentials=diff, products=products[:prod] + ) + + write("e2", 2, False, 3) + write("e2_d2", 2, True, 3) + write("e3", 3, False, 3) + + write("e2_clean", 2, False, 2) + write("e2_d2_clean", 2, True, 2) + write("e3_clean", 3, False, 2) + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/define_module.py b/ext_py/examples/define_module.py new file mode 100755 index 0000000000..f0cafafd0e --- /dev/null +++ b/ext_py/examples/define_module.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Interactively define a finite dimensional or finitely presented module. + +The module JSON is printed to stdout; all prompts go to stderr. + +Python port of ext/examples/define_module.rs. +""" + +import json +import sys + +import _query as query +from ext import algebra + + +def get_gens(): + """Query generators and their degrees. Returns a dict ``degree -> [names]``.""" + print("Input generators. Press return to finish.", file=sys.stderr) + + gens = {} + while True: + gen_deg = query.optional("Generator degree", int) + if gen_deg is None: + print("This is the list of generators and degrees:", file=sys.stderr) + for deg in sorted(gens): + for g in gens[deg]: + print(f"({deg}, {g}) ", end="", file=sys.stderr) + print(file=sys.stderr) + if query.yes_no("Is it okay?"): + break + if query.yes_no("Start over?"): + gens = {} + continue + + gens.setdefault(gen_deg, []) + default = f"x{gen_deg}{len(gens[gen_deg])}".replace("-", "_") + + def parse_name(x): + if not x: + raise ValueError("Variable name cannot be empty") + if not x[0].isalpha(): + raise ValueError("variable name must start with a letter") + for c in x: + if not (c.isalnum() or c == "_"): + raise ValueError( + f"Variable name cannot contain {c}. " + "Should be alphanumeric and '_'" + ) + return x + + gens[gen_deg].append(query.with_default("Generator name", default, parse_name)) + + return gens + + +def define_fdmodule(output_json, p): + output_json["p"] = p + alg = algebra.AdemAlgebra(p, False) + + gens = get_gens() + min_degree = min(gens) if gens else 0 + max_degree = (max(gens) + 1) if gens else 0 + + alg.compute_basis(max_degree - min_degree) + + # Create module + graded_dims = [len(gens.get(i, [])) for i in range(min_degree, max_degree)] + module = algebra.FDModuleBuilder(alg, "", graded_dims, min_degree) + + for deg in sorted(gens): + for j, g in enumerate(gens[deg]): + module.set_basis_element_name(deg, j, g) + + print( + "Input actions. Write the value of the action in the form 'a x0 + b x1 + " + "...' where a, b are non-negative integers and x0, x1 are names of the " + "generators. The coefficient can be omitted if it is 1", + file=sys.stderr, + ) + + for input_deg in reversed(range(min_degree, max_degree)): + for output_deg in range(input_deg + 1, max_degree): + op_deg = output_deg - input_deg + out_gens = gens.get(output_deg, []) + if not out_gens: + continue + for op_idx in alg.generators(op_deg): + for input_idx in range(len(gens.get(input_deg, []))): + + def parse_action(expr): + result = [0] * len(out_gens) + if expr == "0": + return result + for term in expr.split("+"): + term = term.strip() + if " " in term: + coef_str, g = term.split(" ", 1) + coef = int(coef_str) + else: + coef, g = 1, term + if g in out_gens: + result[out_gens.index(g)] += coef + else: + raise ValueError( + f"No generator {g} in degree {output_deg}" + ) + return result + + op_string = alg.basis_element_to_string(op_deg, op_idx) + output = query.raw( + f"{op_string} {gens[input_deg][input_idx]}", parse_action + ) + module.set_action(op_deg, op_idx, input_deg, input_idx, output) + module.extend_actions(input_deg, output_deg) + module.check_validity(input_deg, output_deg) + + output_json.update(module.to_json()) + + +def replace(algebra_elt, g): + """Right-multiply each term of an algebra element string by ``g``.""" + return algebra_elt.replace("+", f"{g} +") + " " + g + + +def define_fpmodule(output_json, p): + gens = get_gens() + ev = algebra.SteenrodEvaluator(p) + + print("Input relations", file=sys.stderr) + if p == 2: + print("Write relations in the form 'Sq6 * Sq2 * x + Sq7 * y'", file=sys.stderr) + else: + print( + "Write relations in the form 'Q5 * P(5) * x + 2 * P(1, 3) * Q2 * y', " + "where P(...) and Qi are Milnor basis elements.", + file=sys.stderr, + ) + + degree_lookup = {g: deg for deg, gs in gens.items() for g in gs} + + adem_relations = [] + milnor_relations = [] + while True: + + def parse_relation(rel): + result = ev.evaluate_module_adem(rel) + if not result: + return result + degrees = [] + for g, (op_deg, _) in sorted(result.items()): + if g not in degree_lookup: + raise ValueError(f"Unknown generator: {g}") + degrees.append(degree_lookup[g] + op_deg) + for a, b in zip(degrees, degrees[1:]): + if a != b: + raise ValueError( + f"Relation terms have different degrees: {a} and {b}" + ) + return result + + relation = query.raw("Enter relation", parse_relation) + if not relation: + break + + adem_terms = [] + milnor_terms = [] + for g, (op_deg, adem_op) in sorted(relation.items()): + if adem_op.is_zero: + continue + milnor_op = ev.adem_to_milnor(op_deg, adem_op) + adem_terms.append(replace(ev.adem_element_to_string(op_deg, adem_op), g)) + milnor_terms.append( + replace(ev.milnor_element_to_string(op_deg, milnor_op), g) + ) + if adem_terms: + adem_relations.append(" + ".join(adem_terms)) + milnor_relations.append(" + ".join(milnor_terms)) + + output_json["p"] = p + output_json["type"] = "finitely presented module" + output_json["gens"] = {g: deg for deg in sorted(gens) for g in gens[deg]} + output_json["adem_relations"] = adem_relations + output_json["milnor_relations"] = milnor_relations + + +def main(): + def parse_type(x): + if x in ("fd", "fp"): + return x + raise ValueError(f"Invalid type '{x}'. Type must be 'fd' or 'fp'") + + module_type = query.with_default( + "Input module type (default 'finite dimensional module'):\n (fd) - finite " + "dimensional module \n (fp) - finitely presented module\n", + "fd", + parse_type, + ) + + p = query.with_default("p", "2", int) + output_json = {} + + print(f"module_type: {module_type}", file=sys.stderr) + if module_type == "fd": + define_fdmodule(output_json, p) + else: + define_fpmodule(output_json, p) + + print(json.dumps(output_json, separators=(",", ":"), ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/differentials.py b/ext_py/examples/differentials.py new file mode 100644 index 0000000000..a46d67f995 --- /dev/null +++ b/ext_py/examples/differentials.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Print all the differentials in the resolution. + +Python port of ext/examples/differentials.rs. +""" + +import _query as query +from ext import sseq + + +def main(): + resolution = query.query_resolution() + resolution.compute_through_stem(query.query_n_s()) + + for b in resolution.iter_stem(): + for i in range(resolution.number_of_gens_in_bidegree(b)): + g = sseq.BidegreeGenerator(b, i) + boundary = resolution.boundary_string(g) + print(f"d x_{g:#} = {boundary}") + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/ext_m_n.py b/ext_py/examples/ext_m_n.py new file mode 100644 index 0000000000..782c73ce9a --- /dev/null +++ b/ext_py/examples/ext_m_n.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Compute Ext(M, N) and print an ASCII depiction. + +Python port of ext/examples/ext_m_n.rs. +""" + +import sys + +import _query as query +import ext +from ext import algebra, fp, sseq + + +class HomCochainComplex: + """Cochain complex Hom(R_*, N) over HomModule + HomPullback. + + Mirrors the inline `hom_cochain_complex` module in ext_m_n.rs: the source is + a free chain complex (a Resolution) and the target is a module N; term s is + HomModule(R_s, N) and the codifferential s -> s+1 is the HomPullback of the + resolution differential d_{s+1}. + """ + + def __init__(self, source, target): + self.source = source + self.target = target + self.modules = [] + self.differentials = [] + + @property + def min_degree(self): + return self.modules[0].min_degree + + def compute_through_stem(self, max): + # OnceBiVec::extend(new_max) is inclusive (`self[new_max]` defined), so + # Rust builds modules through index max.s + 1 and differentials through + # index max.s. + for s in range(len(self.modules), max.s + 2): + if s == 0: + # First term fixes the target-N Arc shared by the whole chain. + self.modules.append( + algebra.HomModule(self.source.module(0), self.target) + ) + else: + # Extend from the immediately-preceding HomModule so every term + # shares the identical target-N Arc (required by HomPullback's + # `source.target == target.target` Arc::ptr_eq assert). + self.modules.append( + self.modules[s - 1].with_source(self.source.module(s)) + ) + for s in range(len(self.differentials), max.s + 1): + self.differentials.append( + algebra.HomPullback( + self.modules[s], + self.modules[s + 1], + self.source.differential(s + 1), + ) + ) + for s, module in enumerate(self.modules): + module.compute_basis(max.n + s + 1) + for s, d in enumerate(self.differentials): + d.compute_auxiliary_data_through_degree(max.n + s + 1) + + def homology_dimension(self, b): + if b.s == 0: + return self.differentials[b.s].kernel(b.t).dimension + # NOTE: depends on Subquotient.from_parts (not yet in API_PROPOSAL §4.4) + return fp.Subquotient.from_parts( + self.differentials[b.s].kernel(b.t), + self.differentials[b.s - 1].image(b.t), + ).dimension + + +def main(): + print("This script computes Ext(M, N)", file=sys.stderr) + # standard backend: this example uses algebra()/module(), unavailable on Nassau + res = query.query_resolution("Module M", algorithm="standard") + module_spec = query.raw("Module N", ext.parse_module_name) + module = algebra.SteenrodModule.from_spec(module_spec, res.algebra) + + max = sseq.Bidegree.n_s( + query.raw("Max n", int), + query.raw("Max s", int), + ) + + res.compute_through_stem(max + sseq.Bidegree.n_s(module.max_degree, 1)) + res.algebra.compute_basis(max.t + module.max_degree + 2) + + hom_cc = HomCochainComplex(res, module) + hom_cc.compute_through_stem(max) + + # FreeChainComplex::graded_dimension_string + result = "" + for s in range(max.s, -1, -1): + for n in range(hom_cc.min_degree, max.n + 1): + b = sseq.Bidegree.n_s(n, s) + result += ext.unicode_num(hom_cc.homology_dimension(b)) + result += " " + result += "\n" + # If it is empty so far, don't print anything + if result.lstrip() == "": + result = "" + print(result, end="") + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/filtration_one.py b/ext_py/examples/filtration_one.py new file mode 100644 index 0000000000..c45c6654c0 --- /dev/null +++ b/ext_py/examples/filtration_one.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Print all available filtration-one products for a module (prime 2 only). + +Outputs where the target bidegree is zero (or not yet computed) are omitted. + +Python port of ext/examples/filtration_one.rs. +""" + +import _query as query +from ext import sseq + + +def main(): + resolution = query.query_resolution() + resolution.compute_through_stem(query.query_n_s()) + assert resolution.prime == 2 + + for b in resolution.iter_stem(): + i = 0 + while resolution.has_computed_bidegree(b + sseq.Bidegree.s_t(1, 1 << i)): + # TODO: This doesn't work with the reordered Adams basis + products = resolution.filtration_one_product(1 << i, 0, b) + for idx, row in enumerate(products): + g = sseq.BidegreeGenerator(b, idx) + if not row: + continue + print(f"h_{i} x_{g} = {list(row)}") + i += 1 + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/lift_hom.py b/ext_py/examples/lift_hom.py new file mode 100644 index 0000000000..ccf5993d00 --- /dev/null +++ b/ext_py/examples/lift_hom.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Lift a Hom map to the induced map Ext(N, k) -> Ext(M, k) by composition. + +Python port of ext/examples/lift_hom.rs. +""" + +import sys + +import _query as query +import ext +from ext import fp, sseq + + +def main(): + source = query.query_resolution("Source module", algorithm="standard") + b = sseq.Bidegree.n_s( + query.with_default("Max source n", "30", int), + query.with_default("Max source s", "7", int), + ) + + source_name = source.name + + def parse_target(s): + if s == source_name: + return source + save_dir = query.optional("Target save directory", str) + target = ext.Resolution.construct(s, save_dir, "standard") + target.set_name(s) + return target + + target = query.with_default("Target module", source_name, parse_target) + + assert source.prime == target.prime + p = source.prime + + name = query.raw("Name of product", str) + + shift = sseq.Bidegree.n_s( + query.with_default("n of product", "0", int), + query.with_default("s of product", "0", int), + ) + + source.compute_through_stem(b) + target.compute_through_stem(b - shift) + + target_module = target.target.module(0) + hom = ext.ResolutionHomomorphism(name, source, target, shift) + + print("\nInput Ext class to lift:", file=sys.stderr) + for output_t in range(0, target_module.max_degree + 1): + output = sseq.Bidegree.s_t(0, output_t) + input = output + shift + matrix = fp.Matrix( + p, + hom.source.number_of_gens_in_bidegree(input), + target_module.dimension(output.t), + ) + + if matrix.rows == 0 or matrix.columns == 0: + hom.extend_step(input, None) + else: + for idx in range(matrix.rows): + row = matrix.row_mut(idx) + g = sseq.BidegreeGenerator(input, idx) + v = query.vector(f"f(x_{g}", len(row.as_slice())) + for i, x in enumerate(v): + row.set_entry(i, x) + hom.extend_step(input, matrix) + + hom.extend_all() + + for b2 in hom.target.iter_stem(): + shifted_b2 = b2 + shift + if ( + shifted_b2.s >= hom.source.next_homological_degree + or shifted_b2.t > hom.source.module(shifted_b2.s).max_computed_degree + ): + continue + matrix = hom.get_map(shifted_b2.s).hom_k(b2.t) + for i, r in enumerate(matrix): + g = sseq.BidegreeGenerator(b2, i) + print(f"{name} x_{g} = {list(r)}") + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/mahowald_invariant.py b/ext_py/examples/mahowald_invariant.py new file mode 100644 index 0000000000..bd7d626b8e --- /dev/null +++ b/ext_py/examples/mahowald_invariant.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Compute algebraic Mahowald invariants (aka algebraic root invariants). + +Python port of ext/examples/mahowald_invariant.rs. +""" + +import os + +import _query as query +import ext +from ext import algebra, fp, sseq + +TWO = 2 + + +def resolve_s_2(s_2_path, k_max): + # `utils::construct(..., "standard")` (the algorithm string selects the resolution type). + s_2_resolution = ext.Resolution.construct("S_2", s_2_path, "standard") + # See the Rust source for the bidegree bounds; resolve S_2 far enough to + # detect Mahowald invariants of all classes of interest. + s_2_resolution.compute_through_stem( + sseq.Bidegree.n_s(2 * k_max - 2, k_max // 2 + 1) + ) + return s_2_resolution + + +class MahowaldInvariant: + def __init__(self, g, output_t, invariant, indeterminacy_basis): + self.g = g + self.output_t = output_t + self.invariant = invariant + self.indeterminacy_basis = indeterminacy_basis + + def __str__(self): + output_t = self.output_t + + def f2_vec_to_sum(v): + elt = sseq.BidegreeElement( + sseq.Bidegree.s_t(self.g.s, output_t), v + ) + return elt.to_basis_string() + + if not self.indeterminacy_basis: + indeterminacy_info = "" + else: + inner = ", ".join(f2_vec_to_sum(v) for v in self.indeterminacy_basis) + indeterminacy_info = f" mod <{inner}>" + + invariant = f2_vec_to_sum(self.invariant) + return f"M(x_{self.g}) = {invariant}{indeterminacy_info}" + + +class PKData: + def __init__(self, k, p_k_prefix, s_2_resolution): + self.k = k + self.s_2_resolution = s_2_resolution + + p_k_config = { + "p": 2, + "type": "real projective space", + "min": -k, + } + p_k_path = p_k_prefix + if p_k_path is not None: + p_k_path = os.path.join(p_k_path, f"RP_{-k}_inf") + # `utils::construct((p_k_config, AlgebraType::Milnor), ..., "standard")`. + self.resolution = ext.Resolution.construct( + (p_k_config, algebra.AlgebraType.Milnor), p_k_path, "standard" + ) + # RP_-k_inf won't detect Mahowald invariants of classes in the k-stem and + # beyond or of filtration higher than k/2+1. + self.resolution.compute_through_stem(sseq.Bidegree.n_s(k - 2, k // 2 + 1)) + + self.bottom_cell = ext.ResolutionHomomorphism.from_class( + "bottom_cell", + self.resolution, + s_2_resolution, + sseq.Bidegree.s_t(0, -k), + [1], + ) + self.bottom_cell.extend_all() + + self.minus_one_cell = ext.ResolutionHomomorphism.from_class( + "minus_one_cell", + self.resolution, + s_2_resolution, + sseq.Bidegree.s_t(0, -1), + [1], + ) + self.minus_one_cell.extend_all() + + def mahowald_invariants(self): + for b in self.s_2_resolution.iter_stem(): + yield from self.mahowald_invariants_for_bidegree(b) + + def mahowald_invariants_for_bidegree(self, b): + b_p_k = b - sseq.Bidegree.s_t(0, 1) + if self.resolution.has_computed_bidegree(b_p_k): + b_bottom = b_p_k + sseq.Bidegree.s_t(0, self.k) + bottom_s_2_gens = self.s_2_resolution.number_of_gens_in_bidegree(b_bottom) + minus_one_s_2_gens = self.s_2_resolution.number_of_gens_in_bidegree(b) + p_k_gens = self.resolution.number_of_gens_in_bidegree(b_p_k) + if bottom_s_2_gens > 0 and minus_one_s_2_gens > 0 and p_k_gens > 0: + bottom_cell_map = self.bottom_cell.get_map(b_bottom.s) + matrix = [[0] * p_k_gens for _ in range(bottom_s_2_gens)] + for p_k_gen in range(p_k_gens): + output = bottom_cell_map.output(b_p_k.t, p_k_gen) + for s_2_gen, row in enumerate(matrix): + index = bottom_cell_map.target.operation_generator_to_index( + 0, 0, b_bottom.t, s_2_gen + ) + row[p_k_gen] = output.entry(index) + + padded_columns, matrix = fp.Matrix.augmented_from_vec(TWO, matrix) + rank = matrix.row_reduce() + + if rank > 0: + kernel_subspace = matrix.compute_kernel(padded_columns) + indeterminacy_basis = [ + row.to_owned() for row in kernel_subspace.basis + ] + image_subspace = matrix.compute_image(p_k_gens, padded_columns) + quasi_inverse = matrix.compute_quasi_inverse( + p_k_gens, padded_columns + ) + + for i in range(minus_one_s_2_gens): + image = fp.FpVector.new(TWO, p_k_gens) + g = sseq.BidegreeGenerator(b, i) + self.minus_one_cell.act(image.slice_mut(0, p_k_gens), 1, g) + if not image.is_zero and image_subspace.contains( + image.slice(0, p_k_gens) + ): + invariant = fp.FpVector.new(TWO, bottom_s_2_gens) + quasi_inverse.apply( + invariant.slice_mut(0, bottom_s_2_gens), + 1, + image.slice(0, p_k_gens), + ) + yield MahowaldInvariant( + g, + b_bottom.t, + invariant, + list(indeterminacy_basis), + ) + + +def main(): + s_2_path = query.optional("Save directory for S_2", str) + p_k_prefix = query.optional( + "Directory containing save directories for RP_-k_inf's", str + ) + # Going up to k=25 is nice because then we see an invariant that is not a + # basis element and one that has non-trivial indeterminacy. + k_max = query.with_default("Max k (positive)", "25", int) + + s_2_resolution = resolve_s_2(s_2_path, k_max) + + print("M({basis element}) = {mahowald_invariant}[ mod {indeterminacy}]") + for k in range(1, k_max + 1): + p_k = PKData(k, p_k_prefix, s_2_resolution) + for mi in p_k.mahowald_invariants(): + print(mi) + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/massey.py b/ext_py/examples/massey.py new file mode 100755 index 0000000000..080d138878 --- /dev/null +++ b/ext_py/examples/massey.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Compute the triple Massey product (up to a sign). + +Optimized to compute for fixed a, b of small degree and all -. + +Python port of ext/examples/massey.rs. +""" + +import _query as query +import ext +from ext import fp, sseq + + +def main(): + # standard backend: this example uses get_unit(), unavailable on Nassau + resolution = query.query_resolution(algorithm="standard") + resolution.compute_through_stem(query.query_n_s()) + p = resolution.prime + + is_unit, unit = ext.get_unit(resolution) + + a = sseq.Bidegree.n_s( + query.raw("n of Ext class a", int), + query.raw("s of Ext class a", int), + ) + unit.compute_through_stem(a) + a_class = query.vector("Input Ext class a", unit.number_of_gens_in_bidegree(a)) + + b = sseq.Bidegree.n_s( + query.raw("n of Ext class b", int), + query.raw("s of Ext class b", int), + ) + unit.compute_through_stem(b) + b_class = query.vector("Input Ext class b", unit.number_of_gens_in_bidegree(b)) + + # The Massey product shifts the bidegree by this amount. + shift = a + b - sseq.Bidegree.s_t(1, 0) + + if not is_unit: + unit.compute_through_stem(shift) + + if not resolution.has_computed_bidegree( + shift + sseq.Bidegree.s_t(0, resolution.min_degree) + ): + return + + b_hom = ext.ResolutionHomomorphism.from_class("", unit, unit, b, b_class) + b_hom.extend_through_stem(shift) + + offset_a = unit.module(a.s).generator_offset(a.t, a.t, 0) + for c in resolution.iter_nonzero_stem(): + if not resolution.has_computed_bidegree(c + shift): + continue + + tot = c + shift + + num_gens = resolution.number_of_gens_in_bidegree(c) + product_num_gens = resolution.number_of_gens_in_bidegree(b + c) + target_num_gens = resolution.number_of_gens_in_bidegree(tot) + if target_num_gens == 0: + continue + + answers = [[0] * target_num_gens for _ in range(num_gens)] + product = fp.AugmentedMatrix2(p, num_gens, [product_num_gens, num_gens]) + product.segment(1, 1).add_identity() + + matrix = fp.Matrix(p, num_gens, 1) + for idx in range(num_gens): + hom = ext.ResolutionHomomorphism("", resolution, unit, c) + + matrix.row_mut(idx).set_entry(0, 1) + hom.extend_step(c, matrix) + matrix.row_mut(idx).set_entry(0, 0) + + hom.extend_through_stem(tot) + + homotopy = ext.ChainHomotopy(hom, b_hom) + homotopy.extend(tot) + + last = homotopy.homotopy(tot.s) + for i in range(target_num_gens): + output = last.output(tot.t, i) + for k, v in enumerate(a_class): + if v != 0: + answers[idx][i] += v * output.entry(offset_a + k) + + for k, v in enumerate(b_class): + if v != 0: + g = sseq.BidegreeGenerator(b, k) + hom.act(product.row_mut(idx).slice_mut(0, product_num_gens), v, g) + + product.row_reduce() + kernel = product.compute_kernel() + + for row in kernel.iter(): + c_element = sseq.BidegreeElement(c, row.to_owned()) + entries = [] + for i in range(target_num_gens): + entry = 0 + for j, v in enumerate(row): + entry += v * answers[j][i] + entries.append(str(entry % p)) + print(f" = [{', '.join(entries)}]") + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/num_gens.py b/ext_py/examples/num_gens.py new file mode 100644 index 0000000000..d3bc224b77 --- /dev/null +++ b/ext_py/examples/num_gens.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Print the number of generators in each Ext^{s, n+s} as `n,s,num_gens`. + +Python port of ext/examples/num_gens.rs. +""" + +import _query as query + + +def main(): + resolution = query.query_resolution() + resolution.compute_through_stem(query.query_n_s()) + + for b in resolution.iter_stem(): + print(f"{b.n},{b.s},{resolution.number_of_gens_in_bidegree(b)}") + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/resolution_size.py b/ext_py/examples/resolution_size.py new file mode 100644 index 0000000000..03c03965c2 --- /dev/null +++ b/ext_py/examples/resolution_size.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Print the dimension of each module in the resolution, by homological degree. + +Python port of ext/examples/resolution_size.rs. +""" + +import _query as query + + +def main(): + # standard backend: this example uses module(), unavailable on Nassau + res = query.query_resolution(algorithm="standard") + res.compute_through_stem(query.query_n_s()) + + for s in reversed(range(res.next_homological_degree)): + module = res.module(s) + for t in range(res.min_degree + s, module.max_computed_degree + 1): + print(f"{module.dimension(t)}, ", end="") + print() + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/resolve.py b/ext_py/examples/resolve.py new file mode 100644 index 0000000000..0bfedf357c --- /dev/null +++ b/ext_py/examples/resolve.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Resolve a module up to a fixed (s, t) and print an ASCII depiction of Ext. + +Python port of ext/examples/resolve.rs. +""" + +import _query as query +from ext import sseq + + +def main(): + res = query.query_resolution("Module") + + t = query.with_default("Max t", "30", int) + s = query.with_default("Max s", "15", int) + + res.compute_through_bidegree(sseq.Bidegree.s_t(s, t)) + + print(res.graded_dimension_string()) + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/resolve_through_stem.py b/ext_py/examples/resolve_through_stem.py new file mode 100644 index 0000000000..4311ebd204 --- /dev/null +++ b/ext_py/examples/resolve_through_stem.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Resolve a module up to an (n, s) and print an ASCII depiction of Ext. + +Python port of ext/examples/resolve_through_stem.rs. +""" + +import _query as query +from ext import sseq + + +def main(): + res = query.query_resolution("Module") + + max_bidegree = sseq.Bidegree.n_s( + query.with_default("Max n", "30", int), + query.with_default("Max s", "15", int), + ) + + res.compute_through_stem(max_bidegree) + + print(res.graded_dimension_string()) + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/resolve_unstable.py b/ext_py/examples/resolve_unstable.py new file mode 100644 index 0000000000..0cde32a18d --- /dev/null +++ b/ext_py/examples/resolve_unstable.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Resolve an unstable module up to an (n, s) and print an ASCII depiction of Ext. + +Python port of ext/examples/resolve_unstable.rs. +""" + +import _query as query +import ext +from ext import algebra, sseq + + +def query_unstable_module(load_quasi_inverse): + """Inline mirror of ext::utils::query_unstable_module. + + Queries a single "Module" spec, parses the optional ``@adem``/``@milnor`` + algebra suffix (default Milnor) and the module name, builds the algebra with + ``unstable=True`` and the corresponding Steenrod module, then wraps it in a + bounded chain complex and an ``UnstableResolution`` with an optional save + directory. + """ + + def parse_spec(spec): + # Mirror Config::try_from(&str): split on '@' for the algebra type. + module_name, _, algebra_name = spec.partition("@") + if algebra_name == "": + algebra_type = algebra.AlgebraType.Milnor + elif algebra_name == "adem": + algebra_type = algebra.AlgebraType.Adem + elif algebra_name == "milnor": + algebra_type = algebra.AlgebraType.Milnor + else: + raise ValueError(f"Invalid algebra type: {algebra_name}") + # NOTE: depends on ext.parse_module_name (API_PROPOSAL §7.7). + module = ext.parse_module_name(module_name) + return (module, algebra_type) + + module_json, algebra_type = query.raw("Module", parse_spec) + alg = algebra.SteenrodAlgebra.from_json(module_json, algebra_type, True) + module = algebra.SteenrodModule.from_spec(module_json, alg) + + # NOTE: depends on ext.ChainComplex.ccdz and + # ext.UnstableResolution.new_with_save (API_PROPOSAL §7.1, §7.2). + cc = ext.ChainComplex.ccdz(module) + + save_dir = query.optional("Module save directory", str) + + resolution = ext.UnstableResolution( + cc, + save_dir=save_dir, + load_quasi_inverse=load_quasi_inverse and save_dir is None, + ) + + return resolution + + +def main(): + res = query_unstable_module(False) + + max = sseq.Bidegree.n_s( + query.raw("Max n", int), + query.raw("Max s", int), + ) + + res.compute_through_stem(max) + + print(res.graded_dimension_string()) + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/save_bruner.py b/ext_py/examples/save_bruner.py new file mode 100644 index 0000000000..0578ec79d7 --- /dev/null +++ b/ext_py/examples/save_bruner.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Save a resolution to Bruner's format in the current working directory. + +Python port of ext/examples/save_bruner.rs. +""" + +import _query as query +from ext.algebra import AlgebraType +# NOTE: depends on MilnorAlgebra-typed resolution algebra and a SteenrodAlgebra -> +# MilnorAlgebra conversion (API_PROPOSAL §5.2); also on Resolution methods +# next_homological_degree / module / differential / prime and FreeModule +# {min_degree, max_computed_degree, number_of_gens_in_degree, generator_offset} and +# FreeModuleHomomorphism.output (API_PROPOSAL §7.1/§7.2/§5.3/§5.4). + + +def main(): + # standard backend: this example uses module(), unavailable on Nassau + resolution = query.query_resolution(alg=AlgebraType.Milnor, algorithm="standard") + resolution.compute_through_stem(query.query_n_s()) + + assert resolution.prime == 2 + # Rust views the resolution's SteenrodAlgebra as a &MilnorAlgebra via try_into. + # No such conversion is bound; we use the resolution's algebra directly, which + # is the Milnor algebra and exposes dimension / basis_element_from_index. + alg = resolution.algebra + + for s in range(resolution.next_homological_degree): + with open(f"hDiff.{s}", "w") as f: + module = resolution.module(s) + # We don't use this when s = 0 + dmodule = resolution.module(max(s - 1, 0)) + min_degree = module.min_degree + max_degree = module.max_computed_degree + num_gens = sum( + module.number_of_gens_in_degree(t) + for t in range(min_degree, max_degree + 1) + ) + + f.write(f" {num_gens} {max_degree}\n\n") + + d = resolution.differential(s) + for t in range(min_degree, max_degree + 1): + for idx in range(module.number_of_gens_in_degree(t)): + f.write(f"{t}\n\n") + + if s == 0: + f.write("1\n0 0 1 i(0).\n\n\n\n") + continue + row_count = 0 + buffer = "" + dx = d.output(t, idx) + + gen_count = 0 + for gen_deg in range(min_degree, t): + for gen_idx in range(dmodule.number_of_gens_in_degree(gen_deg)): + op_deg = t - gen_deg + algebra_dim = alg.dimension(op_deg) + start = dmodule.generator_offset(t, gen_deg, gen_idx) + slice = dx.slice(start, start + algebra_dim) + if slice.is_zero: + gen_count += 1 + continue + row_count += 1 + buffer += f"{gen_count} {op_deg} {algebra_dim} i" + for op_idx, _ in slice.iter_nonzero(): + elt = alg.basis_element_from_index(op_deg, op_idx) + buffer += "(" + ",".join(str(x) for x in elt.p_part) + ")" + buffer += ".\n" + gen_count += 1 + f.write(f"{row_count}\n") + # buffer has one new line, writeln has one new line, add another one. + f.write(f"{buffer}\n") + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/secondary.py b/ext_py/examples/secondary.py new file mode 100644 index 0000000000..0a0565cd3b --- /dev/null +++ b/ext_py/examples/secondary.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Compute d_2 differentials in the Adams spectral sequence (Milnor basis only). + +Differentials are omitted where the target bidegree is zero. + +Python port of ext/examples/secondary.rs. +""" + +import os + +import _query as query +import ext +from ext import algebra, sseq + + +def main(): + # standard backend: this example uses SecondaryResolution, unavailable on Nassau + resolution = query.query_resolution(alg=algebra.AlgebraType.Milnor, algorithm="standard") + resolution.compute_through_stem(query.query_n_s()) + + lift = ext.SecondaryResolution(resolution) + + secondary_job = os.environ.get("SECONDARY_JOB") + if secondary_job is not None: + lift.compute_partial(int(secondary_job)) + return + + lift.extend_all() + + d2_shift = sseq.Bidegree.n_s(-1, 2) + + # Iterate through the target of the d2. + for b in lift.underlying.iter_nonzero_stem(): + if b.s < 3: + continue + if b.t - 1 > resolution.module(b.s - 2).max_computed_degree: + continue + + homotopy = lift.homotopy(b.s) + m = homotopy.homotopies.hom_k(b.t - 1) + + for i, entry in enumerate(m): + source_gen = sseq.BidegreeGenerator(b - d2_shift, i) + print(f"d_2 x_{source_gen} = {list(entry)}") + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/secondary_massey.py b/ext_py/examples/secondary_massey.py new file mode 100644 index 0000000000..cb155ca025 --- /dev/null +++ b/ext_py/examples/secondary_massey.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +"""Compute Massey products in Mod_{C lambda^2}. + +Python port of ext/examples/secondary_massey.rs. +""" + +import os +import sys + +import _query as query +import ext +from ext import algebra, fp, sseq + + +class HomData: + def __init__(self, name, class_, hom_lift, lambda_part): + self.name = name + self.class_ = class_ + self.hom_lift = hom_lift + self.lambda_part = lambda_part + + +def get_hom(name, source, target): + p = source.prime + + shift = sseq.Bidegree.n_s( + query.raw(f"n of {name}", int), + query.raw(f"s of {name}", int), + ) + + ext_name = query.raw(f"Name of Ext part of {name}", str) + + source.underlying.compute_through_stem(shift + ext.LAMBDA_BIDEGREE) + + hom = ext.ResolutionHomomorphism( + ext_name, source.underlying, target.underlying, shift + ) + + num_gens = source.underlying.number_of_gens_in_bidegree(shift) + num_lambda_gens = hom.source.number_of_gens_in_bidegree(shift + ext.LAMBDA_BIDEGREE) + + class_ = fp.FpVector(p, num_gens + num_lambda_gens) + + matrix = fp.Matrix(p, num_gens, 1) + + if hom.name != "": + if matrix.rows == 0: + print("No classes in this bidegree", file=sys.stderr) + else: + v = query.vector(f"Input Ext class {ext_name}", num_gens) + for i, x in enumerate(v): + matrix.row_mut(i).set_entry(0, x) + class_.set_entry(i, x) + + hom.extend_step(shift, matrix) + + hom_lift = ext.SecondaryResolutionHomomorphism(source, target, hom) + + lambda_part = None + if num_lambda_gens > 0: + lambda_name = query.raw(f"Name of λ part of {name}", str) + if lambda_name == "": + lambda_part = None + else: + v = query.vector(f"Input Ext class {lambda_name}", num_lambda_gens) + for i, x in enumerate(v): + class_.set_entry(num_gens + i, x) + lambda_part = ext.ResolutionHomomorphism.from_class( + lambda_name, + hom_lift.source, + hom_lift.target, + shift + ext.LAMBDA_BIDEGREE, + v, + ) + + lambda_name = lambda_part.name if lambda_part is not None else "" + if ext_name == "" and lambda_name == "": + raise AssertionError("Do not compute zero Massey product") + elif ext_name == "": + final_name = f"λ{lambda_name}" + elif lambda_name == "": + final_name = f"[{ext_name}]" + else: + final_name = f"[{ext_name}] + λ{lambda_name}" + + return HomData(final_name, class_, hom_lift, lambda_part) + + +def main(): + print( + "We are going to compute <-, b, a> for all (-), where a is an element in Ext(M, k) and b " + "and (-) are elements in Ext(k, k).", + file=sys.stderr, + ) + + # standard backend: this example uses get_unit()/SecondaryResolution, unavailable on Nassau + resolution = query.query_resolution(alg=algebra.AlgebraType.Milnor, algorithm="standard") + resolution.compute_through_stem(query.query_n_s()) + + is_unit, unit = ext.get_unit(resolution) + + p = resolution.prime + + res_lift = ext.SecondaryResolution(resolution) + unit_lift = res_lift if is_unit else ext.SecondaryResolution(unit) + + a_data = get_hom("a", res_lift, unit_lift) + a_name = a_data.name + a = a_data.hom_lift + a_lambda = a_data.lambda_part + + b_data = get_hom("b", unit_lift, unit_lift) + b_name = b_data.name + b_class = b_data.class_ + b = b_data.hom_lift + b_lambda = b_data.lambda_part + + shift = sseq.Bidegree.s_t( + (a.underlying.shift + b.underlying.shift).s, + (a.shift + b.shift).t, + ) + + # Extend resolutions + if not is_unit: + res_max = sseq.Bidegree.n_s( + resolution.module(0).max_computed_degree, + resolution.next_homological_degree - 1, + ) + unit.compute_through_stem(res_max - a.underlying.shift) + + if is_unit: + res_lift.extend_all() + else: + res_lift.extend_all() + unit_lift.extend_all() + + # Now extend homomorphisms + a.underlying.extend_all() + a.extend_all() + b.underlying.extend_all() + b.extend_all() + if a_lambda is not None: + a_lambda.extend_all() + if b_lambda is not None: + b_lambda.extend_all() + + res_sseq = res_lift.e3_page + unit_sseq = res_sseq if is_unit else res_lift.e3_page + + b_shift = b.underlying.shift + + chain_homotopy = ext.ChainHomotopy(a.underlying, b.underlying) + chain_homotopy.initialize_homotopies((b_shift + a.underlying.shift).s) + + # Compute first homotopy + v = a.product_nullhomotopy(a_lambda, res_sseq, b_shift, b_class) + homotopy = chain_homotopy.homotopy(b_shift.s + a.underlying.shift.s - 1) + htpy_source = a.shift + b_shift + homotopy.extend_by_zero(htpy_source.t - 1) + homotopy.add_generators_from_rows( + htpy_source.t, + [fp.FpVector.from_slice(p, [x]) for x in v], + ) + + chain_homotopy.extend_all() + + ch_lift = ext.SecondaryChainHomotopy( + a, b, chain_homotopy, a_lambda, b_lambda + ) + + secondary_job = os.environ.get("SECONDARY_JOB") + if secondary_job is not None: + ch_lift.compute_partial(int(secondary_job)) + return + + ch_lift.extend_all() + + def get_page_data(ss, b): + # NOTE: depends on Subquotient accessors subspace_dimension / subspace_gens / + # quotient_dimension / quotient_pivots / reduce_by_quotient (API_PROPOSAL §4 lists a + # "full Subquotient pyclass" but does not enumerate these methods) + # + # Sseq.page_data(b, r) returns the E_r page subquotient (r indexed by page + # number, starting at MIN_R = 2). We want the E3 page (r = 3), falling back + # to the last computed page when d2 was the final differential. + for r in (3, 2): + try: + return ss.page_data(b, r) + except IndexError: + continue + raise IndexError(f"no computed page data at bidegree {b}") + + scratch1 = fp.FpVector(p, 0) + + h_0 = ch_lift.algebra.p_tilde() + + p_int = int(p) + + # Iterate through the multiplicand + for c in unit.iter_stem(): + if not resolution.has_computed_bidegree( + c + shift - sseq.Bidegree.s_t(2, 0) + ) or not resolution.has_computed_bidegree(c + shift + sseq.Bidegree.s_t(0, 1)): + continue + + # Now read off the products + source = c + shift - sseq.Bidegree.s_t(1, 0) + + source_num_gens = resolution.number_of_gens_in_bidegree(source) + source_lambda_num_gens = resolution.number_of_gens_in_bidegree( + source + ext.LAMBDA_BIDEGREE + ) + + if source_num_gens + source_lambda_num_gens == 0: + continue + + # We find the kernel of multiplication by b. + target_num_gens = unit.number_of_gens_in_bidegree(c) + target_lambda_num_gens = unit.number_of_gens_in_bidegree(c + ext.LAMBDA_BIDEGREE) + target_all_gens = target_num_gens + target_lambda_num_gens + + prod_num_gens = unit.number_of_gens_in_bidegree(c + b_shift) + prod_lambda_num_gens = unit.number_of_gens_in_bidegree( + c + b_shift + ext.LAMBDA_BIDEGREE + ) + prod_all_gens = prod_num_gens + prod_lambda_num_gens + + target_page_data = get_page_data(unit_sseq, c) + target_lambda_page_data = get_page_data(unit_sseq, c + ext.LAMBDA_BIDEGREE) + product_lambda_page_data = get_page_data( + unit_sseq, c + b_shift + ext.LAMBDA_BIDEGREE + ) + + # We first compute elements whose product vanish mod lambda, and later see what the + # possible lifts are. We do it this way to avoid Z/p^2 problems + + product_matrix = fp.Matrix( + p, + target_page_data.subspace_dimension, + target_num_gens + prod_num_gens, + ) + + m0 = fp.Matrix.from_vec( + p, + b.underlying.get_map(c.s + b.underlying.shift.s).hom_k(c.t), + ) + for g, out in zip(target_page_data.subspace_gens, product_matrix.iter_mut()): + out.slice_mut(prod_num_gens, prod_num_gens + target_num_gens).add(g, 1) + for i, val in g.iter_nonzero(): + out.slice_mut(0, prod_num_gens).add(m0.row(i), val) + product_matrix.row_reduce() + e2_kernel = product_matrix.compute_kernel(prod_num_gens) + + # Now compute the e3 kernel + e2_ker_dim = e2_kernel.dimension + product_matrix = fp.Matrix( + p, + e2_ker_dim + target_lambda_page_data.quotient_dimension, + target_all_gens + prod_all_gens, + ) + + b.hom_k_with( + b_lambda, + unit_sseq, + c, + e2_kernel.basis, + list(product_matrix.slice_mut(0, e2_ker_dim, 0, prod_all_gens).iter_mut()), + ) + for vec, t in zip(e2_kernel.basis, product_matrix.iter_mut()): + t.slice_mut(prod_all_gens, prod_all_gens + target_num_gens).assign(vec) + + # Now add the lambda multiples + m = fp.Matrix.from_vec( + p, + b.underlying.get_map(b_shift.s + c.s + 1).hom_k(c.t + 1), + ) + + count = 0 + for i, vv in enumerate(target_lambda_page_data.quotient_pivots): + if vv >= 0: + continue + row = product_matrix.row_mut(e2_ker_dim + count) + row.add_basis_element(prod_all_gens + target_num_gens + i, 1) + row.slice_mut(prod_num_gens, prod_all_gens).add(m.row(i), 1) + product_lambda_page_data.reduce_by_quotient( + row.slice_mut(prod_num_gens, prod_all_gens) + ) + count += 1 + + product_matrix.row_reduce() + e3_kernel = product_matrix.compute_kernel(prod_all_gens) + + if e3_kernel.dimension == 0: + continue + + m0 = chain_homotopy.homotopy(source.s).hom_k(c.t) + mt = fp.Matrix.from_vec( + p, chain_homotopy.homotopy(source.s + 1).hom_k(c.t + 1) + ) + m1 = fp.Matrix.from_vec( + p, ch_lift.homotopies[source.s + 1].homotopies.hom_k(c.t) + ) + mp = fp.Matrix.from_vec( + p, + resolution.filtration_one_product( + 1, h_0, sseq.Bidegree.s_t(source.s, c.t + shift.t) + ), + ) + ma = a.underlying.get_map(source.s).hom_k(c.t + b_shift.t) + mb = b.underlying.get_map(c.s + b_shift.s).hom_k(c.t) + + for g in e3_kernel.iter(): + # Print name + print(f"<{a_name}, {b_name}, ", end="") + ext_part = g.restrict(0, target_num_gens) + has_ext = sum(1 for _ in ext_part.iter_nonzero()) > 0 + if has_ext: + basis_string = sseq.BidegreeElement( + c, ext_part.to_owned() + ).to_basis_string() + print(f"[{basis_string}]", end="") + + lambda_part = g.restrict(target_num_gens, target_all_gens) + num_entries = sum(1 for _ in lambda_part.iter_nonzero()) + if num_entries > 0: + if has_ext: + print(" + ", end="") + print("λ", end="") + basis_string = sseq.BidegreeElement( + c + ext.LAMBDA_BIDEGREE, + g.restrict(target_num_gens, target_all_gens).to_owned(), + ).to_basis_string() + if num_entries == 1: + print(f"{basis_string}", end="") + else: + print(f"({basis_string})", end="") + print("> = ±", end="") + + scratch0 = [0] * source_num_gens + scratch1.set_scratch_vector_size(source_lambda_num_gens) + + # First deal with the null-homotopy of ab + for i, val in g.restrict(0, target_num_gens).iter_nonzero(): + for k in range(source_num_gens): + scratch0[k] += val * m0[i][k] + scratch1.as_slice_mut().add(m1.row(i), val) + for i, val in g.restrict(target_num_gens, target_all_gens).iter_nonzero(): + scratch1.as_slice_mut().add(mt.row(i), val) + # Now do the -1 part of the null-homotopy of bc. + sign = p_int * p_int - 1 + out = b.product_nullhomotopy(b_lambda, unit_sseq, c, g) + for i, val in out.iter_nonzero(): + for k in range(source_num_gens): + scratch0[k] += val * ma[i][k] * sign + + for i, val in enumerate(scratch0): + extra = val // p_int + scratch1.as_slice_mut().add(mp.row(i), extra % p_int) + + print(f"[{', '.join(str(x % p_int) for x in scratch0)}]", end="") + + # Then deal with the rest of the null-homotopy of bc. This is just the null-homotopy of + # 2. + scratch0 = [0] * prod_num_gens + + for i, val in g.restrict(0, target_num_gens).iter_nonzero(): + for k in range(prod_num_gens): + scratch0[k] += val * mb[i][k] + for i, val in enumerate(scratch0): + extra = (val // p_int) % p_int + if extra == 0: + continue + for gen_idx in range(source_lambda_num_gens): + mm = a.underlying.get_map((source + ext.LAMBDA_BIDEGREE).s) + dx = mm.output((source + ext.LAMBDA_BIDEGREE).t, gen_idx) + idx = unit.module((c + shift).s).operation_generator_to_index( + 1, h_0, (c + shift).t, i + ) + scratch1.add_basis_element(gen_idx, dx.entry(idx)) + print(f" + λ{scratch1}") + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/secondary_product.py b/ext_py/examples/secondary_product.py new file mode 100644 index 0000000000..78a347c982 --- /dev/null +++ b/ext_py/examples/secondary_product.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Compute products in Mod_{C lambda^2}. + +Python port of ext/examples/secondary_product.rs. +""" + +import os + +import _query as query +import ext +from ext import algebra, fp, sseq + + +def main(): + # standard backend: this example uses get_unit()/SecondaryResolution, unavailable on Nassau + resolution = query.query_resolution(alg=algebra.AlgebraType.Milnor, algorithm="standard") + resolution.compute_through_stem(query.query_n_s()) + + is_unit, unit = ext.get_unit(resolution) + + p = resolution.prime + + name = query.raw("Name of product", str) + + shift = sseq.Bidegree.n_s( + query.raw(f"n of Ext class {name}", int), + query.raw(f"s of Ext class {name}", int), + ) + + hom = ext.ResolutionHomomorphism(name, resolution, unit, shift) + + matrix = fp.Matrix(p, hom.source.number_of_gens_in_bidegree(shift), 1) + + if matrix.rows == 0 or matrix.columns == 0: + raise AssertionError("No classes in this bidegree") + + v = query.vector("Input ext class", matrix.rows) + for i, x in enumerate(v): + matrix.row_mut(i).set_entry(0, x) + + if not is_unit: + res_max = sseq.Bidegree.n_s( + resolution.module(0).max_computed_degree, + resolution.next_homological_degree - 1, + ) + unit.compute_through_stem(res_max - shift) + + hom.extend_step(shift, matrix) + hom.extend_all() + + res_lift = ext.SecondaryResolution(resolution) + res_lift.extend_all() + + # Check that class survives to E3. + m = res_lift.homotopy(shift.s + 2).homotopies.hom_k(shift.t) + assert len(m) == len(v) + total = [0] * len(m[0]) + for x, d2 in zip(v, m): + for k in range(len(total)): + total[k] += x * d2[k] + assert all(a % int(p) == 0 for a in total), "Class supports a non-zero d2" + + unit_lift = res_lift + if not is_unit: + unit_lift = ext.SecondaryResolution(unit) + unit_lift.extend_all() + + hom_lift = ext.SecondaryResolutionHomomorphism(res_lift, unit_lift, hom) + + secondary_job = os.environ.get("SECONDARY_JOB") + if secondary_job is not None: + hom_lift.compute_partial(int(secondary_job)) + return + + hom_lift.extend_all() + + # Compute E3 page + res_sseq = res_lift.e3_page + unit_sseq = res_sseq if is_unit else unit_lift.e3_page + + def get_page_data(ss, b): + # NOTE: depends on Subquotient methods complement_pivots / subspace_gens / + # subspace_dimension on Subquotient (API_PROPOSAL §4, listed as "full Subquotient + # pyclass … add_gen, …" but these accessors are not enumerated) + # + # Sseq.page_data(b, r) returns the E_r page subquotient (r indexed by page + # number, starting at MIN_R = 2). We want the E3 page (r = 3), falling back + # to the last computed page when d2 was the final differential. + for r in (3, 2): + try: + return ss.page_data(b, r) + except IndexError: + continue + raise IndexError(f"no computed page data at bidegree {b}") + + name = hom_lift.name + # Iterate through the multiplicand + for b in unit.iter_nonzero_stem(): + # The potential target has to be hit, and we need to have computed (the data need for) the + # d2 that hits the potential target. + if not resolution.has_computed_bidegree(b + shift + ext.LAMBDA_BIDEGREE): + continue + if not resolution.has_computed_bidegree(b + shift - sseq.Bidegree.s_t(1, 0)): + continue + + page_data = get_page_data(unit_sseq, b) + + target_num_gens = resolution.number_of_gens_in_bidegree(b + shift) + lambda_num_gens = resolution.number_of_gens_in_bidegree( + b + shift + ext.LAMBDA_BIDEGREE + ) + + if target_num_gens == 0 and lambda_num_gens == 0: + continue + + # First print the products with non-surviving classes + if target_num_gens > 0: + hom_k = hom.get_map((b + shift).s).hom_k(b.t) + for i in page_data.complement_pivots: + g = sseq.BidegreeGenerator(b, i) + print(f"{name} λ x_{g} = λ {list(hom_k[i])}") + + # Now print the secondary products + if page_data.subspace_dimension == 0: + continue + + outputs = [ + fp.FpVector(p, target_num_gens + lambda_num_gens) + for _ in range(page_data.subspace_dimension) + ] + + hom_lift.hom_k( + res_sseq, + b, + page_data.subspace_gens, + [out.as_slice_mut() for out in outputs], + ) + for g, output in zip(page_data.subspace_gens, outputs): + basis_string = sseq.BidegreeElement(b, g.to_owned()).to_basis_string() + print( + f"{name} [{basis_string}] = " + f"{output.slice(0, target_num_gens)} + " + f"λ {output.slice(target_num_gens, target_num_gens + lambda_num_gens)}" + ) + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/sq0.py b/ext_py/examples/sq0.py new file mode 100644 index 0000000000..70ff9541c8 --- /dev/null +++ b/ext_py/examples/sq0.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Compute the action of Sq^0 on Ext^{*,*}(F_2) for the sphere at the prime 2. + +Python port of ext/examples/sq0.rs. +""" + +import _query as query +import ext +from ext import fp, sseq + +# NOTE: sq0 requires the inline `double` chain-complex machinery from +# ext/examples/sq0.rs, which is NOT part of API_PROPOSAL.md. This port assumes a +# future binding exposing DoubleChainComplex(resolution); it cannot run until +# that (or a Python-subclassable ChainComplex) is provided. + + +def main(): + # Build the resolution and resolve it through the queried (n, s) stem. + res = query.query_resolution(algorithm="standard") + res.compute_through_stem(query.query_n_s()) + assert ( + res.prime == 2 + and res.target.max_s == 1 + and res.target.module(0).is_unit + ), "Sq^0 can only be computed for the sphere at the prime 2" + + # NOTE: depends on a future `DoubleChainComplex` binding (see top-of-file + # note). The doubled chain complex halves Steenrod operations degree-wise. + doubled = ext.DoubleChainComplex(res) + doubled.compute_through_bidegree( + sseq.Bidegree.s_t(res.next_homological_degree - 1, 0) + ) + + hom = ext.ResolutionHomomorphism( + "Sq^0", + res, + doubled, + sseq.Bidegree.zero(), + ) + # NOTE: depends on `ResolutionHomomorphism.extend_step_raw`, which is not + # explicitly listed in API_PROPOSAL.md (§7.3 lists `extend_step`). The Rust + # call passes an optional list of output FpVectors for the first step. + hom.extend_step_raw( + sseq.Bidegree.zero(), + [fp.FpVector.from_slice(res.prime, [1])], + ) + hom.extend_all() + + for b in res.iter_nonzero_stem(): + doubled_b = sseq.Bidegree.s_t(b.s, 2 * b.t) + if not res.has_computed_bidegree(doubled_b): + continue + + source_num_gens = res.number_of_gens_in_bidegree(doubled_b) + module = res.module(b.s) + offset = module.generator_offset(b.t, b.t, 0) + map = hom.get_map(b.s) + + for i in range(res.number_of_gens_in_bidegree(b)): + g = sseq.BidegreeGenerator(b, i) + entries = [ + str(map.output(doubled_b.t, j).entry(offset + i)) + for j in range(source_num_gens) + ] + print(f"Sq^0 x_{g} = [{', '.join(entries)}]") + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/steenrod.py b/ext_py/examples/steenrod.py new file mode 100755 index 0000000000..5dc3789369 --- /dev/null +++ b/ext_py/examples/steenrod.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Compute Steenrod operations on an Ext class of the sphere. + +Python port of ext/examples/steenrod.rs. +""" + +import sys + +import _query as query +import ext +from ext import fp, sseq +from ext.algebra import FreeModuleHomomorphism + +# NOTE: depends on TensorChainComplex / SumModule / TensorChainMap (not in +# API_PROPOSAL / requires Rust-side machinery). steenrod.rs defines these as +# inline modules (`sum_module`, `tensor_product_chain_complex`) implementing the +# `Module`, `ChainComplex` and `ModuleHomomorphism` traits with custom +# block-structure / tensor-product / quasi-inverse logic. They cannot be +# expressed as a thin Python composition of the proposed bindings; the flow +# below is ported faithfully against the proposed API, assuming an +# `ext.TensorChainComplex` providing `.new`, `.module`, `.differential`, +# `.compute_through_bidegree` and the `.swap(result, vec, bidegree)` helper that +# the example's `TensorChainComplex` exposes. + + +def main(): + # ext::utils::init_logging() -> stderr only; no stdout effect. + + resolution = query.query_resolution("Module", None, algorithm="standard") + module = resolution.target.module(0) + p = resolution.prime + + if resolution.target.max_s != 1 or not module.is_unit or p != 2: + raise AssertionError("Can only run Steenrod on the sphere") + + b = sseq.Bidegree.n_s( + query.raw("n of Ext class", int), + query.raw("s of Ext class", int), + ) + + resolution.compute_through_bidegree(b + b) + + class_ = query.vector("Input Ext class", resolution.number_of_gens_in_bidegree(b)) + + yoneda = ext.yoneda_representative_element(resolution, b, class_) + + print("Dimensions of Yoneda representative: 1", end="") + for s in range(b.s + 1): + print(f" {yoneda.module(s).total_dimension}", end="") + print() + + # NOTE: depends on TensorChainComplex (not in API_PROPOSAL / requires + # Rust-side machinery). + square = ext.TensorChainComplex.new(yoneda, yoneda) + doubled_b = b + b + + # tracing::info_span!("Computing quasi-inverses") -> stderr only. + square.compute_through_bidegree(doubled_b) + for s in range(doubled_b.s + 1): + square.differential(s).compute_auxiliary_data_through_degree(doubled_b.t) + + print("Computing Steenrod operations: ", file=sys.stderr) + + delta = [] + + for i in range(b.s + 1): + maps = [] + for s in range(doubled_b.s - i + 1): + source = resolution.module(s) + target = square.module(s + i) + m = FreeModuleHomomorphism(source, target, 0) + maps.append(m) + delta.append(maps) + + # tracing::info_span!("Computing Steenrod operations") -> stderr only. + + # We use the formula d Delta_i + Delta_i d = Delta_{i-1} + tau Delta_{i-1} + for i in range(b.s + 1): + shift_s = sseq.Bidegree.s_t(i, 0) + # Delta_i is a map C_s -> C_{s + i}. So to hit C_{2s}, we only need to + # compute up to 2 * s - i. + # std::time::Instant::now() -> only used for the stderr timing print. + + for s in range((doubled_b - shift_s).s + 1): + if i == 0 and s == 0: + m = delta[0][0] + m.add_generators_from_matrix_rows( + 0, fp.Matrix.from_vec(p, [[1]]).slice_mut(0, 1, 0, 1) + ) + m.extend_by_zero(doubled_b.t) + continue + + source = resolution.module(s) + target = square.module(s + i) + + dtarget_module = square.module(s + i - 1) + + d_res = resolution.differential(s) + d_target = square.differential(s + i) + + m = delta[i][s] + prev_map = None if s == 0 else delta[i][s - 1] + prev_delta = None if i == 0 else delta[i - 1][s] + + for t in range(doubled_b.t + 1): + bb = sseq.Bidegree.s_t(s, t) + + num_gens = source.number_of_gens_in_degree(t) + + fx_dim = target.dimension(t) + fdx_dim = dtarget_module.dimension(t) + + if fx_dim == 0 or fdx_dim == 0 or num_gens == 0: + m.extend_by_zero(t) + continue + + output_matrix = fp.Matrix(p, num_gens, fx_dim) + result = fp.FpVector(p, fdx_dim) + for j in range(num_gens): + if prev_delta is not None: + # Delta_{i-1} x + prevd = prev_delta.output(t, j) + + # tau Delta_{i-1} x + # NOTE: depends on TensorChainComplex.swap (not in + # API_PROPOSAL / requires Rust-side machinery). + square.swap( + result, prevd, bb + shift_s - sseq.Bidegree.s_t(1, 0) + ) + result.add(prevd.as_slice(), 1) + + if prev_map is not None: + dx = d_res.output(t, j) + prev_map.apply( + result.slice_mut(0, fdx_dim), 1, t, dx.as_slice() + ) + + assert d_target.apply_quasi_inverse( + output_matrix.row_mut(j), t, result.as_slice() + ) + + result.set_to_zero() + m.add_generators_from_matrix_rows( + t, output_matrix.slice_mut(0, num_gens, 0, fx_dim) + ) + + final_map = delta[i][(doubled_b - shift_s).s] + num_gens = resolution.number_of_gens_in_bidegree(doubled_b - shift_s) + basis_string = sseq.BidegreeElement( + b, fp.FpVector.from_slice(p, class_) + ).to_basis_string() + result = ", ".join( + str(final_map.output(doubled_b.t, k).entry(0)) for k in range(num_gens) + ) + print(f"Sq^{(b - shift_s).s} {basis_string} = [{result}]", end="") + sys.stdout.flush() + # eprint!(" ({:?})", start.elapsed()) -> stderr timing only. + sys.stderr.flush() + print() + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/tensor.py b/ext_py/examples/tensor.py new file mode 100755 index 0000000000..d9f6fa0654 --- /dev/null +++ b/ext_py/examples/tensor.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Tensor two modules together and print the result as module JSON. + +Python port of ext/examples/tensor.rs. +""" + +import json + +import _query as query +import ext +from ext import algebra + + +def main(): + left = query.with_default("Left module", "S_2", ext.parse_module_name) + p = left["p"] + + def parse_right(name): + module = ext.parse_module_name(name) + if module["p"] != p: + raise ValueError("Two modules must be over the same prime") + return module + + right = query.with_default("Right module", "S_2", parse_right) + + alg = algebra.SteenrodAlgebra.adem(p) + + left_module = algebra.SteenrodModule.from_spec(left, alg) + right_module = algebra.SteenrodModule.from_spec(right, alg) + + tensor_module = algebra.TensorModule(left_module, right_module) + + # Convert to a finite dimensional module for output. + # NOTE: `from_tensor_module` is NOT yet bound (aspirational API); the class + # was renamed FDModule -> FDModuleBuilder, but this conversion constructor is + # still pending in the bindings. This line will not run until it is bound. + tensor = algebra.FDModuleBuilder.from_tensor_module(tensor_module) + tensor.name = "" + + output = {"p": p} + output.update(tensor.to_json()) + + # serde_json's Display is compact (no spaces) and preserves insertion order. + print(json.dumps(output, separators=(",", ":"), ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/unstable_chart.py b/ext_py/examples/unstable_chart.py new file mode 100755 index 0000000000..f32fdae9eb --- /dev/null +++ b/ext_py/examples/unstable_chart.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Compute and chart the suspension maps between unstable Ext groups. + +Given an unstable Steenrod module M, compute the unstable Ext groups of the +suspensions of M for all shifts up to the stable range, writing a TikZ figure +for each shift to stdout. + +Python port of ext/examples/unstable_chart.rs. +""" + +import os +import sys + +import _query as query +import ext +from ext import algebra, sseq + + +def query_unstable_module_only(): + """Inline mirror of ext::utils::query_unstable_module_only. + + Queries a single "Module" spec, parses the optional ``@adem``/``@milnor`` + algebra suffix (default Milnor) and the module name, builds the algebra with + ``unstable=True`` and returns the corresponding Steenrod module. + """ + + def parse_spec(spec): + # Mirror Config::try_from(&str): split on '@' for the algebra type. + module_name, _, algebra_name = spec.partition("@") + if algebra_name == "": + algebra_type = algebra.AlgebraType.Milnor + elif algebra_name == "adem": + algebra_type = algebra.AlgebraType.Adem + elif algebra_name == "milnor": + algebra_type = algebra.AlgebraType.Milnor + else: + raise ValueError(f"Invalid algebra type: {algebra_name}") + # NOTE: depends on ext.parse_module_name (API_PROPOSAL §7.4). + module = ext.parse_module_name(module_name) + return (module, algebra_type) + + module_json, algebra_type = query.raw("Module", parse_spec) + alg = algebra.SteenrodAlgebra.from_json(module_json, algebra_type, True) + return algebra.SteenrodModule.from_spec(module_json, alg) + + +def main(): + module = query_unstable_module_only() + + # Mirror the `save_dir` closure: an optional base directory under which each + # shift gets its own `suspension{shift}` subdirectory. + base = query.optional("Module save directory", str) + + def save_dir(shift): + if base is None: + return None + return os.path.join(base, f"suspension{shift}") + + max = sseq.Bidegree.n_s( + query.raw("Max n", int), + query.raw("Max s", int), + ) + + disp_template = query.raw( + "LaTeX name template (replace % with min degree)", + str, + ) + + products = module.algebra.default_filtration_one_products + + for shift_t in range(0, max.n - module.min_degree + 3): + shift = sseq.Bidegree.s_t(0, shift_t) + # NOTE: depends on ext.SuspensionModule, ext.ChainComplex.ccdz and + # ext.UnstableResolution.new_with_save (API_PROPOSAL §7.1, §7.3, §7.5). + res = ext.UnstableResolution( + ext.ChainComplex.ccdz( + algebra.SuspensionModule(module, shift.t) + ), + save_dir=save_dir(shift.t), + ) + + res.compute_through_stem(max + shift) + + print("\\begin{figure}[p]\\centering") + + ss = res.to_sseq() + shift_products = [ + (name, res.filtration_one_products(op_deg, op_idx)) + for (name, op_deg, op_idx) in products + ] + + def header(g, shift_t=shift_t): + return g.text( + sseq.Bidegree.x_y(1, max.s - 1), + disp_template.replace("%", f"{shift_t}"), + sseq.Orientation.Right, + ) + + ss.write_to_graph( + sseq.TikzBackend(sys.stdout), + page=2, + products=shift_products, + header=header, + ) + + print("\\end{figure}") + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/unstable_suspension.py b/ext_py/examples/unstable_suspension.py new file mode 100644 index 0000000000..f8f08b44f8 --- /dev/null +++ b/ext_py/examples/unstable_suspension.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Compute the suspension map between different unstable Ext groups. + +Given an unstable Steenrod module M, compute the unstable Ext groups of the +suspensions of M for all shifts up to the stable range. Each result is printed +in the form ``n s k: num_gens - matrix``. + +Python port of ext/examples/unstable_suspension.rs. +""" + +import os + +import _query as query +import ext +from ext import algebra, fp, sseq + + +def query_unstable_module_only(): + """Inline mirror of ext::utils::query_unstable_module_only. + + Queries a single "Module" spec, parses the optional ``@adem``/``@milnor`` + algebra suffix (default Milnor) and the module name, builds the algebra with + ``unstable=True`` and returns the corresponding Steenrod module. + """ + + def parse_spec(spec): + # Mirror Config::try_from(&str): split on '@' for the algebra type. + module_name, _, algebra_name = spec.partition("@") + if algebra_name == "": + algebra_type = algebra.AlgebraType.Milnor + elif algebra_name == "adem": + algebra_type = algebra.AlgebraType.Adem + elif algebra_name == "milnor": + algebra_type = algebra.AlgebraType.Milnor + else: + raise ValueError(f"Invalid algebra type: {algebra_name}") + # NOTE: depends on ext.parse_module_name (API_PROPOSAL §7.7). + module = ext.parse_module_name(module_name) + return (module, algebra_type) + + module_json, algebra_type = query.raw("Module", parse_spec) + alg = algebra.SteenrodAlgebra.from_json(module_json, algebra_type, True) + return algebra.SteenrodModule.from_spec(module_json, alg) + + +def main(): + module = query_unstable_module_only() + + # Mirror the `save_dir` closure: an optional base directory under which each + # shift gets its own `suspension{shift}` subdirectory. + base = query.optional("Module save directory", str) + + def save_dir(shift): + if base is None: + return None + return os.path.join(base, f"suspension{shift}") + + max = sseq.Bidegree.n_s( + query.raw("Max n", int), + query.raw("Max s", int), + ) + min_degree = sseq.Bidegree.s_t(0, module.min_degree) + + # NOTE: depends on ext.SuspensionModule, ext.ChainComplex.ccdz and + # ext.UnstableResolution.new_with_save (API_PROPOSAL §5.3, §7.1, §7.2). + res_b = ext.UnstableResolution( + ext.ChainComplex.ccdz(algebra.SuspensionModule(module, 0)), + save_dir=save_dir(0), + ) + res_b.compute_through_stem(max) + + for n in range(min_degree.n, max.n + 1): + for s in range(0, max.s + 1): + b = sseq.Bidegree.n_s(n, s) + source_num_gens = res_b.number_of_gens_in_bidegree(b) + print(f"{n} {s} 0: {source_num_gens}") + + for shift_t in range(1, (max - min_degree).n + 3): + shift = sseq.Bidegree.s_t(0, shift_t) + res_a = res_b + res_b = ext.UnstableResolution( + ext.ChainComplex.ccdz(algebra.SuspensionModule(module, shift_t)), + save_dir=save_dir(shift_t), + ) + + res_b.compute_through_stem(max + shift) + + suspension_shift = sseq.Bidegree.s_t(0, 1) + # NOTE: depends on ext.UnstableResolutionHomomorphism + # (API_PROPOSAL §7.3). + hom = ext.UnstableResolutionHomomorphism( + "suspension", + res_b, + res_a, + suspension_shift, + ) + + # NOTE: depends on UnstableResolutionHomomorphism.extend_step_raw + # (API_PROPOSAL §7.3 lists extend_step; sq0.py relies on the same hook). + hom.extend_step_raw( + min_degree + shift, + [fp.FpVector.from_slice(module.prime, [1])], + ) + hom.extend_all() + + for n in range(2 * ((min_degree + shift).n - 1), (max + shift).n + 1): + if n < (min_degree + shift).n: + continue + for s in range(0, max.s + 1): + source = sseq.Bidegree.n_s(n, s) + target = source - suspension_shift + source_num_gens = res_b.number_of_gens_in_bidegree(source) + target_num_gens = res_a.number_of_gens_in_bidegree(target) + if source_num_gens == 0 or target_num_gens == 0: + m = "" + else: + mat = hom.get_map(target.s).hom_k(target.t) + is_identity = source_num_gens == target_num_gens and all( + all( + (z == 1 if col == row else z == 0) + for (col, z) in enumerate(x) + ) + for (row, x) in enumerate(mat) + ) + if is_identity: + m = "" + else: + m = f" - {[list(row) for row in mat]}" + print(f"{n - shift_t} {s} {shift_t}: {source_num_gens}{m}") + + +if __name__ == "__main__": + main() diff --git a/ext_py/examples/yoneda.py b/ext_py/examples/yoneda.py new file mode 100644 index 0000000000..569790ebd7 --- /dev/null +++ b/ext_py/examples/yoneda.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Compute a Yoneda representative of an Ext class and print module dimensions. + +Python port of ext/examples/yoneda.rs. +""" + +import _query as query +import ext +from ext import sseq + + +def main(): + # standard backend: this example uses yoneda_representative_element, unavailable on Nassau + resolution = query.query_resolution("Module", algorithm="standard") + + b = sseq.Bidegree.n_s( + query.raw("n of Ext class", int), + query.raw("s of Ext class", int), + ) + + resolution.compute_through_stem(b) + + class_ = query.vector( + "Input Ext class", resolution.number_of_gens_in_bidegree(b) + ) + + # NOTE: depends on ext.yoneda_representative_element (API_PROPOSAL §7.5). + yoneda = ext.yoneda_representative_element(resolution, b, class_) + + for s in range(0, b.s + 1): + print( + f"Dimension of {s}th module is {yoneda.module(s).total_dimension}" + ) + + +if __name__ == "__main__": + main() diff --git a/ext_py/flake.lock b/ext_py/flake.lock new file mode 100644 index 0000000000..1bc9f946d1 --- /dev/null +++ b/ext_py/flake.lock @@ -0,0 +1,190 @@ +{ + "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "super", + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1751352216, + "narHash": "sha256-dJj8TUoZGj55Ttro37vvFGF2L+xlYNfspQ9u4BfqTFw=", + "owner": "nix-community", + "repo": "fenix", + "rev": "61b4f1e21bd631da91981f1ed74c959d6993f554", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1751285371, + "narHash": "sha256-/hDU+2AUeFFu5qGHO/UyFMc4UG/x5Cw5uXO36KGTk6c=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b9c03fbbaf84d85bb28eee530c7e9edc4021ca1b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "super", + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ], + "uv2nix": [ + "uv2nix" + ] + }, + "locked": { + "lastModified": 1753063383, + "narHash": "sha256-H+gLv6424OjJSD+l1OU1ejxkN/v0U+yaoQdh2huCXYI=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "45888b7fd4bf36c57acc55f07917bdf49ec89ec9", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "super", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1753773975, + "narHash": "sha256-r0NuyhyLUeLe/kSr+u2VFGjHFdccBJckZyFt74MYL5A=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "62cc4495b3b2d2a259db321a06584378e93843a6", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "super": "super", + "uv2nix": "uv2nix" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1751296293, + "narHash": "sha256-oaGMVdCcI32y6jQ7RE0+CqshZngfI19XnY31eYjdinI=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "eaf37e2c98b66ff7f7a0ac04e4cada39e51fde4b", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "super": { + "inputs": { + "fenix": "fenix", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "path": "..", + "type": "path" + }, + "original": { + "path": "..", + "type": "path" + }, + "parent": [] + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "super", + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1753775881, + "narHash": "sha256-9G0Yo7TXFJEfSyHNrbV1WNEKhEojqQ3J0aWd0aYpixs=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "656928e823e305426200f478a887943a600db303", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/ext_py/flake.nix b/ext_py/flake.nix new file mode 100644 index 0000000000..3ae7f2402b --- /dev/null +++ b/ext_py/flake.nix @@ -0,0 +1,222 @@ +{ + description = "sseq_ext development environment - Python bindings for ext crate"; + + inputs = { + super.url = "path:.."; + + pyproject-nix = { + url = "github:pyproject-nix/pyproject.nix"; + inputs.nixpkgs.follows = "super/nixpkgs"; + }; + + uv2nix = { + url = "github:pyproject-nix/uv2nix"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.nixpkgs.follows = "super/nixpkgs"; + }; + + pyproject-build-systems = { + url = "github:pyproject-nix/build-system-pkgs"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.uv2nix.follows = "uv2nix"; + inputs.nixpkgs.follows = "super/nixpkgs"; + }; + }; + + outputs = { + self, + super, + uv2nix, + pyproject-nix, + pyproject-build-systems, + ... + }: + super.flake-utils.lib.eachDefaultSystem (system: let + inherit (super.nixpkgs) lib; + + # Load a uv workspace from a workspace root. + workspace = uv2nix.lib.workspace.loadWorkspace {workspaceRoot = ./.;}; + + # Create package overlay from workspace. + overlay = workspace.mkPyprojectOverlay { + sourcePreference = "wheel"; + }; + + # Extend generated overlay with build fixups + pyprojectOverrides = _final: _prev: { + # Implement build fixups here if needed. + }; + + pkgs = super.nixpkgs.legacyPackages.${system}; + python = pkgs.python311; + + # Construct package set + pythonSet = + (pkgs.callPackage pyproject-nix.build.packages { + inherit python; + }).overrideScope ( + lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + overlay + pyprojectOverrides + ] + ); + + # Additional packages for PyO3/maturin development + commonPackages = + [ + super.defaultPackages.rustToolchain.${system} + python + pkgs.python311Packages.pip + pkgs.maturin + pkgs.uv + pkgs.basedpyright + pkgs.ruff + pkgs.pkg-config + ] + ++ super.defaultPackages.devTools.${system} + ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ + pkgs.darwin.apple_sdk.frameworks.Security + pkgs.darwin.apple_sdk.frameworks.SystemConfiguration + ]; + in { + packages.default = pythonSet.mkVirtualEnv "sseq_ext-env" workspace.deps.default; + + apps.default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/python"; + }; + + devShells = { + # Impure development shell using uv and maturin + default = pkgs.mkShell { + packages = commonPackages; + env = + { + UV_PYTHON_DOWNLOADS = "never"; + RUST_BACKTRACE = "1"; + } + // lib.optionalAttrs pkgs.stdenv.isLinux { + LD_LIBRARY_PATH = lib.makeLibraryPath [pkgs.openssl]; + }; + shellHook = '' + unset PYTHONPATH + + # Create virtual environment if it doesn't exist + if [ ! -d ".venv" ]; then + echo "Creating Python virtual environment..." + ${python}/bin/python -m venv .venv + + # Only upgrade pip/setuptools/wheel when creating new venv + echo "Setting up virtual environment..." + source .venv/bin/activate + pip install --upgrade pip setuptools wheel + else + # Just activate existing venv + source .venv/bin/activate + fi + + # Override environment variables to ensure maturin uses the venv + export VIRTUAL_ENV="$PWD/.venv" + export PATH="$VIRTUAL_ENV/bin:$PATH" + export UV_PYTHON="$VIRTUAL_ENV/bin/python" + export PIP_PREFIX="$VIRTUAL_ENV" + export PYTHONPATH="$VIRTUAL_ENV/lib/python3.11/site-packages" + export RUST_LOG="ext=info" + + echo "🦀🐍 sseq_ext development environment" + echo "Rust: $(rustc --version)" + echo "Python: $(python --version) (virtual env at $VIRTUAL_ENV)" + echo "Maturin: $(maturin --version)" + echo "uv: $(uv --version)" + echo "" + echo "Quick start:" + echo " maturin develop # Build and install in development mode" + echo " maturin build # Build wheel" + echo " uv sync # Install dependencies" + echo "" + echo "For development:" + echo " cargo test # Run Rust tests" + echo " cargo clippy # Run Rust linter" + echo " uv build # Build package" + echo "" + echo "Note: Using virtual environment at .venv/" + echo "Python path: $(which python)" + echo "Pip path: $(which pip)" + ''; + }; + + # Pure development shell using uv2nix + pure = let + # Create an overlay enabling editable mode for local dependencies. + editableOverlay = workspace.mkEditablePyprojectOverlay { + root = "$REPO_ROOT"; + }; + + # Override previous set with our editable overlay. + editablePythonSet = pythonSet.overrideScope ( + lib.composeManyExtensions [ + editableOverlay + (final: prev: { + sseq_ext = prev.sseq_ext.overrideAttrs (old: { + src = lib.fileset.toSource { + root = old.src; + fileset = lib.fileset.unions [ + (old.src + "/pyproject.toml") + (old.src + "/Cargo.toml") + (old.src + "/Cargo.lock") + (old.src + "/src") + (old.src + "/examples") + ]; + }; + nativeBuildInputs = + old.nativeBuildInputs + ++ final.resolveBuildSystem { + maturin = []; + } + ++ [super.defaultPackages.rustToolchain.${system}]; + }); + }) + ] + ); + + # Build virtual environment, with local packages being editable. + virtualenv = editablePythonSet.mkVirtualEnv "sseq_ext-dev-env" workspace.deps.all; + in + pkgs.mkShell { + packages = [ + virtualenv + super.defaultPackages.rustToolchain.${system} + super.defaultPackages.devTools.${system} + pkgs.uv + pkgs.basedpyright + pkgs.ruff + pkgs.git + pkgs.pkg-config + ]; + + env = { + UV_NO_SYNC = "1"; + UV_PYTHON = "${virtualenv}/bin/python"; + UV_PYTHON_DOWNLOADS = "never"; + RUST_BACKTRACE = "1"; + }; + + shellHook = '' + unset PYTHONPATH + export REPO_ROOT=$(git rev-parse --show-toplevel) + echo "🦀🐍 sseq_ext pure development environment (uv2nix)" + echo "Rust: $(rustc --version)" + echo "Python: $(python --version)" + echo "uv: $(uv --version)" + echo "" + echo "Quick start:" + echo " maturin develop # Build and install (editable)" + echo " python examples/algebra_dim.py # Run example" + echo " uv build # Build package" + echo "" + ''; + }; + }; + }); +} diff --git a/ext_py/pyproject.toml b/ext_py/pyproject.toml new file mode 100644 index 0000000000..21ceef48bc --- /dev/null +++ b/ext_py/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["maturin>=1.8,<2.0"] +build-backend = "maturin" + +[project] +name = "sseq_ext" +requires-python = ">=3.8" +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" diff --git a/ext_py/python/ext/__init__.py b/ext_py/python/ext/__init__.py new file mode 100644 index 0000000000..7ecbbb2cc6 --- /dev/null +++ b/ext_py/python/ext/__init__.py @@ -0,0 +1,92 @@ +"""The ``ext`` package: a compiled PyO3 extension plus a thin pure-Python I/O layer. + +Layout (maturin "mixed" project): the compiled extension is installed as the +submodule ``ext.ext`` (``ext/ext..so``); this ``__init__`` is +the package's pure-Python source (from ``python/ext/`` in the repo). It +re-exports everything from the compiled submodule so that the historical import +surface is preserved EXACTLY, then layers the pure-Python I/O helpers on top. + +Import-surface contract (all of these must keep working): + - ``import ext`` + - ``ext.Resolution`` (and every other top-level compiled symbol) + - ``from ext import sseq`` / ``fp`` / ``algebra`` (as attributes) + - ``from ext.algebra import `` (etc.) + - ``import ext.ext`` (the compiled submodule, dotted path) + - ``ext.Resolution.construct`` / ``ext.query_resolution`` / ``ext.query_n_s`` + +The interactive *pure-Python* helpers ``ext.query_resolution`` (build a +resolution) and ``ext.query_n_s`` (prompt a target ``(n, s)`` bidegree) are +defined in ``ext.utils``. The lower-level *Rust* pyfunctions ``query_module`` / +``query_module_only`` remain reachable as ``ext.ext.query_module`` / +``query_module_only`` for anyone who needs them. +""" + +# 1) Re-export every compiled symbol (top-level functions/classes AND the +# submodules sseq/fp/algebra, which the compiled module exports) so +# ``ext.`` and ``from ext import `` behave as before. +from .ext import * # noqa: F401,F403 + +# 2) Keep the compiled submodule importable as ``ext.ext`` (some code and +# the fresh-build check use that dotted path) and mirror its docstring/__all__. +from . import ext as _ext # noqa: F401 + +# 3) Belt-and-suspenders: ``from .ext import *`` only re-exports names listed +# in the module's ``__all__`` (or, absent that, its non-underscore globals). +# Explicitly bind the submodules so ``from ext import sseq`` works as an +# attribute even if a future ``__all__`` change drops them. This is additive +# and never drops a currently-exported top-level symbol. +from .ext import algebra, fp, sseq # noqa: F401 + +# Register the compiled submodules under their dotted package paths in +# ``sys.modules`` so the true-submodule import form ``from ext.algebra +# import `` (used by several examples) resolves. The compiled module +# exports them with bare ``__name__``s (``algebra`` etc.), which Python's +# import machinery does not find as ``ext.algebra`` on its own; this is +# additive and never breaks the attribute form ``from ext import algebra``. +import sys as _sys # noqa: E402 + +for _sub in (algebra, fp, sseq): + _sys.modules.setdefault(f"{__name__}.{_sub.__name__}", _sub) + +__doc__ = _ext.__doc__ +if hasattr(_ext, "__all__"): + __all__ = list(_ext.__all__) + +# 4) Layer the pure-Python I/O utilities ON TOP of the compiled symbols. This is +# done AFTER ``from .ext import *``. The interactive I/O helpers +# ``query_resolution`` / ``query_n_s`` live in Python, while the Rust +# pyfunctions ``query_module`` / ``query_module_only`` remain bound under +# ``ext.ext.query_module*`` for anyone who needs them. +from .utils import ( # noqa: E402 + LAMBDA_BIDEGREE, + query_n_s, + query_resolution, + query_unstable_module, + query_unstable_module_only, + unicode_num, +) + +# Re-export the low-level query primitives too, so ``ext._query`` consumers +# and examples can reach them via the package if desired. +from ._query import ( # noqa: E402,F401 + optional, + raw, + vector, + with_default, + yes_no, +) + +# Make sure the Python utils appear in __all__ (so ``from ext import *`` in +# downstream code exposes them, and they take precedence over the shadowed +# compiled names). +for _name in ( + "query_resolution", + "query_n_s", + "query_unstable_module", + "query_unstable_module_only", + "construct_unstable", + "unicode_num", + "LAMBDA_BIDEGREE", +): + if "__all__" in dir() and _name not in __all__: + __all__.append(_name) diff --git a/ext_py/python/ext/_query.py b/ext_py/python/ext/_query.py new file mode 100644 index 0000000000..d95322a7cc --- /dev/null +++ b/ext_py/python/ext/_query.py @@ -0,0 +1,107 @@ +"""Python mirror of the Rust ``query`` crate (``ext/crates/query/src/lib.rs``). + +Each query reads the next command-line argument and parses it; if no arguments +remain, it prompts the user on stderr and reads a line from stdin, re-prompting +until the input parses. Prompts, parse errors and logging all go to **stderr**, +so stdout stays byte-identical to the recorded benchmark output. + +The example scripts drive all of their interactive input through this module so +that a single argument stream (``sys.argv[1:]``) feeds every prompt in order, +exactly as the Rust examples consume ``std::env::args()``. +""" + +import sys + +# The argument stream, mirroring the Rust ``ARGV`` thread-local: argv minus the +# program name, consumed left-to-right by successive queries. +_args = iter(sys.argv[1:]) + + +def _reset_args(args=None): + """Reset the module-level argument stream. + + Test hook (and a convenience for embedders): rebuild ``_args`` from ``args`` + (default: the current ``sys.argv[1:]``). Lets a test feed a deterministic + answer sequence without reloading the module. Returns the new iterator. + """ + global _args + _args = iter(sys.argv[1:] if args is None else args) + return _args + + +def raw(prompt, parser): + """Read and parse one answer. + + If a command-line argument remains, parse it, exiting the process on failure + (matching the Rust crate, which treats a bad CLI argument as fatal). + Otherwise prompt on stderr and re-read from stdin until the input parses. + """ + arg = next(_args, None) + if arg is not None: + print(f"{prompt}: {arg}", file=sys.stderr) + try: + return parser(arg) + except Exception as e: # noqa: BLE001 - mirror Rust's fatal CLI parse error + print(f"{e}", file=sys.stderr) + sys.exit(1) + + while True: + print(f"{prompt}: ", end="", file=sys.stderr, flush=True) + line = sys.stdin.readline() + # At EOF, read_line yields "": treated as an empty answer, exactly as the + # Rust crate does. Empty answers are valid for optional/with_default/yes_no + # (they fall back to the default); a parser that rejects empty input would + # loop forever on repeated EOF in Rust, so we exit instead of hanging. + at_eof = line == "" + try: + return parser(line.strip()) + except Exception as e: # noqa: BLE001 - mirror Rust's retry loop + if at_eof: + print(f"{e}", file=sys.stderr) + sys.exit(1) + print(f"{e}\n\nTry again", file=sys.stderr) + + +def with_default(prompt, default, parser): + """Query with a default used when the answer is empty.""" + + def parse(x): + return parser(default) if x == "" else parser(x) + + return raw(f"{prompt} (default: {default})", parse) + + +def optional(prompt, parser): + """Query an optional value; an empty answer yields ``None``.""" + + def parse(x): + return None if x == "" else parser(x) + + return raw(f"{prompt} (optional)", parse) + + +def yes_no(prompt): + """Query a yes/no answer, defaulting to yes.""" + + def parse(response): + if response.startswith("y") or response.startswith("n"): + return response.startswith("y") + raise ValueError( + f"unrecognized response '{response}'. Should be '(y)es' or '(n)o'" + ) + + return with_default(prompt, "y", parse) + + +def vector(prompt, length): + """Query a vector written as ``[a, b, c]`` with a fixed length.""" + + def parse(s): + v = [int(x.strip()) for x in s[1 : len(s) - 1].split(",")] + if len(v) != length: + raise ValueError( + f"Target has dimension {length} but {len(v)} coordinates supplied" + ) + return v + + return raw(prompt, parse) diff --git a/ext_py/python/ext/utils.py b/ext_py/python/ext/utils.py new file mode 100644 index 0000000000..1b626c797e --- /dev/null +++ b/ext_py/python/ext/utils.py @@ -0,0 +1,182 @@ +"""Pure-Python I/O-driven resolution helpers, layered on the compiled bindings. + +These mirror ``ext::utils::query_module`` / ``query_module_only`` (see +``ext/src/utils.rs``): they prompt (via :mod:`ext._query`) for a module spec +and an optional save directory, then build a :class:`ext.Resolution` via the +compiled :meth:`ext.Resolution.construct` pyfunction. All interactive I/O lives here in +Python; the Rust ``Resolution.construct`` does no prompting. + +Algebra vs. algorithm reconciliation +------------------------------------- +The compiled ``Resolution.construct(spec, save_dir=None, algorithm=None)`` takes an +``algorithm`` string selecting the resolution ALGORITHM (``"auto"`` / ``"nassau"`` +/ ``"standard"``), NOT the Steenrod-algebra basis. The algebra basis (Adem vs +Milnor) is instead selected by an ``@adem`` / ``@milnor`` suffix on the spec +string, which ``Config`` parses (matching the Rust ``query_module_only``). So the +``algebra`` argument of these helpers, when given, is encoded as a spec suffix -- +it is never forwarded to ``construct`` as the ``algorithm`` parameter. +""" + +import os + +from . import ext as _ext +from . import _query + +# Mirror of ``ext::utils::unicode_num`` (ext/src/utils.rs): map a small +# non-negative integer to a single UTF-8 "domino"/dots character. This is pure +# string formatting (no Rust state), so it is implemented here in Python and +# matches the upstream output byte-for-byte. The table is exactly upstream's: +# 0 -> ' ', 1 -> '·', 2 -> ':', 3 -> '∴', 4 -> '⁘', 5 -> '⁙', +# 6 -> '⠿', 7 -> '⡿', 8 -> '⣿', 9 -> '9', everything else -> '*'. +_UNICODE_NUM = { + 0: " ", + 1: "·", + 2: ":", + 3: "∴", + 4: "⁘", + 5: "⁙", + 6: "⠿", + 7: "⡿", + 8: "⣿", + 9: "9", +} + + +def unicode_num(n): + """Return a single UTF-8 character depicting the small integer ``n``. + + Faithful port of ``ext::utils::unicode_num(n: usize) -> char``: ``n`` in + ``0..=8`` maps to a Braille/dots glyph (with ``0`` -> a space), ``9`` maps to + the literal ``'9'``, and anything ``>= 10`` maps to ``'*'``. Used by + ``examples/ext_m_n.py`` to render homology dimensions compactly. + """ + return _UNICODE_NUM.get(n, "*") + + +# The lambda-algebra bidegree constant, sourced from the Rust +# ``ext::secondary::LAMBDA_BIDEGREE`` (``Bidegree::n_s(0, 1)``) via the compiled +# ``lambda_bidegree()`` pyfunction so it cannot drift from upstream. Bound as a +# module-level VALUE (a ``sseq.Bidegree``) because the examples use it as one +# (e.g. ``shift + ext.LAMBDA_BIDEGREE``). +LAMBDA_BIDEGREE = _ext.lambda_bidegree() + + +def _algebra_suffix(alg): + """Normalize an ``algebra`` argument to the ``"adem"``/``"milnor"`` suffix. + + Accepts either a plain string (``"adem"``/``"milnor"``) or an + ``algebra.AlgebraType`` (whose ``str()`` is e.g. ``"AlgebraType.Milnor"``). + """ + s = str(alg).rsplit(".", 1)[-1].strip().lower() + if s not in ("adem", "milnor"): + raise ValueError( + f"unrecognized algebra {alg!r}; expected 'adem' or 'milnor' " + "(or an algebra.AlgebraType)" + ) + return s + + +def query_resolution(prompt="Module", alg=None, save_dir=None, algorithm=None): + """Build a :class:`ext.Resolution` from interactive input (formerly + ``query_module_only``). + + Prompt for a module spec (default ``S_2``); prompt for an optional save + directory IN PYTHON unless ``save_dir`` is supplied by the caller; then build + and return a :class:`ext.Resolution` via :meth:`ext.Resolution.construct`. + + ``algebra`` (a string or ``algebra.AlgebraType``), when given and the spec + does not already carry an ``@`` suffix, is appended as ``@`` so the + chosen basis is honored. See the module docstring for why this is not passed + as ``construct``'s ``algorithm`` argument. + + ``algorithm`` selects the resolution TYPE (the algorithm): ``None``/``"auto"`` + (try Nassau, fall back to the standard algorithm), ``"nassau"``, or + ``"standard"``. It is forwarded to :meth:`ext.Resolution.construct`. Use + ``"standard"`` when you need standard-backend-only features (e.g. + ``module``/``target``, ``get_unit``, ``SecondaryResolution``, + ``yoneda_representative_element``), which the Nassau backend cannot provide. + """ + spec = _query.with_default(prompt, "S_2", str) + + if alg is not None and "@" not in spec: + spec = f"{spec}@{_algebra_suffix(alg)}" + + if save_dir is None: + save_dir = _query.optional(f"{prompt} save directory", str) + + return _ext.Resolution.construct(spec, save_dir, algorithm) + + +def query_n_s(): + """Prompt for ``Max n`` (default 30) and ``Max s`` (default 7) and return the + target :class:`ext.sseq.Bidegree` (``n_s``). + + Honors the ``SECONDARY_JOB`` environment hook (capping ``max_s``). This does + NOT build or resolve anything: pair it with :func:`query_resolution` and call + ``compute_through_stem`` yourself, e.g.:: + + res = query_resolution() + res.compute_through_stem(query_n_s()) + """ + max_n = _query.with_default("Max n", "30", int) + max_s = _query.with_default("Max s", "7", int) + + secondary_job = os.environ.get("SECONDARY_JOB") + if secondary_job is not None: + s = int(secondary_job) + if s > max_s: + raise ValueError("SECONDARY_JOB is larger than max_s") + max_s = min(s + 1, max_s) + + return _ext.sseq.Bidegree.n_s(max_n, max_s) + + +def query_unstable_module_only(prompt="Module", alg=None, save_dir=None): + """Mirror of ``ext::utils::query_unstable_module_only``. + + The unstable analogue of :func:`query_module_only`: prompt for a module spec + (default ``S_2``); prompt for an optional save directory IN PYTHON unless + ``save_dir`` is supplied; then build and return an + :class:`ext.UnstableResolution` via :func:`ext.construct_unstable`. + + Unstable resolutions are computed by the general algorithm only (there is no + Nassau analogue), so there is no ``algorithm`` argument. The algebra basis + (Adem vs Milnor, default Milnor) is selected by an ``@adem``/``@milnor`` + suffix on the spec, exactly as in :func:`query_module_only`; ``algebra``, + when given and the spec has no ``@`` suffix, is appended as ``@``. + """ + spec = _query.with_default(prompt, "S_2", str) + + if alg is not None and "@" not in spec: + spec = f"{spec}@{_algebra_suffix(alg)}" + + if save_dir is None: + save_dir = _query.optional(f"{prompt} save directory", str) + + return _ext.construct_unstable(spec, save_dir) + + +def query_unstable_module(alg=None, save_dir=None): + """Mirror of the PYTHON :func:`query_module` flow, for the unstable family. + + NOTE: this does NOT mirror the Rust ``ext::utils::query_unstable_module``, + which only builds the resolution (it neither prompts ``Max n``/``Max s`` nor + resolves through a stem). Like the Python :func:`query_module`, this helper + builds an unstable module via :func:`query_unstable_module_only`, then prompts + for ``Max n`` (default 30) and ``Max s`` (default 7), honors the + ``SECONDARY_JOB`` environment hook (capping ``max_s``), resolves through that + stem, and returns the :class:`ext.UnstableResolution`. + """ + resolution = query_unstable_module_only("Module", alg, save_dir) + max_n = _query.with_default("Max n", "30", int) + max_s = _query.with_default("Max s", "7", int) + + secondary_job = os.environ.get("SECONDARY_JOB") + if secondary_job is not None: + s = int(secondary_job) + if s > max_s: + raise ValueError("SECONDARY_JOB is larger than max_s") + max_s = min(s + 1, max_s) + + resolution.compute_through_stem(_ext.sseq.Bidegree.n_s(max_n, max_s)) + return resolution diff --git a/ext_py/src/algebra_mod.rs b/ext_py/src/algebra_mod.rs new file mode 100644 index 0000000000..0227f2f535 --- /dev/null +++ b/ext_py/src/algebra_mod.rs @@ -0,0 +1,7847 @@ +use pyo3::prelude::*; + +#[pymodule] +#[pyo3(name = "algebra")] +pub mod algebra_py { + use std::sync::Arc; + + use ::algebra::module::{ + block_structure::BlockStructure as RsBlockStructure, + homomorphism::{ + FreeModuleHomomorphism as RsFreeModuleHomomorphism, + FullModuleHomomorphism as RsFullModuleHomomorphism, + GenericZeroHomomorphism as RsGenericZeroHomomorphism, HomPullback as RsHomPullback, + IdentityHomomorphism, ModuleHomomorphism, + MuFreeModuleHomomorphism as RsMuFreeModuleHomomorphism, + QuotientHomomorphism as RsQuotientHomomorphism, + QuotientHomomorphismSource as RsQuotientHomomorphismSource, ZeroHomomorphism, + }, + steenrod_module, FDModule as RsFDModule, FPModule as RsFPModule, + FreeModule as RsFreeModule, HomModule as RsHomModule, Module, + MuFreeModule as RsMuFreeModule, OperationGeneratorPair as RsOperationGeneratorPair, + QuotientModule as RsQuotientModule, RealProjectiveSpace as RsRealProjectiveSpace, + SteenrodModule as RsSteenrodModule, SuspensionModule as RsSuspensionModule, + TensorModule as RsTensorModule, + }; + // Imported on its own line (not folded into the multi-item `module` import + // above) so that later commits extending that import block do not conflict. + use ::algebra::module::ActError; + use ::algebra::{ + Algebra, Bialgebra, CoproductError, DecomposeError, Field as RsField, GeneratedAlgebra, + UnstableAlgebra, + }; + use ::fp::prime::{self, Prime}; + use pyo3::basic::CompareOp; + use pyo3::exceptions::{PyIndexError, PyRuntimeError, PyValueError}; + + use super::*; + + /// The concrete monomorphisations the §5.3 module bindings are built over. + /// Every concrete module the proposal exposes is taken over the + /// `SteenrodAlgebra` union (see `SteenrodModule` below), so we never need a + /// generic-over-algebra binding. + type RsSteenrodAlgebra = ::algebra::SteenrodAlgebra; + type FDModuleInner = RsFDModule; + type FreeModuleInner = RsFreeModule; + /// The derived modules are monomorphised over `RsSteenrodModule` + /// (`Arc`), the boxed dynamic module. The `Module` trait carries + /// `#[auto_impl(Arc, Box)]`, so `Arc` itself implements `Module` + /// (and is `Sized`, unlike `dyn Module`, which the upstream + /// `TensorModule`/`SuspensionModule` type parameters require). The + /// factors are therefore accepted as the bound `SteenrodModule` pyclass and + /// the upstream `new` is given `Arc`. Both + /// `TensorModule` and + /// `SuspensionModule` implement `Module`, so + /// `into_steenrod_module()` unsizes an `Arc` of either directly. + type TensorModuleInner = RsTensorModule; + type SuspensionModuleInner = RsSuspensionModule; + /// The quotient module is monomorphised over the boxed dynamic module + /// (`RsSteenrodModule = Arc`), exactly like Tensor/Suspension: + /// upstream `QuotientModule::new` takes `Arc`, so the inner module is + /// accepted as the bound `SteenrodModule` pyclass and wrapped once more in + /// an `Arc`. `QuotientModule::Algebra` is + /// `SteenrodAlgebra`, so `into_steenrod_module()` unsizes the stored `Arc` + /// directly into a `SteenrodModule`. + type QuotientModuleInner = RsQuotientModule; + /// The Hom module is monomorphised the same way over `RsSteenrodModule` for + /// its *target*; its *source* is the concrete `FreeModule` + /// upstream requires. Crucially `HomModule::Algebra` is `Field` (the + /// ground field), *not* `SteenrodAlgebra`: the module is the graded + /// vector space `Hom(source, target)`, only acted on by scalars. Its + /// `algebra()` is therefore the bound ground-field `Field` pyclass (sharing + /// the module's `Arc`), *not* a `SteenrodAlgebra`. Because its + /// algebra is not `SteenrodAlgebra`, it is *not* a `SteenrodModule` and + /// exposes no `into_steenrod_module()` (see the binding). The flattened + /// `Module` method set is still shared via the algebra-generic `module_*` + /// helpers above. + type HomModuleInner = RsHomModule; + type RpInner = RsRealProjectiveSpace; + /// The finitely presented module is monomorphised over the concrete + /// `SteenrodAlgebra` (like `FreeModule`/`FDModule`), since upstream + /// `FinitelyPresentedModule::new` takes `Arc` and the module's own + /// generators/relations are concrete `FreeModule`s. The + /// inner module is held in an `Arc` so `into_steenrod_module()` can unsize + /// it directly (the `FreeModule` Arc-unsizing pattern) and so the mutating + /// `add_generators`/`add_relations` can take `&mut` via `Arc::get_mut` (the + /// `QuotientModule` pattern: mutation fails while a box shares the `Arc`). + type FPModuleInner = RsFPModule; + /// A `FreeModuleHomomorphism` whose *target* is the boxed dynamic module + /// `RsSteenrodModule` (`Arc`), mirroring the Tensor/Suspension/ + /// Quotient monomorphisation. Upstream's + /// `FreeModuleHomomorphism::Source` is then + /// `FreeModule = FreeModule = FreeModuleInner` + /// (since `RsSteenrodModule::Algebra = SteenrodAlgebra`), so the source is + /// exactly the bound `FreeModule` pyclass and the target is the bound + /// `SteenrodModule` pyclass; both share their `Arc`-held state with the + /// homomorphism. All of its mutators use interior mutability (`OnceVec`/ + /// `OnceBiVec`) and take `&self`, so the pyclass holds the value directly + /// (no `Consumable`/`Arc::get_mut` dance is required). + type FreeModuleHomomorphismInner = RsFreeModuleHomomorphism; + /// A `FreeModuleHomomorphism` whose *target* is itself the concrete + /// `FreeModule = FreeModuleInner` (rather than the boxed + /// dynamic `RsSteenrodModule`). Upstream's `FreeModuleHomomorphism::Source` + /// is `FreeModule`; with `M = FreeModuleInner` (whose `Algebra` + /// is `SteenrodAlgebra`) *both* `Source` and `Target` are `FreeModuleInner`, + /// so the `source()`/`target()` accessors each hand back the bound + /// `FreeModule` pyclass (sharing the `Arc`). This free → free monomorphisation + /// is exactly the `map` type `HomPullback::new` requires, and — unlike the + /// free → dynamic variant — it additionally exposes `hom_k` (the dual map on + /// cohomology), which upstream gates on `M = FreeModule` (see + /// `free_module_homomorphism.rs`, the `impl … MuFreeModuleHomomorphism>` block). The pyclass holds the value behind an `Arc` + /// (not by value, unlike the free → dynamic variant) so the *same* + /// homomorphism can be shared into a `HomPullback`; all of its mutators take + /// `&self` via interior mutability, so the `Arc` needs no `get_mut`. + type FreeModuleHomToFreeInner = RsFreeModuleHomomorphism; + /// The *unstable* (`U = true`) free module `MuFreeModule`. This is a *distinct type* from `FreeModuleInner = + /// MuFreeModule` (the bound `FreeModule` pyclass's + /// inner type): the const-generic `U` flag selects the unstable basis + /// (`dimension_unstable`) tables, so the two cannot be unified. It is the + /// module type of every `UnstableResolution`/`UnstableResolutionHomomorphism` + /// (see `ext_py::UnstableResolution`), and is exposed read-only by the + /// `UnstableFreeModule` pyclass below. Crucially its `Algebra` is still the + /// `SteenrodAlgebra` union, so it implements `Module` and can reuse the algebra-generic `module_*` guard + /// helpers via `&*self.0 as &DynModule`. + type UnstableFreeModuleInner = RsMuFreeModule; + /// The *unstable* free → free module homomorphism `MuFreeModuleHomomorphism< + /// true, MuFreeModule>`, returned by + /// `UnstableResolutionHomomorphism::get_map`. As with the module above, this + /// is a distinct monomorphisation from `FreeModuleHomToFreeInner` (the `U = + /// false` stable variant the bound `FreeModuleHomomorphismToFree` wraps), so + /// it needs its own `UnstableFreeModuleHomomorphism` pyclass. Both its + /// `Source` and `Target` are `UnstableFreeModuleInner`. + type UnstableFreeModuleHomToFreeInner = + RsMuFreeModuleHomomorphism; + /// A `FullModuleHomomorphism` whose *source* and *target* are both the + /// boxed dynamic module `RsSteenrodModule` (`Arc`). Upstream + /// `FullModuleHomomorphism` records the matrix of the map in every + /// degree, so unlike `FreeModuleHomomorphism` it does not need its source + /// to be a concrete `FreeModule`; the symmetric `` monomorphisation lets both factors be the bound + /// `SteenrodModule` pyclass and is the only choice for which the + /// `IdentityHomomorphism` impl (which requires `Source == Target`) is + /// reachable. Since `RsSteenrodModule::Algebra = SteenrodAlgebra`, both the + /// source and target accessors hand back the same `Arc`-shared + /// `SteenrodModule`. Upstream `new`/`from_matrices` take `Arc`/`Arc`, + /// i.e. `Arc = Arc>`, so each factor is + /// wrapped once more in an `Arc`. All of its tables use interior mutability + /// (`OnceBiVec`) and take `&self`, so the pyclass holds the value directly. + type FullModuleHomomorphismInner = RsFullModuleHomomorphism; + /// The induced map on quotients, monomorphised so its underlying + /// homomorphism `F` is exactly the bound `FullModuleHomomorphism` (whose + /// `Source` and `Target` are both the boxed dynamic module + /// `RsSteenrodModule`). With `F = FullModuleHomomorphismInner` we have + /// `QuotientHomomorphism::Source = QuotientModule = + /// QuotientModule = QuotientModuleInner` and likewise for + /// `Target`, so both `source()` and `target()` hand back the bound + /// `QuotientModule` pyclass (sharing the same `Arc`). This is the only + /// monomorphisation for which both quotients are the already-bound + /// `QuotientModule` type. Upstream `new(f, s, t)` takes `Arc` plus the two + /// quotient `Arc`s; the binding clones the `FullModuleHomomorphism`'s inner + /// value into a fresh `Arc` (it is `Clone`) and shares the quotients' + /// `Arc`s. + type QuotientHomomorphismInner = RsQuotientHomomorphism; + /// The source-side quotient map `QuotientModule -> F::Target`, + /// monomorphised the same way over `F = FullModuleHomomorphismInner`. Its + /// `Source` is `QuotientModuleInner` (the bound `QuotientModule`) and its + /// `Target` is `F::Target = RsSteenrodModule` (the bound `SteenrodModule`). + /// Upstream `new(f, s)` takes `Arc` and the source quotient `Arc`. + type QuotientHomomorphismSourceInner = + RsQuotientHomomorphismSource; + /// The generic zero map between two boxed dynamic modules, monomorphised + /// `` so both `source()` and `target()` + /// hand back the bound `SteenrodModule` pyclass. Upstream `new(source, + /// target, degree_shift)` takes `Arc`/`Arc`, i.e. + /// `Arc = Arc>`, so each factor is wrapped + /// once more in an `Arc`. `apply_to_basis_element` is a no-op upstream and + /// the map carries no auxiliary data (kernel/image/quasi_inverse are always + /// `None`). + type GenericZeroHomomorphismInner = + RsGenericZeroHomomorphism; + /// The induced pullback map `Hom(B, X) -> Hom(A, X)` of a free → free map + /// `A -> B`, monomorphised over `M = RsSteenrodModule` (the boxed dynamic + /// `X`). Upstream `HomPullback` has + /// `Source = Target = HomModule = HomModuleInner` (so both `source()` and + /// `target()` hand back the bound `HomModule` pyclass) and requires + /// `map: Arc>>`, i.e. exactly + /// `Arc` (since `RsSteenrodModule::Algebra = + /// SteenrodAlgebra`). All of its auxiliary-data tables use interior + /// mutability (`OnceBiVec`) and take `&self`, so the pyclass holds the value + /// directly; it also keeps an `Arc` clone of the `map` so the binding can + /// guard the map's outputs (the upstream `map` field is private). + type HomPullbackInner = RsHomPullback; + /// A borrowed trait object over the algebra union. The flattened `Module` + /// method set is implemented once against this type and shared by every + /// concrete module pyclass and by `SteenrodModule` via dynamic dispatch. + type DynModule = dyn Module; + + /// Upper bound on accepted primes, mirroring `fp_py::valid_prime`. + const MAX_VALID_PRIME: u32 = 1 << 31; + + /// Convert a plain `int` prime from Python into a `ValidPrime`, raising + /// `ValueError` (never panicking) for a non-prime. `ValidPrime` itself is + /// never exposed to Python. Mirrors the `valid_prime` helper in `fp_mod`. + fn valid_prime(p: u32) -> PyResult { + if p < 2 || p >= MAX_VALID_PRIME { + 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"))) + } + + fn checked_same_prime(lhs: u32, rhs: u32) -> PyResult<()> { + if lhs == rhs { + Ok(()) + } else { + Err(PyValueError::new_err(format!( + "prime mismatch: {lhs} != {rhs}" + ))) + } + } + + fn checked_equal_len(lhs: usize, rhs: usize) -> PyResult<()> { + if lhs == rhs { + Ok(()) + } else { + Err(PyValueError::new_err(format!( + "length mismatch: {lhs} != {rhs}" + ))) + } + } + + /// Ensure a result slice is long enough to receive a product landing in a + /// space of dimension `dim`, raising `ValueError` rather than letting an + /// upstream `add_basis_element` index panic. + fn checked_result_len(len: usize, dim: usize) -> PyResult<()> { + if len >= dim { + Ok(()) + } else { + Err(PyValueError::new_err(format!( + "result has length {len} but the target degree has dimension {dim}" + ))) + } + } + + /// Map a [`CoproductError`] from [`Bialgebra::try_coproduct`] to the Python + /// exception type matching what the hand-rolled coproduct guards used to + /// raise: `OutOfRange` -> `IndexError` (kept for completeness; the callers' + /// degree/index pre-checks make it unreachable), every other variant -> + /// `ValueError`. The messages are reproduced verbatim from + /// `CoproductError`'s `Display`. + fn coproduct_error(e: CoproductError) -> PyErr { + match e { + CoproductError::OutOfRange => PyIndexError::new_err(e.to_string()), + CoproductError::OddPrimeUnsupported + | CoproductError::IndivisibleDegree { .. } + | CoproductError::NonzeroIndex => PyValueError::new_err(e.to_string()), + } + } + + fn non_negative_degree(degree: i32) -> PyResult<()> { + if degree >= 0 { + Ok(()) + } else { + Err(PyIndexError::new_err(format!( + "degree {degree} is negative" + ))) + } + } + + /// Like `non_negative_degree`, but raises `ValueError` rather than + /// `IndexError`. The combinatorics free functions (`inadmissible_pairs`) + /// treat a negative degree as malformed *input*, not an out-of-range index, + /// so the `ValueError` taxonomy that the other combinatorics guards use + /// applies. `non_negative_degree` itself is left unchanged because its other + /// callers use the degree as an index and rely on `IndexError`. + fn non_negative_degree_value(degree: i32) -> PyResult<()> { + if degree >= 0 { + Ok(()) + } else { + Err(PyValueError::new_err(format!( + "degree {degree} is negative" + ))) + } + } + + /// Convert a Python value (`dict`/`list`/`int`/`float`/`str`/`bool`/`None`) + /// into a `serde_json::Value`. This is the minimal hand-rolled half of the + /// `serde_json::Value` <-> Python bridge described in API_PROPOSAL §2.6 + /// (we have no `pythonize` dependency); only the directions exercised by + /// `SteenrodAlgebra.from_json` are implemented. Booleans are checked before + /// integers because Python `bool` is a subclass of `int`. Raises + /// `ValueError` for unsupported types or non-finite floats rather than + /// panicking. + pub(crate) fn py_to_json(value: &Bound<'_, PyAny>) -> PyResult { + use pyo3::types::{PyBool, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple}; + if value.is_none() { + return Ok(serde_json::Value::Null); + } + if let Ok(b) = value.cast::() { + return Ok(serde_json::Value::Bool(b.is_true())); + } + if let Ok(i) = value.cast::() { + // Accept the full `[i64::MIN, u64::MAX]` range JSON numbers can + // represent. Try signed first, then unsigned for the + // `(i64::MAX, u64::MAX]` tail; anything outside that range raises + // `ValueError` (the taxonomy) rather than leaking `OverflowError`. + if let Ok(n) = i.extract::() { + return Ok(serde_json::Value::from(n)); + } + if let Ok(n) = i.extract::() { + return Ok(serde_json::Value::from(n)); + } + return Err(PyValueError::new_err( + "integer out of range for JSON (must fit in i64 or u64)", + )); + } + if let Ok(f) = value.cast::() { + let f: f64 = f.extract()?; + return serde_json::Number::from_f64(f) + .map(serde_json::Value::Number) + .ok_or_else(|| PyValueError::new_err("cannot represent non-finite float as JSON")); + } + if let Ok(s) = value.cast::() { + return Ok(serde_json::Value::String(s.extract()?)); + } + if let Ok(dict) = value.cast::() { + let mut map = serde_json::Map::with_capacity(dict.len()); + for (k, v) in dict.iter() { + let key: String = k + .cast::() + .map_err(|_| PyValueError::new_err("JSON object keys must be strings"))? + .extract()?; + map.insert(key, py_to_json(&v)?); + } + return Ok(serde_json::Value::Object(map)); + } + if let Ok(list) = value.cast::() { + let mut arr = Vec::with_capacity(list.len()); + for item in list.iter() { + arr.push(py_to_json(&item)?); + } + return Ok(serde_json::Value::Array(arr)); + } + if let Ok(tuple) = value.cast::() { + let mut arr = Vec::with_capacity(tuple.len()); + for item in tuple.iter() { + arr.push(py_to_json(&item)?); + } + return Ok(serde_json::Value::Array(arr)); + } + Err(PyValueError::new_err(format!( + "cannot convert {} to JSON", + value.get_type().name()? + ))) + } + + /// Convert a `serde_json::Value` into a native Python object + /// (`None`/`bool`/`int`/`float`/`str`/`list`/`dict`). This is the reverse + /// direction of [`py_to_json`], completing the single `serde_json::Value` + /// <-> Python bridge described in API_PROPOSAL §2.6 (we have no `pythonize` + /// dependency). It is total over `serde_json::Value` and never panics: + /// every number fits in `i64`/`u64`/`f64` by construction (serde_json's own + /// invariant), and object/array recursion mirrors the input structure. + pub(crate) fn json_to_py(py: Python<'_>, value: &serde_json::Value) -> PyResult> { + use pyo3::types::{PyDict, PyList}; + use serde_json::Value; + match value { + Value::Null => Ok(py.None()), + Value::Bool(b) => Ok(b.into_pyobject(py)?.to_owned().into_any().unbind()), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(i.into_pyobject(py)?.into_any().unbind()) + } else if let Some(u) = n.as_u64() { + Ok(u.into_pyobject(py)?.into_any().unbind()) + } else { + // serde_json guarantees a non-integer number round-trips + // through f64. + let f = n.as_f64().ok_or_else(|| { + PyValueError::new_err("JSON number is not representable as f64") + })?; + Ok(f.into_pyobject(py)?.into_any().unbind()) + } + } + Value::String(s) => Ok(s.into_pyobject(py)?.into_any().unbind()), + Value::Array(arr) => { + let list = PyList::empty(py); + for item in arr { + list.append(json_to_py(py, item)?)?; + } + Ok(list.into_any().unbind()) + } + Value::Object(map) => { + let dict = PyDict::new(py); + for (k, v) in map { + dict.set_item(k, json_to_py(py, v)?)?; + } + Ok(dict.into_any().unbind()) + } + } + } + + #[pyclass(from_py_object)] // This will be part of the module + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum AlgebraType { + Adem, + Milnor, + } + + impl From for ::algebra::AlgebraType { + fn from(value: AlgebraType) -> Self { + match value { + AlgebraType::Adem => ::algebra::AlgebraType::Adem, + AlgebraType::Milnor => ::algebra::AlgebraType::Milnor, + } + } + } + + /// A function-argument wrapper around [`AlgebraType`] that accepts either an + /// `AlgebraType` enum value or a case-insensitive string `"adem"`/`"milnor"` + /// from Python. Any other string raises `ValueError`; any non-string, + /// non-`AlgebraType` value raises `TypeError`. Use this in binding + /// signatures (instead of `AlgebraType`) wherever the proposal calls for an + /// algebra-type argument, then convert with `.0`/`.into()`. + pub struct AlgebraTypeArg(pub AlgebraType); + + impl<'a, 'py> FromPyObject<'a, 'py> for AlgebraTypeArg { + type Error = PyErr; + + fn extract(obj: pyo3::Borrowed<'a, 'py, PyAny>) -> PyResult { + // Prefer an actual `AlgebraType` enum value, preserving the exact + // previous behaviour when one is passed. + if let Ok(ty) = obj.extract::() { + return Ok(AlgebraTypeArg(ty)); + } + // Otherwise accept a case-insensitive string spelling. + if let Ok(s) = obj.extract::() { + return match s.to_ascii_lowercase().as_str() { + "adem" => Ok(AlgebraTypeArg(AlgebraType::Adem)), + "milnor" => Ok(AlgebraTypeArg(AlgebraType::Milnor)), + other => Err(PyValueError::new_err(format!( + "invalid algebra type {other:?}; expected \"adem\" or \"milnor\" \ + (case-insensitive), or an AlgebraType enum value" + ))), + }; + } + Err(pyo3::exceptions::PyTypeError::new_err( + "expected an AlgebraType or a string (\"adem\" or \"milnor\")", + )) + } + } + + impl From for AlgebraType { + fn from(value: AlgebraTypeArg) -> Self { + value.0 + } + } + + impl From for ::algebra::AlgebraType { + fn from(value: AlgebraTypeArg) -> Self { + value.0.into() + } + } + + /// A basis element of the Milnor algebra: a product of exterior generators + /// `Q_k` (encoded as the bitmask `q_part`) and a polynomial part `P(p_part)`. + #[pyclass(name = "MilnorBasisElement", skip_from_py_object)] + #[derive(Clone)] + pub struct MilnorBasisElement(::algebra::milnor_algebra::MilnorBasisElement); + + #[pymethods] + impl MilnorBasisElement { + #[new] + #[pyo3(signature = (p_part, q_part = 0, degree = 0))] + pub fn new(p_part: Vec, q_part: u32, degree: i32) -> Self { + MilnorBasisElement(::algebra::milnor_algebra::MilnorBasisElement { + q_part, + p_part, + degree, + }) + } + + #[getter] + pub fn q_part(&self) -> u32 { + self.0.q_part + } + + #[setter] + pub fn set_q_part(&mut self, value: u32) { + self.0.q_part = value; + } + + #[getter] + pub fn p_part(&self) -> Vec { + self.0.p_part.clone() + } + + #[setter] + pub fn set_p_part(&mut self, value: Vec) { + self.0.p_part = value; + } + + #[getter] + pub fn degree(&self) -> i32 { + self.0.degree + } + + #[setter] + pub fn set_degree(&mut self, value: i32) { + self.0.degree = value; + } + + /// Recompute the `degree` field from the `p_part`/`q_part` at prime `p`. + pub fn compute_degree(&mut self, p: u32) -> PyResult<()> { + self.0.compute_degree(valid_prime(p)?); + Ok(()) + } + + pub fn __repr__(&self) -> String { + format!( + "MilnorBasisElement(p_part={:?}, q_part={}, degree={})", + self.0.p_part, self.0.q_part, self.0.degree + ) + } + + pub fn __str__(&self) -> String { + format!("{}", self.0) + } + + pub fn __richcmp__(&self, other: &Bound<'_, PyAny>, op: CompareOp) -> bool { + let eq = other + .extract::>() + .is_ok_and(|other| self.0 == other.0); + match op { + CompareOp::Eq => eq, + CompareOp::Ne => !eq, + _ => false, + } + } + } + + /// A Milnor profile function, describing a sub-Hopf-algebra of the Steenrod + /// algebra. + #[pyclass(name = "MilnorProfile")] + pub struct MilnorProfile(::algebra::milnor_algebra::MilnorProfile); + + impl MilnorProfile { + /// A fresh copy of the inner profile, for handing to an algebra + /// constructor or returning one to Python. + fn to_rust(&self) -> ::algebra::milnor_algebra::MilnorProfile { + self.0.clone() + } + } + + #[pymethods] + impl MilnorProfile { + #[new] + #[pyo3(signature = (truncated = false, q_part = u32::MAX, p_part = Vec::new()))] + pub fn new(truncated: bool, q_part: u32, p_part: Vec) -> Self { + MilnorProfile(::algebra::milnor_algebra::MilnorProfile { + truncated, + q_part, + p_part, + }) + } + + #[getter] + pub fn truncated(&self) -> bool { + self.0.truncated + } + + #[setter] + pub fn set_truncated(&mut self, value: bool) { + self.0.truncated = value; + } + + #[getter] + pub fn q_part(&self) -> u32 { + self.0.q_part + } + + #[setter] + pub fn set_q_part(&mut self, value: u32) { + self.0.q_part = value; + } + + #[getter(p_part)] + pub fn profile_p_part(&self) -> Vec { + self.0.p_part.clone() + } + + #[setter(p_part)] + pub fn set_p_part(&mut self, value: Vec) { + self.0.p_part = value; + } + + pub fn is_trivial(&self) -> bool { + self.0.is_trivial() + } + + pub fn get_p_part(&self, i: usize) -> u32 { + self.0.get_p_part(i) + } + + pub fn is_valid(&self) -> bool { + self.0.is_valid() + } + + pub fn is_an(&self, generic: bool) -> bool { + self.0.is_an(generic) + } + + pub fn __repr__(&self) -> String { + format!( + "MilnorProfile(truncated={}, q_part={}, p_part={:?})", + self.0.truncated, self.0.q_part, self.0.p_part + ) + } + } + + /// Emit the entire `#[pymethods]` block (plus the small inherent helper + /// block it depends on) for a Steenrod-style algebra pyclass: the class's + /// own (unique) methods passed through `$extra`, followed by the shared + /// "flattened `Algebra`/`GeneratedAlgebra`/`Bialgebra` method set" that + /// every participating algebra pyclass exposes identically (forwarding to + /// the inner algebra `self.0`). + /// + /// This is the algebra sibling of `module_pymethods!`/`module_hom_pymethods!` + /// below and follows the same pattern for the same reason: the crate does + /// not enable PyO3's `multiple-pymethods` feature, so each class may have + /// only one `#[pymethods]` block and a `macro_rules!` call cannot appear + /// *inside* one. The macro therefore emits the whole block, splicing each + /// class's unique methods in through the `$extra` token block. + /// + /// Only methods whose bodies are byte-identical across every participating + /// class live in the shared set; every class accesses its inner algebra + /// uniformly as `self.0` (`SteenrodAlgebra` stores an `Arc`, which derefs). + /// The genuinely-varying surface — constructors, `coproduct` (guards differ + /// per variant), `basis_element_from_index`, and the Milnor/Adem-specific + /// methods — is passed through `$extra` per class. + /// + /// The macro also emits the three inherent helpers (`ensure_basis`, + /// `product_target`, `checked_basis_index`) as a separate inherent `impl` + /// block, rather than hoisting them into generic free functions over an + /// `Algebra` bound, to keep the `self.0` accessor uniform: `SteenrodAlgebra` + /// wraps an `Arc`, so a generic free function would force a divergent + /// `&self.0` vs `&*self.0` call site — the very split this pattern avoids. + macro_rules! algebra_pymethods { + ($Ty:ident, { $($extra:tt)* }) => { + impl $Ty { + /// Lazily compute book-keeping up to `degree`. These algebras are + /// infinite-dimensional and their internal `OnceVec` tables panic + /// when indexed past the computed range, so every degree-indexed + /// Python method funnels through here first. `compute_basis` is + /// idempotent and cheap to re-call; this is a no-op for negative + /// degrees. + fn ensure_basis(&self, degree: i32) { + if degree >= 0 { + self.0.compute_basis(degree); + } + } + + /// Validate two factor degrees and compute the (basis-populated) + /// target degree of their product. + fn product_target(&self, r_degree: i32, s_degree: i32) -> PyResult { + non_negative_degree(r_degree)?; + non_negative_degree(s_degree)?; + let target = r_degree + .checked_add(s_degree) + .ok_or_else(|| PyValueError::new_err("product degree overflows i32"))?; + self.ensure_basis(target); + Ok(target) + } + + fn checked_basis_index(&self, degree: i32, idx: usize) -> PyResult<()> { + let dim = self.0.dimension(degree); + if idx < dim { + Ok(()) + } else { + Err(PyIndexError::new_err(format!( + "index {idx} out of range for degree {degree} (dimension {dim})" + ))) + } + } + } + + #[pymethods] + impl $Ty { + $($extra)* + + // --- shared Algebra/GeneratedAlgebra/Bialgebra surface -------- + + /// The prime as a plain `int` (`ValidPrime` is never exposed). + #[getter] + pub fn prime(&self) -> u32 { + self.0.prime().as_u32() + } + + pub fn compute_basis(&self, degree: i32) { + self.ensure_basis(degree); + } + + pub fn dimension(&self, degree: i32) -> usize { + if degree < 0 { + return 0; + } + self.ensure_basis(degree); + self.0.dimension(degree) + } + + pub fn basis_element_to_string(&self, degree: i32, idx: usize) -> PyResult { + self.0.try_basis_element_to_string(degree, idx).ok_or_else(|| { + PyIndexError::new_err(format!( + "no basis element at degree {degree} index {idx}" + )) + }) + } + + /// Parse a basis element, returning `(degree, index)`. Raises + /// `ValueError` if the string does not parse, or if it names an + /// element that is not present in this algebra. + /// + /// Upstream's `basis_element_from_string` is now total: a + /// parseable but absent/inadmissible name (e.g. `"Sq0"`) returns + /// `None` rather than panicking. We map that `None` to + /// `ValueError`. + pub fn basis_element_from_string(&self, elt: &str) -> PyResult<(i32, usize)> { + self.0.basis_element_from_string(elt).ok_or_else(|| { + PyValueError::new_err(format!( + "{elt} does not name a basis element of this algebra" + )) + }) + } + + pub fn element_to_string( + &self, + py: Python<'_>, + degree: i32, + element: &Bound<'_, PyAny>, + ) -> PyResult { + non_negative_degree(degree)?; + self.ensure_basis(degree); + crate::fp_py::with_input_slice(py, element, |slice| { + checked_same_prime(slice.prime().as_u32(), self.0.prime().as_u32())?; + checked_equal_len(slice.len(), self.0.dimension(degree))?; + Ok(self.0.element_to_string(degree, slice)) + }) + } + + pub fn multiply_basis_elements( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + r_degree: i32, + r_idx: usize, + s_degree: i32, + s_idx: usize, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + // Reduce the coefficient mod p before handing it to upstream, + // which computes `coeff * value` before reducing and would + // overflow (panicking in debug, wrapping in release) for large + // `coeff`. The algebra is over F_p, so this is mathematically + // equivalent. + let coeff = coeff % p; + let target = self.product_target(r_degree, s_degree)?; + let dim = self.0.dimension(target); + self.checked_basis_index(r_degree, r_idx)?; + self.checked_basis_index(s_degree, s_idx)?; + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_result_len(res.as_slice().len(), dim)?; + self.0 + .multiply_basis_elements(res.copy(), coeff, r_degree, r_idx, s_degree, s_idx); + Ok(()) + }) + } + + pub fn multiply_basis_element_by_element( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + r_degree: i32, + r_idx: usize, + s_degree: i32, + s: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + // See `multiply_basis_elements`: reduce mod p to avoid the + // upstream `coeff * value` overflow. + let coeff = coeff % p; + let target = self.product_target(r_degree, s_degree)?; + let dim = self.0.dimension(target); + self.checked_basis_index(r_degree, r_idx)?; + crate::fp_py::with_input_slice(py, s, |s_slice| { + checked_same_prime(s_slice.prime().as_u32(), p)?; + checked_equal_len(s_slice.len(), self.0.dimension(s_degree))?; + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_result_len(res.as_slice().len(), dim)?; + self.0.multiply_basis_element_by_element( + res.copy(), + coeff, + r_degree, + r_idx, + s_degree, + s_slice, + ); + Ok(()) + }) + }) + } + + pub fn multiply_element_by_basis_element( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + r_degree: i32, + r: &Bound<'_, PyAny>, + s_degree: i32, + s_idx: usize, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + // See `multiply_basis_elements`: reduce mod p to avoid the + // upstream `coeff * value` overflow. + let coeff = coeff % p; + let target = self.product_target(r_degree, s_degree)?; + let dim = self.0.dimension(target); + self.checked_basis_index(s_degree, s_idx)?; + crate::fp_py::with_input_slice(py, r, |r_slice| { + checked_same_prime(r_slice.prime().as_u32(), p)?; + checked_equal_len(r_slice.len(), self.0.dimension(r_degree))?; + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_result_len(res.as_slice().len(), dim)?; + self.0.multiply_element_by_basis_element( + res.copy(), + coeff, + r_degree, + r_slice, + s_degree, + s_idx, + ); + Ok(()) + }) + }) + } + + pub fn multiply_element_by_element( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + r_degree: i32, + r: &Bound<'_, PyAny>, + s_degree: i32, + s: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + // See `multiply_basis_elements`: reduce mod p to avoid the + // upstream `coeff * value` overflow. + let coeff = coeff % p; + let target = self.product_target(r_degree, s_degree)?; + let dim = self.0.dimension(target); + crate::fp_py::with_input_slice(py, r, |r_slice| { + checked_same_prime(r_slice.prime().as_u32(), p)?; + checked_equal_len(r_slice.len(), self.0.dimension(r_degree))?; + crate::fp_py::with_input_slice(py, s, |s_slice| { + checked_same_prime(s_slice.prime().as_u32(), p)?; + checked_equal_len(s_slice.len(), self.0.dimension(s_degree))?; + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_result_len(res.as_slice().len(), dim)?; + self.0.multiply_element_by_element( + res.copy(), + coeff, + r_degree, + r_slice, + s_degree, + s_slice, + ); + Ok(()) + }) + }) + }) + } + + #[getter] + pub fn default_filtration_one_products(&self) -> Vec<(String, i32, usize)> { + self.0.default_filtration_one_products() + } + + // --- GeneratedAlgebra trait surface --------------------------- + + pub fn generators(&self, degree: i32) -> PyResult> { + if degree < 0 { + return Ok(Vec::new()); + } + self.ensure_basis(degree); + Ok(self.0.generators(degree)) + } + + pub fn generator_to_string(&self, degree: i32, idx: usize) -> PyResult { + non_negative_degree(degree)?; + self.ensure_basis(degree); + self.checked_basis_index(degree, idx)?; + Ok(self.0.generator_to_string(degree, idx)) + } + + /// Decompose a non-generator basis element into a sum of products + /// of generators. Decomposition is only defined for + /// non-generators: the unit and the algebra generators are + /// indecomposable. Upstream's + /// [`GeneratedAlgebra::try_decompose_basis_element`] reports the + /// two failure modes separately, which we map to the matching + /// Python exceptions: `Indecomposable` -> `ValueError`, + /// `OutOfRange` -> `IndexError`. + pub fn decompose_basis_element( + &self, + degree: i32, + idx: usize, + ) -> PyResult> { + // The degree/index guards below produce detailed messages and + // make `OutOfRange` unreachable, so the only failure upstream + // reports here is `Indecomposable`. + non_negative_degree(degree)?; + self.ensure_basis(degree); + self.checked_basis_index(degree, idx)?; + self.0 + .try_decompose_basis_element(degree, idx) + .map_err(|e| match e { + DecomposeError::Indecomposable => PyValueError::new_err(e.to_string()), + DecomposeError::OutOfRange => PyIndexError::new_err(e.to_string()), + }) + } + + pub fn generating_relations( + &self, + degree: i32, + ) -> PyResult>> { + if degree < 0 { + return Ok(Vec::new()); + } + self.ensure_basis(degree); + Ok(self.0.generating_relations(degree)) + } + + pub fn decompose(&self, degree: i32, idx: usize) -> PyResult> { + non_negative_degree(degree)?; + self.ensure_basis(degree); + self.checked_basis_index(degree, idx)?; + Ok(self.0.decompose(degree, idx)) + } + + pub fn __repr__(&self) -> String { + format!("{}", self.0) + } + } + }; + } + + #[pyclass] + pub struct MilnorAlgebra(::algebra::MilnorAlgebra, bool); + + impl MilnorAlgebra { + /// Reconstruct an `Arc` of the Milnor variant matching + /// this algebra's prime, profile and `unstable` flag. The concrete + /// `::algebra::MilnorAlgebra` is neither `Clone` nor `Arc`-shared, so a + /// `SteenrodAlgebra` view cannot borrow it; we rebuild an equal algebra + /// (same prime/profile/unstable => identical basis indexing). Used to let + /// module builders accept a `MilnorAlgebra` directly. + pub(crate) fn to_steenrod(&self) -> Arc<::algebra::SteenrodAlgebra> { + let profile = self.0.profile().clone(); + Arc::new(::algebra::SteenrodAlgebra::MilnorAlgebra( + ::algebra::MilnorAlgebra::new_with_profile(self.0.prime(), profile, self.1), + )) + } + } + + algebra_pymethods!(MilnorAlgebra, { + #[new] + #[pyo3(signature = (p, unstable_enabled = false))] + pub fn new(p: u32, unstable_enabled: bool) -> PyResult { + Ok(MilnorAlgebra( + ::algebra::MilnorAlgebra::new(valid_prime(p)?, unstable_enabled), + unstable_enabled, + )) + } + + /// Construct a Milnor algebra restricted to the given profile. Raises + /// `ValueError` for an invalid profile rather than panicking (upstream + /// `new_with_profile` asserts validity). + #[staticmethod] + #[pyo3(signature = (p, profile, unstable_enabled = false))] + pub fn new_with_profile( + p: u32, + profile: PyRef<'_, MilnorProfile>, + unstable_enabled: bool, + ) -> PyResult { + let p = valid_prime(p)?; + let profile = profile.to_rust(); + if !profile.is_valid() { + return Err(PyValueError::new_err("invalid Milnor profile")); + } + Ok(MilnorAlgebra( + ::algebra::MilnorAlgebra::new_with_profile(p, profile, unstable_enabled), + unstable_enabled, + )) + } + + // --- Bialgebra trait surface ------------------------------------------ + + /// Compute a coproduct. Only supported at `p = 2` upstream; + /// [`Bialgebra::try_coproduct`] reports the odd-prime case as + /// `OddPrimeUnsupported`, which we map to `ValueError` rather than + /// panicking on the assertion. The degree/index pre-checks below give + /// detailed messages and make `OutOfRange` unreachable. + pub fn coproduct( + &self, + degree: i32, + idx: usize, + ) -> PyResult> { + non_negative_degree(degree)?; + self.ensure_basis(degree); + self.checked_basis_index(degree, idx)?; + self.0.try_coproduct(degree, idx).map_err(coproduct_error) + } + + // --- Milnor-specific methods ------------------------------------------ + + pub fn generic(&self) -> bool { + self.0.generic() + } + + pub fn q(&self) -> i32 { + self.0.q() + } + + pub fn profile(&self) -> MilnorProfile { + MilnorProfile(self.0.profile().clone()) + } + + pub fn basis_element_from_index( + &self, + degree: i32, + idx: usize, + ) -> PyResult { + non_negative_degree(degree)?; + self.ensure_basis(degree); + self.checked_basis_index(degree, idx)?; + Ok(MilnorBasisElement( + self.0.basis_element_from_index(degree, idx).clone(), + )) + } + + pub fn try_basis_element_to_index( + &self, + elt: PyRef<'_, MilnorBasisElement>, + ) -> Option { + if elt.0.degree < 0 { + return None; + } + self.ensure_basis(elt.0.degree); + self.0.try_basis_element_to_index(&elt.0) + } + + /// Like `try_basis_element_to_index`, but raises `ValueError` if the + /// element is not in the algebra (upstream panics). + pub fn basis_element_to_index( + &self, + elt: PyRef<'_, MilnorBasisElement>, + ) -> PyResult { + non_negative_degree(elt.0.degree)?; + self.ensure_basis(elt.0.degree); + self.0 + .try_basis_element_to_index(&elt.0) + .ok_or_else(|| PyValueError::new_err(format!("element not in algebra: {}", elt.0))) + } + + /// The list of `P(R)` partitions in degree `t`. + pub fn ppart_table(&self, t: i32) -> PyResult>> { + non_negative_degree(t)?; + // The internal table is indexed by `degree / q`, so compute enough + // book-keeping that index `t` is in range at every prime. + let needed = t + .checked_mul(self.0.q()) + .ok_or_else(|| PyValueError::new_err("degree overflows i32"))?; + self.ensure_basis(needed); + Ok(self.0.ppart_table(t).to_vec()) + } + + /// The degree and index of `Q_1^e P(x)`. Raises `ValueError` if that + /// element is not in the (profiled) algebra (upstream's non-panicking + /// `try_beps_pn` returns `None`). + pub fn beps_pn(&self, e: u32, x: u32) -> PyResult<(i32, usize)> { + self.0.try_beps_pn(e, x).ok_or_else(|| { + PyValueError::new_err(format!("Q_1^{e} P({x}) is not in the algebra")) + }) + } + + /// Multiply two `MilnorBasisElement`s, accumulating into `result`. + pub fn multiply( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + m1: PyRef<'_, MilnorBasisElement>, + m2: PyRef<'_, MilnorBasisElement>, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + // See `multiply_basis_elements`: reduce mod p to avoid the upstream + // `coeff * v` overflow. + let coeff = coeff % p; + let target = self.product_target(m1.0.degree, m2.0.degree)?; + let dim = self.0.dimension(target); + // Reject elements that are not genuine basis elements of this + // algebra up front, since the inner multiply panics if an + // intermediate term cannot be indexed. + self.ensure_basis(m1.0.degree); + self.ensure_basis(m2.0.degree); + if self.0.try_basis_element_to_index(&m1.0).is_none() { + return Err(PyValueError::new_err(format!( + "left factor is not a basis element of this algebra: {}", + m1.0 + ))); + } + if self.0.try_basis_element_to_index(&m2.0).is_none() { + return Err(PyValueError::new_err(format!( + "right factor is not a basis element of this algebra: {}", + m2.0 + ))); + } + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_result_len(res.as_slice().len(), dim)?; + self.0.multiply(res.copy(), coeff, &m1.0, &m2.0); + Ok(()) + }) + } + }); + + /// A Steenrod power `P^i`, or a Bockstein `b^e`. Mirrors upstream's + /// `PorBockstein` enum (the pieces of an Adem basis element's + /// decomposition). + #[pyclass(name = "PorBockstein", skip_from_py_object)] + #[derive(Clone, Debug)] + pub enum PorBockstein { + P(u32), + Bockstein(bool), + } + + /// An Adem basis element of the Steenrod algebra: a sequence of Steenrod + /// powers `ps` interleaved with Bocksteins encoded in the bitmask + /// `bocksteins`. + #[pyclass(name = "AdemBasisElement", skip_from_py_object)] + #[derive(Clone)] + pub struct AdemBasisElement(::algebra::adem_algebra::AdemBasisElement); + + #[pymethods] + impl AdemBasisElement { + #[new] + #[pyo3(signature = (ps, bocksteins = 0, degree = 0, p_or_sq = false))] + pub fn new(ps: Vec, bocksteins: u32, degree: i32, p_or_sq: bool) -> Self { + AdemBasisElement(::algebra::adem_algebra::AdemBasisElement { + degree, + bocksteins, + ps, + p_or_sq, + }) + } + + #[getter] + pub fn degree(&self) -> i32 { + self.0.degree + } + + #[setter] + pub fn set_degree(&mut self, value: i32) { + self.0.degree = value; + } + + #[getter] + pub fn bocksteins(&self) -> u32 { + self.0.bocksteins + } + + #[setter] + pub fn set_bocksteins(&mut self, value: u32) { + self.0.bocksteins = value; + } + + #[getter] + pub fn ps(&self) -> Vec { + self.0.ps.clone() + } + + #[setter] + pub fn set_ps(&mut self, value: Vec) { + self.0.ps = value; + } + + #[getter] + pub fn p_or_sq(&self) -> bool { + self.0.p_or_sq + } + + #[setter] + pub fn set_p_or_sq(&mut self, value: bool) { + self.0.p_or_sq = value; + } + + /// The decomposition into alternating Bocksteins and Steenrod powers, + /// dropping trivial (`b^0`) Bocksteins. Mirrors the upstream private + /// `iter_filtered`. + pub fn iter_filtered(&self) -> Vec { + let bocksteins: Vec = + ::fp::prime::iter::BitflagIterator::new(self.0.bocksteins as u64).collect(); + let n = bocksteins.len().max(self.0.ps.len()); + let mut out = Vec::new(); + for i in 0..n { + if let Some(&b) = bocksteins.get(i) { + if b { + out.push(PorBockstein::Bockstein(true)); + } + } + if let Some(&p) = self.0.ps.get(i) { + out.push(PorBockstein::P(p)); + } + } + out + } + + pub fn __repr__(&self) -> String { + format!( + "AdemBasisElement(ps={:?}, bocksteins={}, degree={}, p_or_sq={})", + self.0.ps, self.0.bocksteins, self.0.degree, self.0.p_or_sq + ) + } + + pub fn __str__(&self) -> String { + format!("{}", self.0) + } + + pub fn __richcmp__(&self, other: &Bound<'_, PyAny>, op: CompareOp) -> bool { + // Upstream equality compares only `ps` and `bocksteins`. + let eq = other + .extract::>() + .is_ok_and(|other| self.0 == other.0); + match op { + CompareOp::Eq => eq, + CompareOp::Ne => !eq, + _ => false, + } + } + } + + #[pyclass] + pub struct AdemAlgebra(::algebra::AdemAlgebra, bool); + + impl AdemAlgebra { + /// Reconstruct an `Arc` of the Adem variant matching + /// this algebra's prime and `unstable` flag (the `generic` flag is + /// derived from the prime upstream). See `MilnorAlgebra::to_steenrod` for + /// why an equal algebra is rebuilt rather than shared. + pub(crate) fn to_steenrod(&self) -> Arc<::algebra::SteenrodAlgebra> { + Arc::new(::algebra::SteenrodAlgebra::AdemAlgebra( + ::algebra::AdemAlgebra::new(self.0.prime(), self.1), + )) + } + } + + algebra_pymethods!(AdemAlgebra, { + #[new] + #[pyo3(signature = (p, unstable_enabled = false))] + pub fn new(p: u32, unstable_enabled: bool) -> PyResult { + // `generic` is not a constructor flag upstream: it is derived as + // `p != 2`. + Ok(AdemAlgebra( + ::algebra::AdemAlgebra::new(valid_prime(p)?, unstable_enabled), + unstable_enabled, + )) + } + + // --- Bialgebra trait surface ------------------------------------------ + + /// Compute a coproduct. [`Bialgebra::try_coproduct`] reports the inputs + /// that would trip an upstream assertion — a non-`q`-divisible degree in + /// the generic case (`IndivisibleDegree`), or a nonzero index in the + /// `p = 2` case (`NonzeroIndex`) — which we map to `ValueError`. The + /// degree/index pre-checks below give detailed messages and make + /// `OutOfRange` unreachable. + pub fn coproduct( + &self, + degree: i32, + idx: usize, + ) -> PyResult> { + non_negative_degree(degree)?; + self.ensure_basis(degree); + self.checked_basis_index(degree, idx)?; + self.0.try_coproduct(degree, idx).map_err(coproduct_error) + } + + // --- Adem-specific methods -------------------------------------------- + + pub fn generic(&self) -> bool { + self.0.generic() + } + + pub fn q(&self) -> i32 { + self.0.q() + } + + pub fn basis_element_from_index( + &self, + degree: i32, + idx: usize, + ) -> PyResult { + non_negative_degree(degree)?; + self.ensure_basis(degree); + self.checked_basis_index(degree, idx)?; + Ok(AdemBasisElement( + self.0.basis_element_from_index(degree, idx).clone(), + )) + } + + pub fn try_basis_element_to_index( + &self, + elt: PyRef<'_, AdemBasisElement>, + ) -> Option { + if elt.0.degree < 0 { + return None; + } + self.ensure_basis(elt.0.degree); + self.0.try_basis_element_to_index(&elt.0) + } + + /// Like `try_basis_element_to_index`, but raises `ValueError` if the + /// element is not in the algebra (upstream panics). + pub fn basis_element_to_index(&self, elt: PyRef<'_, AdemBasisElement>) -> PyResult { + non_negative_degree(elt.0.degree)?; + self.ensure_basis(elt.0.degree); + self.0 + .try_basis_element_to_index(&elt.0) + .ok_or_else(|| PyValueError::new_err(format!("element not in algebra: {}", elt.0))) + } + + /// The degree and index of `b^e P^x`. Raises `ValueError` if that + /// element is not in the algebra (upstream's non-panicking `try_beps_pn` + /// returns `None`). + pub fn beps_pn(&self, e: u32, x: u32) -> PyResult<(i32, usize)> { + self.0.try_beps_pn(e, x).ok_or_else(|| { + PyValueError::new_err(format!("b^{e} P^{x} is not in the algebra")) + }) + } + }); + + /// The `enum_dispatch` union of the Adem and Milnor Steenrod algebras + /// (`::algebra::SteenrodAlgebra`). A single value is *either* Adem or Milnor + /// at runtime; every `Algebra`/`GeneratedAlgebra`/`Bialgebra` method + /// dispatches to the active variant. This is one pyclass that wraps the + /// union and dispatches; it does not inherit from `MilnorAlgebra`/ + /// `AdemAlgebra`. + #[pyclass] + pub struct SteenrodAlgebra(Arc<::algebra::SteenrodAlgebra>); + + impl SteenrodAlgebra { + /// Wrap an already-shared algebra (e.g. the `Arc` a module hands back + /// from `Module::algebra`) into the bound pyclass without cloning the + /// underlying algebra. This is how a module's `algebra()` accessor + /// returns a `SteenrodAlgebra` to Python. + pub(crate) fn from_arc(algebra: Arc<::algebra::SteenrodAlgebra>) -> Self { + SteenrodAlgebra(algebra) + } + + /// Build the union `SteenrodAlgebra` of the Milnor variant from a + /// concrete `::algebra::MilnorAlgebra`'s prime + profile. Used to expose + /// the algebra of a Nassau-backed resolution (which resolves over a bare + /// `MilnorAlgebra`, not the union) as a `SteenrodAlgebra`. The concrete + /// algebra is neither `Clone` nor `Arc`-shareable as a union, so an equal + /// algebra is rebuilt (same prime/profile => identical basis indexing). + /// Nassau is the stable mod-2 sphere, so `unstable = false`. + pub(crate) fn from_milnor(algebra: &::algebra::MilnorAlgebra) -> Self { + let profile = algebra.profile().clone(); + SteenrodAlgebra(Arc::new(::algebra::SteenrodAlgebra::MilnorAlgebra( + ::algebra::MilnorAlgebra::new_with_profile(algebra.prime(), profile, false), + ))) + } + + /// A cheap clone of the shared algebra handle, for feeding module + /// constructors that take `Arc` upstream. + pub(crate) fn arc(&self) -> Arc<::algebra::SteenrodAlgebra> { + Arc::clone(&self.0) + } + } + + algebra_pymethods!(SteenrodAlgebra, { + // --- §5.2 constructors ------------------------------------------------ + + /// Construct a `SteenrodAlgebra` from a module-spec `dict` (the JSON the + /// crate reads from a module file), the desired `AlgebraType`, and the + /// `unstable` flag. Mirrors `::algebra::SteenrodAlgebra::from_json`, + /// which reads `{"p": , "algebra": [..]?, "profile": {..}?}`. If + /// the spec's `algebra` list does not contain the requested type, the + /// upstream falls back to the first listed type. Upstream returns an + /// `anyhow::Error` for every failure (bad prime, malformed spec, parse + /// error) without distinguishing them, so all `from_json` failures map + /// to `RuntimeError`. (Type conversion of the Python value itself, in + /// `py_to_json`, still raises `ValueError` before upstream is called.) + #[staticmethod] + #[pyo3(signature = (value, ty, unstable = false))] + pub fn from_json( + value: &Bound<'_, PyAny>, + ty: AlgebraTypeArg, + unstable: bool, + ) -> PyResult { + let json = py_to_json(value)?; + ::algebra::SteenrodAlgebra::from_json(&json, ty.into(), unstable) + .map(|a| SteenrodAlgebra(Arc::new(a))) + .map_err(|e| { + use pyo3::exceptions::PyRuntimeError; + PyRuntimeError::new_err(e.to_string()) + }) + } + + /// Construct the Adem variant at prime `p`. Validates the prime -> + /// `ValueError`. + #[staticmethod] + #[pyo3(signature = (p, unstable = false))] + pub fn adem(p: u32, unstable: bool) -> PyResult { + let p = valid_prime(p)?; + Ok(SteenrodAlgebra(Arc::new( + ::algebra::SteenrodAlgebra::AdemAlgebra(::algebra::AdemAlgebra::new(p, unstable)), + ))) + } + + /// Construct the Milnor variant at prime `p`. Validates the prime -> + /// `ValueError`. + #[staticmethod] + #[pyo3(signature = (p, unstable = false))] + pub fn milnor(p: u32, unstable: bool) -> PyResult { + let p = valid_prime(p)?; + Ok(SteenrodAlgebra(Arc::new( + ::algebra::SteenrodAlgebra::MilnorAlgebra(::algebra::MilnorAlgebra::new( + p, unstable, + )), + ))) + } + + /// Which variant this value is (`AlgebraType.ADEM`/`MILNOR`). + pub fn algebra_type(&self) -> AlgebraType { + match self.0.as_ref() { + ::algebra::SteenrodAlgebra::AdemAlgebra(_) => AlgebraType::Adem, + ::algebra::SteenrodAlgebra::MilnorAlgebra(_) => AlgebraType::Milnor, + } + } + + /// The index of the degree-1 indecomposable classifying the element `p` + /// in the secondary (pair-algebra) machinery, as a plain `int`. Forwards + /// to the upstream `PairAlgebra::p_tilde`, which the `SteenrodAlgebra` + /// union dispatches to its Adem/Milnor variant. + pub fn p_tilde(&self) -> usize { + use ::algebra::pair_algebra::PairAlgebra; + self.0.p_tilde() + } + + /// The `MilnorBasisElement` at `(degree, idx)`. Only the Milnor variant + /// has Milnor basis elements (with a `p_part`); the Adem variant raises + /// `ValueError`. Mirrors the concrete `MilnorAlgebra` binding. + pub fn basis_element_from_index( + &self, + degree: i32, + idx: usize, + ) -> PyResult { + non_negative_degree(degree)?; + self.ensure_basis(degree); + self.checked_basis_index(degree, idx)?; + match self.0.as_ref() { + ::algebra::SteenrodAlgebra::MilnorAlgebra(a) => Ok(MilnorBasisElement( + a.basis_element_from_index(degree, idx).clone(), + )), + ::algebra::SteenrodAlgebra::AdemAlgebra(_) => Err(PyValueError::new_err( + "basis_element_from_index is only supported for the Milnor variant", + )), + } + } + + // --- Bialgebra trait surface ------------------------------------------ + + /// Compute a coproduct. The underlying assertions differ by variant, but + /// [`Bialgebra::try_coproduct`] is `enum_dispatch`'d onto + /// `SteenrodAlgebra` and already applies the correct per-variant guard + /// (Milnor `p = 2`; generic Adem `q`-divisibility; `p = 2` Adem index 0), + /// so we delegate to it directly and map its error to the matching + /// Python exception. The degree/index pre-checks below give detailed + /// messages and make `OutOfRange` unreachable. + pub fn coproduct( + &self, + degree: i32, + idx: usize, + ) -> PyResult> { + non_negative_degree(degree)?; + self.ensure_basis(degree); + self.checked_basis_index(degree, idx)?; + self.0.try_coproduct(degree, idx).map_err(coproduct_error) + } + }); + + /// The ground field $\mathbb{F}_p$ viewed as a (graded) **algebra over + /// itself** — the *trivial* 1-dimensional algebra concentrated in degree 0, + /// with single basis element `1` (the unit). This is `algebra::Field`. + /// + /// Do **not** confuse this with `fp_py.Fp`: `fp_py.Fp` is the *field type* + /// (the scalars $\mathbb{F}_p$ themselves, used to build `FpVector`s), + /// whereas `algebra_py.Field` is that field re-packaged as an `Algebra` so + /// it can be the coefficient algebra of a graded module. Concretely + /// `dimension(0) == 1`, `dimension(d) == 0` for `d != 0`, and + /// `multiply_basis_elements` is just the field multiplication on the unit. + /// + /// `Field` is the `algebra()` of a `HomModule` (which is the graded vector + /// space `Hom(source, target)`, acted on only by scalars), so a freshly + /// constructed `Field` shares the module's `Arc` storage there. + /// + /// Like the other algebra bindings the prime is exposed as a plain `int` + /// (`ValidPrime` is never surfaced); an invalid prime raises `ValueError`. + #[pyclass(name = "Field")] + pub struct Field(Arc); + + impl Field { + /// Re-wrap an `Arc` handed back by a module's `algebra()` (the + /// `SteenrodAlgebra::from_arc` pattern) so the same ground field is + /// shared with Python rather than deep-copied. + pub(crate) fn from_arc(algebra: Arc) -> Self { + Field(algebra) + } + + /// Range-check a basis index. The field is 1-dimensional concentrated + /// in degree 0, so the only valid `(degree, idx)` is `(0, 0)`; every + /// other pair is `IndexError`. This also forces `degree == 0` wherever + /// it is applied, which is exactly the precondition the upstream + /// `basis_element_to_string`/`element_to_string` `assert!(degree == 0)` + /// guards rely on. + fn checked_basis_index(&self, degree: i32, idx: usize) -> PyResult<()> { + let dim = self.0.dimension(degree); + if idx < dim { + Ok(()) + } else { + Err(PyIndexError::new_err(format!( + "index {idx} out of range for degree {degree} (dimension {dim})" + ))) + } + } + } + + #[pymethods] + impl Field { + /// Construct the ground-field algebra `F_p`. Raises `ValueError` for a + /// non-prime `p` (see `algebra_py.Field` vs `fp_py.Fp` above). + #[new] + pub fn new(p: u32) -> PyResult { + Ok(Field(Arc::new(RsField::new(valid_prime(p)?)))) + } + + // --- Algebra trait surface -------------------------------------------- + + /// The prime as a plain `int` (`ValidPrime` is never exposed). + #[getter] + pub fn prime(&self) -> u32 { + self.0.prime().as_u32() + } + + /// A no-op upstream (the field is finite-dimensional and needs no + /// book-keeping), kept for parity with the other algebra bindings. + pub fn compute_basis(&self, _degree: i32) {} + + /// `1` in degree 0, `0` everywhere else (including negative degrees). + pub fn dimension(&self, degree: i32) -> usize { + self.0.dimension(degree) + } + + pub fn basis_element_to_string(&self, degree: i32, idx: usize) -> PyResult { + // `try_basis_element_to_string` returns `None` for a negative degree + // or an out-of-range index (the field is 1-dimensional in degree 0), + // i.e. exactly the upstream `assert!(degree == 0)` precondition. + self.0.try_basis_element_to_string(degree, idx).ok_or_else(|| { + PyIndexError::new_err(format!( + "no basis element at degree {degree} index {idx}" + )) + }) + } + + /// Parse a basis element, returning `(degree, index)`. The field has the + /// single basis element `1`, so upstream returns `(0, 0)` for *any* + /// input; we surface that total behaviour unchanged. + pub fn basis_element_from_string(&self, elt: &str) -> Option<(i32, usize)> { + self.0.basis_element_from_string(elt) + } + + pub fn element_to_string( + &self, + py: Python<'_>, + degree: i32, + element: &Bound<'_, PyAny>, + ) -> PyResult { + non_negative_degree(degree)?; + // Upstream `element_to_string` asserts `degree == 0` and reads + // `element.entry(0)`, so the element must live in degree 0 (where + // the dimension is 1). + checked_equal_len(self.0.dimension(degree), 1)?; + crate::fp_py::with_input_slice(py, element, |slice| { + checked_same_prime(slice.prime().as_u32(), self.0.prime().as_u32())?; + checked_equal_len(slice.len(), self.0.dimension(degree))?; + Ok(self.0.element_to_string(degree, slice)) + }) + } + + /// Multiply two basis elements, accumulating `coeff * (r * s)` into + /// `result`. The only basis element is the unit `1` in degree 0, so a + /// valid product requires `r` and `s` to both be `(0, 0)`; the index + /// guards reject anything else. Upstream simply adds `coeff` into + /// component 0, so `result` must have length at least 1. + pub fn multiply_basis_elements( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + r_degree: i32, + r_idx: usize, + s_degree: i32, + s_idx: usize, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + // Reduce mod p for parity with the other bindings (harmless here: + // upstream only forwards `coeff` to `add_basis_element`). + let coeff = coeff % p; + // Both factors must be the unit `(0, 0)`; this also pins both + // degrees to 0, so the product lands in degree 0 (dimension 1). + self.checked_basis_index(r_degree, r_idx)?; + self.checked_basis_index(s_degree, s_idx)?; + let dim = self.0.dimension(0); + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_result_len(res.as_slice().len(), dim)?; + self.0 + .multiply_basis_elements(res.copy(), coeff, r_degree, r_idx, s_degree, s_idx); + Ok(()) + }) + } + + #[getter] + pub fn default_filtration_one_products(&self) -> Vec<(String, i32, usize)> { + self.0.default_filtration_one_products() + } + + // --- Bialgebra trait surface ------------------------------------------ + // + // `Field` is a `Bialgebra` (trivial diagonal comultiplication), so + // `coproduct`/`decompose` are bound. It does *not* implement + // `GeneratedAlgebra` (it has no generators/relations — it is the unit + // algebra), so `generators`/`generator_to_string`/ + // `decompose_basis_element`/`generating_relations` are intentionally + // *not* bound (unlike `MilnorAlgebra`/`AdemAlgebra`). The provided + // `multiply_*_by_*` element-level helpers are likewise omitted: every + // product reduces to scaling the single unit basis element, fully + // exercised by `multiply_basis_elements`. + + /// The (trivial) coproduct of the unit. Only `(0, 0)` is a basis + /// element, so any other `(degree, idx)` raises rather than describing a + /// nonexistent element. + pub fn coproduct( + &self, + degree: i32, + idx: usize, + ) -> PyResult> { + non_negative_degree(degree)?; + self.checked_basis_index(degree, idx)?; + Ok(self.0.coproduct(degree, idx)) + } + + pub fn decompose(&self, degree: i32, idx: usize) -> PyResult> { + non_negative_degree(degree)?; + self.checked_basis_index(degree, idx)?; + Ok(self.0.decompose(degree, idx)) + } + + pub fn __repr__(&self) -> String { + format!("{}", self.0) + } + } + + // ========================================================================= + // §5.3 modules over the Steenrod algebra + // + // All modules are taken over the `SteenrodAlgebra` union. A module holds its + // algebra as `Arc` upstream; the bound algebra pyclass also + // holds an `Arc`, so module constructors take a + // `SteenrodAlgebra` pyclass and clone its `Arc` (`SteenrodAlgebra::arc`), + // while a module's `algebra()` accessor re-wraps the `Arc` upstream hands + // back (`SteenrodAlgebra::from_arc`) -- no algebra is ever deep-copied. + // + // The flattened `Module` method set is shared by every concrete module and + // by `SteenrodModule` through the `&DynModule` helpers below, which apply a + // uniform panic-guard before each degree-indexed read. The upstream + // `OnceVec`/`BiVec` tables panic when indexed past the computed range, and + // `act*`/`basis_element_to_string` assert on out-of-range op/module indices, + // so we always `compute_basis` (idempotent) and range-check first. + // ========================================================================= + + /// Compute book-keeping so that degree-`degree` data of `m` (and the algebra + /// operations landing there) can be queried. Idempotent; a no-op below the + /// module's `min_degree`. Both the algebra and the module are advanced, + /// because a `FreeModule`'s own `compute_basis` reads (but does not extend) + /// the algebra's tables. + fn module_ensure(m: &dyn Module, degree: i32) { + if degree >= m.min_degree() { + // op degrees landing in `degree` are at most `degree - min_degree`. + m.algebra().compute_basis(degree - m.min_degree()); + m.compute_basis(degree); + } + } + + /// Dimension of `m` in `degree`, guarded so the `FreeModule` `OnceVec` + /// length assertion can never fire across the boundary. Degrees below + /// `min_degree` are empty. + fn module_dimension(m: &dyn Module, degree: i32) -> usize { + if degree < m.min_degree() { + return 0; + } + module_ensure(m, degree); + m.dimension(degree) + } + + /// Number of generators of a concrete `FreeModule` in `degree`, reading 0 + /// (never panicking) outside the populated generator range — the free + /// function mirror of `FreeModule::num_gens_safe`/ + /// `FreeModuleHomomorphism::source_num_gens`, used where only an + /// `&FreeModuleInner` is in hand. + fn fm_num_gens_safe(m: &FreeModuleInner, degree: i32) -> usize { + if degree < m.min_degree() || degree > m.max_computed_degree() { + return 0; + } + m.number_of_gens_in_degree(degree) + } + + fn module_basis_element_to_string( + m: &dyn Module, + degree: i32, + idx: usize, + ) -> PyResult { + let dim = module_dimension(m, degree); + if idx >= dim { + return Err(PyIndexError::new_err(format!( + "index {idx} out of range for degree {degree} (dimension {dim})" + ))); + } + Ok(m.basis_element_to_string(degree, idx)) + } + + fn module_element_to_string( + m: &dyn Module, + py: Python<'_>, + degree: i32, + element: &Bound<'_, PyAny>, + ) -> PyResult { + let dim = module_dimension(m, degree); + crate::fp_py::with_input_slice(py, element, |slice| { + checked_same_prime(slice.prime().as_u32(), m.prime().as_u32())?; + checked_equal_len(slice.len(), dim)?; + Ok(m.element_to_string(degree, slice)) + }) + } + + /// Validate the output degree of an action and ensure every degree it + /// touches is computed. Returns `(prime, reduced_coeff, output_degree)`. + fn action_target( + m: &dyn Module, + coeff: u32, + op_degree: i32, + mod_degree: i32, + ) -> PyResult { + non_negative_degree(op_degree)?; + let _ = coeff; + let output_degree = mod_degree + .checked_add(op_degree) + .ok_or_else(|| PyValueError::new_err("output degree overflows i32"))?; + module_ensure(m, output_degree); + // The op degree must be computed in the algebra to range-check op_index. + m.algebra().compute_basis(op_degree); + Ok(output_degree) + } + + fn checked_op_index( + m: &dyn Module, + op_degree: i32, + op_index: usize, + ) -> PyResult<()> { + let dim = m.algebra().dimension(op_degree); + if op_index < dim { + Ok(()) + } else { + Err(PyIndexError::new_err(format!( + "operation index {op_index} out of range for degree {op_degree} (algebra \ + dimension {dim})" + ))) + } + } + + fn checked_mod_index( + m: &dyn Module, + mod_degree: i32, + mod_index: usize, + ) -> PyResult<()> { + let dim = module_dimension(m, mod_degree); + if mod_index < dim { + Ok(()) + } else { + Err(PyIndexError::new_err(format!( + "module index {mod_index} out of range for degree {mod_degree} (dimension {dim})" + ))) + } + } + + /// Translate the typed [`ActError`] from `Module::try_act`/`try_act_on_basis` + /// into the matching Python exception: an out-of-range degree/index is an + /// `IndexError`, an over-long input vector is a `ValueError`. + fn act_error_to_py(e: ActError) -> PyErr { + match e { + ActError::IndexOutOfRange(m) => PyIndexError::new_err(m), + ActError::InvalidInput(m) => PyValueError::new_err(m), + } + } + + #[allow(clippy::too_many_arguments)] + fn module_act_on_basis( + m: &dyn Module, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + op_degree: i32, + op_index: usize, + mod_degree: i32, + mod_index: usize, + ) -> PyResult<()> { + let p = m.prime().as_u32(); + let coeff = coeff % p; + // `action_target` validates the op degree and computes the output degree + // (and hence the required `result` length); `try_act_on_basis` performs + // the op/module index range checks that previously needed `checked_*`. + let output_degree = action_target(m, coeff, op_degree, mod_degree)?; + let out_dim = module_dimension(m, output_degree); + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_result_len(res.as_slice().len(), out_dim)?; + m.try_act_on_basis( + res.copy(), + coeff, + op_degree, + op_index, + mod_degree, + mod_index, + ) + .map_err(act_error_to_py)?; + Ok(()) + }) + } + + #[allow(clippy::too_many_arguments)] + fn module_act( + m: &dyn Module, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + op_degree: i32, + op_index: usize, + input_degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let p = m.prime().as_u32(); + let coeff = coeff % p; + // `action_target` validates the op degree and computes the output degree + // (hence the required `result` length); `try_act` performs the op-index + // range check and the `input.len() <= dimension(input_degree)` check that + // previously needed `checked_op_index` and the manual length guard. + let output_degree = action_target(m, coeff, op_degree, input_degree)?; + let out_dim = module_dimension(m, output_degree); + // Borrow the input transiently rather than cloning it. If the same + // object is passed as both `input` and `result`, the nested + // shared+mutable borrows raise `RuntimeError` (PyO3 borrow conflict) + // rather than UB. `try_act` performs the op-index range check and the + // `input.len() <= dimension(input_degree)` check internally. + crate::fp_py::with_input_slice(py, input, |input_slice| { + checked_same_prime(input_slice.prime().as_u32(), p)?; + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_result_len(res.as_slice().len(), out_dim)?; + m.try_act( + res.copy(), + coeff, + op_degree, + op_index, + input_degree, + input_slice, + ) + .map_err(act_error_to_py)?; + Ok(()) + }) + }) + } + + #[allow(clippy::too_many_arguments)] + fn module_act_by_element( + m: &dyn Module, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + op_degree: i32, + op: &Bound<'_, PyAny>, + input_degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let p = m.prime().as_u32(); + let coeff = coeff % p; + let output_degree = action_target(m, coeff, op_degree, input_degree)?; + let in_dim = module_dimension(m, input_degree); + let out_dim = module_dimension(m, output_degree); + let op_dim = m.algebra().dimension(op_degree); + // Borrow both inputs transiently rather than cloning. Aliasing with the + // mutable `result` surfaces as a `RuntimeError` (PyO3 borrow conflict). + crate::fp_py::with_input_slice(py, op, |op_slice| { + checked_same_prime(op_slice.prime().as_u32(), p)?; + // Upstream `act_by_element` asserts both lengths exactly. + checked_equal_len(op_slice.len(), op_dim)?; + crate::fp_py::with_input_slice(py, input, |input_slice| { + checked_same_prime(input_slice.prime().as_u32(), p)?; + checked_equal_len(input_slice.len(), in_dim)?; + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_result_len(res.as_slice().len(), out_dim)?; + m.act_by_element( + res.copy(), + coeff, + op_degree, + op_slice, + input_degree, + input_slice, + ); + Ok(()) + }) + }) + }) + } + + fn module_total_dimension(m: &dyn Module) -> PyResult { + match m.max_degree() { + Some(max) => { + module_ensure(m, max); + Ok(m.total_dimension()) + } + None => Err(PyValueError::new_err( + "total_dimension requires the module to be bounded above", + )), + } + } + + /// Emit the entire `#[pymethods]` block for a module pyclass: the class's + /// own (unique) methods passed through `$extra`, followed by the shared + /// "flattened `Module` method set" that every concrete module pyclass + /// exposes (forwarding to the `module_*` free helpers, or to the `Module` + /// trait directly). + /// + /// This crate does not enable PyO3's `multiple-pymethods` feature, so each + /// class may have only one `#[pymethods]` block, and PyO3's `#[pymethods]` + /// proc-macro additionally rejects `macro_rules!` invocations *inside* the + /// block. We therefore follow the same pattern as `augmented_matrix_pyclass!` + /// (in `fp_mod.rs`): this macro emits the whole `#[pymethods]` block, + /// splicing each class's unique methods in through the `$extra` token block. + /// + /// The only per-class variation in the shared set is the accessor turning + /// `self` into the `&DynModule` it dispatches through; every participating + /// class provides that as an inherent `as_dyn(&self) -> &DynModule`, so the + /// macro can call `self.as_dyn()` uniformly. (`HomModule` is *not* built with + /// this macro: its `algebra()` is the ground `Field` and several methods add + /// overflow guards around its own `ensure`.) + macro_rules! module_pymethods { + ($Ty:ident, { $($extra:tt)* }) => { + #[pymethods] + impl $Ty { + $($extra)* + + // --- flattened Module method set ------------------------------ + + #[getter] + pub fn algebra(&self) -> SteenrodAlgebra { + SteenrodAlgebra::from_arc(self.as_dyn().algebra()) + } + + #[getter] + pub fn min_degree(&self) -> i32 { + self.as_dyn().min_degree() + } + + #[getter] + pub fn max_computed_degree(&self) -> i32 { + self.as_dyn().max_computed_degree() + } + + #[getter] + pub fn max_degree(&self) -> Option { + self.as_dyn().max_degree() + } + + /// The prime as a plain `int` (`ValidPrime` is never exposed). + #[getter] + pub fn prime(&self) -> u32 { + self.as_dyn().prime().as_u32() + } + + pub fn compute_basis(&self, degree: i32) { + module_ensure(self.as_dyn(), degree); + } + + pub fn dimension(&self, degree: i32) -> usize { + module_dimension(self.as_dyn(), degree) + } + + #[getter] + pub fn total_dimension(&self) -> PyResult { + module_total_dimension(self.as_dyn()) + } + + #[getter] + pub fn is_unit(&self) -> bool { + self.as_dyn().is_unit() + } + + pub fn basis_element_to_string(&self, degree: i32, idx: usize) -> PyResult { + module_basis_element_to_string(self.as_dyn(), degree, idx) + } + + pub fn element_to_string( + &self, + py: Python<'_>, + degree: i32, + element: &Bound<'_, PyAny>, + ) -> PyResult { + module_element_to_string(self.as_dyn(), py, degree, element) + } + + #[allow(clippy::too_many_arguments)] + pub fn act_on_basis( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + op_degree: i32, + op_index: usize, + mod_degree: i32, + mod_index: usize, + ) -> PyResult<()> { + module_act_on_basis( + self.as_dyn(), + py, + result, + coeff, + op_degree, + op_index, + mod_degree, + mod_index, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn act( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + op_degree: i32, + op_index: usize, + input_degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult<()> { + module_act( + self.as_dyn(), + py, + result, + coeff, + op_degree, + op_index, + input_degree, + input, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn act_by_element( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + op_degree: i32, + op: &Bound<'_, PyAny>, + input_degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult<()> { + module_act_by_element( + self.as_dyn(), + py, + result, + coeff, + op_degree, + op, + input_degree, + input, + ) + } + } + }; + } + + /// Emit the entire `#[pymethods]` block for a module-homomorphism pyclass: + /// the class's own (unique) methods passed through `$extra`, followed by the + /// shared "flattened `ModuleHomomorphism` method set" that every participating + /// homomorphism pyclass exposes identically (forwarding to the inner upstream + /// homomorphism `self.0`). + /// + /// This is the homomorphism sibling of `module_pymethods!` above and follows + /// the same pattern for the same reason: the crate does not enable PyO3's + /// `multiple-pymethods` feature, so each class may have only one + /// `#[pymethods]` block and a `macro_rules!` call cannot appear *inside* one. + /// The macro therefore emits the whole block, splicing each class's unique + /// methods in through the `$extra` token block. + /// + /// Only methods whose bodies are byte-identical across every participating + /// class live in the shared set: `degree_shift`, `min_degree`, `prime`, + /// `kernel`, `image`, `quasi_inverse` and `apply_quasi_inverse`, all + /// accessing the inner upstream homomorphism uniformly as `self.0`. The + /// remaining surface (`source`/`target`, which return different pyclass + /// types, and `apply`/`apply_to_basis_element`/`get_partial_matrix`/ + /// `compute_auxiliary_data_through_degree`, whose guard logic genuinely + /// differs) is passed through `$extra` per class. `HomPullback` (inner state + /// in named fields, routed through `ensure_apply`) and the read-only + /// `UnstableFreeModuleHomomorphism` are *not* built with this macro. + macro_rules! module_hom_pymethods { + ($Ty:ident, { $($extra:tt)* }) => { + #[pymethods] + impl $Ty { + $($extra)* + + // --- flattened ModuleHomomorphism method set ------------------ + + /// The degree shift: `output_degree = input_degree - degree_shift`. + #[getter] + pub fn degree_shift(&self) -> i32 { + self.0.degree_shift() + } + + /// The smallest input degree the homomorphism is defined on. + #[getter] + pub fn min_degree(&self) -> i32 { + self.0.min_degree() + } + + /// The prime as a plain `int` (`ValidPrime` is never exposed). + #[getter] + pub fn prime(&self) -> u32 { + self.0.prime().as_u32() + } + + /// The kernel of the homomorphism in `degree`, if it has been + /// computed. Returns `None` otherwise (never panics). + pub fn kernel(&self, degree: i32) -> Option { + self.0 + .kernel(degree) + .map(|s| crate::fp_py::PySubspace::from_rust(s.clone())) + } + + /// The image of the homomorphism in `degree`, if it has been + /// computed. Returns `None` otherwise (never panics). + pub fn image(&self, degree: i32) -> Option { + self.0 + .image(degree) + .map(|s| crate::fp_py::PySubspace::from_rust(s.clone())) + } + + /// The quasi-inverse of the homomorphism in `degree`, if it has + /// been computed. Returns `None` otherwise (never panics). + pub fn quasi_inverse(&self, degree: i32) -> Option { + self.0 + .quasi_inverse(degree) + .map(|qi| crate::fp_py::PyQuasiInverse::from_rust(qi.clone())) + } + + /// Apply the quasi-inverse at `degree` to `input`, adding the + /// result into `result`. Returns `True` if the quasi-inverse was + /// available (and applied), `False` otherwise. + pub fn apply_quasi_inverse( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult { + let p = self.0.prime().as_u32(); + let Some(qi) = self.0.quasi_inverse(degree) else { + return Ok(false); + }; + let source_dim = qi.source_dimension(); + let target_dim = qi.target_dimension(); + crate::fp_py::with_input_slice(py, input, |in_slice| { + checked_same_prime(in_slice.prime().as_u32(), p)?; + checked_equal_len(in_slice.len(), target_dim)?; + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), source_dim)?; + qi.apply(res.copy(), 1, in_slice); + Ok(()) + }) + })?; + Ok(true) + } + } + }; + } + + /// The boxed (`Arc`'d) dynamic module accepted downstream by chain complexes + /// and resolutions. Wraps `::algebra::module::SteenrodModule`, i.e. + /// `Arc>`. This is the type + /// `into_steenrod_module()` produces; every flattened `Module` method + /// dispatches dynamically to the underlying concrete module. + #[pyclass(name = "SteenrodModule")] + pub struct SteenrodModule(RsSteenrodModule); + + impl SteenrodModule { + /// Wrap an upstream boxed dynamic module (`Arc`). Used by the + /// `ext` chain-complex bindings to hand back the modules of a `CCC` + /// while sharing the same `Arc`. + pub(crate) fn from_rust(module: RsSteenrodModule) -> Self { + SteenrodModule(module) + } + + /// Borrow the underlying `Arc` (shares interior-mutable state). + pub(crate) fn as_rust(&self) -> &RsSteenrodModule { + &self.0 + } + + fn as_dyn(&self) -> &DynModule { + &*self.0 + } + } + + module_pymethods!(SteenrodModule, { + /// Build a `SteenrodModule` from a module-spec `dict` (the JSON the + /// crate reads from a module file) over the given `algebra`, with the + /// spec first and the algebra second. `algebra` may be a + /// `SteenrodAlgebra`, `AdemAlgebra` or `MilnorAlgebra` instance, OR the + /// string `"milnor"`/`"adem"` (case-insensitive); for a string the + /// algebra is constructed at the prime read from the spec's `"p"` + /// field, so the spec must carry a `"p"` (otherwise `ValueError`). + /// + /// Mirrors `::algebra::module::steenrod_module::from_json`. Two panic + /// hazards are guarded: a spec prime that disagrees with the supplied + /// algebra's prime is rejected up front (for the pyclass case), and the + /// upstream call is wrapped in `catch_unwind` so a malformed spec + /// surfaces as a `ValueError`. All failures map to `ValueError`; a + /// non-string, non-algebra argument raises `TypeError`. + #[staticmethod] + pub fn from_spec( + module_spec: &Bound<'_, PyAny>, + algebra: &Bound<'_, PyAny>, + ) -> PyResult { + use std::panic::{catch_unwind, AssertUnwindSafe}; + let json = py_to_json(module_spec)?; + let arc = if let Ok(name) = algebra.extract::() { + // String algebra: take the prime from the spec and build the + // matching variant at that prime. + let spec_p = json["p"].as_u64().ok_or_else(|| { + PyValueError::new_err( + "a string algebra requires a \"p\" field in the module spec", + ) + })?; + let p = valid_prime(spec_p as u32)?; + match name.to_ascii_lowercase().as_str() { + "adem" => Arc::new(::algebra::SteenrodAlgebra::AdemAlgebra( + ::algebra::AdemAlgebra::new(p, false), + )), + "milnor" => Arc::new(::algebra::SteenrodAlgebra::MilnorAlgebra( + ::algebra::MilnorAlgebra::new(p, false), + )), + other => { + return Err(PyValueError::new_err(format!( + "unknown algebra {other:?} (expected \"milnor\" or \"adem\")" + ))) + } + } + } else { + let arc = algebra_arg_to_steenrod(algebra)?; + if let Some(spec_p) = json["p"].as_u64() { + let algebra_p = arc.prime().as_u32() as u64; + if spec_p != algebra_p { + return Err(PyValueError::new_err(format!( + "module spec is over p = {spec_p} but the algebra is over p = {algebra_p}" + ))); + } + } + arc + }; + match catch_unwind(AssertUnwindSafe(|| steenrod_module::from_json(arc, &json))) { + Ok(Ok(module)) => Ok(SteenrodModule(module)), + Ok(Err(e)) => Err(PyValueError::new_err(e.to_string())), + Err(_) => Err(PyValueError::new_err( + "failed to build module from JSON (malformed spec)", + )), + } + } + + pub fn __repr__(&self) -> String { + format!("SteenrodModule({})", self.0) + } + }); + + /// A pair `(operation, generator)` indexing a basis element of a + /// `FreeModule`: the basis element is `operation * generator`. Mirrors + /// upstream `OperationGeneratorPair`'s four integer fields. + #[pyclass(name = "OperationGeneratorPair", skip_from_py_object)] + #[derive(Clone)] + pub struct OperationGeneratorPair(RsOperationGeneratorPair); + + #[pymethods] + impl OperationGeneratorPair { + #[getter] + pub fn operation_degree(&self) -> i32 { + self.0.operation_degree + } + + #[getter] + pub fn operation_index(&self) -> usize { + self.0.operation_index + } + + #[getter] + pub fn generator_degree(&self) -> i32 { + self.0.generator_degree + } + + #[getter] + pub fn generator_index(&self) -> usize { + self.0.generator_index + } + + pub fn __repr__(&self) -> String { + format!( + "OperationGeneratorPair(operation_degree={}, operation_index={}, \ + generator_degree={}, generator_index={})", + self.0.operation_degree, + self.0.operation_index, + self.0.generator_degree, + self.0.generator_index + ) + } + } + + /// Coerce a Python algebra argument into an `Arc`, + /// accepting any of the bound algebra pyclasses: a [`SteenrodAlgebra`] + /// (shared directly), or a concrete [`AdemAlgebra`]/[`MilnorAlgebra`] + /// (reconstructed into the matching `SteenrodAlgebra` variant via + /// `to_steenrod`). Raises `TypeError` for anything else. This is what lets + /// module builders such as `FDModuleBuilder` accept either the union algebra + /// or one of its concrete variants. + fn algebra_arg_to_steenrod( + algebra: &Bound<'_, PyAny>, + ) -> PyResult> { + if let Ok(a) = algebra.extract::>() { + Ok(a.arc()) + } else if let Ok(a) = algebra.extract::>() { + Ok(a.to_steenrod()) + } else if let Ok(a) = algebra.extract::>() { + Ok(a.to_steenrod()) + } else { + Err(pyo3::exceptions::PyTypeError::new_err( + "expected a SteenrodAlgebra, AdemAlgebra or MilnorAlgebra", + )) + } + } + + /// A mutable builder for a finite-dimensional module over the Steenrod + /// algebra. The graded dimensions are given as a `list[int]` starting at + /// `min_degree`. Populate the actions with + /// `add_generator`/`set_action`/`extend_actions`/`set_basis_element_name`, + /// then call [`FDModuleBuilder::build`] to obtain an immutable + /// `SteenrodModule`. + /// + /// The inner module is held in an `Arc` (like `FreeModule`) so that + /// `build()` can unsize that `Arc` directly into a `SteenrodModule`, + /// sharing state rather than deep-cloning. `build()` flips a `built` flag; + /// once it is set, every mutating method raises `RuntimeError` (checked + /// first, before any other validation, so the error is deterministic and + /// never a `ValueError`/panic). The `Arc::get_mut` guard in `inner_mut` + /// remains as a backstop. Read-only query methods stay available for + /// inspection during construction. + #[pyclass(name = "FDModuleBuilder")] + pub struct FDModuleBuilder { + inner: Arc, + /// Set by `build()`; once set, all mutators raise `RuntimeError`. + built: bool, + } + + impl FDModuleBuilder { + fn as_dyn(&self) -> &DynModule { + &*self.inner + } + + /// Mutable access for the action/generator setters. Fails with + /// `RuntimeError` (rather than panicking or diverging) once `build()` + /// has been called: either the `built` flag is set, or the shared `Arc` + /// makes `Arc::get_mut` return `None`. + fn inner_mut(&mut self) -> PyResult<&mut FDModuleInner> { + if self.built { + return Err(PyRuntimeError::new_err( + "cannot mutate an FDModuleBuilder after build()", + )); + } + Arc::get_mut(&mut self.inner).ok_or_else(|| { + PyRuntimeError::new_err("cannot mutate an FDModuleBuilder after build()") + }) + } + } + + module_pymethods!(FDModuleBuilder, { + /// Build an in-progress finite-dimensional module with `graded_dims[i]` + /// generators in degree `min_degree + i`. All actions are initialised to + /// zero; use `add_generator`/`set_action`/`extend_actions` to populate + /// them, then call `build()` to obtain the immutable `SteenrodModule`. + #[new] + #[pyo3(signature = (algebra, name, graded_dims, min_degree = 0))] + pub fn new( + algebra: &Bound<'_, PyAny>, + name: String, + graded_dims: Vec, + min_degree: i32, + ) -> PyResult { + let alg = algebra_arg_to_steenrod(algebra)?; + let graded_dimension = ::bivec::BiVec::from_vec(min_degree, graded_dims); + Ok(FDModuleBuilder { + inner: Arc::new(FDModuleInner::new(alg, name, graded_dimension)), + built: false, + }) + } + + /// Convert a `TensorModule` into a finite-dimensional module builder + /// (mirroring upstream `FiniteDimensionalModule::from(&M)`, which + /// computes the basis up to `max_degree` and copies in all actions). + /// The tensor module must be bounded: an unbounded factor would make + /// upstream panic, so we pre-check `max_degree()` and raise a + /// `ValueError` instead. The returned builder is mutable until + /// `build()`. + #[staticmethod] + pub fn from_tensor_module(module: PyRef<'_, TensorModule>) -> PyResult { + // `FiniteDimensionalModule::from(&M)` panics if the module is + // unbounded; pre-check. + if module.0.max_degree().is_none() { + return Err(PyValueError::new_err( + "cannot convert an unbounded TensorModule to a finite-dimensional module", + )); + } + let fd = FDModuleInner::from(&*module.0); + Ok(FDModuleBuilder { + inner: Arc::new(fd), + built: false, + }) + } + + /// The module's name (the `name` field used by `to_json`). Settable + /// until `build()`; after `build()` the setter raises `RuntimeError`. + #[getter] + pub fn name(&self) -> String { + self.inner.name.clone() + } + + #[setter] + pub fn set_name(&mut self, name: String) -> PyResult<()> { + self.inner_mut()?.name = name; + Ok(()) + } + + // --- FDModuleBuilder-specific (thin) ---------------------------------- + + /// Rename a basis element. Raises `RuntimeError` after `build()` + /// (checked first), or `IndexError` if `(degree, idx)` is not a basis + /// element (upstream indexes `gen_names` and would panic). + pub fn set_basis_element_name( + &mut self, + degree: i32, + idx: usize, + name: String, + ) -> PyResult<()> { + if self.built { + return Err(PyRuntimeError::new_err( + "cannot mutate an FDModuleBuilder after build()", + )); + } + checked_mod_index(self.as_dyn(), degree, idx)?; + self.inner_mut()?.set_basis_element_name(degree, idx, name); + Ok(()) + } + + /// Append a new generator in `degree`. Raises `RuntimeError` after + /// `build()`. + pub fn add_generator(&mut self, degree: i32, name: String) -> PyResult<()> { + if self.built { + return Err(PyRuntimeError::new_err( + "cannot mutate an FDModuleBuilder after build()", + )); + } + self.inner_mut()?.add_generator(degree, name); + Ok(()) + } + + /// Set the action `op * x = output`, where `op = (op_degree, op_index)` + /// and `x = (input_degree, input_index)`. `output` is a coefficient + /// vector in degree `input_degree + op_degree`. Raises `RuntimeError` + /// after `build()` (checked first), otherwise `IndexError`/`ValueError` + /// rather than letting an upstream assertion/`copy_from_slice` + /// length-mismatch panic. + #[allow(clippy::too_many_arguments)] + pub fn set_action( + &mut self, + op_degree: i32, + op_index: usize, + input_degree: i32, + input_index: usize, + output: Vec, + ) -> PyResult<()> { + if self.built { + return Err(PyRuntimeError::new_err( + "cannot mutate an FDModuleBuilder after build()", + )); + } + non_negative_degree(op_degree)?; + self.inner.algebra().compute_basis(op_degree); + checked_op_index(self.as_dyn(), op_degree, op_index)?; + checked_mod_index(self.as_dyn(), input_degree, input_index)?; + let output_degree = input_degree + .checked_add(op_degree) + .ok_or_else(|| PyValueError::new_err("output degree overflows i32"))?; + // Upstream indexes `actions[input_degree][output_degree]`, whose + // `BiVec::Index` panics when `output_degree` is outside the module's + // graded range (e.g. above `max_degree`). An empty `output` with an + // empty (out-of-range) `output_degree` passes the length check but + // would then panic, so reject it the same way the `action` getter + // does: an empty output degree is a `ValueError`. + let out_dim = module_dimension(self.as_dyn(), output_degree); + if out_dim == 0 { + return Err(PyValueError::new_err(format!( + "output degree {output_degree} is empty" + ))); + } + checked_equal_len(output.len(), out_dim)?; + let p = self.inner.prime().as_u32(); + for v in &output { + if *v >= p { + return Err(PyValueError::new_err(format!( + "coefficient {v} is not reduced mod {p}" + ))); + } + } + self.inner_mut()? + .set_action(op_degree, op_index, input_degree, input_index, &output); + Ok(()) + } + + /// The stored action `op * x` as a coefficient vector. Raises rather + /// than panicking for out-of-range indices or an empty output degree. + pub fn action( + &self, + op_degree: i32, + op_index: usize, + input_degree: i32, + input_index: usize, + ) -> PyResult> { + non_negative_degree(op_degree)?; + self.inner.algebra().compute_basis(op_degree); + checked_op_index(self.as_dyn(), op_degree, op_index)?; + checked_mod_index(self.as_dyn(), input_degree, input_index)?; + let output_degree = input_degree + .checked_add(op_degree) + .ok_or_else(|| PyValueError::new_err("output degree overflows i32"))?; + if module_dimension(self.as_dyn(), output_degree) == 0 { + return Err(PyValueError::new_err(format!( + "output degree {output_degree} is empty" + ))); + } + let vec = self + .inner + .action(op_degree, op_index, input_degree, input_index); + Ok(vec.iter().collect()) + } + + /// Fill in actions of decomposable operations in the given bidegree from + /// the actions of the algebra generators. Raises if `output_deg <= + /// input_deg` (upstream asserts). + pub fn extend_actions(&mut self, input_degree: i32, output_degree: i32) -> PyResult<()> { + if self.built { + return Err(PyRuntimeError::new_err( + "cannot mutate an FDModuleBuilder after build()", + )); + } + if output_degree <= input_degree { + return Err(PyValueError::new_err( + "output_degree must be strictly greater than input_degree", + )); + } + self.inner + .algebra() + .compute_basis(output_degree - input_degree); + self.inner_mut()? + .extend_actions(input_degree, output_degree); + Ok(()) + } + + /// Check that the stored actions satisfy the algebra's relations in the + /// given bidegree. Raises `ValueError` (with the failing relation) if a + /// relation fails, or if `output_deg <= input_deg`. + pub fn check_validity(&self, input_degree: i32, output_degree: i32) -> PyResult<()> { + if output_degree <= input_degree { + return Err(PyValueError::new_err( + "output_degree must be strictly greater than input_degree", + )); + } + self.inner + .algebra() + .compute_basis(output_degree - input_degree); + self.inner + .check_validity(input_degree, output_degree) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + /// Look up a basis element by its name, returning `(degree, index)` or + /// `None`. + pub fn string_to_basis_element(&self, string: &str) -> Option<(i32, usize)> { + self.inner.string_to_basis_element(string) + } + + /// Finalize the builder and return the immutable `SteenrodModule` it has + /// constructed. This is the only producer of the finished module. + /// + /// The returned `SteenrodModule` **shares state** with this builder via + /// an `Arc` (the `FreeModule.into_steenrod_module` pattern): no deep + /// clone is made, so any pre-build mutation is reflected in the built + /// module. `build()` flips a `built` flag; afterwards every mutating + /// method (`set_action`/`add_generator`/`set_basis_element_name`/ + /// `extend_actions`) raises `RuntimeError` (checked first, never a + /// `ValueError`/panic). `build()` may be called multiple times to obtain + /// additional handles to the same shared module. + pub fn build(&mut self) -> SteenrodModule { + self.built = true; + // `Arc` unsizes directly to `Arc`. + SteenrodModule(Arc::clone(&self.inner) as RsSteenrodModule) + } + + /// Serialize the module to a JSON `dict` (mirroring upstream + /// `FiniteDimensionalModule::to_json`): the `name` (if non-empty), + /// `type` (`"finite dimensional module"`), `gens` (name -> degree) and + /// `actions`. The prime `p` is intentionally NOT included, matching + /// upstream, whose caller writes it separately. This is a read-only query + /// and stays available before and after `build()`. + pub fn to_json(&self, py: Python<'_>) -> PyResult> { + let mut value = serde_json::Value::Object(serde_json::Map::new()); + self.inner.to_json(&mut value); + json_to_py(py, &value) + } + + pub fn __repr__(&self) -> String { + format!("FDModuleBuilder({})", self.inner) + } + }); + + /// A free module over the Steenrod algebra, determined by its list of + /// generators (added in increasing degree). + #[pyclass(name = "FreeModule")] + pub struct FreeModule(Arc); + + impl FreeModule { + fn as_dyn(&self) -> &DynModule { + &*self.0 + } + + /// Wrap an existing `Arc>`, sharing the + /// `Arc` rather than deep-copying. Used by `ext_py::Resolution::module` + /// to expose a resolution's free modules (which live behind an `Arc`) + /// without cloning their generator tables. + pub(crate) fn from_arc(module: Arc) -> Self { + FreeModule(module) + } + + /// The number of generators in `degree`, returning 0 (never panicking) + /// for degrees outside the populated `num_gens` range. Upstream + /// `number_of_gens_in_degree` only guards `degree < min_degree` and then + /// indexes `num_gens[degree]`, whose `OnceBiVec::Index` asserts + /// `degree < num_gens.len()`. `num_gens` is extended only by + /// `add_generators`/`extend_by_zero` (not by `compute_basis`), and its + /// populated upper bound is exactly `max_computed_degree()` (defined + /// upstream as `num_gens.max_degree() == num_gens.len() - 1`). So a + /// degree `>= min_degree` but `> max_computed_degree()` has no + /// generators added yet and must read as 0 rather than panic. + fn num_gens_safe(&self, degree: i32) -> usize { + if degree < self.0.min_degree() || degree > self.0.max_computed_degree() { + return 0; + } + self.0.number_of_gens_in_degree(degree) + } + } + + module_pymethods!(FreeModule, { + #[new] + #[pyo3(signature = (algebra, name, min_degree = 0))] + pub fn new(algebra: PyRef<'_, SteenrodAlgebra>, name: String, min_degree: i32) -> Self { + FreeModule(Arc::new(FreeModuleInner::new( + algebra.arc(), + name, + min_degree, + ))) + } + + // --- FreeModule-specific (thin) --------------------------------------- + // + // `FreeModule` is intentionally query-only from Python: it has no + // mutating methods (no `add_generators`/`extend_by_zero`). A populated + // `FreeModule` is only ever obtained from a path that owns its + // generators (e.g. `FPModule.generators()` or a resolution), so a + // handed-out `FreeModule` can never desync the state of whatever module + // produced it. Construction of generators happens through the owning + // module's builder (`FPModuleBuilder`) or upstream Rust APIs. + + /// The number of generators in `degree`. Returns 0 for degrees that + /// have not had generators added yet (including a fresh module or any + /// degree above the highest generator degree), rather than panicking on + /// the upstream `num_gens[degree]` index assertion. + pub fn number_of_gens_in_degree(&self, degree: i32) -> usize { + self.num_gens_safe(degree) + } + + /// The generator names up to the maximum computed generator degree, as a + /// list (indexed from `min_degree`) of lists. + #[getter] + pub fn gen_names(&self) -> Vec> { + self.0.gen_names().iter().map(|(_, v)| v.clone()).collect() + } + + /// The offset in `degree` of the first basis element coming from the + /// generator `(gen_degree, gen_index)`. + pub fn generator_offset( + &self, + degree: i32, + gen_degree: i32, + gen_index: usize, + ) -> PyResult { + if gen_degree < self.0.min_degree() { + return Err(PyValueError::new_err(format!( + "generator degree {gen_degree} is below min_degree {}", + self.0.min_degree() + ))); + } + if gen_index >= self.num_gens_safe(gen_degree) { + return Err(PyIndexError::new_err(format!( + "generator index {gen_index} out of range in degree {gen_degree}" + ))); + } + module_ensure(self.as_dyn(), degree); + Ok(self.0.generator_offset(degree, gen_degree, gen_index)) + } + + /// The offset in `degree` of the first basis element coming from the + /// generator with internal index `internal_gen_idx`. + pub fn internal_generator_offset( + &self, + degree: i32, + internal_gen_idx: usize, + ) -> PyResult { + module_ensure(self.as_dyn(), degree); + let dim = module_dimension(self.as_dyn(), degree); + // `generator_to_index[degree]` has one entry per generator with a + // basis element in `degree`; guard against an out-of-range internal + // index to avoid the upstream `OnceVec` panic. + if degree < self.0.min_degree() { + return Err(PyIndexError::new_err(format!( + "degree {degree} is below min_degree {}", + self.0.min_degree() + ))); + } + let _ = dim; + let count = self.0.iter_gens(degree).count(); + if internal_gen_idx >= count { + return Err(PyIndexError::new_err(format!( + "internal generator index {internal_gen_idx} out of range (only {count} \ + generators up to degree {degree})" + ))); + } + Ok(self.0.internal_generator_offset(degree, internal_gen_idx)) + } + + /// The basis index of `op * gen`, where `op = (op_degree, op_index)` and + /// `gen = (gen_degree, gen_index)`. + #[allow(clippy::too_many_arguments)] + pub fn operation_generator_to_index( + &self, + op_degree: i32, + op_index: usize, + gen_degree: i32, + gen_index: usize, + ) -> PyResult { + non_negative_degree(op_degree)?; + if gen_degree < self.0.min_degree() { + return Err(PyValueError::new_err(format!( + "generator degree {gen_degree} is below min_degree {}", + self.0.min_degree() + ))); + } + if gen_index >= self.num_gens_safe(gen_degree) { + return Err(PyIndexError::new_err(format!( + "generator index {gen_index} out of range in degree {gen_degree}" + ))); + } + let output_degree = op_degree + .checked_add(gen_degree) + .ok_or_else(|| PyValueError::new_err("degree overflows i32"))?; + module_ensure(self.as_dyn(), output_degree); + self.0.algebra().compute_basis(op_degree); + checked_op_index(self.as_dyn(), op_degree, op_index)?; + Ok(self + .0 + .operation_generator_to_index(op_degree, op_index, gen_degree, gen_index)) + } + + /// The `(operation, generator)` pair for the basis element at + /// `(degree, index)`. + pub fn index_to_op_gen( + &self, + degree: i32, + index: usize, + ) -> PyResult { + checked_mod_index(self.as_dyn(), degree, index)?; + Ok(OperationGeneratorPair( + self.0.index_to_op_gen(degree, index).clone(), + )) + } + + /// Iterate the `(degree, index)` of every generator up to `degree`. + /// Returns an empty list for `degree < min_degree`: upstream computes + /// `take((degree - min_degree + 1) as usize)`, which for a negative + /// difference wraps to a huge `usize` and would otherwise yield *all* + /// generators. + pub fn iter_gens(&self, degree: i32) -> Vec<(i32, usize)> { + if degree < self.0.min_degree() { + return Vec::new(); + } + self.0.iter_gens(degree).collect() + } + + /// Box this module into a `SteenrodModule` for downstream use. + pub fn into_steenrod_module(&self) -> SteenrodModule { + // `Arc` unsizes directly to `Arc`. + SteenrodModule(Arc::clone(&self.0) as RsSteenrodModule) + } + + pub fn __repr__(&self) -> String { + format!("FreeModule({})", self.0) + } + }); + + // ========================================================================= + // Derived / standalone modules (§5.3) + // + // Each holds its concrete module in an `Arc`, both so the flattened `Module` + // method set can dispatch through `&DynModule` (the shared guard helpers) + // and so `into_steenrod_module()` can unsize the `Arc` directly into a + // `SteenrodModule` (the `FreeModule` Arc-unsizing pattern), sharing state + // rather than deep-cloning. The two derived modules (`TensorModule`, + // `SuspensionModule`) accept their factor(s) as the already-boxed + // `SteenrodModule` trait object; callers box concrete modules first with + // `.into_steenrod_module()`. + // ========================================================================= + + /// The tensor product `left (x) right` of two modules over the Steenrod + /// algebra. The factors are passed as `SteenrodModule`s (box concrete + /// modules with `.into_steenrod_module()` first). + #[pyclass(name = "TensorModule")] + pub struct TensorModule(Arc); + + impl TensorModule { + fn as_dyn(&self) -> &DynModule { + &*self.0 + } + } + + module_pymethods!(TensorModule, { + /// Build `left (x) right`. Both factors must be built from the *same* + /// `SteenrodAlgebra` Python object: the same-algebra check uses + /// `Arc::ptr_eq` (there is no cheap structural equality on + /// `SteenrodAlgebra`, so we cannot accept distinct-but-equal algebras), + /// meaning a distinct-but-equal algebra object is rejected with + /// `ValueError`. The requirement exists because upstream takes the + /// coproduct from `left`'s algebra and applies it to `right`'s basis, + /// so a prime mismatch would panic on a length/prime mismatch inside + /// the `FpVector` action and an algebra mismatch would silently compute + /// the wrong answer. We therefore reject both up front with + /// `ValueError` (upstream `new` does no such check). + #[new] + pub fn new( + left: PyRef<'_, SteenrodModule>, + right: PyRef<'_, SteenrodModule>, + ) -> PyResult { + let left_alg = left.0.algebra(); + let right_alg = right.0.algebra(); + checked_same_prime(left_alg.prime().as_u32(), right_alg.prime().as_u32())?; + if !Arc::ptr_eq(&left_alg, &right_alg) { + return Err(PyValueError::new_err( + "tensor factors must be built over the same algebra", + )); + } + Ok(TensorModule(Arc::new(TensorModuleInner::new( + Arc::new(Arc::clone(&left.0)), + Arc::new(Arc::clone(&right.0)), + )))) + } + + // --- TensorModule-specific (thin) ------------------------------------- + + /// The degree of the left tensor factor of basis element `index` in + /// total degree `degree`. Raises `IndexError` rather than panicking on + /// an out-of-range basis index (upstream indexes the block structure). + pub fn seek_module_num(&self, degree: i32, index: usize) -> PyResult { + checked_mod_index(self.as_dyn(), degree, index)?; + Ok(self.0.seek_module_num(degree, index)) + } + + /// The offset, within total degree `degree`, of the block of basis + /// elements whose left factor lives in `left_degree`. Raises + /// `IndexError`/`ValueError` rather than letting the block structure + /// index out of range. + pub fn offset(&self, degree: i32, left_degree: i32) -> PyResult { + // The block structure is indexed by total degree; ensure it is + // computed and `degree` is a populated degree of the tensor module. + module_ensure(self.as_dyn(), degree); + if degree < self.0.min_degree() || degree > self.0.max_computed_degree() { + return Err(PyIndexError::new_err(format!( + "degree {degree} is outside the computed range of the tensor module" + ))); + } + // `left_degree` must index a left block: the left factor's degree + // ranges over `[left.min_degree(), degree - right.min_degree()]`. + let left_min = self.0.left.min_degree(); + let left_max = degree - self.0.right.min_degree(); + if left_degree < left_min || left_degree > left_max { + return Err(PyIndexError::new_err(format!( + "left_degree {left_degree} out of range [{left_min}, {left_max}] for total \ + degree {degree}" + ))); + } + // A `left_degree` inside the accepted range can still address an + // empty block when the left factor has dimension 0 there (an + // internal degree gap, e.g. graded dims `[1, 0, 1]`). Upstream + // `offset` would then index `blocks[left_degree][0]` out of bounds, + // so reject it explicitly. `&**self.0.left` reaches the left factor + // as a `DynModule` for the shared dimension helper. + if module_dimension(&**self.0.left, left_degree) == 0 { + return Err(PyIndexError::new_err(format!( + "left_degree {left_degree} addresses an empty block (the left factor has \ + dimension 0 there); the offset of an empty block is undefined" + ))); + } + Ok(self.0.offset(degree, left_degree)) + } + + /// Box this module into a `SteenrodModule` for downstream use. Shares + /// state with this `TensorModule` via an `Arc` (the `FreeModule` + /// pattern), so the boxed module sees the same computed basis. + pub fn into_steenrod_module(&self) -> SteenrodModule { + SteenrodModule(Arc::clone(&self.0) as RsSteenrodModule) + } + + pub fn __repr__(&self) -> String { + format!("TensorModule({})", self.0) + } + }); + + /// A degree shift of a module: `SuspensionModule(inner, shift)` is `inner` + /// with every degree raised by `shift`. The inner module is passed as a + /// `SteenrodModule` (box concrete modules with `.into_steenrod_module()`). + #[pyclass(name = "SuspensionModule")] + pub struct SuspensionModule { + inner: Arc, + // The `shift` field is private upstream with no accessor, so we keep our + // own copy to back the `shift()` getter. + shift: i32, + } + + impl SuspensionModule { + fn as_dyn(&self) -> &DynModule { + &*self.inner + } + } + + module_pymethods!(SuspensionModule, { + #[new] + pub fn new(inner: PyRef<'_, SteenrodModule>, shift: i32) -> Self { + SuspensionModule { + inner: Arc::new(SuspensionModuleInner::new( + Arc::new(Arc::clone(&inner.0)), + shift, + )), + shift, + } + } + + // --- SuspensionModule-specific (thin) --------------------------------- + + /// The degree shift this suspension applies. (Upstream's `shift` field + /// is private with no accessor, so we report our stored copy.) + #[getter] + pub fn shift(&self) -> i32 { + self.shift + } + + /// Box this module into a `SteenrodModule` for downstream use. Shares + /// state with this `SuspensionModule` via an `Arc`. + pub fn into_steenrod_module(&self) -> SteenrodModule { + SteenrodModule(Arc::clone(&self.inner) as RsSteenrodModule) + } + + pub fn __repr__(&self) -> String { + format!("SuspensionModule({})", self.inner) + } + }); + + /// The zero module over the Steenrod algebra with the given `min_degree` + /// (an empty finite-dimensional module). Dimension 0 in every degree. + #[pyclass(name = "ZeroModule")] + pub struct ZeroModule(Arc); + + impl ZeroModule { + fn as_dyn(&self) -> &DynModule { + &*self.0 + } + } + + module_pymethods!(ZeroModule, { + /// Build the zero module. Mirrors upstream + /// `FDModule::zero_module(algebra, min_degree)`, i.e. an `FDModule` with + /// an empty graded dimension starting at `min_degree`. + #[new] + #[pyo3(signature = (algebra, min_degree = 0))] + pub fn new(algebra: PyRef<'_, SteenrodAlgebra>, min_degree: i32) -> Self { + let graded_dimension = ::bivec::BiVec::new(min_degree); + ZeroModule(Arc::new(FDModuleInner::new( + algebra.arc(), + "zero".to_string(), + graded_dimension, + ))) + } + + /// Box this module into a `SteenrodModule` for downstream use. Shares + /// state with this `ZeroModule` via an `Arc`. + pub fn into_steenrod_module(&self) -> SteenrodModule { + SteenrodModule(Arc::clone(&self.0) as RsSteenrodModule) + } + + pub fn __repr__(&self) -> String { + format!("ZeroModule({})", self.0) + } + }); + + /// The real projective space module + /// `RP_min^max` over the Steenrod algebra at `p = 2`. `max = None` gives + /// `RP_min^oo`. `clear_bottom` mods out the `A(2)`-submodule generated below + /// `min` (see the upstream docs); note it always shifts `min` to `-1 mod 8`. + #[pyclass(name = "RealProjectiveSpace")] + pub struct RealProjectiveSpace(Arc); + + impl RealProjectiveSpace { + fn as_dyn(&self) -> &DynModule { + &*self.0 + } + } + + module_pymethods!(RealProjectiveSpace, { + /// Build `RP_min^max`. Raises `ValueError` for a non-`p = 2` algebra or + /// `max < min` (upstream `new` asserts both). + #[new] + #[pyo3(signature = (algebra, min, max = None, clear_bottom = false))] + pub fn new( + algebra: PyRef<'_, SteenrodAlgebra>, + min: i32, + max: Option, + clear_bottom: bool, + ) -> PyResult { + if algebra.prime() != 2 { + return Err(PyValueError::new_err( + "RealProjectiveSpace is only defined at p = 2", + )); + } + if let Some(max) = max { + if max < min { + return Err(PyValueError::new_err(format!( + "max {max} must be at least min {min}" + ))); + } + } + Ok(RealProjectiveSpace(Arc::new(RpInner::new( + algebra.arc(), + min, + max, + clear_bottom, + )))) + } + + // --- RealProjectiveSpace-specific (thin) ------------------------------ + + #[getter] + pub fn min(&self) -> i32 { + self.0.min + } + + #[getter] + pub fn max(&self) -> Option { + self.0.max + } + + #[getter] + pub fn clear_bottom(&self) -> bool { + self.0.clear_bottom + } + + /// Box this module into a `SteenrodModule` for downstream use. Shares + /// state via an `Arc`. + pub fn into_steenrod_module(&self) -> SteenrodModule { + SteenrodModule(Arc::clone(&self.0) as RsSteenrodModule) + } + + pub fn __repr__(&self) -> String { + format!("RealProjectiveSpace({})", self.0) + } + }); + + /// A quotient `module / W` of a module over the Steenrod algebra, truncated + /// above `truncation`: every degree `> truncation` is quotiented to zero, + /// and in each degree `<= truncation` a subspace `W` (built up with the + /// `quotient*` methods) is divided out. The inner module is passed as a + /// `SteenrodModule` (box concrete modules with `.into_steenrod_module()`). + /// + /// The `quotient*` methods mutate the subspace and therefore require unique + /// ownership of the inner `Arc`; while a boxed `SteenrodModule` produced + /// from this module (via `into_steenrod_module()`) is still alive it shares + /// the `Arc`, so mutation raises `RuntimeError`. Dropping every such box + /// restores unique ownership and mutation works again. Build up the + /// quotient first, then box it. + #[pyclass(name = "QuotientModule")] + pub struct QuotientModule(Arc); + + impl QuotientModule { + fn as_dyn(&self) -> &DynModule { + &*self.0 + } + + /// Mutable access to the inner module for the `quotient*` setters. + /// Fails while the `Arc` is shared (i.e. while a boxed `SteenrodModule` + /// produced via `into_steenrod_module()` is still alive), since that + /// box observes the same state and a mutation would be unsound. Once + /// every such box is dropped, unique ownership is restored and mutation + /// succeeds again. + fn inner_mut(&mut self) -> PyResult<&mut QuotientModuleInner> { + Arc::get_mut(&mut self.0).ok_or_else(|| { + PyRuntimeError::new_err( + "cannot mutate a QuotientModule after it has been boxed into a SteenrodModule", + ) + }) + } + + /// Validate that `degree` indexes a populated subspace, i.e. lies in + /// `[min_degree, truncation]`. Below `min_degree` or above `truncation` + /// the `subspaces`/`basis_list` `BiVec`s have no entry and upstream + /// would index-panic. + fn checked_subspace_degree(&self, degree: i32) -> PyResult<()> { + let min = self.0.min_degree(); + let trunc = self.0.truncation; + if degree < min || degree > trunc { + Err(PyIndexError::new_err(format!( + "degree {degree} is outside the quotient's range [{min}, {trunc}]" + ))) + } else { + Ok(()) + } + } + } + + module_pymethods!(QuotientModule, { + /// Build the quotient of `module` truncated above `truncation`. Raises + /// `ValueError` if `truncation` is below `min_degree - 1` (upstream + /// builds `BiVec`s spanning `[min_degree, truncation]` and would + /// allocate a negative-length capacity / `debug_assert`), or if + /// `truncation + 1` overflows `i32`. + #[new] + pub fn new(module: PyRef<'_, SteenrodModule>, truncation: i32) -> PyResult { + let min_degree = module.0.min_degree(); + truncation + .checked_add(1) + .ok_or_else(|| PyValueError::new_err("truncation is too large"))?; + if truncation < min_degree - 1 { + return Err(PyValueError::new_err(format!( + "truncation {truncation} is below the module's min_degree {min_degree}" + ))); + } + // Upstream `QuotientModuleInner::new` calls `module.compute_basis(truncation)`, + // which for a `FreeModule` inner reads `algebra.dimension_unstable(..)` *without* + // extending the algebra and `OnceVec`-panics if the algebra is not computed + // through `truncation`. Pre-extend the inner module (and its algebra) here. + module_ensure(&*module.0, truncation); + Ok(QuotientModule(Arc::new(QuotientModuleInner::new( + Arc::new(Arc::clone(&module.0)), + truncation, + )))) + } + + // --- QuotientModule-specific (thin) ----------------------------------- + + /// The degree above which everything is quotiented out. + #[getter] + pub fn truncation(&self) -> i32 { + self.0.truncation + } + + /// Quotient out the subspace spanned (additionally) by `element` in + /// `degree`. `element` is a coefficient vector of length equal to the + /// *original* module's dimension in `degree`. A `degree > truncation` + /// is a no-op upstream; we still require a valid in-range `degree` + /// (`[min_degree, truncation]`) for the subspace it indexes, the right + /// prime, and the right length, raising rather than letting + /// `Subspace::add_vector`/the `BiVec` index panic. + pub fn quotient( + &mut self, + py: Python<'_>, + degree: i32, + element: &Bound<'_, PyAny>, + ) -> PyResult<()> { + self.checked_subspace_degree(degree)?; + let p = self.0.prime().as_u32(); + let orig_dim = module_dimension(&**self.0.module, degree); + crate::fp_py::with_input_slice(py, element, |slice| { + checked_same_prime(slice.prime().as_u32(), p)?; + checked_equal_len(slice.len(), orig_dim)?; + self.inner_mut()?.quotient(degree, slice); + Ok(()) + }) + } + + /// Quotient out the original basis elements at the given `indices` in + /// `degree`. Each index must be a valid basis index of the *original* + /// module in `degree` (`Subspace::add_basis_elements` would otherwise + /// `set_entry` out of bounds), and `degree` must be in + /// `[min_degree, truncation]`. + pub fn quotient_basis_elements( + &mut self, + degree: i32, + indices: Vec, + ) -> PyResult<()> { + self.checked_subspace_degree(degree)?; + let orig_dim = module_dimension(&**self.0.module, degree); + for &idx in &indices { + if idx >= orig_dim { + return Err(PyIndexError::new_err(format!( + "basis index {idx} out of range for degree {degree} (original dimension \ + {orig_dim})" + ))); + } + } + self.inner_mut()? + .quotient_basis_elements(degree, indices.into_iter()); + Ok(()) + } + + /// Quotient out the entire degree `degree` (set it to zero in the + /// quotient). `degree` must be in `[min_degree, truncation]`. + pub fn quotient_all(&mut self, degree: i32) -> PyResult<()> { + self.checked_subspace_degree(degree)?; + self.inner_mut()?.quotient_all(degree); + Ok(()) + } + + /// Reduce `vec` modulo the quotient subspace in `degree`, in place. + /// For `degree > truncation` this zeroes `vec` (any length is fine); + /// for `degree` in `[min_degree, truncation]`, `vec` must have length + /// equal to the *original* module's dimension there (the subspace's + /// ambient dimension), which `Subspace::reduce` asserts. A + /// `degree < min_degree` raises `IndexError`. + pub fn reduce(&self, py: Python<'_>, degree: i32, vec: &Bound<'_, PyAny>) -> PyResult<()> { + let p = self.0.prime().as_u32(); + if degree < self.0.min_degree() { + return Err(PyIndexError::new_err(format!( + "degree {degree} is below the module's min_degree {}", + self.0.min_degree() + ))); + } + if degree <= self.0.truncation { + let orig_dim = module_dimension(&**self.0.module, degree); + crate::fp_py::with_target_slice_mut(py, vec, |res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), orig_dim)?; + self.0.reduce(degree, res); + Ok(()) + }) + } else { + crate::fp_py::with_target_slice_mut(py, vec, |res| { + checked_same_prime(res.prime().as_u32(), p)?; + self.0.reduce(degree, res); + Ok(()) + }) + } + } + + /// Re-express an element written in the *original* module's basis as an + /// element of the quotient's basis, accumulating into `new`. `old` must + /// have length equal to the original dimension in `degree`, and `new` + /// must have length at least the quotient dimension there. `degree` + /// must be in `[min_degree, truncation]`. + pub fn old_basis_to_new( + &self, + py: Python<'_>, + degree: i32, + new: &Bound<'_, PyAny>, + old: &Bound<'_, PyAny>, + ) -> PyResult<()> { + self.checked_subspace_degree(degree)?; + let p = self.0.prime().as_u32(); + let orig_dim = module_dimension(&**self.0.module, degree); + let quot_dim = module_dimension(self.as_dyn(), degree); + crate::fp_py::with_input_slice(py, old, |old_slice| { + checked_same_prime(old_slice.prime().as_u32(), p)?; + checked_equal_len(old_slice.len(), orig_dim)?; + crate::fp_py::with_target_slice_mut(py, new, |res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_result_len(res.as_slice().len(), quot_dim)?; + self.0.old_basis_to_new(degree, res, old_slice); + Ok(()) + }) + }) + } + + /// Box this module into a `SteenrodModule` for downstream use. Shares + /// state with this `QuotientModule` via an `Arc` (the `FreeModule` + /// pattern); while a boxed `SteenrodModule` from this module is alive + /// the `quotient*` setters raise `RuntimeError`, and they work again + /// once every such box is dropped. + pub fn into_steenrod_module(&self) -> SteenrodModule { + SteenrodModule(Arc::clone(&self.0) as RsSteenrodModule) + } + + pub fn __repr__(&self) -> String { + format!("QuotientModule({})", self.0) + } + }); + + /// The Hom module `Hom(source, target)` over the Steenrod algebra, where + /// `source` is a `FreeModule` and `target` is a *bounded* module. This is + /// the graded vector space of degree-shifting maps, graded *opposite* to + /// the usual grading so that it is bounded below; it is a module over the + /// ground field `F_p` (acted on only by scalars), **not** over the Steenrod + /// algebra. Its `algebra()` therefore returns the bound ground-field `Field` + /// pyclass (sharing the module's `Arc`), not a `SteenrodAlgebra`. + /// + /// It is still *not* a `SteenrodModule`, so it exposes no + /// `into_steenrod_module()`: a `SteenrodModule` is an + /// `Arc>`, whereas a `HomModule`'s + /// `Algebra` is `Field` — there is no unsizing coercion between + /// `dyn Module` and `dyn Module` + /// (the associated `Algebra` types differ), so boxing it as a + /// `SteenrodModule` is a type error, not merely unimplemented. It is left + /// unbound for that reason. + #[pyclass(name = "HomModule")] + pub struct HomModule(Arc); + + impl HomModule { + fn as_dyn(&self) -> &dyn Module { + &*self.0 + } + + /// Populate book-keeping so that degree-`degree` data can be queried. + /// + /// Unlike the other modules, a `HomModule`'s `algebra()` is the ground + /// field, *not* the Steenrod algebra its source/target are built over. + /// The generic `module_ensure` therefore cannot extend the right + /// algebra: `HomModule::compute_basis(degree)` internally runs + /// `source.compute_basis(degree + target.max_degree())`, which reads + /// (but does not extend) the source's *Steenrod* algebra tables and + /// would `OnceVec`-panic if they are not computed far enough. So we + /// extend the source's Steenrod algebra here, then call the upstream + /// `compute_basis` (idempotent). + /// + /// Returns `true` when degree-`degree` data is (or already was) + /// computable, and `false` *without computing anything* when `degree` + /// is so large that the upstream `compute_basis` — which itself adds + /// `degree + target.max_degree()` (the same sum guarded below) — would + /// overflow `i32`. Such a degree is not a reachable module degree, so + /// callers short-circuit to a clean error / zero dimension rather than + /// panic. A no-op (returning `true`) below `min_degree`, where the + /// guarded `module_*` helpers already treat the degree as empty. + fn ensure(&self, degree: i32) -> bool { + if degree < self.0.min_degree() { + return true; + } + // `target.max_degree()` is `Some` (checked in `new`). + let tmax = self.0.target().max_degree().unwrap(); + let Some(src_deg) = degree.checked_add(tmax) else { + // Upstream `HomModule::compute_basis(degree)` recomputes this + // same `degree + tmax`; bail before it overflows. + return false; + }; + let source = self.0.source(); + source + .algebra() + .compute_basis(src_deg - source.min_degree()); + self.0.compute_basis(degree); + true + } + } + + #[pymethods] + impl HomModule { + /// Build `Hom(source, target)`. `source` must be a `FreeModule` and + /// `target` any (boxed) `SteenrodModule`. Both must be built from the + /// *same* `SteenrodAlgebra` Python object: the same-algebra check uses + /// `Arc::ptr_eq` (there is no cheap structural equality on + /// `SteenrodAlgebra`), so a distinct-but-equal algebra object is + /// rejected with `ValueError`. A prime mismatch is rejected first. + /// `target` must be bounded above (`max_degree()` is not `None`); + /// otherwise upstream `new` panics, so we raise `ValueError`. + #[new] + pub fn new( + source: PyRef<'_, FreeModule>, + target: PyRef<'_, SteenrodModule>, + ) -> PyResult { + let source_arc = Arc::clone(&source.0); + let source_alg = source_arc.algebra(); + let target_alg = target.0.algebra(); + checked_same_prime(source_alg.prime().as_u32(), target_alg.prime().as_u32())?; + if !Arc::ptr_eq(&source_alg, &target_alg) { + return Err(PyValueError::new_err( + "Hom source and target must be built over the same algebra", + )); + } + if target.0.max_degree().is_none() { + return Err(PyValueError::new_err( + "HomModule requires the target module to be bounded above", + )); + } + Ok(HomModule(Arc::new(HomModuleInner::new( + source_arc, + Arc::new(Arc::clone(&target.0)), + )))) + } + + // --- flattened Module method set -------------------------------------- + + /// The ground-field algebra `F_p` this Hom space is a module over, + /// as the bound `Field` pyclass. Shares the module's `Arc` (no + /// `ValidPrime` is exposed, and the prime matches the source/target). + #[getter] + pub fn algebra(&self) -> Field { + Field::from_arc(self.0.algebra()) + } + + #[getter] + pub fn min_degree(&self) -> i32 { + self.0.min_degree() + } + + #[getter] + pub fn max_computed_degree(&self) -> i32 { + self.0.max_computed_degree() + } + + #[getter] + pub fn max_degree(&self) -> Option { + self.0.max_degree() + } + + #[getter] + pub fn prime(&self) -> u32 { + self.0.prime().as_u32() + } + + pub fn compute_basis(&self, degree: i32) { + self.ensure(degree); + } + + pub fn dimension(&self, degree: i32) -> usize { + // An uncomputable (overflowing) degree is not a reachable module + // degree; report a 0 dimension instead of letting the upstream + // `compute_basis` re-add `degree + tmax` and overflow-panic. + if !self.ensure(degree) { + return 0; + } + module_dimension(self.as_dyn(), degree) + } + + #[getter] + pub fn total_dimension(&self) -> PyResult { + // `HomModule` is unbounded above (over a free source), so + // `max_degree()` is `None` and this raises without computing. + module_total_dimension(self.as_dyn()) + } + + #[getter] + pub fn is_unit(&self) -> bool { + self.0.is_unit() + } + + pub fn basis_element_to_string(&self, degree: i32, idx: usize) -> PyResult { + // Uncomputable degree -> dimension 0, so any index is out of range. + if !self.ensure(degree) { + return Err(PyIndexError::new_err(format!( + "index {idx} out of range for degree {degree} (dimension 0)" + ))); + } + module_basis_element_to_string(self.as_dyn(), degree, idx) + } + + pub fn element_to_string( + &self, + py: Python<'_>, + degree: i32, + element: &Bound<'_, PyAny>, + ) -> PyResult { + if !self.ensure(degree) { + return Err(PyValueError::new_err(format!( + "degree {degree} is too large to compute" + ))); + } + module_element_to_string(self.as_dyn(), py, degree, element) + } + + #[allow(clippy::too_many_arguments)] + pub fn act_on_basis( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + op_degree: i32, + op_index: usize, + mod_degree: i32, + mod_index: usize, + ) -> PyResult<()> { + // Pre-extend the source algebra for every degree the guard helper + // will touch (`mod_degree` and the output `mod_degree + op_degree`). + // An uncomputable (overflowing) degree has dimension 0, so the + // basis index cannot exist; bail with a clean `IndexError` before + // the upstream `compute_basis` overflow-panics. + if !self.ensure(mod_degree) { + return Err(PyIndexError::new_err(format!( + "module index {mod_index} out of range for degree {mod_degree} (dimension 0)" + ))); + } + if op_degree >= 0 { + if let Some(out) = mod_degree.checked_add(op_degree) { + if !self.ensure(out) { + return Err(PyValueError::new_err( + "output degree is too large to compute", + )); + } + } + } + module_act_on_basis( + self.as_dyn(), + py, + result, + coeff, + op_degree, + op_index, + mod_degree, + mod_index, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn act( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + op_degree: i32, + op_index: usize, + input_degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult<()> { + // Uncomputable (overflowing) input/output degrees are unreachable + // module degrees; raise cleanly before the upstream `compute_basis` + // overflow-panics. + if !self.ensure(input_degree) { + return Err(PyValueError::new_err(format!( + "degree {input_degree} is too large to compute" + ))); + } + if op_degree >= 0 { + if let Some(out) = input_degree.checked_add(op_degree) { + if !self.ensure(out) { + return Err(PyValueError::new_err( + "output degree is too large to compute", + )); + } + } + } + module_act( + self.as_dyn(), + py, + result, + coeff, + op_degree, + op_index, + input_degree, + input, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn act_by_element( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + op_degree: i32, + op: &Bound<'_, PyAny>, + input_degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult<()> { + // See `act`: bail cleanly on uncomputable (overflowing) degrees. + if !self.ensure(input_degree) { + return Err(PyValueError::new_err(format!( + "degree {input_degree} is too large to compute" + ))); + } + if op_degree >= 0 { + if let Some(out) = input_degree.checked_add(op_degree) { + if !self.ensure(out) { + return Err(PyValueError::new_err( + "output degree is too large to compute", + )); + } + } + } + module_act_by_element( + self.as_dyn(), + py, + result, + coeff, + op_degree, + op, + input_degree, + input, + ) + } + + // --- HomModule-specific (thin) ---------------------------------------- + + /// The source `FreeModule` (shares state via an `Arc`). + #[getter] + pub fn source(&self) -> FreeModule { + FreeModule(self.0.source()) + } + + /// The target module, as a `SteenrodModule` (shares state via an + /// `Arc`). + #[getter] + pub fn target(&self) -> SteenrodModule { + SteenrodModule((*self.0.target()).clone()) + } + + /// Build another `HomModule` `Hom(new_source, X)` over the *same* target + /// module `X` as this one, sharing `X`'s exact `Arc` storage (not just an + /// equal module). + /// + /// This is needed to build a compatible `(source, target)` pair for + /// `HomPullback`: its upstream constructor asserts the two Hom modules + /// share the identical `X` `Arc` (`Arc::ptr_eq`). Because the dynamic + /// monomorphisation stores `X` behind a *per-instance* outer `Arc`, two + /// independent `HomModule(f, X)` constructions each wrap `X` afresh and + /// would fail that identity check; building the second Hom module with + /// `with_source` reuses the first's outer `Arc` so the check passes. + /// + /// `new_source` must be over the same algebra as `X` (checked by prime + /// and `Arc` identity, like `new`). + pub fn with_source(&self, new_source: PyRef<'_, FreeModule>) -> PyResult { + let source_arc = Arc::clone(&new_source.0); + let source_alg = source_arc.algebra(); + let x = self.0.target(); + let target_alg = x.algebra(); + checked_same_prime(source_alg.prime().as_u32(), target_alg.prime().as_u32())?; + if !Arc::ptr_eq(&source_alg, &target_alg) { + return Err(PyValueError::new_err( + "Hom source and target must be built over the same algebra", + )); + } + // `X` was already checked bounded above when `self` was built. + Ok(HomModule(Arc::new(HomModuleInner::new( + source_arc, + Arc::clone(&x), + )))) + } + + pub fn __repr__(&self) -> String { + format!("HomModule({})", self.0) + } + } + + /// A finitely presented module over the Steenrod algebra: the quotient of a + /// `FreeModule` (the *generators*) by the sub-`FreeModule` spanned by a set + /// of *relations*. Build it by adding generators (in consecutive degrees) + /// and then relations, or all at once with `from_json`. + /// + /// `FPModule` is an *immutable* view: it has no mutating methods and is not + /// directly constructible from Python (no `#[new]`). Obtain one from + /// `FPModuleBuilder.build()` or `FPModule.from_json(...)`. The inner module + /// is held in an `Arc`; `into_steenrod_module()` shares that `Arc` (the + /// `FreeModule` pattern). Because `FPModule` exposes no `add_relations`/ + /// `add_generators`, the relation-counter desync that a mutable + /// `from_json` result could exhibit is impossible by construction. + #[pyclass(name = "FPModule")] + pub struct FPModule { + inner: Arc, + } + + impl FPModule { + fn as_dyn(&self) -> &DynModule { + &*self.inner + } + } + + module_pymethods!(FPModule, { + // --- FPModule-specific (thin) ----------------------------------------- + + /// The underlying generators `FreeModule` (shares state via an `Arc`). + /// A general element of the FP module is a homogeneous sum of operations + /// on these generators, modulo the relations. + pub fn generators(&self) -> FreeModule { + FreeModule(self.inner.generators()) + } + + /// Map a generator basis index `idx` in `degree` to its index in the FP + /// module's basis, or `-1` if that generator is killed by a relation. + /// Raises `IndexError` for an out-of-range `idx` or a `degree` below + /// `min_degree` (the degree's data is computed first). + pub fn gen_idx_to_fp_idx(&self, degree: i32, idx: usize) -> PyResult { + if degree < self.inner.min_degree() { + return Err(PyIndexError::new_err(format!( + "degree {degree} is below the module's min_degree {}", + self.inner.min_degree() + ))); + } + module_ensure(self.as_dyn(), degree); + // The `gen_idx_to_fp_idx` table has one entry per generator basis + // element in `degree`, i.e. `generators().dimension(degree)`. + let gen_dim = module_dimension(&*self.inner.generators(), degree); + if idx >= gen_dim { + return Err(PyIndexError::new_err(format!( + "generator index {idx} out of range for degree {degree} (generator dimension \ + {gen_dim})" + ))); + } + Ok(self.inner.gen_idx_to_fp_idx(degree, idx)) + } + + /// Map an FP module basis index `idx` in `degree` to the generator basis + /// index it represents. Raises `IndexError` for an out-of-range `idx` + /// or a `degree` below `min_degree`. + pub fn fp_idx_to_gen_idx(&self, degree: i32, idx: usize) -> PyResult { + if degree < self.inner.min_degree() { + return Err(PyIndexError::new_err(format!( + "degree {degree} is below the module's min_degree {}", + self.inner.min_degree() + ))); + } + module_ensure(self.as_dyn(), degree); + // The `fp_idx_to_gen_idx` table has one entry per FP-module basis + // element in `degree`, i.e. `self.dimension(degree)`. + let dim = module_dimension(self.as_dyn(), degree); + if idx >= dim { + return Err(PyIndexError::new_err(format!( + "fp index {idx} out of range for degree {degree} (dimension {dim})" + ))); + } + Ok(self.inner.fp_idx_to_gen_idx(degree, idx)) + } + + /// Build a finitely presented module from a module-spec `dict` over + /// `algebra`. Mirrors `FinitelyPresentedModule::from_json`, which reads + /// the generators (`"gens"`) and the `_relations` list. All + /// failures map to `ValueError`. + /// + /// Two panic hazards are guarded, exactly as `SteenrodModule.from_spec`. + /// First, upstream does not check the spec's prime against `algebra`; a + /// mismatch (or wrong-prefix relations) makes the relation parser + /// compute the wrong degree and index out of bounds, so we reject a + /// spec `p` that disagrees with `algebra.prime()` up front. Second, the + /// upstream `from_json` `unwrap()`s the `_relations` array and + /// other fields, so we wrap the call in `catch_unwind` to surface a + /// malformed spec as `ValueError` rather than aborting across the FFI + /// boundary. + #[staticmethod] + pub fn from_json( + algebra: PyRef<'_, SteenrodAlgebra>, + value: &Bound<'_, PyAny>, + ) -> PyResult { + use std::panic::{catch_unwind, AssertUnwindSafe}; + let json = py_to_json(value)?; + if let Some(spec_p) = json["p"].as_u64() { + let algebra_p = algebra.prime() as u64; + if spec_p != algebra_p { + return Err(PyValueError::new_err(format!( + "module spec is over p = {spec_p} but the algebra is over p = {algebra_p}" + ))); + } + } + let arc = algebra.arc(); + match catch_unwind(AssertUnwindSafe(|| FPModuleInner::from_json(arc, &json))) { + Ok(Ok(module)) => Ok(FPModule { + inner: Arc::new(module), + }), + Ok(Err(e)) => Err(PyValueError::new_err(e.to_string())), + Err(_) => Err(PyValueError::new_err( + "failed to build FPModule from JSON (malformed spec)", + )), + } + } + + /// Box this immutable module into a `SteenrodModule` for downstream + /// use. Shares state with this `FPModule` via an `Arc` (the + /// `FreeModule` pattern). + pub fn into_steenrod_module(&self) -> SteenrodModule { + SteenrodModule(Arc::clone(&self.inner) as RsSteenrodModule) + } + + pub fn __repr__(&self) -> String { + format!("FPModule({})", self.inner) + } + }); + + /// A mutable builder for a finitely presented module. Add generators (in + /// consecutive degrees starting at `min_degree`) and then relations, then + /// call [`FPModuleBuilder::build`] to obtain an immutable [`FPModule`]. + /// + /// The builder owns the in-progress module in an `Arc` that is unique while + /// building, so the mutating upstream `add_generators`/`add_relations` + /// (which take `&mut self`) reach it via `Arc::get_mut`. `build()` clones + /// that `Arc` into the returned `FPModule` and flips a `built` flag; any + /// further mutation then raises `RuntimeError` (it never panics). The + /// builder is built incrementally from empty, so the `next_relation_degree` + /// counter is always correct — there is no `from_json` path into the + /// builder, which is why the builder cannot exhibit the relation-counter + /// desync. + #[pyclass(name = "FPModuleBuilder")] + pub struct FPModuleBuilder { + inner: Arc, + /// The degree at which the next batch of relations must be added. + /// Upstream pushes relations into an `OnceBiVec` starting at + /// `min_degree` via `push_checked`, which asserts the appended index is + /// exactly the next one; we track that next degree here so we can raise + /// `ValueError` instead of letting the assertion fire. + next_relation_degree: i32, + /// Set by `build()`; once set, all mutators raise `RuntimeError`. + built: bool, + } + + impl FPModuleBuilder { + /// Mutable access for `add_generators`/`add_relations`. Fails (rather + /// than panicking) once `build()` has been called: either the `built` + /// flag is set, or the shared `Arc` makes `Arc::get_mut` return `None`. + fn inner_mut(&mut self) -> PyResult<&mut FPModuleInner> { + if self.built { + return Err(PyRuntimeError::new_err( + "cannot mutate an FPModuleBuilder after build()", + )); + } + Arc::get_mut(&mut self.inner).ok_or_else(|| { + PyRuntimeError::new_err("cannot mutate an FPModuleBuilder after build()") + }) + } + } + + #[pymethods] + impl FPModuleBuilder { + /// Build an empty finitely presented module over `algebra`, named + /// `name`, with generators living in degrees `>= min_degree`. + #[new] + #[pyo3(signature = (algebra, name, min_degree = 0))] + pub fn new(algebra: PyRef<'_, SteenrodAlgebra>, name: String, min_degree: i32) -> Self { + FPModuleBuilder { + inner: Arc::new(FPModuleInner::new(algebra.arc(), name, min_degree)), + next_relation_degree: min_degree, + built: false, + } + } + + #[getter] + pub fn prime(&self) -> u32 { + self.inner.prime().as_u32() + } + + #[getter] + pub fn min_degree(&self) -> i32 { + self.inner.min_degree() + } + + /// Add generators in `degree`, one per name in `gen_names`. Generators + /// must be added at the next consecutive degree (mirroring upstream + /// `FreeModule::add_generators`, which `push_checked`s into an + /// `OnceBiVec` keyed by degree): `degree` must equal + /// `generators().max_computed_degree() + 1` and be `>= min_degree`. + /// Raises `ValueError` (never panics) otherwise, or `RuntimeError` + /// after `build()`. + pub fn add_generators(&mut self, degree: i32, gen_names: Vec) -> PyResult<()> { + if self.built { + return Err(PyRuntimeError::new_err( + "cannot mutate an FPModuleBuilder after build()", + )); + } + let min_degree = self.inner.min_degree(); + if degree < min_degree { + return Err(PyValueError::new_err(format!( + "degree {degree} is below the module's min_degree {min_degree}" + ))); + } + let next_expected = self.inner.generators().max_computed_degree() + 1; + if degree != next_expected { + return Err(PyValueError::new_err(format!( + "generators must be added at the next consecutive degree {next_expected}, got \ + {degree}" + ))); + } + // `add_generators` reads the algebra/opgen tables up to the current + // computed degree, so make sure they are populated through `degree`. + module_ensure(&*self.inner.generators(), degree); + self.inner_mut()?.add_generators(degree, gen_names); + Ok(()) + } + + /// Add relations in `degree`: each relation is a coefficient vector over + /// the generators' basis in `degree` (length + /// `generators().dimension(degree)`, same prime as the module). Pass an + /// empty list to register a degree with no relations. + /// + /// Relations are stored in an `OnceBiVec` starting at `min_degree` and + /// pushed with `push_checked`, so they must be added at consecutive + /// degrees starting from `min_degree`: `degree` must equal the next + /// pending relation degree. Fill intervening degrees with empty lists. + /// Raises `ValueError` for a wrong degree, prime, or length (never + /// panics), or `RuntimeError` after `build()`. + pub fn add_relations( + &mut self, + py: Python<'_>, + degree: i32, + relations: Vec>, + ) -> PyResult<()> { + if self.built { + return Err(PyRuntimeError::new_err( + "cannot mutate an FPModuleBuilder after build()", + )); + } + let min_degree = self.inner.min_degree(); + if degree < min_degree { + return Err(PyValueError::new_err(format!( + "degree {degree} is below the module's min_degree {min_degree}" + ))); + } + if degree != self.next_relation_degree { + return Err(PyValueError::new_err(format!( + "relations must be added at consecutive degrees starting from min_degree; \ + expected degree {} but got {degree} (fill gaps with empty relation lists)", + self.next_relation_degree + ))); + } + let p = self.inner.prime().as_u32(); + // The relation vectors live in the generators' space in `degree`; + // make sure it is computed, then validate every vector's prime and + // length before handing them to the upstream (which pushes them + // verbatim and would only panic much later, in `compute_basis`). + let gens = self.inner.generators(); + module_ensure(&*gens, degree); + let gen_dim = module_dimension(&*gens, degree); + let mut rows = Vec::with_capacity(relations.len()); + for reln in &relations { + let v = crate::fp_py::extract_input_owned(py, reln)?; + checked_same_prime(v.prime().as_u32(), p)?; + checked_equal_len(v.len(), gen_dim)?; + rows.push(v); + } + self.inner_mut()?.add_relations(degree, rows); + self.next_relation_degree = degree + 1; + Ok(()) + } + + /// Finalize the builder and return an immutable [`FPModule`] sharing the + /// underlying module via an `Arc`. After `build()`, any further + /// mutation on this builder raises `RuntimeError` (never panics). + /// `build()` may be called again to obtain another handle to the same + /// immutable module. + pub fn build(&mut self) -> FPModule { + self.built = true; + FPModule { + inner: Arc::clone(&self.inner), + } + } + + pub fn __repr__(&self) -> String { + format!("FPModuleBuilder({})", self.inner) + } + } + + /// One basis element of a [`BlockStructure`]: the `basis_index`-th basis + /// element of the block belonging to generator `(generator_degree, + /// generator_index)`. Mirrors upstream `GeneratorBasisEltPair`'s three + /// public fields. + #[pyclass(name = "GeneratorBasisEltPair", skip_from_py_object)] + #[derive(Clone)] + pub struct GeneratorBasisEltPair { + #[pyo3(get)] + pub generator_degree: i32, + #[pyo3(get)] + pub generator_index: usize, + #[pyo3(get)] + pub basis_index: usize, + } + + #[pymethods] + impl GeneratorBasisEltPair { + #[new] + pub fn new(generator_degree: i32, generator_index: usize, basis_index: usize) -> Self { + GeneratorBasisEltPair { + generator_degree, + generator_index, + basis_index, + } + } + + pub fn __repr__(&self) -> String { + format!( + "GeneratorBasisEltPair(generator_degree={}, generator_index={}, basis_index={})", + self.generator_degree, self.generator_index, self.basis_index + ) + } + } + + /// A book-keeping structure mapping between an index into a direct sum of + /// vector spaces (one "block" per generator) and the individual block + /// coordinates. Used internally by `FreeModule`/`TensorModule`; exposed as a + /// standalone helper. + /// + /// Construct from `min_degree` and `block_sizes`, a list (indexed from + /// `min_degree`) of lists giving the size of each generator's block in that + /// degree. + #[pyclass(name = "BlockStructure")] + pub struct BlockStructure { + inner: RsBlockStructure, + min_degree: i32, + /// The block sizes the structure was built from, kept so the query + /// methods can bounds-check (degree, generator, basis element) against + /// the private `BiVec`/`Vec` upstream indexes, which would otherwise + /// panic on out-of-range input. + block_sizes: Vec>, + } + + impl BlockStructure { + /// The block sizes for `gen_deg`, or `None` (raising `IndexError`) if + /// `gen_deg` is outside the populated degree range. + fn sizes_in_degree(&self, gen_deg: i32) -> PyResult<&Vec> { + if gen_deg < self.min_degree { + return Err(PyIndexError::new_err(format!( + "generator degree {gen_deg} is below min_degree {}", + self.min_degree + ))); + } + let i = (gen_deg - self.min_degree) as usize; + self.block_sizes.get(i).ok_or_else(|| { + PyIndexError::new_err(format!( + "generator degree {gen_deg} is above the maximum degree {}", + self.min_degree + self.block_sizes.len() as i32 - 1 + )) + }) + } + + fn checked_generator(&self, gen_deg: i32, gen_idx: usize) -> PyResult { + let sizes = self.sizes_in_degree(gen_deg)?; + sizes.get(gen_idx).copied().ok_or_else(|| { + PyIndexError::new_err(format!( + "generator index {gen_idx} out of range in degree {gen_deg} ({} generators)", + sizes.len() + )) + }) + } + } + + #[pymethods] + impl BlockStructure { + #[new] + pub fn new(min_degree: i32, block_sizes: Vec>) -> Self { + let bivec = ::bivec::BiVec::from_vec(min_degree, block_sizes.clone()); + BlockStructure { + inner: RsBlockStructure::new(&bivec), + min_degree, + block_sizes, + } + } + + /// The half-open index range `(start, end)` of the block belonging to + /// generator `(gen_deg, gen_idx)`. + pub fn generator_to_block(&self, gen_deg: i32, gen_idx: usize) -> PyResult<(usize, usize)> { + self.checked_generator(gen_deg, gen_idx)?; + let range = self.inner.generator_to_block(gen_deg, gen_idx); + Ok((range.start, range.end)) + } + + /// The index in the direct sum of the `basis_elt`-th element of the + /// block belonging to generator `(gen_deg, gen_idx)`. + pub fn generator_basis_elt_to_index( + &self, + gen_deg: i32, + gen_idx: usize, + basis_elt: usize, + ) -> PyResult { + let size = self.checked_generator(gen_deg, gen_idx)?; + if basis_elt >= size { + return Err(PyIndexError::new_err(format!( + "basis element {basis_elt} out of range for the block of generator \ + ({gen_deg}, {gen_idx}) (block size {size})" + ))); + } + Ok(self + .inner + .generator_basis_elt_to_index(gen_deg, gen_idx, basis_elt)) + } + + /// The `(generator, basis element)` pair corresponding to index `idx` + /// of the direct sum. + pub fn index_to_generator_basis_elt(&self, idx: usize) -> PyResult { + let total = self.inner.total_dimension(); + if idx >= total { + return Err(PyIndexError::new_err(format!( + "index {idx} out of range (total dimension {total})" + ))); + } + let pair = self.inner.index_to_generator_basis_elt(idx); + Ok(GeneratorBasisEltPair { + generator_degree: pair.generator_degree, + generator_index: pair.generator_index, + basis_index: pair.basis_index, + }) + } + + /// The total dimension of the direct sum (the sum of all block sizes). + #[getter] + pub fn total_dimension(&self) -> usize { + self.inner.total_dimension() + } + + /// Add `coeff * source` into the block of `target` belonging to + /// generator `(gen_deg, gen_idx)`. `source` must have length equal to + /// that block's size and `target` must be long enough to cover the + /// block; both must share the same prime. Raises `ValueError`/ + /// `IndexError` (never panics) on a mismatch. + pub fn add_block( + &self, + py: Python<'_>, + target: &Bound<'_, PyAny>, + coeff: u32, + gen_deg: i32, + gen_idx: usize, + source: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let size = self.checked_generator(gen_deg, gen_idx)?; + let range = self.inner.generator_to_block(gen_deg, gen_idx); + crate::fp_py::with_input_slice(py, source, |source_slice| { + let p = source_slice.prime().as_u32(); + let coeff = coeff % p; + checked_equal_len(source_slice.len(), size)?; + crate::fp_py::with_target_slice_mut(py, target, |res| { + checked_same_prime(res.prime().as_u32(), p)?; + // `add_block` writes into `target[range.start..range.end]`. + if res.as_slice().len() < range.end { + return Err(PyValueError::new_err(format!( + "target has length {} but the block ends at index {}", + res.as_slice().len(), + range.end + ))); + } + self.inner + .add_block(res, coeff, gen_deg, gen_idx, source_slice); + Ok(()) + }) + }) + } + + pub fn __repr__(&self) -> String { + format!( + "BlockStructure(total_dimension={})", + self.inner.total_dimension() + ) + } + } + + /// A homomorphism `f: F -> M` out of a `FreeModule` `F` (the *source*) into + /// an arbitrary module `M` (the *target*), with `output_degree = + /// input_degree - degree_shift`. This is the workhorse of resolution + /// machinery: a differential is a `FreeModuleHomomorphism` built up + /// degree-by-degree by specifying the image of each new generator. + /// + /// The source is the bound `FreeModule` pyclass and the target is the bound + /// `SteenrodModule` pyclass; both share their `Arc`-held state with this + /// homomorphism (the `source()`/`target()` accessors hand back the same + /// underlying module, not a copy). Box a concrete target module with + /// `.into_steenrod_module()` first. + /// + /// Every degree-indexed access is pre-checked so that an uncomputed degree, + /// out-of-range index, prime/length mismatch, or non-consecutive mutation + /// raises `ValueError`/`IndexError` rather than panicking across the FFI + /// boundary. The internal `outputs`/`images`/`kernels`/`quasi_inverses` + /// tables use interior mutability (`OnceBiVec`), so every method takes + /// `&self`. + #[pyclass(name = "FreeModuleHomomorphism")] + pub struct FreeModuleHomomorphism(FreeModuleHomomorphismInner); + + impl FreeModuleHomomorphism { + /// Dimension of the source `FreeModule` in `degree` (guarded; computes + /// the basis first and reads 0 below `min_degree`). + fn source_dim(&self, degree: i32) -> usize { + module_dimension(&*self.0.source() as &DynModule, degree) + } + + /// Dimension of the target module in `degree` (guarded). + fn target_dim(&self, degree: i32) -> usize { + module_dimension(&**self.0.target() as &DynModule, degree) + } + + /// Ensure both the source basis through `input_degree` and the target + /// basis through `output_degree` are computed. + fn ensure_through(&self, input_degree: i32, output_degree: i32) { + module_ensure(&*self.0.source() as &DynModule, input_degree); + module_ensure(&**self.0.target() as &DynModule, output_degree); + } + + /// `input_degree - degree_shift`, raising `ValueError` on overflow. + fn output_degree(&self, input_degree: i32) -> PyResult { + input_degree + .checked_sub(self.0.degree_shift()) + .ok_or_else(|| PyValueError::new_err("output degree overflows i32")) + } + + /// Number of generators of the source in `degree`, reading 0 (never + /// panicking) outside the populated generator range — mirrors + /// `FreeModule::num_gens_safe`. + fn source_num_gens(&self, degree: i32) -> usize { + let source = self.0.source(); + if degree < source.min_degree() || degree > source.max_computed_degree() { + return 0; + } + source.number_of_gens_in_degree(degree) + } + + /// Verify the outputs are defined on every source generator that can + /// appear in a basis element of degree `<= hi`. `apply_to_basis_element` + /// reads `outputs[generator_degree]` (panicking if `generator_degree >= + /// next_degree()`), so a basis element built on a generator whose output + /// is not yet set would abort across the boundary. A generator in degree + /// `d` only exists once it has been added to the source, i.e. for `d <= + /// source.max_computed_degree()`. We therefore reject if any such + /// generator degree in `[next_degree(), hi]` carries generators. (Used + /// by the methods that touch *every* basis element in a degree — + /// `get_partial_matrix`/`compute_auxiliary_data_through_degree`.) + fn check_outputs_cover(&self, hi: i32) -> PyResult<()> { + let source = self.0.source(); + let lo = self.0.next_degree().max(source.min_degree()); + let hi = hi.min(source.max_computed_degree()); + for d in lo..=hi { + if source.number_of_gens_in_degree(d) > 0 { + return Err(PyValueError::new_err(format!( + "the homomorphism's outputs are not defined on the source generators in \ + degree {d}; define them (extend_by_zero / add_generators_from_rows) up to \ + degree {hi} first" + ))); + } + } + Ok(()) + } + + /// Verify that the single basis element `(input_degree, input_idx)` only + /// involves a generator whose output is defined. Returns `Ok` for a + /// generator below `min_degree()` (which `apply_to_basis_element` treats + /// as contributing zero) and for any generator degree `< next_degree()`; + /// rejects a generator degree `>= next_degree()` (which would index the + /// `outputs` table out of range). The caller must have already computed + /// the source basis through `input_degree` and bounds-checked + /// `input_idx`. + fn check_basis_element_defined(&self, input_degree: i32, input_idx: usize) -> PyResult<()> { + let source = self.0.source(); + let generator_degree = source + .index_to_op_gen(input_degree, input_idx) + .generator_degree; + if generator_degree >= self.0.next_degree() { + return Err(PyValueError::new_err(format!( + "the homomorphism's output is not defined on the source generator in degree \ + {generator_degree} (define it with add_generators_from_rows / extend_by_zero \ + first)" + ))); + } + Ok(()) + } + } + + module_hom_pymethods!(FreeModuleHomomorphism, { + /// Build the zero homomorphism `source -> target` with the given + /// `degree_shift` (`output_degree = input_degree - degree_shift`). The + /// outputs on generators are all unset; populate them with + /// `add_generators_from_rows`/`add_generators_from_matrix_rows`/ + /// `extend_by_zero`. The factors must be built over the *same* algebra + /// object (checked by prime and `Arc` identity, like `TensorModule`), + /// since the homomorphism applies the source's algebra action on the + /// target. + #[new] + #[pyo3(signature = (source, target, degree_shift = 0))] + pub fn new( + source: PyRef<'_, FreeModule>, + target: PyRef<'_, SteenrodModule>, + degree_shift: i32, + ) -> PyResult { + let source_alg = source.0.algebra(); + let target_alg = target.0.algebra(); + checked_same_prime(source_alg.prime().as_u32(), target_alg.prime().as_u32())?; + if !Arc::ptr_eq(&source_alg, &target_alg) { + return Err(PyValueError::new_err( + "source and target must be built over the same algebra", + )); + } + Ok(FreeModuleHomomorphism(FreeModuleHomomorphismInner::new( + Arc::clone(&source.0), + Arc::new(Arc::clone(&target.0)), + degree_shift, + ))) + } + + // --- flattened ModuleHomomorphism method set -------------------------- + + /// The source `FreeModule` (shares state via `Arc`). + #[getter] + pub fn source(&self) -> FreeModule { + FreeModule(self.0.source()) + } + + /// The target module, boxed as a `SteenrodModule` (shares state via + /// `Arc`). + #[getter] + pub fn target(&self) -> SteenrodModule { + SteenrodModule((*self.0.target()).clone()) + } + + /// Apply the homomorphism to the basis element `input_idx` in + /// `input_degree`, adding `coeff` times its image into `result` (a + /// vector of length `target.dimension(input_degree - degree_shift)`). + pub fn apply_to_basis_element( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input_idx: usize, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + if input_degree < self.0.source().min_degree() { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {}", + self.0.source().min_degree() + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_through(input_degree, output_degree); + let src_dim = self.source_dim(input_degree); + if input_idx >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {input_idx} out of range for source degree {input_degree} \ + (dimension {src_dim})" + ))); + } + self.check_basis_element_defined(input_degree, input_idx)?; + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + self.0 + .apply_to_basis_element(res.copy(), coeff, input_degree, input_idx); + Ok(()) + }) + } + + /// Apply the homomorphism to a general `input` element of + /// `source` in `input_degree` (length `source.dimension(input_degree)`), + /// adding `coeff` times its image into `result`. + pub fn apply( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + if input_degree < self.0.source().min_degree() { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {}", + self.0.source().min_degree() + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_through(input_degree, output_degree); + let src_dim = self.source_dim(input_degree); + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_input_slice(py, input, |in_slice| { + checked_same_prime(in_slice.prime().as_u32(), p)?; + checked_equal_len(in_slice.len(), src_dim)?; + // Every basis element with a nonzero coefficient must be built + // on a generator whose output is defined, else + // `apply_to_basis_element` would index the `outputs` table out + // of range. Check precisely so a partially-defined map can still + // be applied to inputs that only touch the defined part. + for (i, _) in in_slice.iter_nonzero() { + self.check_basis_element_defined(input_degree, i)?; + } + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + self.0.apply(res.copy(), coeff, input_degree, in_slice); + Ok(()) + }) + }) + } + + /// Compute (and cache) the image, kernel and quasi-inverse at every + /// input degree up to `degree`. Requires the outputs on generators to be + /// defined through `degree` (otherwise raises `ValueError`); also raises + /// `ValueError` if a previous manual `set_image`/`set_kernel`/ + /// `set_quasi_inverse` has left the three auxiliary tables out of sync + /// (which would otherwise panic on a non-consecutive insert). + pub fn compute_auxiliary_data_through_degree(&self, degree: i32) -> PyResult<()> { + let kernels_len = self.0.kernels.len(); + // The auxiliary data is only computed for degrees `>= kernels_len`; + // each such degree's matrix touches every basis element, so the + // outputs must be defined on every source generator up to `degree`. + if degree >= kernels_len { + self.check_outputs_cover(degree)?; + } + if self.0.images.len() != kernels_len || self.0.quasi_inverses.len() != kernels_len { + return Err(PyValueError::new_err( + "auxiliary data tables are out of sync (a prior set_image/set_kernel/\ + set_quasi_inverse advanced them unequally); cannot compute", + )); + } + self.0.compute_auxiliary_data_through_degree(degree); + Ok(()) + } + + /// The matrix whose rows are the images of the source basis elements + /// `inputs` in `degree`. Columns index `target.dimension(degree)`. + /// + /// Note: upstream sizes the matrix columns by `target.dimension(degree)` + /// but the per-row application lands in `target.dimension(degree - + /// degree_shift)`; the two agree (so the call is well-defined) exactly + /// when those dimensions coincide — always the case for `degree_shift == + /// 0`. We pre-check that equality and raise `ValueError` otherwise rather + /// than letting the dimension assertion panic. + pub fn get_partial_matrix( + &self, + degree: i32, + inputs: Vec, + ) -> PyResult { + if degree < self.0.source().min_degree() { + return Err(PyIndexError::new_err(format!( + "degree {degree} is below the source min_degree {}", + self.0.source().min_degree() + ))); + } + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + let src_dim = self.source_dim(degree); + for &i in &inputs { + if i >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {i} out of range for source degree {degree} (dimension \ + {src_dim})" + ))); + } + } + self.check_outputs_cover(degree)?; + if self.target_dim(degree) != self.target_dim(output_degree) { + return Err(PyValueError::new_err( + "get_partial_matrix is only well-defined when target.dimension(degree) == \ + target.dimension(degree - degree_shift) (e.g. degree_shift == 0)", + )); + } + Ok(crate::fp_py::PyMatrix::from_rust( + self.0.get_partial_matrix(degree, &inputs), + )) + } + + // --- FreeModuleHomomorphism-specific methods -------------------------- + + /// The first input degree whose outputs on generators have *not* yet + /// been defined (i.e. the length of the `outputs` table). + #[getter] + pub fn next_degree(&self) -> i32 { + self.0.next_degree() + } + + /// The image of the generator `(generator_degree, generator_index)`, a + /// vector of length `target.dimension(generator_degree - degree_shift)`. + pub fn output( + &self, + generator_degree: i32, + generator_index: usize, + ) -> PyResult { + if generator_degree < self.0.min_degree() { + return Err(PyIndexError::new_err(format!( + "generator degree {generator_degree} is below min_degree {}", + self.0.min_degree() + ))); + } + if generator_degree >= self.0.next_degree() { + return Err(PyValueError::new_err(format!( + "outputs are only defined through degree {} (add generators / extend_by_zero \ + first)", + self.0.next_degree() - 1 + ))); + } + let num_gens = self.source_num_gens(generator_degree); + if generator_index >= num_gens { + return Err(PyIndexError::new_err(format!( + "generator index {generator_index} out of range in degree {generator_degree} \ + ({num_gens} generators)" + ))); + } + Ok(crate::fp_py::PyFpVector::from_rust( + self.0.output(generator_degree, generator_index).clone(), + )) + } + + /// Apply the homomorphism to the generator `idx` in `degree`, adding + /// `coeff` times its image (`output(degree, idx)`) into `result`. + pub fn apply_to_generator( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + degree: i32, + idx: usize, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + if degree < self.0.min_degree() { + return Err(PyIndexError::new_err(format!( + "generator degree {degree} is below min_degree {}", + self.0.min_degree() + ))); + } + if degree >= self.0.next_degree() { + return Err(PyValueError::new_err(format!( + "outputs are only defined through degree {} (add generators / extend_by_zero \ + first)", + self.0.next_degree() - 1 + ))); + } + let num_gens = self.source_num_gens(degree); + if idx >= num_gens { + return Err(PyIndexError::new_err(format!( + "generator index {idx} out of range in degree {degree} ({num_gens} generators)" + ))); + } + let output_degree = self.output_degree(degree)?; + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + res.add(self.0.output(degree, idx).as_slice(), coeff); + Ok(()) + }) + } + + /// Set the outputs on the generators in `degree` to zero, extending the + /// `outputs` table up to `degree`. Requires the source's generator + /// counts to be defined through `degree`. + pub fn extend_by_zero(&self, degree: i32) -> PyResult<()> { + if degree >= self.0.next_degree() && degree > self.0.source().max_computed_degree() { + return Err(PyValueError::new_err(format!( + "source generators are only defined through degree {} (cannot extend \ + outputs to degree {degree})", + self.0.source().max_computed_degree() + ))); + } + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + self.0.extend_by_zero(degree); + Ok(()) + } + + /// Define the outputs on the generators in `degree` from `rows`, one + /// vector per generator (each of length `target.dimension(degree - + /// degree_shift)`). `degree` must be the next undefined degree + /// (`next_degree()`), consistent with the consecutive `OnceVec` push. + pub fn add_generators_from_rows( + &self, + py: Python<'_>, + degree: i32, + rows: Vec>, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + if degree != self.0.next_degree() { + return Err(PyValueError::new_err(format!( + "generators must be added consecutively: expected degree {}, got {degree}", + self.0.next_degree() + ))); + } + if degree > self.0.source().max_computed_degree() { + return Err(PyValueError::new_err(format!( + "source generators are only defined through degree {}", + self.0.source().max_computed_degree() + ))); + } + let num_gens = self.source_num_gens(degree); + if rows.len() != num_gens { + return Err(PyValueError::new_err(format!( + "expected {num_gens} rows (one per generator in degree {degree}), got {}", + rows.len() + ))); + } + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + let out_dim = self.target_dim(output_degree); + let mut owned: Vec<::fp::vector::FpVector> = Vec::with_capacity(rows.len()); + for row in &rows { + let vec = crate::fp_py::extract_input_owned(py, row)?; + checked_same_prime(vec.prime().as_u32(), p)?; + checked_equal_len(vec.len(), out_dim)?; + owned.push(vec); + } + self.0.add_generators_from_rows(degree, owned); + Ok(()) + } + + /// Define the outputs on the generators in `degree` from the rows of + /// `matrix` (the first `num_gens` rows are used). `degree` must be the + /// next undefined degree. The matrix must have at least `num_gens` rows + /// and exactly `target.dimension(degree - degree_shift)` columns. + pub fn add_generators_from_matrix_rows( + &self, + degree: i32, + matrix: PyRef<'_, crate::fp_py::PyMatrix>, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + if degree != self.0.next_degree() { + return Err(PyValueError::new_err(format!( + "generators must be added consecutively: expected degree {}, got {degree}", + self.0.next_degree() + ))); + } + if degree > self.0.source().max_computed_degree() { + return Err(PyValueError::new_err(format!( + "source generators are only defined through degree {}", + self.0.source().max_computed_degree() + ))); + } + let num_gens = self.source_num_gens(degree); + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + let out_dim = self.target_dim(output_degree); + let m = matrix.as_rust(); + checked_same_prime(m.prime().as_u32(), p)?; + if m.rows() < num_gens { + return Err(PyValueError::new_err(format!( + "matrix has {} rows but {num_gens} generators in degree {degree}", + m.rows() + ))); + } + if out_dim != 0 && m.columns() != out_dim { + return Err(PyValueError::new_err(format!( + "matrix has {} columns but the target degree has dimension {out_dim}", + m.columns() + ))); + } + let mut owned = m.clone(); + self.0 + .add_generators_from_matrix_rows(degree, owned.as_slice_mut()); + Ok(()) + } + + /// The average density (fraction of nonzero entries) of the output + /// vectors on the generators in `degree`. Returns `nan` if there are no + /// generators in `degree`. Requires the outputs in `degree` to be + /// defined. + pub fn differential_density(&self, degree: i32) -> PyResult { + if degree < self.0.min_degree() || degree >= self.0.next_degree() { + return Err(PyValueError::new_err(format!( + "outputs are not defined in degree {degree} (defined for {}..{})", + self.0.min_degree(), + self.0.next_degree() + ))); + } + Ok(self.0.differential_density(degree)) + } + + /// Manually set the cached image in `degree`. `degree` must be the next + /// undefined image degree (consecutive `OnceVec` push). + pub fn set_image( + &self, + degree: i32, + image: Option>, + ) -> PyResult<()> { + if degree != self.0.images.len() { + return Err(PyValueError::new_err(format!( + "image must be set consecutively: expected degree {}, got {degree}", + self.0.images.len() + ))); + } + self.0.set_image(degree, image.map(|s| s.as_rust().clone())); + Ok(()) + } + + /// Manually set the cached kernel in `degree`. `degree` must be the next + /// undefined kernel degree. + pub fn set_kernel( + &self, + degree: i32, + kernel: Option>, + ) -> PyResult<()> { + if degree != self.0.kernels.len() { + return Err(PyValueError::new_err(format!( + "kernel must be set consecutively: expected degree {}, got {degree}", + self.0.kernels.len() + ))); + } + self.0 + .set_kernel(degree, kernel.map(|s| s.as_rust().clone())); + Ok(()) + } + + /// Manually set the cached quasi-inverse in `degree`. `degree` must be + /// the next undefined quasi-inverse degree. + pub fn set_quasi_inverse( + &self, + degree: i32, + quasi_inverse: Option>, + ) -> PyResult<()> { + if degree != self.0.quasi_inverses.len() { + return Err(PyValueError::new_err(format!( + "quasi-inverse must be set consecutively: expected degree {}, got {degree}", + self.0.quasi_inverses.len() + ))); + } + self.0 + .set_quasi_inverse(degree, quasi_inverse.map(|qi| qi.as_rust().clone())); + Ok(()) + } + + pub fn __repr__(&self) -> String { + format!( + "FreeModuleHomomorphism(source={}, target={}, degree_shift={})", + self.0.source(), + self.0.target(), + self.0.degree_shift() + ) + } + }); + + /// A `FreeModuleHomomorphism` whose target is itself a concrete `FreeModule` + /// (the free → free variant), i.e. a map `F -> G` between two free modules + /// over the same Steenrod algebra. This is the variant `HomPullback` needs; + /// it is distinct from `FreeModuleHomomorphism` (whose target is an arbitrary + /// boxed `SteenrodModule`). + /// + /// Both the source and target are the bound `FreeModule` pyclass and share + /// their `Arc`-held state with this homomorphism (the `source()`/`target()` + /// accessors hand back the same underlying module, not a copy). + /// + /// Because the target is a concrete `FreeModule`, this variant additionally + /// exposes `hom_k` (the dual map on the generators / cohomology), which + /// upstream gates on a `FreeModule` target. + /// + /// Every degree-indexed access is pre-checked so that an uncomputed degree, + /// out-of-range index, prime/length mismatch, or non-consecutive mutation + /// raises `ValueError`/`IndexError` rather than panicking across the FFI + /// boundary. The internal `outputs`/`images`/`kernels`/`quasi_inverses` + /// tables use interior mutability (`OnceBiVec`), so every method takes + /// `&self`; the homomorphism itself is held behind an `Arc` so the *same* + /// instance can be shared into a `HomPullback`. + #[pyclass(name = "FreeModuleHomomorphismToFree")] + pub struct FreeModuleHomomorphismToFree(Arc); + + impl FreeModuleHomomorphismToFree { + /// Wrap an existing `Arc>`, sharing + /// the *same* underlying homomorphism (a cheap refcount bump). Exposed + /// `pub(crate)` so sibling binding modules can hand back maps they hold + /// behind an `Arc` without copying — in particular + /// `ResolutionHomomorphism.get_map`, whose upstream `get_map(s)` returns + /// exactly `Arc>`. Mirrors + /// the `FreeModule::from_arc` Arc-sharing precedent. + pub(crate) fn from_arc(inner: Arc) -> Self { + FreeModuleHomomorphismToFree(inner) + } + + /// Dimension of the source `FreeModule` in `degree` (guarded; computes + /// the basis first and reads 0 below `min_degree`). + fn source_dim(&self, degree: i32) -> usize { + module_dimension(&*self.0.source() as &DynModule, degree) + } + + /// Dimension of the target `FreeModule` in `degree` (guarded). + fn target_dim(&self, degree: i32) -> usize { + module_dimension(&*self.0.target() as &DynModule, degree) + } + + /// Ensure both the source basis through `input_degree` and the target + /// basis through `output_degree` are computed. + fn ensure_through(&self, input_degree: i32, output_degree: i32) { + module_ensure(&*self.0.source() as &DynModule, input_degree); + module_ensure(&*self.0.target() as &DynModule, output_degree); + } + + /// `input_degree - degree_shift`, raising `ValueError` on overflow. + fn output_degree(&self, input_degree: i32) -> PyResult { + input_degree + .checked_sub(self.0.degree_shift()) + .ok_or_else(|| PyValueError::new_err("output degree overflows i32")) + } + + /// Number of generators of the source in `degree`, reading 0 (never + /// panicking) outside the populated generator range. + fn source_num_gens(&self, degree: i32) -> usize { + fm_num_gens_safe(&self.0.source(), degree) + } + + /// See `FreeModuleHomomorphism::check_outputs_cover`. + fn check_outputs_cover(&self, hi: i32) -> PyResult<()> { + let source = self.0.source(); + let lo = self.0.next_degree().max(source.min_degree()); + let hi = hi.min(source.max_computed_degree()); + for d in lo..=hi { + if source.number_of_gens_in_degree(d) > 0 { + return Err(PyValueError::new_err(format!( + "the homomorphism's outputs are not defined on the source generators in \ + degree {d}; define them (extend_by_zero / add_generators_from_rows) up to \ + degree {hi} first" + ))); + } + } + Ok(()) + } + + /// See `FreeModuleHomomorphism::check_basis_element_defined`. + fn check_basis_element_defined(&self, input_degree: i32, input_idx: usize) -> PyResult<()> { + let source = self.0.source(); + let generator_degree = source + .index_to_op_gen(input_degree, input_idx) + .generator_degree; + if generator_degree >= self.0.next_degree() { + return Err(PyValueError::new_err(format!( + "the homomorphism's output is not defined on the source generator in degree \ + {generator_degree} (define it with add_generators_from_rows / extend_by_zero \ + first)" + ))); + } + Ok(()) + } + } + + module_hom_pymethods!(FreeModuleHomomorphismToFree, { + /// Build the zero homomorphism `source -> target` (both `FreeModule`s) + /// with the given `degree_shift` (`output_degree = input_degree - + /// degree_shift`). The outputs on generators are all unset; populate them + /// with `add_generators_from_rows`/`add_generators_from_matrix_rows`/ + /// `extend_by_zero`. The factors must be built over the *same* algebra + /// object (checked by prime and `Arc` identity). + #[new] + #[pyo3(signature = (source, target, degree_shift = 0))] + pub fn new( + source: PyRef<'_, FreeModule>, + target: PyRef<'_, FreeModule>, + degree_shift: i32, + ) -> PyResult { + let source_alg = source.0.algebra(); + let target_alg = target.0.algebra(); + checked_same_prime(source_alg.prime().as_u32(), target_alg.prime().as_u32())?; + if !Arc::ptr_eq(&source_alg, &target_alg) { + return Err(PyValueError::new_err( + "source and target must be built over the same algebra", + )); + } + Ok(FreeModuleHomomorphismToFree(Arc::new( + FreeModuleHomToFreeInner::new( + Arc::clone(&source.0), + Arc::clone(&target.0), + degree_shift, + ), + ))) + } + + // --- flattened ModuleHomomorphism method set -------------------------- + + /// The source `FreeModule` (shares state via `Arc`). + #[getter] + pub fn source(&self) -> FreeModule { + FreeModule(self.0.source()) + } + + /// The target `FreeModule` (shares state via `Arc`). + #[getter] + pub fn target(&self) -> FreeModule { + FreeModule(self.0.target()) + } + + /// Apply the homomorphism to the basis element `input_idx` in + /// `input_degree`, adding `coeff` times its image into `result` (a + /// vector of length `target.dimension(input_degree - degree_shift)`). + pub fn apply_to_basis_element( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input_idx: usize, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + if input_degree < self.0.source().min_degree() { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {}", + self.0.source().min_degree() + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_through(input_degree, output_degree); + let src_dim = self.source_dim(input_degree); + if input_idx >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {input_idx} out of range for source degree {input_degree} \ + (dimension {src_dim})" + ))); + } + self.check_basis_element_defined(input_degree, input_idx)?; + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + self.0 + .apply_to_basis_element(res.copy(), coeff, input_degree, input_idx); + Ok(()) + }) + } + + /// Apply the homomorphism to a general `input` element of `source` in + /// `input_degree` (length `source.dimension(input_degree)`), adding + /// `coeff` times its image into `result`. Aliasing the same vector as + /// both `input` and `result` raises `RuntimeError` (the borrow + /// conflict). + pub fn apply( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + if input_degree < self.0.source().min_degree() { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {}", + self.0.source().min_degree() + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_through(input_degree, output_degree); + let src_dim = self.source_dim(input_degree); + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_input_slice(py, input, |in_slice| { + checked_same_prime(in_slice.prime().as_u32(), p)?; + checked_equal_len(in_slice.len(), src_dim)?; + for (i, _) in in_slice.iter_nonzero() { + self.check_basis_element_defined(input_degree, i)?; + } + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + self.0.apply(res.copy(), coeff, input_degree, in_slice); + Ok(()) + }) + }) + } + + /// Compute (and cache) the image, kernel and quasi-inverse at every + /// input degree up to `degree`. Requires the outputs on generators to be + /// defined through `degree` (otherwise raises `ValueError`); also raises + /// `ValueError` if a previous manual `set_image`/`set_kernel`/ + /// `set_quasi_inverse` has left the three auxiliary tables out of sync. + pub fn compute_auxiliary_data_through_degree(&self, degree: i32) -> PyResult<()> { + let kernels_len = self.0.kernels.len(); + if degree >= kernels_len { + self.check_outputs_cover(degree)?; + } + if self.0.images.len() != kernels_len || self.0.quasi_inverses.len() != kernels_len { + return Err(PyValueError::new_err( + "auxiliary data tables are out of sync (a prior set_image/set_kernel/\ + set_quasi_inverse advanced them unequally); cannot compute", + )); + } + self.0.compute_auxiliary_data_through_degree(degree); + Ok(()) + } + + /// The matrix whose rows are the images of the source basis elements + /// `inputs` in `degree`. Columns index `target.dimension(degree)`. + /// + /// As with `FreeModuleHomomorphism`, this is only well-defined when + /// `target.dimension(degree) == target.dimension(degree - degree_shift)` + /// (always so for `degree_shift == 0`); otherwise it raises `ValueError`. + /// An out-of-range / uncomputed target degree reads as dimension 0 and + /// yields the empty (`len(inputs) x 0`) matrix instead of panicking. + pub fn get_partial_matrix( + &self, + degree: i32, + inputs: Vec, + ) -> PyResult { + if degree < self.0.source().min_degree() { + return Err(PyIndexError::new_err(format!( + "degree {degree} is below the source min_degree {}", + self.0.source().min_degree() + ))); + } + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + let src_dim = self.source_dim(degree); + for &i in &inputs { + if i >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {i} out of range for source degree {degree} (dimension \ + {src_dim})" + ))); + } + } + self.check_outputs_cover(degree)?; + let tgt_dim = self.target_dim(degree); + if tgt_dim == 0 { + return Ok(crate::fp_py::PyMatrix::from_rust(fp::matrix::Matrix::new( + self.0.prime(), + inputs.len(), + 0, + ))); + } + if tgt_dim != self.target_dim(output_degree) { + return Err(PyValueError::new_err( + "get_partial_matrix is only well-defined when target.dimension(degree) == \ + target.dimension(degree - degree_shift) (e.g. degree_shift == 0)", + )); + } + Ok(crate::fp_py::PyMatrix::from_rust( + self.0.get_partial_matrix(degree, &inputs), + )) + } + + // --- FreeModuleHomomorphism-specific methods -------------------------- + + /// The first input degree whose outputs on generators have *not* yet + /// been defined (i.e. the length of the `outputs` table). + #[getter] + pub fn next_degree(&self) -> i32 { + self.0.next_degree() + } + + /// The image of the generator `(generator_degree, generator_index)`, a + /// vector of length `target.dimension(generator_degree - degree_shift)`. + pub fn output( + &self, + generator_degree: i32, + generator_index: usize, + ) -> PyResult { + if generator_degree < self.0.min_degree() { + return Err(PyIndexError::new_err(format!( + "generator degree {generator_degree} is below min_degree {}", + self.0.min_degree() + ))); + } + if generator_degree >= self.0.next_degree() { + return Err(PyValueError::new_err(format!( + "outputs are only defined through degree {} (add generators / extend_by_zero \ + first)", + self.0.next_degree() - 1 + ))); + } + let num_gens = self.source_num_gens(generator_degree); + if generator_index >= num_gens { + return Err(PyIndexError::new_err(format!( + "generator index {generator_index} out of range in degree {generator_degree} \ + ({num_gens} generators)" + ))); + } + Ok(crate::fp_py::PyFpVector::from_rust( + self.0.output(generator_degree, generator_index).clone(), + )) + } + + /// Apply the homomorphism to the generator `idx` in `degree`, adding + /// `coeff` times its image (`output(degree, idx)`) into `result`. + pub fn apply_to_generator( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + degree: i32, + idx: usize, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + if degree < self.0.min_degree() { + return Err(PyIndexError::new_err(format!( + "generator degree {degree} is below min_degree {}", + self.0.min_degree() + ))); + } + if degree >= self.0.next_degree() { + return Err(PyValueError::new_err(format!( + "outputs are only defined through degree {} (add generators / extend_by_zero \ + first)", + self.0.next_degree() - 1 + ))); + } + let num_gens = self.source_num_gens(degree); + if idx >= num_gens { + return Err(PyIndexError::new_err(format!( + "generator index {idx} out of range in degree {degree} ({num_gens} generators)" + ))); + } + let output_degree = self.output_degree(degree)?; + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + res.add(self.0.output(degree, idx).as_slice(), coeff); + Ok(()) + }) + } + + /// Set the outputs on the generators in `degree` to zero, extending the + /// `outputs` table up to `degree`. + pub fn extend_by_zero(&self, degree: i32) -> PyResult<()> { + if degree >= self.0.next_degree() && degree > self.0.source().max_computed_degree() { + return Err(PyValueError::new_err(format!( + "source generators are only defined through degree {} (cannot extend \ + outputs to degree {degree})", + self.0.source().max_computed_degree() + ))); + } + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + self.0.extend_by_zero(degree); + Ok(()) + } + + /// Define the outputs on the generators in `degree` from `rows`, one + /// vector per generator (each of length `target.dimension(degree - + /// degree_shift)`). `degree` must be the next undefined degree. + pub fn add_generators_from_rows( + &self, + py: Python<'_>, + degree: i32, + rows: Vec>, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + if degree != self.0.next_degree() { + return Err(PyValueError::new_err(format!( + "generators must be added consecutively: expected degree {}, got {degree}", + self.0.next_degree() + ))); + } + if degree > self.0.source().max_computed_degree() { + return Err(PyValueError::new_err(format!( + "source generators are only defined through degree {}", + self.0.source().max_computed_degree() + ))); + } + let num_gens = self.source_num_gens(degree); + if rows.len() != num_gens { + return Err(PyValueError::new_err(format!( + "expected {num_gens} rows (one per generator in degree {degree}), got {}", + rows.len() + ))); + } + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + let out_dim = self.target_dim(output_degree); + let mut owned: Vec<::fp::vector::FpVector> = Vec::with_capacity(rows.len()); + for row in &rows { + let vec = crate::fp_py::extract_input_owned(py, row)?; + checked_same_prime(vec.prime().as_u32(), p)?; + checked_equal_len(vec.len(), out_dim)?; + owned.push(vec); + } + self.0.add_generators_from_rows(degree, owned); + Ok(()) + } + + /// Define the outputs on the generators in `degree` from the rows of + /// `matrix` (the first `num_gens` rows are used). `degree` must be the + /// next undefined degree. + pub fn add_generators_from_matrix_rows( + &self, + degree: i32, + matrix: PyRef<'_, crate::fp_py::PyMatrix>, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + if degree != self.0.next_degree() { + return Err(PyValueError::new_err(format!( + "generators must be added consecutively: expected degree {}, got {degree}", + self.0.next_degree() + ))); + } + if degree > self.0.source().max_computed_degree() { + return Err(PyValueError::new_err(format!( + "source generators are only defined through degree {}", + self.0.source().max_computed_degree() + ))); + } + let num_gens = self.source_num_gens(degree); + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + let out_dim = self.target_dim(output_degree); + let m = matrix.as_rust(); + checked_same_prime(m.prime().as_u32(), p)?; + if m.rows() < num_gens { + return Err(PyValueError::new_err(format!( + "matrix has {} rows but {num_gens} generators in degree {degree}", + m.rows() + ))); + } + if out_dim != 0 && m.columns() != out_dim { + return Err(PyValueError::new_err(format!( + "matrix has {} columns but the target degree has dimension {out_dim}", + m.columns() + ))); + } + let mut owned = m.clone(); + self.0 + .add_generators_from_matrix_rows(degree, owned.as_slice_mut()); + Ok(()) + } + + /// The average density (fraction of nonzero entries) of the output + /// vectors on the generators in `degree`. Returns `nan` if there are no + /// generators in `degree`. Requires the outputs in `degree` to be + /// defined. + pub fn differential_density(&self, degree: i32) -> PyResult { + if degree < self.0.min_degree() || degree >= self.0.next_degree() { + return Err(PyValueError::new_err(format!( + "outputs are not defined in degree {degree} (defined for {}..{})", + self.0.min_degree(), + self.0.next_degree() + ))); + } + Ok(self.0.differential_density(degree)) + } + + /// Manually set the cached image in `degree` (consecutive `OnceVec` push). + pub fn set_image( + &self, + degree: i32, + image: Option>, + ) -> PyResult<()> { + if degree != self.0.images.len() { + return Err(PyValueError::new_err(format!( + "image must be set consecutively: expected degree {}, got {degree}", + self.0.images.len() + ))); + } + self.0.set_image(degree, image.map(|s| s.as_rust().clone())); + Ok(()) + } + + /// Manually set the cached kernel in `degree` (consecutive `OnceVec` push). + pub fn set_kernel( + &self, + degree: i32, + kernel: Option>, + ) -> PyResult<()> { + if degree != self.0.kernels.len() { + return Err(PyValueError::new_err(format!( + "kernel must be set consecutively: expected degree {}, got {degree}", + self.0.kernels.len() + ))); + } + self.0 + .set_kernel(degree, kernel.map(|s| s.as_rust().clone())); + Ok(()) + } + + /// Manually set the cached quasi-inverse in `degree` (consecutive + /// `OnceVec` push). + pub fn set_quasi_inverse( + &self, + degree: i32, + quasi_inverse: Option>, + ) -> PyResult<()> { + if degree != self.0.quasi_inverses.len() { + return Err(PyValueError::new_err(format!( + "quasi-inverse must be set consecutively: expected degree {}, got {degree}", + self.0.quasi_inverses.len() + ))); + } + self.0 + .set_quasi_inverse(degree, quasi_inverse.map(|qi| qi.as_rust().clone())); + Ok(()) + } + + /// The dual map on generators in source degree `t`: given `f: F -> G`, + /// computes `f*: Hom(G, k) -> Hom(F, k)` as the matrix (rows indexed by + /// `G`'s generators in degree `t`, columns by `F`'s generators in degree + /// `t + degree_shift`). Only available on this free → free variant + /// (upstream gates `hom_k` on a `FreeModule` target). Returns an empty + /// list when the target has no generators in degree `t` (including when + /// `t` is above the target's computed range, which morally has 0 + /// generators). When the source has no generators in degree + /// `t + degree_shift` (including degrees above the source's computed + /// range), the dual matrix has `target_dim` rows of length 0, matching + /// upstream's `vec![vec![0; source_dim]; target_dim]` with `source_dim == + /// 0`. + /// + /// Guards: the relevant source/target generator degrees must have their + /// bases computed (done here) and, when the source has generators in + /// degree `t + degree_shift`, their outputs must be defined (otherwise + /// `ValueError`). Out-of-computed-range degrees never panic. + pub fn hom_k(&self, t: i32) -> PyResult>> { + let degree_shift = self.0.degree_shift(); + let gen_degree = t + .checked_add(degree_shift) + .ok_or_else(|| PyValueError::new_err("input degree overflows i32"))?; + let source = self.0.source(); + let target = self.0.target(); + module_ensure(&*source as &DynModule, gen_degree); + module_ensure(&*target as &DynModule, t); + // Upstream `hom_k` reads `target.number_of_gens_in_degree(t)` before + // any early return. That read PANICS (OnceBiVec index) when + // `t > target.max_computed_degree()`; `fm_num_gens_safe` returns 0 + // there instead, and upstream returns `vec![]` when the target has no + // generators in degree `t`. An uncomputed target degree morally has 0 + // generators, so `vec![]` is the correct (and safe) result. + let target_dim = fm_num_gens_safe(&target, t); + if target_dim == 0 { + return Ok(vec![]); + } + // Upstream then reads `source.number_of_gens_in_degree(gen_degree)`, + // which likewise PANICS for `gen_degree > source.max_computed_degree()` + // (and returns 0 below `source.min_degree()`). In either case the + // source has no generators in `gen_degree`, so `source_dim` is morally + // 0 and upstream's result `vec![vec![0; source_dim]; target_dim]` is + // `target_dim` empty rows. Return that directly to match upstream + // without tripping the out-of-bounds index. + if gen_degree < source.min_degree() || gen_degree > source.max_computed_degree() { + return Ok(vec![Vec::new(); target_dim]); + } + let source_dim = fm_num_gens_safe(&source, gen_degree); + if source_dim > 0 + && (gen_degree < self.0.min_degree() || gen_degree >= self.0.next_degree()) + { + return Err(PyValueError::new_err(format!( + "the homomorphism's outputs are not defined on the source generators in degree \ + {gen_degree}; define them (add_generators_from_rows / extend_by_zero) first" + ))); + } + Ok(self.0.hom_k(t)) + } + + pub fn __repr__(&self) -> String { + format!( + "FreeModuleHomomorphismToFree(source={}, target={}, degree_shift={})", + self.0.source(), + self.0.target(), + self.0.degree_shift() + ) + } + }); + + /// A read-only view of an *unstable* free module `MuFreeModule` — the module type of an `UnstableResolution` and of the + /// source/target of an `UnstableFreeModuleHomomorphism`. It is a *distinct + /// type* from the bound `FreeModule` pyclass (whose inner module is the + /// stable `U = false` variant), so it gets its own pyclass. + /// + /// Handed out by `ext_py::UnstableResolution.module(s)` and + /// `UnstableFreeModuleHomomorphism.source()`/`.target()`, always sharing the + /// underlying `Arc` (a live view, never a copy). It is query-only: there is + /// no Python constructor (a populated unstable free module is only ever + /// produced by resolving an `UnstableResolution`, whose algebra was built + /// with `unstable = true`), so a handed-out `UnstableFreeModule` can never + /// desync the resolution that produced it. + /// + /// Every degree-indexed accessor is guarded so an uncomputed degree or + /// out-of-range index reads as empty / raises `ValueError`/`IndexError` + /// rather than tripping an upstream `OnceVec` assertion across the FFI + /// boundary. + #[pyclass(name = "UnstableFreeModule")] + pub struct UnstableFreeModule(Arc); + + impl UnstableFreeModule { + /// Borrow the inner unstable module as the algebra-generic + /// `&DynModule`. Sound because `MuFreeModule` + /// implements `Module`, so it reuses the + /// same `module_*` guard helpers as every stable module pyclass. + fn as_dyn(&self) -> &DynModule { + &*self.0 + } + + /// Wrap an existing `Arc>`, sharing + /// the `Arc` (cheap refcount bump). Used by `ext_py::UnstableResolution` + /// and `UnstableResolutionHomomorphism` to hand back the unstable + /// modules they hold behind an `Arc` without cloning. + pub(crate) fn from_arc(module: Arc) -> Self { + UnstableFreeModule(module) + } + + /// Number of generators in `degree`, reading 0 (never panicking) for any + /// degree outside the populated `num_gens` range (mirrors + /// `FreeModule::num_gens_safe`). + fn num_gens_safe(&self, degree: i32) -> usize { + if degree < self.0.min_degree() || degree > self.0.max_computed_degree() { + return 0; + } + self.0.number_of_gens_in_degree(degree) + } + } + + #[pymethods] + impl UnstableFreeModule { + /// The Steenrod algebra the module is built over (an unstable-flagged + /// `SteenrodAlgebra`; shares the `Arc`). + #[getter] + pub fn algebra(&self) -> SteenrodAlgebra { + SteenrodAlgebra::from_arc(self.0.algebra()) + } + + #[getter] + pub fn min_degree(&self) -> i32 { + self.0.min_degree() + } + + #[getter] + pub fn max_computed_degree(&self) -> i32 { + self.0.max_computed_degree() + } + + #[getter] + pub fn prime(&self) -> u32 { + self.0.prime().as_u32() + } + + pub fn compute_basis(&self, degree: i32) { + module_ensure(self.as_dyn(), degree); + } + + pub fn dimension(&self, degree: i32) -> usize { + module_dimension(self.as_dyn(), degree) + } + + /// The number of generators in `degree`. Returns 0 for degrees that have + /// no generators added yet (rather than panicking on the upstream + /// `num_gens[degree]` index assertion). + pub fn number_of_gens_in_degree(&self, degree: i32) -> usize { + self.num_gens_safe(degree) + } + + pub fn basis_element_to_string(&self, degree: i32, idx: usize) -> PyResult { + module_basis_element_to_string(self.as_dyn(), degree, idx) + } + + /// The basis index of `op * gen`, where `op = (op_degree, op_index)` and + /// `gen = (gen_degree, gen_index)`. + /// + /// UNSTABLE-SPECIFIC guard: unlike the stable `FreeModule`, the number of + /// admissible operations on a generator of internal degree `gen_degree` + /// in operation degree `op_degree` is `algebra.dimension_unstable( + /// op_degree, gen_degree)` (the unstable basis is a *subset* of the + /// stable one, cut down by the excess/instability condition), NOT + /// `algebra.dimension(op_degree)`. Upstream + /// `MuFreeModule::operation_generator_to_index` does NOT range-check + /// `op_index` (it merely adds it to a generator offset), so an + /// `op_index >= dimension_unstable(..)` would index past the generator's + /// block and read a neighbouring generator's basis element (or panic on + /// the `generator_to_index` `OnceVec`). We therefore validate `op_index` + /// against `dimension_unstable(op_degree, gen_degree)` here — the live + /// `if U` bound that is gated *off* for the stable `FreeModule` binding + /// (which checks `dimension(op_degree)` instead). + pub fn operation_generator_to_index( + &self, + op_degree: i32, + op_index: usize, + gen_degree: i32, + gen_index: usize, + ) -> PyResult { + non_negative_degree(op_degree)?; + if gen_degree < self.0.min_degree() { + return Err(PyValueError::new_err(format!( + "generator degree {gen_degree} is below min_degree {}", + self.0.min_degree() + ))); + } + if gen_index >= self.num_gens_safe(gen_degree) { + return Err(PyIndexError::new_err(format!( + "generator index {gen_index} out of range in degree {gen_degree}" + ))); + } + let output_degree = op_degree + .checked_add(gen_degree) + .ok_or_else(|| PyValueError::new_err("degree overflows i32"))?; + module_ensure(self.as_dyn(), output_degree); + let algebra = self.0.algebra(); + algebra.compute_basis(op_degree); + let dim = UnstableAlgebra::dimension_unstable(&*algebra, op_degree, gen_degree); + if op_index >= dim { + return Err(PyIndexError::new_err(format!( + "operation index {op_index} out of range for op_degree {op_degree} on a \ + generator of degree {gen_degree} (unstable algebra dimension {dim})" + ))); + } + Ok(self + .0 + .operation_generator_to_index(op_degree, op_index, gen_degree, gen_index)) + } + + pub fn __repr__(&self) -> String { + format!("UnstableFreeModule({})", self.0) + } + } + + /// The chain map on a single source module of an + /// `ext_py::UnstableResolutionHomomorphism`: an *unstable* free → free + /// homomorphism `MuFreeModuleHomomorphism>`. It is the unstable analogue of + /// `FreeModuleHomomorphismToFree`, and a distinct type from it (the stable + /// variant is `U = false`), so it gets its own pyclass. + /// + /// Returned by `UnstableResolutionHomomorphism.get_map(s)`, always sharing + /// the homomorphism's internal `Arc` — a *live read-only view*. It exposes + /// only safe read accessors (`source`/`target`/`degree_shift`/`min_degree`/ + /// `prime`/`next_degree`/`output`): the mutating surface + /// (`add_generators_from_rows`/`extend_by_zero`/`set_*`) is intentionally + /// NOT bound here, since this object only ever views a resolution + /// homomorphism's map (whose state is owned/maintained by the + /// `extend*`/`from_class` machinery); exposing mutators would let Python + /// logically corrupt the chain map. + #[pyclass(name = "UnstableFreeModuleHomomorphism")] + pub struct UnstableFreeModuleHomomorphism(Arc); + + impl UnstableFreeModuleHomomorphism { + /// Wrap an existing `Arc>`, sharing + /// the same homomorphism. Used by + /// `UnstableResolutionHomomorphism.get_map`, whose upstream `get_map(s)` + /// returns exactly this `Arc`. + pub(crate) fn from_arc(inner: Arc) -> Self { + UnstableFreeModuleHomomorphism(inner) + } + + /// Number of generators of the (unstable) source in `degree`, reading 0 + /// outside the populated generator range. + fn source_num_gens(&self, degree: i32) -> usize { + let s = self.0.source(); + if degree < s.min_degree() || degree > s.max_computed_degree() { + return 0; + } + s.number_of_gens_in_degree(degree) + } + + /// Number of generators of the (unstable) target in `degree`, reading 0 + /// outside the populated generator range (the target-side mirror of + /// [`Self::source_num_gens`]). + fn target_num_gens(&self, degree: i32) -> usize { + let t = self.0.target(); + if degree < t.min_degree() || degree > t.max_computed_degree() { + return 0; + } + t.number_of_gens_in_degree(degree) + } + } + + #[pymethods] + impl UnstableFreeModuleHomomorphism { + /// The source unstable `FreeModule` (shares state via `Arc`). + #[getter] + pub fn source(&self) -> UnstableFreeModule { + UnstableFreeModule::from_arc(self.0.source()) + } + + /// The target unstable `FreeModule` (shares state via `Arc`). + #[getter] + pub fn target(&self) -> UnstableFreeModule { + UnstableFreeModule::from_arc(self.0.target()) + } + + /// The degree shift: `output_degree = input_degree - degree_shift`. + #[getter] + pub fn degree_shift(&self) -> i32 { + self.0.degree_shift() + } + + /// The smallest input degree the homomorphism is defined on. + #[getter] + pub fn min_degree(&self) -> i32 { + self.0.min_degree() + } + + /// The prime as a plain `int`. + #[getter] + pub fn prime(&self) -> u32 { + self.0.prime().as_u32() + } + + /// The first input degree whose outputs on generators have *not* yet + /// been defined (the length of the `outputs` table). + #[getter] + pub fn next_degree(&self) -> i32 { + self.0.next_degree() + } + + /// The image of the generator `(generator_degree, generator_index)`, a + /// vector of length `target.dimension(generator_degree - degree_shift)`. + /// Guarded exactly like `FreeModuleHomomorphismToFree.output`: an + /// undefined generator degree or out-of-range index raises + /// `ValueError`/`IndexError` rather than panicking. + pub fn output( + &self, + generator_degree: i32, + generator_index: usize, + ) -> PyResult { + if generator_degree < self.0.min_degree() { + return Err(PyIndexError::new_err(format!( + "generator degree {generator_degree} is below min_degree {}", + self.0.min_degree() + ))); + } + if generator_degree >= self.0.next_degree() { + return Err(PyValueError::new_err(format!( + "outputs are only defined through degree {} (extend the homomorphism first)", + self.0.next_degree() - 1 + ))); + } + let num_gens = self.source_num_gens(generator_degree); + if generator_index >= num_gens { + return Err(PyIndexError::new_err(format!( + "generator index {generator_index} out of range in degree {generator_degree} \ + ({num_gens} generators)" + ))); + } + Ok(crate::fp_py::PyFpVector::from_rust( + self.0.output(generator_degree, generator_index).clone(), + )) + } + + /// The dual map on generators in source degree `t`: given `f: F -> G`, + /// computes `f*: Hom(G, k) -> Hom(F, k)` as the matrix (rows indexed by + /// `G`'s generators in degree `t`, columns by `F`'s generators in degree + /// `t + degree_shift`). The unstable mirror of + /// `FreeModuleHomomorphismToFree.hom_k`. Only available because both + /// source and target are (unstable) free modules (upstream gates `hom_k` + /// on a `MuFreeModule` + /// target). Returns an empty list when the target has no generators in + /// degree `t` (including when `t` is above the target's computed range, + /// which morally has 0 generators). When the source has no generators in + /// degree `t + degree_shift` (including degrees above the source's + /// computed range), the dual matrix has `target_dim` rows of length 0, + /// matching upstream's `vec![vec![0; source_dim]; target_dim]` with + /// `source_dim == 0`. + /// + /// Guards: the relevant source/target generator degrees must have their + /// bases computed (done here) and, when the source has generators in + /// degree `t + degree_shift`, their outputs must be defined (otherwise + /// `ValueError`). Out-of-computed-range degrees never panic. + pub fn hom_k(&self, t: i32) -> PyResult>> { + let degree_shift = self.0.degree_shift(); + let gen_degree = t + .checked_add(degree_shift) + .ok_or_else(|| PyValueError::new_err("input degree overflows i32"))?; + let source = self.0.source(); + let target = self.0.target(); + module_ensure(&*source as &DynModule, gen_degree); + module_ensure(&*target as &DynModule, t); + // Upstream `hom_k` reads `target.number_of_gens_in_degree(t)` before + // any early return. That read PANICS (OnceBiVec index) when + // `t > target.max_computed_degree()`; `target_num_gens` returns 0 + // there instead, and upstream returns `vec![]` when the target has no + // generators in degree `t`. An uncomputed target degree morally has 0 + // generators, so `vec![]` is the correct (and safe) result. + let target_dim = self.target_num_gens(t); + if target_dim == 0 { + return Ok(vec![]); + } + // Upstream then reads `source.number_of_gens_in_degree(gen_degree)`, + // which likewise PANICS for `gen_degree > source.max_computed_degree()` + // (and returns 0 below `source.min_degree()`). In either case the + // source has no generators in `gen_degree`, so `source_dim` is morally + // 0 and upstream's result `vec![vec![0; source_dim]; target_dim]` is + // `target_dim` empty rows. Return that directly to match upstream + // without tripping the out-of-bounds index. + if gen_degree < source.min_degree() || gen_degree > source.max_computed_degree() { + return Ok(vec![Vec::new(); target_dim]); + } + let source_dim = self.source_num_gens(gen_degree); + if source_dim > 0 + && (gen_degree < self.0.min_degree() || gen_degree >= self.0.next_degree()) + { + return Err(PyValueError::new_err(format!( + "the homomorphism's outputs are not defined on the source generators in degree \ + {gen_degree}; define them (add_generators_from_rows / extend_by_zero) first" + ))); + } + Ok(self.0.hom_k(t)) + } + + pub fn __repr__(&self) -> String { + format!( + "UnstableFreeModuleHomomorphism(source={}, target={}, degree_shift={})", + self.0.source(), + self.0.target(), + self.0.degree_shift() + ) + } + } + + /// A `ModuleHomomorphism` `f: S -> M` that simply records its matrix in + /// every degree (`output_degree = input_degree - degree_shift`). Both the + /// source and target are arbitrary modules, accepted (and returned) as the + /// bound `SteenrodModule` pyclass; both share their `Arc`-held state with + /// this homomorphism. Box a concrete module with `.into_steenrod_module()` + /// first. + /// + /// Unlike `FreeModuleHomomorphism`, an unspecified matrix is treated as + /// zero, so applying the map to any degree never requires the outputs to be + /// "defined" — undefined degrees simply contribute nothing. Every + /// degree-indexed access is still pre-checked so that an out-of-range index, + /// prime/length mismatch, or unbounded `identity`/`from_matrices` request + /// raises `ValueError`/`IndexError` rather than panicking across the FFI + /// boundary. The internal `matrices`/`images`/`kernels`/`quasi_inverses` + /// tables use interior mutability (`OnceBiVec`), so every method takes + /// `&self`. + /// + /// NOTE (deferred to later §5.4 tasks): `FullModuleHomomorphism::from`, + /// `replace_source` and `replace_target` are *not* bound here. `from` + /// converts another `ModuleHomomorphism` with the *same* `Source`/`Target` + /// type parameters into a `FullModuleHomomorphism`; with the single + /// `` monomorphisation the only + /// reachable conversion is from another `FullModuleHomomorphism` (i.e. a + /// plain clone), while the useful conversions (e.g. from a + /// `FreeModuleHomomorphism`, whose `Source` is a concrete `FreeModule`) + /// require additional monomorphisations not bound in this task. + /// `replace_source`/`replace_target` only change a *type parameter* (not the + /// mathematical module) and consume `self` by value; with one + /// monomorphisation there is no distinct type to replace into, so they are + /// likewise deferred until those other module-typed homomorphisms exist. + #[pyclass(name = "FullModuleHomomorphism")] + pub struct FullModuleHomomorphism(FullModuleHomomorphismInner); + + impl FullModuleHomomorphism { + /// Wrap an upstream `FullModuleHomomorphism` (the + /// differential type of `CCC`). Used by the `ext` chain-complex + /// bindings; the inner value is cloned out of its `Arc` (cheap: the + /// recorded matrices are `Arc`-shared). + pub(crate) fn from_rust(inner: FullModuleHomomorphismInner) -> Self { + FullModuleHomomorphism(inner) + } + + /// Clone the underlying upstream homomorphism out of the pyclass (cheap: + /// the recorded matrices are `Arc`-shared). Used by `ChainComplex.new`. + pub(crate) fn clone_rust(&self) -> FullModuleHomomorphismInner { + self.0.clone() + } + + /// The algebra of the source module (`Arc`-shared). Used by + /// `ChainComplex.new` to check a differential is built over the same + /// algebra as the complex's modules (via `Arc::ptr_eq`). + pub(crate) fn source_algebra(&self) -> Arc { + self.0.source().algebra() + } + + /// The algebra of the target module (`Arc`-shared); see + /// [`Self::source_algebra`]. + pub(crate) fn target_algebra(&self) -> Arc { + self.0.target().algebra() + } + + /// `min_degree()` of the source module (the smallest input degree). + fn source_min_degree(&self) -> i32 { + self.0.source().min_degree() + } + + /// Dimension of the source module in `degree` (guarded; computes the + /// basis first and reads 0 below `min_degree`). + fn source_dim(&self, degree: i32) -> usize { + module_dimension(&**self.0.source() as &DynModule, degree) + } + + /// Dimension of the target module in `degree` (guarded). + fn target_dim(&self, degree: i32) -> usize { + module_dimension(&**self.0.target() as &DynModule, degree) + } + + /// Ensure both the source basis through `input_degree` and the target + /// basis through `output_degree` are computed (algebra + module). + fn ensure_through(&self, input_degree: i32, output_degree: i32) { + module_ensure(&**self.0.source() as &DynModule, input_degree); + module_ensure(&**self.0.target() as &DynModule, output_degree); + } + + /// `input_degree - degree_shift`, raising `ValueError` on overflow. + fn output_degree(&self, input_degree: i32) -> PyResult { + input_degree + .checked_sub(self.0.degree_shift()) + .ok_or_else(|| PyValueError::new_err("output degree overflows i32")) + } + + /// Validate that `source` and `target` are built over the *same* algebra + /// object (checked by prime and `Arc` identity, like `TensorModule`), + /// raising `ValueError` otherwise. + fn check_same_algebra( + source: &RsSteenrodModule, + target: &RsSteenrodModule, + ) -> PyResult<()> { + let source_alg = source.algebra(); + let target_alg = target.algebra(); + checked_same_prime(source_alg.prime().as_u32(), target_alg.prime().as_u32())?; + if !Arc::ptr_eq(&source_alg, &target_alg) { + return Err(PyValueError::new_err( + "source and target must be built over the same algebra", + )); + } + Ok(()) + } + } + + module_hom_pymethods!(FullModuleHomomorphism, { + /// Build the zero homomorphism `source -> target` with the given + /// `degree_shift` (every recorded matrix is absent, i.e. zero). The + /// factors must be built over the *same* algebra object. + #[new] + #[pyo3(signature = (source, target, degree_shift = 0))] + pub fn new( + source: PyRef<'_, SteenrodModule>, + target: PyRef<'_, SteenrodModule>, + degree_shift: i32, + ) -> PyResult { + Self::check_same_algebra(&source.0, &target.0)?; + Ok(FullModuleHomomorphism(FullModuleHomomorphismInner::new( + Arc::new(source.0.clone()), + Arc::new(target.0.clone()), + degree_shift, + ))) + } + + /// Build a `FullModuleHomomorphism` from explicit per-degree matrices. + /// `matrices[i]` is the matrix in *output* degree `min_degree + i` + /// (defaulting `min_degree` to `target.min_degree()`); its rows index + /// the source basis in degree `min_degree + i + degree_shift` and its + /// columns index the target basis in degree `min_degree + i`. Each + /// matrix's prime and both dimensions are validated against the modules + /// (raising `ValueError`) so that later `apply`/auxiliary-data + /// computations can never index a row/column out of range. + #[staticmethod] + #[pyo3(signature = (source, target, matrices, degree_shift = 0, min_degree = None))] + pub fn from_matrices( + source: PyRef<'_, SteenrodModule>, + target: PyRef<'_, SteenrodModule>, + matrices: Vec>, + degree_shift: i32, + min_degree: Option, + ) -> PyResult { + Self::check_same_algebra(&source.0, &target.0)?; + let p = source.0.prime().as_u32(); + let target_min = target.0.min_degree(); + let min_degree = min_degree.unwrap_or(target_min); + // Upstream `FullModuleHomomorphism::from_matrices` always builds the + // kernels/images/quasi_inverses `OnceBiVec`s starting at + // `target.min_degree()`, regardless of the `matrices` BiVec's min. + // A `min_degree` below `target.min_degree()` would therefore record + // matrices whose auxiliary data is never computed — a silent + // correctness surprise. Reject it up front. + if min_degree < target_min { + return Err(PyValueError::new_err(format!( + "min_degree {min_degree} is below the target min_degree {target_min}; \ + auxiliary data is only computed at and above target.min_degree()" + ))); + } + let mut bivec = ::bivec::BiVec::new(min_degree); + for (offset, m) in matrices.iter().enumerate() { + let offset = i32::try_from(offset) + .map_err(|_| PyValueError::new_err("too many matrices"))?; + let output_degree = min_degree + .checked_add(offset) + .ok_or_else(|| PyValueError::new_err("output degree overflows i32"))?; + let input_degree = output_degree + .checked_add(degree_shift) + .ok_or_else(|| PyValueError::new_err("input degree overflows i32"))?; + module_ensure(&*source.0 as &DynModule, input_degree); + module_ensure(&*target.0 as &DynModule, output_degree); + let src_dim = module_dimension(&*source.0 as &DynModule, input_degree); + let tgt_dim = module_dimension(&*target.0 as &DynModule, output_degree); + let m = m.as_rust(); + checked_same_prime(m.prime().as_u32(), p)?; + if m.rows() != src_dim { + return Err(PyValueError::new_err(format!( + "matrix for output degree {output_degree} has {} rows but the source \ + degree {input_degree} has dimension {src_dim}", + m.rows() + ))); + } + if m.columns() != tgt_dim { + return Err(PyValueError::new_err(format!( + "matrix for output degree {output_degree} has {} columns but the target \ + degree {output_degree} has dimension {tgt_dim}", + m.columns() + ))); + } + bivec.push(m.clone()); + } + Ok(FullModuleHomomorphism( + FullModuleHomomorphismInner::from_matrices( + Arc::new(source.0.clone()), + Arc::new(target.0.clone()), + degree_shift, + bivec, + ), + )) + } + + /// The zero homomorphism `source -> target` with the given + /// `degree_shift` (the `ZeroHomomorphism` constructor). Identical to the + /// `new` constructor for `FullModuleHomomorphism`, exposed separately to + /// mirror the upstream trait surface. + #[staticmethod] + #[pyo3(signature = (source, target, degree_shift = 0))] + pub fn zero( + source: PyRef<'_, SteenrodModule>, + target: PyRef<'_, SteenrodModule>, + degree_shift: i32, + ) -> PyResult { + Self::check_same_algebra(&source.0, &target.0)?; + Ok(FullModuleHomomorphism( + >::zero_homomorphism( + Arc::new(source.0.clone()), + Arc::new(target.0.clone()), + degree_shift, + ), + )) + } + + /// The identity homomorphism on `module` (the `IdentityHomomorphism` + /// constructor): `degree_shift = 0` and the identity matrix in every + /// degree. Its source and target are the *same* module, so source == + /// target holds by construction. Requires the module to be bounded + /// above (`max_degree()` is `Some`); raises `ValueError` otherwise + /// rather than letting the upstream `expect` panic. + #[staticmethod] + pub fn identity(module: PyRef<'_, SteenrodModule>) -> PyResult { + let Some(max) = module.0.max_degree() else { + return Err(PyValueError::new_err( + "identity requires a module that is bounded above", + )); + }; + // Populate the module (and algebra) basis through `max` so the + // upstream loop's `dimension(i)` reads never index past the computed + // range. + module_ensure(&*module.0 as &DynModule, max); + Ok( + FullModuleHomomorphism(>::identity_homomorphism(Arc::new( + module.0.clone(), + ))), + ) + } + + // --- flattened ModuleHomomorphism method set -------------------------- + + /// The source module (shares state via `Arc`). + #[getter] + pub fn source(&self) -> SteenrodModule { + SteenrodModule((*self.0.source()).clone()) + } + + /// The target module (shares state via `Arc`). + #[getter] + pub fn target(&self) -> SteenrodModule { + SteenrodModule((*self.0.target()).clone()) + } + + /// Apply the homomorphism to the basis element `input_idx` in + /// `input_degree`, adding `coeff` times its image into `result` (a + /// vector of length `target.dimension(input_degree - degree_shift)`). + pub fn apply_to_basis_element( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input_idx: usize, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + let src_min = self.source_min_degree(); + if input_degree < src_min { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_through(input_degree, output_degree); + let src_dim = self.source_dim(input_degree); + if input_idx >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {input_idx} out of range for source degree {input_degree} \ + (dimension {src_dim})" + ))); + } + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + self.0 + .apply_to_basis_element(res.copy(), coeff, input_degree, input_idx); + Ok(()) + }) + } + + /// Apply the homomorphism to a general `input` element of `source` in + /// `input_degree` (length `source.dimension(input_degree)`), adding + /// `coeff` times its image into `result`. Aliasing the same vector as + /// both `input` and `result` raises `RuntimeError` (the borrow + /// conflict). + pub fn apply( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + let src_min = self.source_min_degree(); + if input_degree < src_min { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_through(input_degree, output_degree); + let src_dim = self.source_dim(input_degree); + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_input_slice(py, input, |in_slice| { + checked_same_prime(in_slice.prime().as_u32(), p)?; + checked_equal_len(in_slice.len(), src_dim)?; + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + self.0.apply(res.copy(), coeff, input_degree, in_slice); + Ok(()) + }) + }) + } + + /// Compute (and cache) the image, kernel and quasi-inverse at every + /// input degree up to `degree`. Upstream clamps the work to the range of + /// recorded matrices, so degrees beyond the recorded matrices are a + /// no-op (the zero homomorphism built by `new`/`zero` records no + /// matrices, hence computes nothing). The source/target bases (and the + /// algebra) are computed first so the per-degree matrix reductions never + /// index past the computed range. + pub fn compute_auxiliary_data_through_degree(&self, degree: i32) -> PyResult<()> { + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + self.0.compute_auxiliary_data_through_degree(degree); + Ok(()) + } + + /// The matrix whose rows are the images of the source basis elements + /// `inputs` in `degree`. Columns index `target.dimension(degree)`. + /// + /// As with `FreeModuleHomomorphism`, the per-row application lands in + /// `target.dimension(degree - degree_shift)`, so the call is only + /// well-defined when that equals `target.dimension(degree)` (always the + /// case for `degree_shift == 0`); otherwise this raises `ValueError` + /// rather than letting the dimension mismatch panic. + pub fn get_partial_matrix( + &self, + degree: i32, + inputs: Vec, + ) -> PyResult { + let src_min = self.source_min_degree(); + if degree < src_min { + return Err(PyIndexError::new_err(format!( + "degree {degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + let src_dim = self.source_dim(degree); + for &i in &inputs { + if i >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {i} out of range for source degree {degree} (dimension \ + {src_dim})" + ))); + } + } + if self.target_dim(degree) != self.target_dim(output_degree) { + return Err(PyValueError::new_err( + "get_partial_matrix is only well-defined when target.dimension(degree) == \ + target.dimension(degree - degree_shift) (e.g. degree_shift == 0)", + )); + } + Ok(crate::fp_py::PyMatrix::from_rust( + self.0.get_partial_matrix(degree, &inputs), + )) + } + + pub fn __repr__(&self) -> String { + format!( + "FullModuleHomomorphism(degree_shift={}, min_degree={}, prime={})", + self.0.degree_shift(), + self.0.min_degree(), + self.0.prime().as_u32() + ) + } + }); + + /// The homomorphism induced on quotient modules by an underlying + /// `FullModuleHomomorphism` `f`: given quotients `s` of `f.source()` and `t` + /// of `f.target()`, this is the map `s -> t` sending the class of a basis + /// element to the class of its image. Both source and target are the bound + /// `QuotientModule` pyclass and share their `Arc`-held state with the inputs. + /// + /// The two quotients must genuinely be quotients of `f`'s source and target + /// modules (checked by `Arc` identity at construction); otherwise the + /// basis-index translation `s.basis_list[..]` would index a foreign module + /// and panic. Every degree-indexed access is pre-checked so that an + /// out-of-range index, prime/length mismatch, or an output degree outside + /// the target quotient's range raises `ValueError`/`IndexError` rather than + /// panicking across the FFI boundary. + /// + /// NOTE: upstream this homomorphism overrides only `apply_to_basis_element`; + /// it carries no auxiliary data, so `kernel`/`image`/`quasi_inverse` always + /// return `None`, `compute_auxiliary_data_through_degree` is a no-op, and + /// `apply_quasi_inverse` always returns `False`. They are bound anyway so the + /// flattened `ModuleHomomorphism` surface is uniform across homomorphisms. + #[pyclass(name = "QuotientHomomorphism")] + pub struct QuotientHomomorphism(QuotientHomomorphismInner); + + impl QuotientHomomorphism { + /// `min_degree()` of the (quotient) source module. + fn source_min_degree(&self) -> i32 { + self.0.source().min_degree() + } + + /// Dimension of the (quotient) source module in `degree` (guarded). + fn source_dim(&self, degree: i32) -> usize { + module_dimension(&*self.0.source() as &DynModule, degree) + } + + /// Dimension of the (quotient) target module in `degree` (guarded). + fn target_dim(&self, degree: i32) -> usize { + module_dimension(&*self.0.target() as &DynModule, degree) + } + + /// Ensure both quotients' bases (and algebra) are computed through the + /// relevant degrees. (Quotient `compute_basis` is a no-op upstream; the + /// underlying modules are already computed through each quotient's + /// truncation at construction.) + fn ensure_through(&self, input_degree: i32, output_degree: i32) { + module_ensure(&*self.0.source() as &DynModule, input_degree); + module_ensure(&*self.0.target() as &DynModule, output_degree); + } + + /// `input_degree - degree_shift`, raising `ValueError` on overflow. + fn output_degree(&self, input_degree: i32) -> PyResult { + input_degree + .checked_sub(self.0.degree_shift()) + .ok_or_else(|| PyValueError::new_err("output degree overflows i32")) + } + } + + module_hom_pymethods!(QuotientHomomorphism, { + /// Build the induced map `source -> target` from the underlying + /// `FullModuleHomomorphism` `f`. `source` must be a quotient of + /// `f.source()` and `target` a quotient of `f.target()` (checked by + /// `Arc` identity); otherwise raises `ValueError`. + #[new] + pub fn new( + f: PyRef<'_, FullModuleHomomorphism>, + source: PyRef<'_, QuotientModule>, + target: PyRef<'_, QuotientModule>, + ) -> PyResult { + if !Arc::ptr_eq(&*f.0.source(), &*source.0.module) { + return Err(PyValueError::new_err( + "source must be a quotient of the homomorphism's source module", + )); + } + if !Arc::ptr_eq(&*f.0.target(), &*target.0.module) { + return Err(PyValueError::new_err( + "target must be a quotient of the homomorphism's target module", + )); + } + Ok(QuotientHomomorphism(QuotientHomomorphismInner::new( + Arc::new(f.0.clone()), + Arc::clone(&source.0), + Arc::clone(&target.0), + ))) + } + + // --- flattened ModuleHomomorphism method set -------------------------- + + /// The (quotient) source module (shares state via `Arc`). + #[getter] + pub fn source(&self) -> QuotientModule { + QuotientModule(self.0.source()) + } + + /// The (quotient) target module (shares state via `Arc`). + #[getter] + pub fn target(&self) -> QuotientModule { + QuotientModule(self.0.target()) + } + + /// Apply the homomorphism to the basis element `input_idx` in + /// `input_degree`, adding `coeff` times its image into `result` (a vector + /// of length `target.dimension(input_degree - degree_shift)`). + pub fn apply_to_basis_element( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input_idx: usize, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + let src_min = self.source_min_degree(); + if input_degree < src_min { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_through(input_degree, output_degree); + let src_dim = self.source_dim(input_degree); + if input_idx >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {input_idx} out of range for source degree {input_degree} \ + (dimension {src_dim})" + ))); + } + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + // When the target quotient is zero in the output degree (the + // output degree is above the target truncation or below its min + // degree) the image is zero; the upstream call would index the + // target's `basis_list`/underlying-module dimension out of range, + // so skip it. `out_dim == 0` already forces `res` to length 0. + if out_dim != 0 { + self.0 + .apply_to_basis_element(res.copy(), coeff, input_degree, input_idx); + } + Ok(()) + }) + } + + /// Apply the homomorphism to a general `input` element of the (quotient) + /// source in `input_degree` (length `source.dimension(input_degree)`), + /// adding `coeff` times its image into `result`. Aliasing the same vector + /// as both `input` and `result` raises `RuntimeError`. + pub fn apply( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + let src_min = self.source_min_degree(); + if input_degree < src_min { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_through(input_degree, output_degree); + let src_dim = self.source_dim(input_degree); + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_input_slice(py, input, |in_slice| { + checked_same_prime(in_slice.prime().as_u32(), p)?; + checked_equal_len(in_slice.len(), src_dim)?; + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + if out_dim != 0 { + self.0.apply(res.copy(), coeff, input_degree, in_slice); + } + Ok(()) + }) + }) + } + + /// No-op upstream (this homomorphism stores no auxiliary data); bound for + /// surface uniformity. Still validates the output degree against `i32` + /// overflow. + pub fn compute_auxiliary_data_through_degree(&self, degree: i32) -> PyResult<()> { + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + self.0.compute_auxiliary_data_through_degree(degree); + Ok(()) + } + + /// The matrix whose rows are the images of the (quotient) source basis + /// elements `inputs` in `degree`. Columns index `target.dimension(degree)`. + /// + /// The per-row application lands in `target.dimension(degree - + /// degree_shift)`, so the call is only well-defined when that equals + /// `target.dimension(degree)` (always so for `degree_shift == 0`); + /// otherwise this raises `ValueError` rather than panicking. + pub fn get_partial_matrix( + &self, + degree: i32, + inputs: Vec, + ) -> PyResult { + let src_min = self.source_min_degree(); + if degree < src_min { + return Err(PyIndexError::new_err(format!( + "degree {degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + let src_dim = self.source_dim(degree); + for &i in &inputs { + if i >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {i} out of range for source degree {degree} (dimension \ + {src_dim})" + ))); + } + } + // The trait-default builds a `target.dimension(degree)`-column + // matrix and returns early when that is 0. But evaluating + // `target.dimension(degree)` upstream indexes `QuotientModule`'s + // `subspaces` BiVec directly, which panics when `degree` is outside + // the target quotient's defined range (below its min degree or above + // its truncation) — reachable even with `degree_shift == 0` when the + // source/target quotients have asymmetric min degrees. Use the safe + // dimension wrapper and return the empty (`len(inputs) x 0`) matrix + // directly in that case. + let tgt_dim = self.target_dim(degree); + if tgt_dim == 0 { + return Ok(crate::fp_py::PyMatrix::from_rust(fp::matrix::Matrix::new( + self.0.prime(), + inputs.len(), + 0, + ))); + } + if tgt_dim != self.target_dim(output_degree) { + return Err(PyValueError::new_err( + "get_partial_matrix is only well-defined when target.dimension(degree) == \ + target.dimension(degree - degree_shift) (e.g. degree_shift == 0)", + )); + } + Ok(crate::fp_py::PyMatrix::from_rust( + self.0.get_partial_matrix(degree, &inputs), + )) + } + + pub fn __repr__(&self) -> String { + format!( + "QuotientHomomorphism(degree_shift={}, min_degree={}, prime={})", + self.0.degree_shift(), + self.0.min_degree(), + self.0.prime().as_u32() + ) + } + }); + + /// The source-side quotient map `s -> f.target()` induced by a + /// `FullModuleHomomorphism` `f` and a quotient `s` of `f.source()`: it sends + /// the class of a basis element to its image in the (un-quotiented) target. + /// Its source is the bound `QuotientModule` pyclass and its target is the + /// bound `SteenrodModule` pyclass; both share their `Arc`-held state. + /// + /// `s` must genuinely be a quotient of `f.source()` (checked by `Arc` + /// identity at construction). As with `QuotientHomomorphism`, this carries no + /// auxiliary data: `kernel`/`image`/`quasi_inverse` are always `None`, + /// `compute_auxiliary_data_through_degree` is a no-op and + /// `apply_quasi_inverse` always returns `False`. + #[pyclass(name = "QuotientHomomorphismSource")] + pub struct QuotientHomomorphismSource(QuotientHomomorphismSourceInner); + + impl QuotientHomomorphismSource { + /// `min_degree()` of the (quotient) source module. + fn source_min_degree(&self) -> i32 { + self.0.source().min_degree() + } + + /// Dimension of the (quotient) source module in `degree` (guarded). + fn source_dim(&self, degree: i32) -> usize { + module_dimension(&*self.0.source() as &DynModule, degree) + } + + /// Dimension of the (plain) target module in `degree` (guarded). + fn target_dim(&self, degree: i32) -> usize { + module_dimension(&**self.0.target() as &DynModule, degree) + } + + /// Ensure the (quotient) source basis and the (plain) target basis are + /// computed through the relevant degrees (the latter is a genuine module + /// whose basis must be extended). + fn ensure_through(&self, input_degree: i32, output_degree: i32) { + module_ensure(&*self.0.source() as &DynModule, input_degree); + module_ensure(&**self.0.target() as &DynModule, output_degree); + } + + /// `input_degree - degree_shift`, raising `ValueError` on overflow. + fn output_degree(&self, input_degree: i32) -> PyResult { + input_degree + .checked_sub(self.0.degree_shift()) + .ok_or_else(|| PyValueError::new_err("output degree overflows i32")) + } + } + + module_hom_pymethods!(QuotientHomomorphismSource, { + /// Build the source-side quotient map from the underlying + /// `FullModuleHomomorphism` `f` and a quotient `source` of `f.source()` + /// (checked by `Arc` identity; otherwise raises `ValueError`). + #[new] + pub fn new( + f: PyRef<'_, FullModuleHomomorphism>, + source: PyRef<'_, QuotientModule>, + ) -> PyResult { + if !Arc::ptr_eq(&*f.0.source(), &*source.0.module) { + return Err(PyValueError::new_err( + "source must be a quotient of the homomorphism's source module", + )); + } + Ok(QuotientHomomorphismSource( + QuotientHomomorphismSourceInner::new(Arc::new(f.0.clone()), Arc::clone(&source.0)), + )) + } + + // --- flattened ModuleHomomorphism method set -------------------------- + + /// The (quotient) source module (shares state via `Arc`). + #[getter] + pub fn source(&self) -> QuotientModule { + QuotientModule(self.0.source()) + } + + /// The (plain) target module, boxed as a `SteenrodModule` (shares state + /// via `Arc`). + #[getter] + pub fn target(&self) -> SteenrodModule { + SteenrodModule((*self.0.target()).clone()) + } + + /// Apply the homomorphism to the basis element `input_idx` in + /// `input_degree`, adding `coeff` times its image into `result` (a vector + /// of length `target.dimension(input_degree - degree_shift)`). + pub fn apply_to_basis_element( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input_idx: usize, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + let src_min = self.source_min_degree(); + if input_degree < src_min { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_through(input_degree, output_degree); + let src_dim = self.source_dim(input_degree); + if input_idx >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {input_idx} out of range for source degree {input_degree} \ + (dimension {src_dim})" + ))); + } + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + self.0 + .apply_to_basis_element(res.copy(), coeff, input_degree, input_idx); + Ok(()) + }) + } + + /// Apply the homomorphism to a general `input` element of the (quotient) + /// source in `input_degree` (length `source.dimension(input_degree)`), + /// adding `coeff` times its image into `result`. Aliasing the same vector + /// as both `input` and `result` raises `RuntimeError`. + pub fn apply( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + let src_min = self.source_min_degree(); + if input_degree < src_min { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_through(input_degree, output_degree); + let src_dim = self.source_dim(input_degree); + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_input_slice(py, input, |in_slice| { + checked_same_prime(in_slice.prime().as_u32(), p)?; + checked_equal_len(in_slice.len(), src_dim)?; + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + self.0.apply(res.copy(), coeff, input_degree, in_slice); + Ok(()) + }) + }) + } + + /// No-op upstream (no auxiliary data); validates output-degree overflow. + pub fn compute_auxiliary_data_through_degree(&self, degree: i32) -> PyResult<()> { + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + self.0.compute_auxiliary_data_through_degree(degree); + Ok(()) + } + + /// The matrix whose rows are the images of the (quotient) source basis + /// elements `inputs` in `degree`. Columns index `target.dimension(degree)`. + /// Only well-defined when `target.dimension(degree) == target.dimension( + /// degree - degree_shift)` (e.g. `degree_shift == 0`); otherwise raises + /// `ValueError`. + pub fn get_partial_matrix( + &self, + degree: i32, + inputs: Vec, + ) -> PyResult { + let src_min = self.source_min_degree(); + if degree < src_min { + return Err(PyIndexError::new_err(format!( + "degree {degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + let src_dim = self.source_dim(degree); + for &i in &inputs { + if i >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {i} out of range for source degree {degree} (dimension \ + {src_dim})" + ))); + } + } + if self.target_dim(degree) != self.target_dim(output_degree) { + return Err(PyValueError::new_err( + "get_partial_matrix is only well-defined when target.dimension(degree) == \ + target.dimension(degree - degree_shift) (e.g. degree_shift == 0)", + )); + } + Ok(crate::fp_py::PyMatrix::from_rust( + self.0.get_partial_matrix(degree, &inputs), + )) + } + + pub fn __repr__(&self) -> String { + format!( + "QuotientHomomorphismSource(degree_shift={}, min_degree={}, prime={})", + self.0.degree_shift(), + self.0.min_degree(), + self.0.prime().as_u32() + ) + } + }); + + /// The generic zero homomorphism `source -> target` with a given + /// `degree_shift`: it maps every element to `0`. Both source and target are + /// the bound `SteenrodModule` pyclass and share their `Arc`-held state. + /// + /// Upstream `apply_to_basis_element` is a no-op, so `apply` never changes + /// `result`. The map carries no auxiliary data: + /// `kernel`/`image`/`quasi_inverse` are always `None`, + /// `compute_auxiliary_data_through_degree` is a no-op and + /// `apply_quasi_inverse` always returns `False` (and, because there is never + /// a quasi-inverse, does not validate its inputs). The `apply`/ + /// `apply_to_basis_element` paths still validate their inputs + /// (prime/length/index/aliasing) so misuse raises `ValueError`/`IndexError`/ + /// `RuntimeError` rather than silently succeeding. + #[pyclass(name = "GenericZeroHomomorphism")] + pub struct GenericZeroHomomorphism(GenericZeroHomomorphismInner); + + impl GenericZeroHomomorphism { + fn source_min_degree(&self) -> i32 { + self.0.source().min_degree() + } + + fn source_dim(&self, degree: i32) -> usize { + module_dimension(&**self.0.source() as &DynModule, degree) + } + + fn target_dim(&self, degree: i32) -> usize { + module_dimension(&**self.0.target() as &DynModule, degree) + } + + fn ensure_through(&self, input_degree: i32, output_degree: i32) { + module_ensure(&**self.0.source() as &DynModule, input_degree); + module_ensure(&**self.0.target() as &DynModule, output_degree); + } + + fn output_degree(&self, input_degree: i32) -> PyResult { + input_degree + .checked_sub(self.0.degree_shift()) + .ok_or_else(|| PyValueError::new_err("output degree overflows i32")) + } + } + + module_hom_pymethods!(GenericZeroHomomorphism, { + /// Build the zero homomorphism `source -> target` with the given + /// `degree_shift`. The factors must be built over the *same* algebra + /// object (checked by prime and `Arc` identity); otherwise raises + /// `ValueError`. + #[new] + #[pyo3(signature = (source, target, degree_shift = 0))] + pub fn new( + source: PyRef<'_, SteenrodModule>, + target: PyRef<'_, SteenrodModule>, + degree_shift: i32, + ) -> PyResult { + let source_alg = source.0.algebra(); + let target_alg = target.0.algebra(); + checked_same_prime(source_alg.prime().as_u32(), target_alg.prime().as_u32())?; + if !Arc::ptr_eq(&source_alg, &target_alg) { + return Err(PyValueError::new_err( + "source and target must be built over the same algebra", + )); + } + Ok(GenericZeroHomomorphism(GenericZeroHomomorphismInner::new( + Arc::new(source.0.clone()), + Arc::new(target.0.clone()), + degree_shift, + ))) + } + + // --- flattened ModuleHomomorphism method set -------------------------- + + /// The source module (shares state via `Arc`). + #[getter] + pub fn source(&self) -> SteenrodModule { + SteenrodModule((*self.0.source()).clone()) + } + + /// The target module (shares state via `Arc`). + #[getter] + pub fn target(&self) -> SteenrodModule { + SteenrodModule((*self.0.target()).clone()) + } + + /// Apply the homomorphism to the basis element `input_idx` in + /// `input_degree`. A no-op (the zero map), but still validates the + /// inputs. `result` has length `target.dimension(input_degree - + /// degree_shift)`. + pub fn apply_to_basis_element( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input_idx: usize, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + let src_min = self.source_min_degree(); + if input_degree < src_min { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_through(input_degree, output_degree); + let src_dim = self.source_dim(input_degree); + if input_idx >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {input_idx} out of range for source degree {input_degree} \ + (dimension {src_dim})" + ))); + } + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + self.0 + .apply_to_basis_element(res.copy(), coeff, input_degree, input_idx); + Ok(()) + }) + } + + /// Apply the homomorphism to a general `input` element in `input_degree`. + /// A no-op (the zero map), but still validates the inputs. Aliasing the + /// same vector as both `input` and `result` raises `RuntimeError`. + pub fn apply( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let p = self.0.prime().as_u32(); + let coeff = coeff % p; + let src_min = self.source_min_degree(); + if input_degree < src_min { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_through(input_degree, output_degree); + let src_dim = self.source_dim(input_degree); + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_input_slice(py, input, |in_slice| { + checked_same_prime(in_slice.prime().as_u32(), p)?; + checked_equal_len(in_slice.len(), src_dim)?; + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + self.0.apply(res.copy(), coeff, input_degree, in_slice); + Ok(()) + }) + }) + } + + /// No-op upstream (no auxiliary data); validates output-degree overflow. + pub fn compute_auxiliary_data_through_degree(&self, degree: i32) -> PyResult<()> { + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + self.0.compute_auxiliary_data_through_degree(degree); + Ok(()) + } + + /// The matrix whose rows are the images of the source basis elements + /// `inputs` in `degree` — always the zero matrix of shape `len(inputs) x + /// target.dimension(degree)`. Validates the input indices. + pub fn get_partial_matrix( + &self, + degree: i32, + inputs: Vec, + ) -> PyResult { + let src_min = self.source_min_degree(); + if degree < src_min { + return Err(PyIndexError::new_err(format!( + "degree {degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(degree)?; + self.ensure_through(degree, output_degree); + let src_dim = self.source_dim(degree); + for &i in &inputs { + if i >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {i} out of range for source degree {degree} (dimension \ + {src_dim})" + ))); + } + } + // The trait-default builds a `target.dimension(degree)`-column matrix + // and returns early when that is 0. `ensure_through` above only + // computes the target through `output_degree`, so for + // `degree_shift > 0` the upstream `target.dimension(degree)` query + // would reach beyond the target's computed range (or below its min) + // and trip the `OnceVec`/BiVec assertion. Calling the safe wrapper + // ensures the target through `degree` (returning 0 when it is out of + // range) and makes the subsequent upstream call safe; when it is 0 we + // build the empty (`len(inputs) x 0`) matrix directly. + let tgt_dim = self.target_dim(degree); + if tgt_dim == 0 { + return Ok(crate::fp_py::PyMatrix::from_rust(fp::matrix::Matrix::new( + self.0.prime(), + inputs.len(), + 0, + ))); + } + Ok(crate::fp_py::PyMatrix::from_rust( + self.0.get_partial_matrix(degree, &inputs), + )) + } + + pub fn __repr__(&self) -> String { + format!( + "GenericZeroHomomorphism(degree_shift={}, min_degree={}, prime={})", + self.0.degree_shift(), + self.0.min_degree(), + self.0.prime().as_u32() + ) + } + }); + + /// The induced pullback map `Hom(B, X) -> Hom(A, X)` of a free → free map + /// `map: A -> B`, where `A`, `B` are `FreeModule`s and `X` is a (boxed) + /// `SteenrodModule`. Its `source` is `Hom(B, X)` and its `target` is + /// `Hom(A, X)`, both the bound `HomModule` pyclass (sharing their `Arc`-held + /// state). The `map` is the bound `FreeModuleHomomorphismToFree`. + /// + /// `HomModule`'s algebra is the ground `Field` (it is *not* a + /// `SteenrodModule`), so the binding drives basis computation through + /// `HomModule::ensure` — which extends the underlying source's *Steenrod* + /// algebra and is the same machinery the `HomModule` pyclass uses. + /// + /// Construction enforces the three upstream `assert!`s as `ValueError`s (not + /// panics): `source.source() == map.target()`, `target.source() == + /// map.source()` and `source.target() == target.target()` (all compared by + /// `Arc::ptr_eq` on the underlying `FreeModule`/`SteenrodModule`). + /// + /// Upstream `HomPullback` overrides `apply_to_basis_element`, + /// `compute_auxiliary_data_through_degree`, `kernel`, `image`, + /// `quasi_inverse`, `source`, `target`, `degree_shift` and `min_degree`; the + /// remaining `ModuleHomomorphism` surface (`apply`, `get_matrix`/ + /// `get_partial_matrix`, `auxiliary_data`, `apply_quasi_inverse`) uses the + /// trait defaults. Unlike `QuotientHomomorphism`, the auxiliary data is + /// genuinely computed and stored (`kernel`/`image`/`quasi_inverse` return + /// real subspaces once `compute_auxiliary_data_through_degree` runs). + /// + /// Every degree-indexed access is pre-checked: an uncomputed/out-of-range + /// degree reads as dimension 0 (yielding a zero matrix / skipped apply), an + /// out-of-range index, prime/length mismatch or aliasing raises + /// `IndexError`/`ValueError`/`RuntimeError`, and a `map` whose outputs are + /// not defined far enough raises `ValueError` rather than panicking. The + /// pyclass keeps an `Arc` clone of the `map` so these guards can inspect its + /// outputs (the upstream `map` field is private). + #[pyclass(name = "HomPullback")] + pub struct HomPullback { + inner: HomPullbackInner, + map: Arc, + } + + impl HomPullback { + /// The source `Hom(B, X)` module as the bound `HomModule` pyclass + /// (sharing the `Arc`). + fn src_hom(&self) -> HomModule { + HomModule(self.inner.source()) + } + + /// The target `Hom(A, X)` module as the bound `HomModule` pyclass. + fn tgt_hom(&self) -> HomModule { + HomModule(self.inner.target()) + } + + /// Dimension of the source `HomModule` in `degree` (guarded; reuses + /// `HomModule::dimension`, which short-circuits to 0 for an + /// out-of-range / uncomputable degree and never panics). + fn source_dim(&self, degree: i32) -> usize { + self.src_hom().dimension(degree) + } + + /// Dimension of the target `HomModule` in `degree` (guarded). + fn target_dim(&self, degree: i32) -> usize { + self.tgt_hom().dimension(degree) + } + + /// `input_degree - degree_shift`, raising `ValueError` on overflow. + /// (`HomPullback::degree_shift() == -map.degree_shift()`, so the output + /// degree is `input_degree + map.degree_shift()`.) + fn output_degree(&self, input_degree: i32) -> PyResult { + input_degree + .checked_sub(self.inner.degree_shift()) + .ok_or_else(|| PyValueError::new_err("output degree overflows i32")) + } + + /// Compute every basis (both `HomModule`s, and their underlying Steenrod + /// algebra) the upstream `apply_to_basis_element` touches at input degree + /// `fn_degree`, and verify the `map`'s outputs cover every target + /// free-module generator it reads. Returns `Ok` once it is safe to apply + /// the pullback to *any* basis element of `fn_degree`. + /// + /// Upstream iterates `map.source()`'s generators up to `max_degree = + /// fn_degree + map.degree_shift() + X.max_degree() = output_degree + + /// X.max_degree()`, calling `map.output(..)` on each, which panics if the + /// outputs are not yet defined there; we replicate + /// `FreeModuleHomomorphism::check_outputs_cover` against the `map`. + /// + /// `map.output(..)` also asserts `target_gen_deg >= map.min_degree()` + /// (`free_module_homomorphism.rs:150`). This is unreachable: the upstream + /// per-call filter (`hom_pullback.rs:86`) keeps only generators with + /// `target_gen_deg >= max(generator_degree + degree_shift, ..)`, where + /// `generator_degree` is a generator degree of `B = map.target()`, hence + /// `>= B.min_degree()`. So the filter's lower bound is + /// `>= B.min_degree() + degree_shift`. Since `map.min_degree() == + /// max(A.min_degree(), B.min_degree() + degree_shift)`, every admitted + /// generator satisfies `target_gen_deg >= map.min_degree()` whether the + /// max is attained by `A` (the iterated generators all live `>= + /// A.min_degree()`) or by `B` (the filter bound dominates). No guard is + /// needed for the `min_degree` assert; only the outputs-cover check + /// above is required. + fn ensure_apply(&self, fn_degree: i32, output_degree: i32) -> PyResult<()> { + // Computing the source HomModule through `fn_degree` and the target + // HomModule through `output_degree` also computes (via + // `HomModule::compute_basis`) the underlying free modules through the + // degrees upstream reads, plus the shared module `X`. + self.src_hom().ensure(fn_degree); + self.tgt_hom().ensure(output_degree); + let tmax = self.inner.source().target().max_degree().ok_or_else(|| { + PyValueError::new_err("the common module X must be bounded above") + })?; + let max_degree = output_degree + .checked_add(tmax) + .ok_or_else(|| PyValueError::new_err("degree overflows i32"))?; + let a = self.map.source(); + let lo = self.map.next_degree().max(a.min_degree()); + let hi = max_degree.min(a.max_computed_degree()); + for d in lo..=hi { + if a.number_of_gens_in_degree(d) > 0 { + return Err(PyValueError::new_err(format!( + "the pullback map's outputs are not defined on its source generators in \ + degree {d}; extend the map (add_generators_from_rows / extend_by_zero) up \ + to degree {max_degree} first" + ))); + } + } + Ok(()) + } + } + + #[pymethods] + impl HomPullback { + /// Build the pullback `source = Hom(B, X) -> target = Hom(A, X)` of the + /// free → free `map: A -> B`. The three upstream identities are checked + /// by `Arc::ptr_eq` and raise `ValueError` on mismatch: + /// * `source.source()` (the free module `B`) `== map.target()`, + /// * `target.source()` (the free module `A`) `== map.source()`, + /// * `source.target() == target.target()` (the common module `X`). + #[new] + pub fn new( + source: PyRef<'_, HomModule>, + target: PyRef<'_, HomModule>, + map: PyRef<'_, FreeModuleHomomorphismToFree>, + ) -> PyResult { + let map_arc = Arc::clone(&map.0); + if !Arc::ptr_eq(&source.0.source(), &map_arc.target()) { + return Err(PyValueError::new_err( + "source.source() must equal map.target() (source must be Hom(B, X) for \ + map: A -> B)", + )); + } + if !Arc::ptr_eq(&target.0.source(), &map_arc.source()) { + return Err(PyValueError::new_err( + "target.source() must equal map.source() (target must be Hom(A, X) for \ + map: A -> B)", + )); + } + if !Arc::ptr_eq(&source.0.target(), &target.0.target()) { + return Err(PyValueError::new_err( + "source.target() must equal target.target() (both Hom modules must share the \ + same module X)", + )); + } + let inner = HomPullbackInner::new( + Arc::clone(&source.0), + Arc::clone(&target.0), + Arc::clone(&map_arc), + ); + Ok(HomPullback { + inner, + map: map_arc, + }) + } + + // --- flattened ModuleHomomorphism method set -------------------------- + + /// The source `Hom(B, X)` module (shares state via `Arc`). + #[getter] + pub fn source(&self) -> HomModule { + self.src_hom() + } + + /// The target `Hom(A, X)` module (shares state via `Arc`). + #[getter] + pub fn target(&self) -> HomModule { + self.tgt_hom() + } + + /// The degree shift: `output_degree = input_degree - degree_shift`. + /// Upstream this is `-map.degree_shift()`. + #[getter] + pub fn degree_shift(&self) -> i32 { + self.inner.degree_shift() + } + + /// The smallest input degree the homomorphism is defined on + /// (`source.min_degree()`). + #[getter] + pub fn min_degree(&self) -> i32 { + self.inner.min_degree() + } + + /// The prime as a plain `int` (`ValidPrime` is never exposed). + #[getter] + pub fn prime(&self) -> u32 { + self.inner.prime().as_u32() + } + + /// Apply the pullback to the basis element `input_idx` in `input_degree`, + /// adding `coeff` times its image into `result` (a vector of length + /// `target.dimension(input_degree - degree_shift)`). + pub fn apply_to_basis_element( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input_idx: usize, + ) -> PyResult<()> { + let p = self.inner.prime().as_u32(); + let coeff = coeff % p; + let src_min = self.inner.min_degree(); + if input_degree < src_min { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_apply(input_degree, output_degree)?; + let src_dim = self.source_dim(input_degree); + if input_idx >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {input_idx} out of range for source degree {input_degree} \ + (dimension {src_dim})" + ))); + } + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + // When the target Hom module is zero in the output degree the + // image is zero; the upstream call would index the target's + // block structure out of range, so skip it. `out_dim == 0` + // already forces `res` to length 0. + if out_dim != 0 { + self.inner + .apply_to_basis_element(res.copy(), coeff, input_degree, input_idx); + } + Ok(()) + }) + } + + /// Apply the pullback to a general `input` element of `source` in + /// `input_degree` (length `source.dimension(input_degree)`), adding + /// `coeff` times its image into `result`. Aliasing the same vector as + /// both `input` and `result` raises `RuntimeError`. + pub fn apply( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + coeff: u32, + input_degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let p = self.inner.prime().as_u32(); + let coeff = coeff % p; + let src_min = self.inner.min_degree(); + if input_degree < src_min { + return Err(PyIndexError::new_err(format!( + "input degree {input_degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(input_degree)?; + self.ensure_apply(input_degree, output_degree)?; + let src_dim = self.source_dim(input_degree); + let out_dim = self.target_dim(output_degree); + crate::fp_py::with_input_slice(py, input, |in_slice| { + checked_same_prime(in_slice.prime().as_u32(), p)?; + checked_equal_len(in_slice.len(), src_dim)?; + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), out_dim)?; + if out_dim != 0 { + self.inner.apply(res.copy(), coeff, input_degree, in_slice); + } + Ok(()) + }) + }) + } + + /// The kernel of the pullback in `degree`, if it has been computed (via + /// `compute_auxiliary_data_through_degree`). Returns `None` otherwise. + pub fn kernel(&self, degree: i32) -> Option { + self.inner + .kernel(degree) + .map(|s| crate::fp_py::PySubspace::from_rust(s.clone())) + } + + /// The image of the pullback in `degree`, if it has been computed. + pub fn image(&self, degree: i32) -> Option { + self.inner + .image(degree) + .map(|s| crate::fp_py::PySubspace::from_rust(s.clone())) + } + + /// The quasi-inverse of the pullback in `degree`, if it has been + /// computed. + pub fn quasi_inverse(&self, degree: i32) -> Option { + self.inner + .quasi_inverse(degree) + .map(|qi| crate::fp_py::PyQuasiInverse::from_rust(qi.clone())) + } + + /// Compute (and cache) the image, kernel and quasi-inverse at every + /// input degree up to `degree`. Requires the `map`'s outputs to be + /// defined far enough (else `ValueError`); computing the top degree's + /// bases also computes every lower degree's (the bases are cumulative). + pub fn compute_auxiliary_data_through_degree(&self, degree: i32) -> PyResult<()> { + if degree >= self.inner.min_degree() { + let output_degree = self.output_degree(degree)?; + self.ensure_apply(degree, output_degree)?; + } + self.inner.compute_auxiliary_data_through_degree(degree); + Ok(()) + } + + /// The matrix whose rows are the images of the source basis elements + /// `inputs` in `degree`. Columns index `target.dimension(degree)`. + /// + /// Only well-defined when `target.dimension(degree) == + /// target.dimension(degree - degree_shift)` (always so for + /// `degree_shift == 0`); otherwise raises `ValueError`. An out-of-range / + /// uncomputed target degree reads as dimension 0 and yields the empty + /// (`len(inputs) x 0`) matrix instead of panicking. + pub fn get_partial_matrix( + &self, + degree: i32, + inputs: Vec, + ) -> PyResult { + let src_min = self.inner.min_degree(); + if degree < src_min { + return Err(PyIndexError::new_err(format!( + "degree {degree} is below the source min_degree {src_min}" + ))); + } + let output_degree = self.output_degree(degree)?; + self.ensure_apply(degree, output_degree)?; + let src_dim = self.source_dim(degree); + for &i in &inputs { + if i >= src_dim { + return Err(PyIndexError::new_err(format!( + "input index {i} out of range for source degree {degree} (dimension \ + {src_dim})" + ))); + } + } + let tgt_dim = self.target_dim(degree); + if tgt_dim == 0 { + return Ok(crate::fp_py::PyMatrix::from_rust(fp::matrix::Matrix::new( + self.inner.prime(), + inputs.len(), + 0, + ))); + } + if tgt_dim != self.target_dim(output_degree) { + return Err(PyValueError::new_err( + "get_partial_matrix is only well-defined when target.dimension(degree) == \ + target.dimension(degree - degree_shift) (e.g. degree_shift == 0)", + )); + } + Ok(crate::fp_py::PyMatrix::from_rust( + self.inner.get_partial_matrix(degree, &inputs), + )) + } + + /// Apply the quasi-inverse at `degree` to `input`, adding the result into + /// `result`. Returns `True` if the quasi-inverse was available (and + /// applied), `False` otherwise. `input` has length + /// `target.dimension(degree - degree_shift)` and `result` has length + /// `source.dimension(degree)`. + pub fn apply_quasi_inverse( + &self, + py: Python<'_>, + result: &Bound<'_, PyAny>, + degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult { + let p = self.inner.prime().as_u32(); + let Some(qi) = self.inner.quasi_inverse(degree) else { + return Ok(false); + }; + let source_dim = qi.source_dimension(); + let target_dim = qi.target_dimension(); + crate::fp_py::with_input_slice(py, input, |in_slice| { + checked_same_prime(in_slice.prime().as_u32(), p)?; + checked_equal_len(in_slice.len(), target_dim)?; + crate::fp_py::with_target_slice_mut(py, result, |mut res| { + checked_same_prime(res.prime().as_u32(), p)?; + checked_equal_len(res.as_slice().len(), source_dim)?; + qi.apply(res.copy(), 1, in_slice); + Ok(()) + }) + })?; + Ok(true) + } + + pub fn __repr__(&self) -> String { + format!( + "HomPullback(source={}, target={}, degree_shift={})", + self.inner.source(), + self.inner.target(), + self.inner.degree_shift() + ) + } + } + + // === §5.5 Steenrod evaluator / parser ==================================== + + /// A single factor of an admissible (`A(..)`) list in a parsed Steenrod + /// expression: either a Bockstein `b` or a Steenrod power `Sq^n`/`P^n`. + /// Mirrors upstream's `steenrod_parser::BocksteinOrSq`. This is a faithful + /// (complete) binding of the upstream enum; the upstream + /// `to_adem_basis_elt` helper is `pub(crate)` and intentionally not exposed. + #[pyclass(name = "BocksteinOrSq", skip_from_py_object)] + #[derive(Clone, Debug)] + pub enum BocksteinOrSq { + Bockstein {}, + Sq(u32), + } + + impl From<::algebra::steenrod_parser::BocksteinOrSq> for BocksteinOrSq { + fn from(value: ::algebra::steenrod_parser::BocksteinOrSq) -> Self { + match value { + ::algebra::steenrod_parser::BocksteinOrSq::Bockstein => Self::Bockstein {}, + ::algebra::steenrod_parser::BocksteinOrSq::Sq(x) => Self::Sq(x), + } + } + } + + /// A basis element appearing in a parsed Steenrod expression. Mirrors + /// upstream's `steenrod_parser::AlgebraBasisElt`, which is a (non-recursive) + /// enum with four shapes. Rather than a PyO3 complex enum (one variant, + /// `AList`, carries a `Vec` of bound pyclasses) we wrap the + /// upstream value and expose a `kind()` discriminator plus per-shape + /// accessors, each of which raises `ValueError` when called on the wrong + /// shape. This is a faithful, fully-inspectable binding: every field of + /// every variant is reachable. + #[pyclass(name = "AlgebraBasisElt", skip_from_py_object)] + #[derive(Clone)] + pub struct AlgebraBasisElt(::algebra::steenrod_parser::AlgebraBasisElt); + + #[pymethods] + impl AlgebraBasisElt { + /// One of `"AList"`, `"PList"`, `"P"`, `"Q"`. + pub fn kind(&self) -> &'static str { + use ::algebra::steenrod_parser::AlgebraBasisElt::*; + match self.0 { + AList(_) => "AList", + PList(_) => "PList", + P(_) => "P", + Q(_) => "Q", + } + } + + /// The admissible list, for an `AList` element. Raises `ValueError` + /// otherwise. + pub fn a_list(&self) -> PyResult> { + use ::algebra::steenrod_parser::AlgebraBasisElt::*; + match &self.0 { + AList(list) => Ok(list.iter().map(|&x| x.into()).collect()), + _ => Err(PyValueError::new_err("not an AList basis element")), + } + } + + /// The `P(R)` partition, for a `PList` element. Raises `ValueError` + /// otherwise. + pub fn p_list(&self) -> PyResult> { + use ::algebra::steenrod_parser::AlgebraBasisElt::*; + match &self.0 { + PList(p_part) => Ok(p_part.clone()), + _ => Err(PyValueError::new_err("not a PList basis element")), + } + } + + /// The exponent `n`, for a `P` (i.e. `P^n`/`Sq^n`) element. Raises + /// `ValueError` otherwise. + pub fn p(&self) -> PyResult { + use ::algebra::steenrod_parser::AlgebraBasisElt::*; + match self.0 { + P(x) => Ok(x), + _ => Err(PyValueError::new_err("not a P basis element")), + } + } + + /// The index `k`, for a `Q` (Milnor `Q_k`) element. Raises `ValueError` + /// otherwise. + pub fn q(&self) -> PyResult { + use ::algebra::steenrod_parser::AlgebraBasisElt::*; + match self.0 { + Q(x) => Ok(x), + _ => Err(PyValueError::new_err("not a Q basis element")), + } + } + + pub fn __repr__(&self) -> String { + format!("{:?}", self.0) + } + } + + /// A node of a parsed algebra expression tree. Mirrors upstream's recursive + /// `steenrod_parser::AlgebraNode` enum (`Product`/`Sum`/`BasisElt`/`Scalar`). + /// Because the upstream enum is recursive (`Box` children), we wrap it + /// and expose a `kind()` discriminator plus accessors that hand back the + /// child `AlgebraNode`s (for `Product`/`Sum`), the `AlgebraBasisElt` (for + /// `BasisElt`), or the `int` scalar (for `Scalar`). A Python user can fully + /// walk the tree; each accessor raises `ValueError` on the wrong shape. + #[pyclass(name = "AlgebraNode", skip_from_py_object)] + #[derive(Clone)] + pub struct AlgebraNode(::algebra::steenrod_parser::AlgebraNode); + + #[pymethods] + impl AlgebraNode { + /// One of `"Product"`, `"Sum"`, `"BasisElt"`, `"Scalar"`. + pub fn kind(&self) -> &'static str { + use ::algebra::steenrod_parser::AlgebraNode::*; + match self.0 { + Product(..) => "Product", + Sum(..) => "Sum", + BasisElt(_) => "BasisElt", + Scalar(_) => "Scalar", + } + } + + /// The left child of a `Product`/`Sum` node. Raises `ValueError` + /// otherwise. + #[getter] + pub fn left(&self) -> PyResult { + use ::algebra::steenrod_parser::AlgebraNode::*; + match &self.0 { + Product(l, _) | Sum(l, _) => Ok(AlgebraNode((**l).clone())), + _ => Err(PyValueError::new_err("node has no left child")), + } + } + + /// The right child of a `Product`/`Sum` node. Raises `ValueError` + /// otherwise. + #[getter] + pub fn right(&self) -> PyResult { + use ::algebra::steenrod_parser::AlgebraNode::*; + match &self.0 { + Product(_, r) | Sum(_, r) => Ok(AlgebraNode((**r).clone())), + _ => Err(PyValueError::new_err("node has no right child")), + } + } + + /// The basis element of a `BasisElt` node. Raises `ValueError` + /// otherwise. + pub fn basis_element(&self) -> PyResult { + use ::algebra::steenrod_parser::AlgebraNode::*; + match &self.0 { + BasisElt(b) => Ok(AlgebraBasisElt(b.clone())), + _ => Err(PyValueError::new_err("not a BasisElt node")), + } + } + + /// The integer of a `Scalar` node. Raises `ValueError` otherwise. + pub fn scalar(&self) -> PyResult { + use ::algebra::steenrod_parser::AlgebraNode::*; + match self.0 { + Scalar(x) => Ok(x), + _ => Err(PyValueError::new_err("not a Scalar node")), + } + } + + pub fn __repr__(&self) -> String { + format!("{:?}", self.0) + } + } + + /// Parse an algebra expression string into an `AlgebraNode` tree. Raises + /// `ValueError` on any parse failure (upstream returns `anyhow::Error`; + /// `parse_algebra` itself never panics). + #[pyfunction] + pub fn parse_algebra(input: &str) -> PyResult { + ::algebra::steenrod_parser::parse_algebra(input) + .map(AlgebraNode) + .map_err(|e| PyValueError::new_err(format!("{e:#}"))) + } + + /// Parse a module expression string into the upstream `ModuleNode`, a list + /// of `(AlgebraNode, generator_name)` pairs. Raises `ValueError` on any + /// parse failure (upstream returns `anyhow::Error`; `parse_module` itself + /// never panics). + #[pyfunction] + pub fn parse_module(input: &str) -> PyResult> { + ::algebra::steenrod_parser::parse_module(input) + .map(|tree| { + tree.into_iter() + .map(|(node, g)| (AlgebraNode(node), g)) + .collect() + }) + .map_err(|e| PyValueError::new_err(format!("{e:#}"))) + } + + // ------------------------------------------------------------------------ + // §5.2 standalone algebra-crate items: module generator parsing and + // combinatorics free functions. + // ------------------------------------------------------------------------ + + /// The largest prime for which the `fp` crate precomputes its index map and + /// binomial/degree tables (`MAX_PRIME` upstream). `tau_degrees`, + /// `xi_degrees`, and `adem_relation_coefficient` look up + /// `PRIME_TO_INDEX_MAP[p]` (and, for the latter, the binomial table) by + /// indexing arrays of length `MAX_PRIME + 1` / `NUM_PRIMES`, so a prime + /// above this bound — though accepted by `valid_prime` — would index out of + /// bounds and panic. These functions therefore validate against this + /// tighter bound and raise `ValueError`. + const MAX_TABLE_PRIME: u32 = 251; + + /// Upper bound on the magnitude of any degree (and on the span between the + /// smallest and largest degree) accepted by `module_gens_from_json`. Real + /// module specifications have tiny degrees (well under a few hundred), so + /// this cap is far above any realistic spec. It is also far below the point + /// where upstream's `BiVec::with_capacity(min_degree, max_degree + 1)` + /// (which eagerly allocates one `usize` *and* one `Vec` for every + /// degree in the whole `[min, max]` span) would exhaust memory and abort + /// the process — an allocation failure that `catch_unwind` cannot catch. A + /// cap of 1_000_000 also keeps `max_degree + 1` (computed upstream as + /// `i32`) comfortably clear of the `i32::MAX` overflow. + const MAX_MODULE_DEGREE: i32 = 1_000_000; + + /// Upper bound on the `degree` accepted by `inadmissible_pairs`. Upstream + /// loops roughly `p * degree / (q * (p + 1))` times, pushing a + /// `(u32, u32, u32)` triple each iteration, so an unbounded huge degree + /// would allocate a multi-gigabyte `Vec` and abort the process (an OOM that + /// `catch_unwind` cannot catch). Resolutions in practice use degrees in the + /// hundreds, so this cap is far above realistic use; it also keeps the + /// internal `p * (degree / q)` arithmetic well within `u32` (with the + /// degree capped, `degree / q` shrinks as `p` grows), so the computation + /// cannot overflow in release either. + const MAX_INADMISSIBLE_DEGREE: i32 = 100_000; + + /// Upper bound on the magnitude of the `x`, `y`, `j`, `e1`, `e2` arguments + /// to `adem_relation_coefficient`. Upstream casts these to `i32` and forms + /// `(y - j) * (p - 1) + e1 - 1` and `x - p * j - e2` with `p <= 251`; with + /// each argument capped at this bound those products stay well below + /// `i32::MAX` (`251 * 1_000_000 < 2.6e8`), so the result is well-defined in + /// BOTH debug (no overflow panic) and release (no silent wrap). Real Adem + /// inputs are tiny (degrees in the hundreds), so this cap is far above + /// realistic use. + const MAX_ADEM_ARG: u32 = 1_000_000; + + /// Validate a prime that will be used to index the `fp` precomputed tables. + /// Raises `ValueError` for a non-prime (via `valid_prime`) or for a prime + /// larger than `MAX_TABLE_PRIME` (which would index out of bounds upstream). + fn table_prime(p: u32) -> PyResult { + let prime = valid_prime(p)?; + if p > MAX_TABLE_PRIME { + return Err(PyValueError::new_err(format!( + "p = {p} exceeds the largest precomputed prime ({MAX_TABLE_PRIME})" + ))); + } + Ok(prime) + } + + /// Parse a module's generator specification (a JSON object mapping each + /// generator name to its integer degree) into its graded structure. + /// + /// Returns `(graded_dims, names)` where + /// * `graded_dims` is a `dict[int, int]` mapping each degree to the number + /// of generators in that degree, and + /// * `names` is a `dict[int, list[str]]` mapping each degree to the names + /// of its generators (in the order they index that degree's basis). + /// + /// Upstream `module_gens_from_json` returns a third element: a name-lookup + /// *closure* `&str -> Result<(i32, usize)>`. Per API_PROPOSAL §8 ("Closures + /// returned from Rust"), returned Rust closures cannot be wrapped thinly and + /// are intentionally dropped; the same information is recoverable from + /// `names` (the index of a name within `names[degree]` is its basis index). + /// + /// The upstream function uses `unwrap`/`as_i64` and panics on a value that is + /// not a JSON object or whose degrees are not integers, so we validate the + /// shape up front and raise `ValueError` instead of letting it panic across + /// the FFI boundary. (Type conversion of the Python value, in `py_to_json`, + /// also raises `ValueError`.) + #[pyfunction] + pub fn module_gens_from_json( + value: &Bound<'_, PyAny>, + ) -> PyResult<( + std::collections::BTreeMap, + std::collections::BTreeMap>, + )> { + let json = py_to_json(value)?; + let obj = json.as_object().ok_or_else(|| { + PyValueError::new_err( + "module generator spec must be a JSON object mapping names to degrees", + ) + })?; + let mut min_degree: Option = None; + let mut max_degree: Option = None; + for (name, degree) in obj { + let Some(degree) = degree.as_i64() else { + return Err(PyValueError::new_err(format!( + "generator {name:?} must have an integer degree" + ))); + }; + // Reject any single degree whose magnitude is so large that + // upstream's `BiVec::with_capacity(min, max + 1)` would over-allocate + // (or `max + 1` would overflow `i32`). See `MAX_MODULE_DEGREE`. + if degree < i64::from(-MAX_MODULE_DEGREE) || degree > i64::from(MAX_MODULE_DEGREE) { + return Err(PyValueError::new_err(format!( + "generator {name:?} has degree {degree} outside the supported \ + range [-{MAX_MODULE_DEGREE}, {MAX_MODULE_DEGREE}]" + ))); + } + min_degree = Some(min_degree.map_or(degree, |m| m.min(degree))); + max_degree = Some(max_degree.map_or(degree, |m| m.max(degree))); + } + // Reject an oversized degree *span*: upstream allocates the full + // `[min, max]` range, so a spec like `{"a": -1e6, "b": 1e6}` would still + // over-allocate even though each individual degree is within bounds. + if let (Some(min), Some(max)) = (min_degree, max_degree) { + if max - min > i64::from(MAX_MODULE_DEGREE) { + return Err(PyValueError::new_err(format!( + "module generator degrees span {} ({min}..={max}), exceeding the \ + supported span of {MAX_MODULE_DEGREE}", + max - min + ))); + } + } + // Validated above, so the upstream `unwrap`/`as_i64` calls cannot panic + // and the bounded degree span cannot over-allocate or overflow. + let (graded_dimension, gen_names, _name_lookup) = ::algebra::module_gens_from_json(&json); + let dims = graded_dimension + .iter_enum() + .map(|(degree, &dim)| (degree, dim)) + .collect(); + let names = gen_names + .iter_enum() + .map(|(degree, names)| (degree, names.clone())) + .collect(); + Ok((dims, names)) + } + + /// The Adem relation coefficient for the (in)admissible pair encoded by + /// `(x, y, j, e1, e2)` at the prime `p`, reduced mod `p`. Mirrors upstream + /// `combinatorics::adem_relation_coefficient`. + /// + /// Upstream takes a `ValidPrime` and indexes the `fp` binomial table by + /// `PRIME_TO_INDEX_MAP[p]`, so `p` is validated against `MAX_TABLE_PRIME` + /// (raising `ValueError` otherwise). The intermediate degree arithmetic is + /// `i32` and could overflow (panicking in a debug build) for pathologically + /// large inputs, so the computation is run under `catch_unwind` and any such + /// overflow is reported as `ValueError` rather than aborting across the FFI + /// boundary. + #[pyfunction] + pub fn adem_relation_coefficient( + p: u32, + x: u32, + y: u32, + j: u32, + e1: u32, + e2: u32, + ) -> PyResult { + use std::panic::catch_unwind; + let prime = table_prime(p)?; + // Range pre-check: cap each argument so the internal `i32` degree + // arithmetic cannot overflow for accepted inputs. Without this the + // overflow is a silent wrap in release (overflow-checks off) and only a + // panic in debug, so the result would otherwise be ill-defined. See + // `MAX_ADEM_ARG`. `catch_unwind` is kept below purely as a backstop. + for (label, arg) in [("x", x), ("y", y), ("j", j), ("e1", e1), ("e2", e2)] { + if arg > MAX_ADEM_ARG { + return Err(PyValueError::new_err(format!( + "argument {label} = {arg} exceeds the supported maximum of {MAX_ADEM_ARG}" + ))); + } + } + catch_unwind(|| ::algebra::combinatorics::adem_relation_coefficient(prime, x, y, j, e1, e2)) + .map_err(|_| PyValueError::new_err("degree arithmetic overflowed for these inputs")) + } + + /// The inadmissible `(P^i, b, P^j)` pairs in the given `degree` at the prime + /// `p` (with `generic` selecting the odd-primary/generic relations). Each + /// triple `(i, b, j)` denotes `P^i P^j` when `b == 0` and `P^i β P^j` when + /// `b == 1`. Mirrors upstream `combinatorics::inadmissible_pairs`. + /// + /// Upstream casts `degree` to `u32` (so a negative degree would wrap to a + /// huge value) and performs `u32` degree arithmetic that could overflow + /// (panicking in a debug build) for pathological inputs. We require a + /// non-negative degree and run the computation under `catch_unwind`, + /// reporting any overflow as `ValueError`. + #[pyfunction] + pub fn inadmissible_pairs( + p: u32, + generic: bool, + degree: i32, + ) -> PyResult> { + use std::panic::catch_unwind; + let prime = valid_prime(p)?; + // A negative degree is malformed input for this combinatorics function, + // so raise `ValueError` (not `IndexError`). Upstream would otherwise + // cast it to a huge `u32`. + non_negative_degree_value(degree)?; + // Magnitude pre-check: a huge degree makes upstream push a multi-GB + // `Vec`, an OOM abort that `catch_unwind` cannot catch. The cap also + // bounds the internal `u32` arithmetic, so it cannot overflow in + // release. See `MAX_INADMISSIBLE_DEGREE`. + if degree > MAX_INADMISSIBLE_DEGREE { + return Err(PyValueError::new_err(format!( + "degree {degree} exceeds the supported maximum of {MAX_INADMISSIBLE_DEGREE}" + ))); + } + catch_unwind(|| ::algebra::combinatorics::inadmissible_pairs(prime, generic, degree)) + .map_err(|_| PyValueError::new_err("degree arithmetic overflowed for these inputs")) + } + + /// The degrees of the exterior generators `τ_i` of the dual Steenrod algebra + /// at the prime `p` (the values are meaningless at `p = 2`). Mirrors + /// upstream `combinatorics::tau_degrees`, returning the precomputed slice as + /// a Python `list[int]`. `p` is validated against `MAX_TABLE_PRIME` since + /// upstream indexes `PRIME_TO_INDEX_MAP[p]`. + #[pyfunction] + pub fn tau_degrees(p: u32) -> PyResult> { + let prime = table_prime(p)?; + Ok(::algebra::combinatorics::tau_degrees(prime).to_vec()) + } + + /// The degrees (divided by `q = 2p - 2`, or `1` at `p = 2`) of the + /// polynomial generators `ξ_i` of the dual Steenrod algebra at the prime + /// `p`. Mirrors upstream `combinatorics::xi_degrees`, returning the + /// precomputed slice as a Python `list[int]`. `p` is validated against + /// `MAX_TABLE_PRIME` since upstream indexes `PRIME_TO_INDEX_MAP[p]`. + #[pyfunction] + pub fn xi_degrees(p: u32) -> PyResult> { + let prime = table_prime(p)?; + Ok(::algebra::combinatorics::xi_degrees(prime).to_vec()) + } + + /// An evaluator for Steenrod algebra expressions. Wraps upstream's + /// `steenrod_evaluator::SteenrodEvaluator`, which holds an `AdemAlgebra` and + /// a `MilnorAlgebra` at a fixed prime and can parse + evaluate expression + /// strings into elements, as well as change basis between the Adem and + /// Milnor bases. + /// + /// `adem_element_to_string`/`milnor_element_to_string` are *not* re-bound + /// here: they are reachable via the already-bound `AdemAlgebra` / + /// `MilnorAlgebra` `element_to_string`. The upstream `PairAlgebra` / + /// `pair_algebra` element type is deferred (low priority; only used by + /// `SecondaryResolution` internals). + #[pyclass(name = "SteenrodEvaluator")] + pub struct SteenrodEvaluator(::algebra::steenrod_evaluator::SteenrodEvaluator); + + impl SteenrodEvaluator { + /// Run an evaluation closure, translating both the upstream + /// `anyhow::Error` (parse / degree-mismatch errors) and any deeper + /// `panic!`/`unwrap` (e.g. an out-of-range `Q_k`, an inadmissible list, + /// or a `P(R)` not present in the algebra — the evaluator reaches the + /// panicking `basis_element_to_index`/index paths buried in the Adem and + /// Milnor algebras for such inputs) into a clean `ValueError`. The panic + /// is contained with `catch_unwind`: it always originates from a failed + /// lookup, never a half-finished mutation of shared state, so no + /// inconsistent state survives the unwind. + fn eval( + &self, + f: impl FnOnce( + &::algebra::steenrod_evaluator::SteenrodEvaluator, + ) -> anyhow::Result<(i32, ::fp::vector::FpVector)>, + ) -> PyResult<(i32, crate::fp_py::PyFpVector)> { + use std::panic::{catch_unwind, AssertUnwindSafe}; + match catch_unwind(AssertUnwindSafe(|| f(&self.0))) { + Ok(Ok((degree, vec))) => Ok((degree, crate::fp_py::PyFpVector::from_rust(vec))), + Ok(Err(e)) => Err(PyValueError::new_err(format!("{e:#}"))), + Err(_) => Err(PyValueError::new_err( + "could not evaluate Steenrod expression", + )), + } + } + } + + #[pymethods] + impl SteenrodEvaluator { + /// Construct an evaluator at prime `p`. Validates the prime -> + /// `ValueError` (`ValidPrime` is never exposed). + #[new] + pub fn new(p: u32) -> PyResult { + Ok(SteenrodEvaluator( + ::algebra::steenrod_evaluator::SteenrodEvaluator::new(valid_prime(p)?), + )) + } + + /// The prime as a plain `int`. + #[getter] + pub fn prime(&self) -> u32 { + self.0.adem.prime().as_u32() + } + + /// Parse and evaluate `input` in the Adem basis, returning + /// `(degree, FpVector)`. Raises `ValueError` on a parse error, a degree + /// mismatch, or an otherwise-invalid expression. + pub fn evaluate_algebra_adem( + &self, + input: &str, + ) -> PyResult<(i32, crate::fp_py::PyFpVector)> { + self.eval(|ev| ev.evaluate_algebra_adem(input)) + } + + /// Parse and evaluate `input` in the Milnor basis, returning + /// `(degree, FpVector)`. Raises `ValueError` on a parse error, a degree + /// mismatch, or an otherwise-invalid expression. + pub fn evaluate_algebra_milnor( + &self, + input: &str, + ) -> PyResult<(i32, crate::fp_py::PyFpVector)> { + self.eval(|ev| ev.evaluate_algebra_milnor(input)) + } + + /// Parse and evaluate a module expression `input` in the Adem basis, + /// returning a `dict` mapping each generator name to its + /// `(degree, FpVector)` coefficient. Raises `ValueError` on a parse + /// error or an otherwise-invalid expression. + /// + /// (Upstream has only an Adem variant of `evaluate_module_*`; there is + /// no `evaluate_module_milnor`, so none is bound.) + pub fn evaluate_module_adem( + &self, + input: &str, + ) -> PyResult> { + use std::panic::{catch_unwind, AssertUnwindSafe}; + match catch_unwind(AssertUnwindSafe(|| self.0.evaluate_module_adem(input))) { + Ok(Ok(map)) => Ok(map + .into_iter() + .map(|(g, (degree, vec))| { + (g, (degree, crate::fp_py::PyFpVector::from_rust(vec))) + }) + .collect()), + Ok(Err(e)) => Err(PyValueError::new_err(format!("{e:#}"))), + Err(_) => Err(PyValueError::new_err( + "could not evaluate Steenrod module expression", + )), + } + } + + /// Convert an element given in the Adem basis (in degree `degree`) to + /// the Milnor basis, returning a freshly-allocated `FpVector`. Validates + /// the degree (non-negative), the prime, and the input length against + /// the dimension of `degree`. + pub fn adem_to_milnor( + &self, + py: Python<'_>, + degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult { + self.change_basis(py, degree, input, true) + } + + /// Convert an element given in the Milnor basis (in degree `degree`) to + /// the Adem basis, returning a freshly-allocated `FpVector`. Validates + /// the degree (non-negative), the prime, and the input length against + /// the dimension of `degree`. + pub fn milnor_to_adem( + &self, + py: Python<'_>, + degree: i32, + input: &Bound<'_, PyAny>, + ) -> PyResult { + self.change_basis(py, degree, input, false) + } + + pub fn __repr__(&self) -> String { + format!("SteenrodEvaluator(p={})", self.0.adem.prime().as_u32()) + } + } + + impl SteenrodEvaluator { + /// Shared own-output change-of-basis helper for + /// `adem_to_milnor`/`milnor_to_adem`. Both upstream methods take a + /// `&mut FpVector` result of the *same* dimension as the input (the Adem + /// and Milnor bases agree dimension-wise in every degree), so we + /// allocate the result, copy the input into an owned `FpVector`, and run + /// upstream with `coeff = 1`. + fn change_basis( + &self, + py: Python<'_>, + degree: i32, + input: &Bound<'_, PyAny>, + adem_to_milnor: bool, + ) -> PyResult { + non_negative_degree(degree)?; + // Populate both algebras' book-keeping so the dimension read and the + // internal index lookups are in range. + self.0.adem.compute_basis(degree); + self.0.milnor.compute_basis(degree); + let p = self.0.adem.prime(); + let dim = self.0.adem.dimension(degree); + crate::fp_py::with_input_slice(py, input, |slice| { + checked_same_prime(slice.prime().as_u32(), p.as_u32())?; + checked_equal_len(slice.len(), dim)?; + let mut owned = ::fp::vector::FpVector::new(p, dim); + owned.as_slice_mut().assign(slice); + let mut result = ::fp::vector::FpVector::new(p, dim); + if adem_to_milnor { + self.0.adem_to_milnor(&mut result, 1, degree, &owned); + } else { + self.0.milnor_to_adem(&mut result, 1, degree, &owned); + } + Ok(crate::fp_py::PyFpVector::from_rust(result)) + }) + } + } + + #[pymodule_init] + fn init(_m: &Bound<'_, PyModule>) -> PyResult<()> { + // Arbitrary code to run at the module initialization + // m.add("double2", m.getattr("double")?) + Ok(()) + } +} diff --git a/ext_py/src/double.rs b/ext_py/src/double.rs new file mode 100644 index 0000000000..43efe04c93 --- /dev/null +++ b/ext_py/src/double.rs @@ -0,0 +1,451 @@ +//! Port of the inline `mod double` from `ext/examples/sq0.rs`. +//! +//! This presents the "doubled" chain complex of a resolution: a chain complex +//! whose modules halve Steenrod operations degree-wise. It is used by the +//! `sq0` example to compute the action of `Sq^0` on `Ext`. +//! +//! Ported verbatim from `ext/examples/sq0.rs` (submodules `double_algebra`, +//! `double_module`, `double_chain_complex`), adjusting only crate paths to the +//! `ext_py` dependency names. Nothing here is exposed to Python directly; the +//! `DoubleChainComplex` pyclass in `lib.rs` wraps `DoubleChainComplex` over the +//! standard resolution type. + +use double_algebra::DoubleAlgebra; +pub use double_chain_complex::DoubleChainComplex; +pub use double_module::DoubleModule; +use double_module::DoubleModuleHomomorphism; +use sseq::coordinates::Bidegree; + +/// Divide by 2 and round towards -infty +fn div_floor(x: i32) -> i32 { + ((x as u32) / 2) as i32 +} + +fn div_bidegree(b: Bidegree) -> Bidegree { + Bidegree::s_t(b.s(), div_floor(b.t())) +} + +mod double_algebra { + use algebra::{ + adem_algebra::AdemBasisElement, milnor_algebra::MilnorBasisElement, AdemAlgebra, Algebra, + MilnorAlgebra, SteenrodAlgebra, + }; + + pub trait DoubleAlgebra: Algebra { + /// `degree` is guaranteed to be even + fn halve(&self, degree: i32, idx: usize) -> Option; + } + + impl DoubleAlgebra for MilnorAlgebra { + fn halve(&self, degree: i32, idx: usize) -> Option { + let elt = self.basis_element_from_index(degree, idx); + let p_part = elt + .p_part + .iter() + .map(|&x| { + if x.is_multiple_of(2) { + Some(x / 2) + } else { + None + } + }) + .collect::>>()?; + Some(self.basis_element_to_index(&MilnorBasisElement { + degree: degree / 2, + p_part, + q_part: 0, + })) + } + } + + impl DoubleAlgebra for AdemAlgebra { + fn halve(&self, degree: i32, idx: usize) -> Option { + let elt = self.basis_element_from_index(degree, idx); + let ps = elt + .ps + .iter() + .map(|&x| { + if x.is_multiple_of(2) { + Some(x / 2) + } else { + None + } + }) + .collect::>>()?; + Some(self.basis_element_to_index(&AdemBasisElement { + degree: degree / 2, + ps, + bocksteins: 0, + p_or_sq: false, + })) + } + } + + impl DoubleAlgebra for SteenrodAlgebra { + fn halve(&self, degree: i32, idx: usize) -> Option { + match self { + SteenrodAlgebra::AdemAlgebra(a) => a.halve(degree, idx), + SteenrodAlgebra::MilnorAlgebra(a) => a.halve(degree, idx), + } + } + } +} + +pub mod double_module { + use std::sync::Arc; + + use algebra::module::{homomorphism::ModuleHomomorphism, Module}; + use fp::{ + matrix::{Matrix, MatrixSliceMut, QuasiInverse, Subspace}, + vector::{FpSlice, FpSliceMut}, + }; + + use super::DoubleAlgebra; + + pub struct DoubleModule { + inner: Arc, + } + + impl std::fmt::Display for DoubleModule { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "Double({}", *self.inner) + } + } + + impl DoubleModule + where + M::Algebra: DoubleAlgebra, + { + pub fn new(inner: Arc) -> Self { + Self { inner } + } + } + + impl Module for DoubleModule + where + M::Algebra: DoubleAlgebra, + { + type Algebra = M::Algebra; + + fn algebra(&self) -> Arc { + self.inner.algebra() + } + + fn min_degree(&self) -> i32 { + self.inner.min_degree() * 2 + } + + fn max_computed_degree(&self) -> i32 { + self.inner.max_computed_degree() * 2 + } + + fn dimension(&self, degree: i32) -> usize { + if degree % 2 == 0 { + self.inner.dimension(degree / 2) + } else { + 0 + } + } + + fn act_on_basis( + &self, + result: fp::vector::FpSliceMut, + coeff: u32, + op_degree: i32, + op_index: usize, + mod_degree: i32, + mod_index: usize, + ) { + if op_degree % 2 == 1 { + return; + } + if let Some(op_index) = self.algebra().halve(op_degree, op_index) { + self.inner.act_on_basis( + result, + coeff, + op_degree / 2, + op_index, + mod_degree / 2, + mod_index, + ); + } + } + + fn basis_element_to_string(&self, degree: i32, idx: usize) -> String { + self.inner.basis_element_to_string(degree / 2, idx) + } + + /// Whether this is the unit module. + fn is_unit(&self) -> bool { + self.inner.is_unit() + } + + /// `max_degree` is the a degree such that if t > `max_degree`, then `self.dimension(t) = 0`. + fn max_degree(&self) -> Option { + self.inner.max_degree().map(|x| x * 2) + } + + /// Maximum degree of a generator under the Steenrod action. Every element in higher degree + /// must be obtainable from applying a Steenrod action to a lower degree element. + fn max_generator_degree(&self) -> Option { + self.inner.max_generator_degree().map(|x| x * 2) + } + + fn total_dimension(&self) -> usize { + self.inner.total_dimension() + } + + /// The length of `input` need not be equal to the dimension of the module in said degree. + /// Missing entries are interpreted to be 0, while extra entries must be zero. + /// + /// This flexibility is useful when resolving to a stem. The point is that we have elements in + /// degree `t` that are guaranteed to not contain generators of degree `t`, and we don't know + /// what generators will be added in degree `t` yet. + fn act( + &self, + result: FpSliceMut, + coeff: u32, + op_degree: i32, + op_index: usize, + input_degree: i32, + input: FpSlice, + ) { + if op_degree % 2 == 1 { + return; + } + if let Some(op_index) = self.algebra().halve(op_degree, op_index) { + self.inner.act( + result, + coeff, + op_degree / 2, + op_index, + input_degree / 2, + input, + ); + } + } + + /// Gives the name of an element. The default implementation is derived from + /// [`Module::basis_element_to_string`] in the obvious way. + fn element_to_string(&self, degree: i32, element: FpSlice) -> String { + self.inner.element_to_string(degree, element) + } + } + + pub struct DoubleModuleHomomorphism { + source: Arc>, + target: Arc>, + inner: Arc, + trivial_subspace: Subspace, + trivial_qi: QuasiInverse, + } + + impl DoubleModuleHomomorphism + where + ::Algebra: DoubleAlgebra, + { + pub fn new( + source: Arc>, + target: Arc>, + inner: Arc, + ) -> Self { + Self { + trivial_subspace: Subspace::new(source.prime(), 0), + trivial_qi: QuasiInverse::new(None, Matrix::new(source.prime(), 0, 0)), + source, + target, + inner, + } + } + } + + impl ModuleHomomorphism for DoubleModuleHomomorphism + where + ::Algebra: DoubleAlgebra, + { + type Source = DoubleModule; + type Target = DoubleModule; + + fn source(&self) -> Arc { + Arc::clone(&self.source) + } + + fn target(&self) -> Arc { + Arc::clone(&self.target) + } + + fn degree_shift(&self) -> i32 { + self.inner.degree_shift() * 2 + } + + fn apply_to_basis_element( + &self, + result: FpSliceMut, + coeff: u32, + input_degree: i32, + input_idx: usize, + ) { + self.inner + .apply_to_basis_element(result, coeff, input_degree / 2, input_idx) + } + + fn apply(&self, result: FpSliceMut, coeff: u32, input_degree: i32, input: FpSlice) { + if input_degree % 2 == 0 { + self.inner.apply(result, coeff, input_degree / 2, input) + } + } + + fn kernel(&self, degree: i32) -> Option<&Subspace> { + if degree % 2 == 0 { + self.inner.kernel(degree / 2) + } else { + Some(&self.trivial_subspace) + } + } + + fn quasi_inverse(&self, degree: i32) -> Option<&QuasiInverse> { + if degree % 2 == 0 { + self.inner.quasi_inverse(degree / 2) + } else { + Some(&self.trivial_qi) + } + } + + fn image(&self, degree: i32) -> Option<&Subspace> { + if degree % 2 == 0 { + self.inner.image(degree / 2) + } else { + Some(&self.trivial_subspace) + } + } + + fn compute_auxiliary_data_through_degree(&self, degree: i32) { + // trick to round towards -infty + self.inner + .compute_auxiliary_data_through_degree(super::div_floor(degree)) + } + + fn get_matrix(&self, matrix: MatrixSliceMut, degree: i32) { + if degree % 2 == 0 { + self.inner.get_matrix(matrix, degree / 2) + } + } + + /// Get the values of the homomorphism on the specified inputs to `matrix`. + fn get_partial_matrix(&self, degree: i32, inputs: &[usize]) -> Matrix { + if degree % 2 == 0 { + self.inner.get_partial_matrix(degree / 2, inputs) + } else { + Matrix::new(self.prime(), 0, 0) + } + } + + /// Attempt to apply quasi inverse to the input. Returns whether the operation was + /// successful. This is required to either always succeed or always fail for each degree. + fn apply_quasi_inverse(&self, result: FpSliceMut, degree: i32, input: FpSlice) -> bool { + if degree % 2 == 0 { + self.inner.apply_quasi_inverse(result, degree / 2, input) + } else { + true + } + } + } +} + +mod double_chain_complex { + use std::sync::Arc; + + use ext::chain_complex::ChainComplex; + use once::OnceBiVec; + use sseq::coordinates::Bidegree; + + use super::{DoubleAlgebra, DoubleModule, DoubleModuleHomomorphism}; + + pub struct DoubleChainComplex { + inner: Arc, + zero_module: Arc>, + modules: OnceBiVec>>, + differentials: OnceBiVec>>, + } + + impl DoubleChainComplex + where + CC::Algebra: DoubleAlgebra, + { + pub fn new(inner: Arc) -> Self { + Self { + zero_module: Arc::new(DoubleModule::new(inner.zero_module())), + inner, + modules: OnceBiVec::new(0), + differentials: OnceBiVec::new(0), + } + } + } + + impl ChainComplex for DoubleChainComplex + where + CC::Algebra: DoubleAlgebra, + { + type Algebra = CC::Algebra; + type Homomorphism = DoubleModuleHomomorphism; + type Module = DoubleModule; + + fn algebra(&self) -> Arc { + self.inner.algebra() + } + + fn min_degree(&self) -> i32 { + self.inner.min_degree() * 2 + } + + fn zero_module(&self) -> Arc { + Arc::clone(&self.zero_module) + } + + fn module(&self, s: i32) -> Arc { + Arc::clone(&self.modules[s]) + } + + fn differential(&self, s: i32) -> Arc { + Arc::clone(&self.differentials[s]) + } + + fn has_computed_bidegree(&self, b: Bidegree) -> bool { + self.inner.has_computed_bidegree(super::div_bidegree(b)) + } + + fn compute_through_bidegree(&self, b: Bidegree) { + self.inner.compute_through_bidegree(super::div_bidegree(b)); + self.modules + .extend(b.s(), |s| Arc::new(DoubleModule::new(self.inner.module(s)))); + self.differentials.extend(b.s(), |s| { + Arc::new(DoubleModuleHomomorphism::new( + self.module(s), + if s == 0 { + self.zero_module() + } else { + self.module(s - 1) + }, + self.inner.differential(s), + )) + }); + } + + fn apply_quasi_inverse(&self, results: &mut [T], b: Bidegree, inputs: &[S]) -> bool + where + for<'a> &'a mut T: Into>, + for<'a> &'a S: Into>, + { + if b.t() % 2 == 0 { + let halved_b = Bidegree::s_t(b.s(), b.t() / 2); + self.inner.apply_quasi_inverse(results, halved_b, inputs) + } else { + true + } + } + + fn next_homological_degree(&self) -> i32 { + self.inner.next_homological_degree() + } + } +} diff --git a/ext_py/src/fp_mod.rs b/ext_py/src/fp_mod.rs new file mode 100644 index 0000000000..b4c936af59 --- /dev/null +++ b/ext_py/src/fp_mod.rs @@ -0,0 +1,3025 @@ +use pyo3::prelude::*; + +#[pymodule] +#[pyo3(name = "fp")] +pub mod fp_py { + use fp::field::{ + DivError, DynFieldElement, Field, Fp as RustFp, SmallFq as RustSmallFq, + }; + use fp::matrix::{ + AffineSubspace as RustAffineSubspace, AugmentedMatrix as RustAugmentedMatrix, + Matrix as RustMatrix, MatrixSliceMut as RustMatrixSliceMut, + QuasiInverse as RustQuasiInverse, Subquotient as RustSubquotient, Subspace as RustSubspace, + }; + use fp::prime::{self, Binomial, Prime}; + use fp::vector::{ + FpSlice as RustFpSlice, FpSliceMut as RustFpSliceMut, FpVector as RustFpVector, + }; + use pyo3::basic::CompareOp; + use pyo3::exceptions::{PyIndexError, PyRuntimeError, PyValueError, PyZeroDivisionError}; + use pyo3::types::PyBytes; + use std::hash::{DefaultHasher, Hash, Hasher}; + use std::io::Cursor; + + use super::*; + + const MAX_VALID_PRIME: u32 = 1 << 31; + + type DynFp = RustFp; + type DynSmallFq = RustSmallFq; + + #[pyclass(name = "Fp", frozen, from_py_object)] + #[derive(Clone, Copy)] + struct PyFp(DynFp); + + #[pyclass(name = "SmallFq", frozen, from_py_object)] + #[derive(Clone, Copy)] + struct PySmallFq(DynSmallFq); + + #[pyclass(name = "FieldElement", frozen, from_py_object)] + #[derive(Clone, Copy)] + struct PyFieldElement(DynFieldElement); + + #[pyclass(name = "FpVector")] + pub struct PyFpVector(RustFpVector); + + /// A matrix-like parent that can back a borrowed row or rectangle view. + /// + /// A plain `Matrix` is held directly; an `AugmentedMatrix` is held as its + /// concrete pyclass and accessed through its `Deref` so + /// that segment rectangles and segment rows can revalidate against the inner + /// matrix's current dimensions. We keep the parent Python object alive and + /// reconstruct the underlying Rust matrix view on each call. + enum MatrixParent { + Matrix(Py), + Augmented2(Py), + Augmented3(Py), + } + + impl MatrixParent { + fn clone_ref(&self, py: Python<'_>) -> Self { + match self { + Self::Matrix(m) => Self::Matrix(m.clone_ref(py)), + Self::Augmented2(m) => Self::Augmented2(m.clone_ref(py)), + Self::Augmented3(m) => Self::Augmented3(m.clone_ref(py)), + } + } + + /// Run `f` on the current inner `Matrix`, holding the borrow for the + /// duration of the call. Deref coercion turns an `&AugmentedMatrix` + /// into the `&Matrix` expected by `f`. + fn with_matrix(&self, py: Python<'_>, f: impl FnOnce(&RustMatrix) -> R) -> PyResult { + match self { + Self::Matrix(m) => Ok(f(&m.try_borrow(py).map_err(borrow_error)?.0)), + Self::Augmented2(m) => Ok(f(m.try_borrow(py).map_err(borrow_error)?.0.get()?)), + Self::Augmented3(m) => Ok(f(m.try_borrow(py).map_err(borrow_error)?.0.get()?)), + } + } + + /// Whether `self` and `other` are backed by the same Python object + /// (same `Matrix`/`AugmentedMatrix` instance). Different enum variants + /// are necessarily different objects. Used to decide whether a shared + /// borrow of one and a mutable borrow of the other would collide. + fn same_object(&self, py: Python<'_>, other: &MatrixParent) -> bool { + match (self, other) { + (Self::Matrix(a), Self::Matrix(b)) => a.bind(py).is(b.bind(py)), + (Self::Augmented2(a), Self::Augmented2(b)) => a.bind(py).is(b.bind(py)), + (Self::Augmented3(a), Self::Augmented3(b)) => a.bind(py).is(b.bind(py)), + _ => false, + } + } + + /// Run `f` on the current inner `Matrix` mutably, holding the borrow for + /// the duration of the call. + fn with_matrix_mut( + &self, + py: Python<'_>, + f: impl FnOnce(&mut RustMatrix) -> R, + ) -> PyResult { + match self { + Self::Matrix(m) => Ok(f(&mut m.try_borrow_mut(py).map_err(borrow_error)?.0)), + Self::Augmented2(m) => Ok(f(m + .try_borrow_mut(py) + .map_err(borrow_error)? + .0 + .get_mut()?)), + Self::Augmented3(m) => Ok(f(m + .try_borrow_mut(py) + .map_err(borrow_error)? + .0 + .get_mut()?)), + } + } + } + + /// The source backing a slice handle: either an owned vector, or a row of a + /// matrix-like parent. In both cases we keep the parent Python object alive + /// and store enough metadata to reconstruct the underlying Rust slice on + /// each call, revalidating against the parent's current dimensions first. + enum SliceParent { + Vector(Py), + MatrixRow { matrix: MatrixParent, row: usize }, + } + + impl SliceParent { + fn clone_ref(&self, py: Python<'_>) -> Self { + match self { + Self::Vector(v) => Self::Vector(v.clone_ref(py)), + Self::MatrixRow { matrix, row } => Self::MatrixRow { + matrix: matrix.clone_ref(py), + row: *row, + }, + } + } + + /// Whether `self` and `other` are backed by the same Python object, so + /// that taking a shared borrow of one while the other is mutably + /// borrowed would collide in PyO3. + /// + /// Two `MatrixRow`s of the *same* matrix object count as the same + /// object **regardless of row index**: the whole `Matrix` pyclass is + /// borrowed as a unit, so co-borrowing two different rows of it still + /// trips PyO3's runtime borrow check. We therefore treat any shared + /// matrix parent as aliased (the clone fallback), which is both sound + /// and free of behavior regressions (the previous code always cloned). + /// + /// This is conservative in the safe direction: a false "different" + /// would merely surface as a PyO3 `RuntimeError` at borrow time rather + /// than UB, and a false "same" would only cost an unnecessary clone. + fn same_object(&self, py: Python<'_>, other: &SliceParent) -> bool { + match (self, other) { + (Self::Vector(a), Self::Vector(b)) => a.bind(py).is(b.bind(py)), + (Self::MatrixRow { matrix: a, .. }, Self::MatrixRow { matrix: b, .. }) => { + a.same_object(py, b) + } + _ => false, + } + } + + /// Whether `self` is backed by the same Python object as the bound + /// `FpVector` `other`. Used to detect aliasing when an `FpVector` is + /// supplied directly as an operand (rather than via an `FpSlice`), + /// mirroring [`same_object`]'s role for slice operands. + fn same_vector(&self, py: Python<'_>, other: &Bound<'_, PyFpVector>) -> bool { + match self { + Self::Vector(v) => v.bind(py).is(other), + _ => false, + } + } + } + + /// Length of a slice-like operand argument (`FpVector` or `FpSlice`), + /// computed without retaining a borrow. Used for pre-borrow dimension + /// checks by the `FpSliceMut` operand-taking methods, which accept either + /// an `FpVector` or an `FpSlice`. + fn slice_like_len(operand: &Bound<'_, PyAny>) -> PyResult { + if let Ok(slice) = operand.extract::>() { + Ok(slice.span()) + } else if let Ok(vector) = operand.extract::>() { + Ok(vector.0.len()) + } else { + Err(PyValueError::new_err("expected an FpVector or FpSlice")) + } + } + + /// Run `f` on the reconstructed immutable slice for `parent[start..end]`, + /// after revalidating the parent's current dimensions. + /// + /// Revalidation only guards the parent's current *dimensions* (vector length + /// or matrix row count and row length). It deliberately does not track + /// logical-coordinate remapping: an operation like `Matrix::trim` with + /// `col_start > 0` shifts the data backwards in each row without shrinking it + /// below the slice's `end`, so a surviving slice silently reads the remapped + /// columns rather than raising. Preventing that would require tracking the + /// origin of every coordinate, which is out of scope for the + /// handle+range design. + fn with_parent_slice( + parent: &SliceParent, + start: usize, + end: usize, + py: Python<'_>, + f: impl FnOnce(RustFpSlice<'_>) -> R, + ) -> PyResult { + match parent { + SliceParent::Vector(v) => { + let parent = v.try_borrow(py).map_err(borrow_error)?; + checked_range(start, end, parent.0.len())?; + Ok(f(parent.0.slice(start, end))) + } + SliceParent::MatrixRow { matrix, row } => matrix.with_matrix(py, |m| { + checked_row(*row, m.rows())?; + let full = m.row(*row); + checked_range(start, end, full.len())?; + Ok(f(full.restrict(start, end))) + })?, + } + } + + /// Run `f` on the reconstructed mutable slice for `parent[start..end]`, + /// after revalidating the parent's current dimensions. + fn with_parent_slice_mut( + parent: &SliceParent, + start: usize, + end: usize, + py: Python<'_>, + f: impl FnOnce(RustFpSliceMut<'_>) -> R, + ) -> PyResult { + match parent { + SliceParent::Vector(v) => { + let mut parent = v.try_borrow_mut(py).map_err(borrow_error)?; + checked_range(start, end, parent.0.len())?; + Ok(f(parent.0.slice_mut(start, end))) + } + SliceParent::MatrixRow { matrix, row } => matrix.with_matrix_mut(py, |m| { + checked_row(*row, m.rows())?; + // Validate against the actual current row length, matching the + // read path (`with_parent_slice`). For a `Matrix` this equals + // `columns()`, but deriving it from the row keeps both paths + // consistent regardless of that invariant. + let row_len = m.row(*row).len(); + checked_range(start, end, row_len)?; + Ok(f(m.row_mut(*row).slice_mut(start, end))) + })?, + } + } + + #[pyclass(name = "FpSlice")] + pub struct PyFpSlice { + parent: SliceParent, + start: usize, + end: usize, + } + + #[pyclass(name = "FpSliceMut")] + pub struct PyFpSliceMut { + parent: SliceParent, + start: usize, + end: usize, + } + + #[pyclass(name = "FpVectorIterator")] + pub struct PyFpVectorIterator { + entries: Vec, + index: usize, + } + + /// A borrowed mutable rectangular view into a matrix-like parent. We hold + /// the parent plus the rectangle (row range + column range) and reconstruct + /// the Rust `MatrixSliceMut` on each call, revalidating the rectangle + /// against the parent's current dimensions first. + #[pyclass(name = "MatrixSliceMut")] + pub struct PyMatrixSliceMut { + parent: MatrixParent, + row_start: usize, + row_end: usize, + col_start: usize, + col_end: usize, + } + + #[pyclass(name = "Matrix")] + pub struct PyMatrix(RustMatrix); + + #[pyclass(name = "Subspace")] + pub struct PySubspace(RustSubspace); + + #[pyclass(name = "QuasiInverse")] + pub struct PyQuasiInverse(RustQuasiInverse); + + impl PyMatrix { + /// Wrap an owned upstream `Matrix` into the bound pyclass. Exposed + /// `pub(crate)` so sibling binding modules (e.g. `algebra_py`) can + /// return matrices computed by their own Rust APIs. + pub(crate) fn from_rust(matrix: RustMatrix) -> Self { + Self(matrix) + } + + /// Borrow the underlying upstream `Matrix`. Exposed `pub(crate)` so + /// sibling binding modules can read its rows (e.g. + /// `FreeModuleHomomorphism.add_generators_from_matrix_rows`, which only + /// needs the row data). + pub(crate) fn as_rust(&self) -> &RustMatrix { + &self.0 + } + } + + impl PySubspace { + /// Wrap an owned upstream `Subspace` into the bound pyclass. Exposed + /// `pub(crate)` so sibling binding modules can return subspaces (e.g. + /// `ModuleHomomorphism.kernel`/`image`). + pub(crate) fn from_rust(subspace: RustSubspace) -> Self { + Self(subspace) + } + + /// Borrow the underlying upstream `Subspace`. Exposed `pub(crate)` so + /// sibling binding modules can store it (e.g. + /// `FreeModuleHomomorphism.set_image`/`set_kernel`). + pub(crate) fn as_rust(&self) -> &RustSubspace { + &self.0 + } + } + + impl PyQuasiInverse { + /// Wrap an owned upstream `QuasiInverse` into the bound pyclass. Exposed + /// `pub(crate)` so sibling binding modules can return quasi-inverses + /// (e.g. `ModuleHomomorphism.quasi_inverse`). + pub(crate) fn from_rust(quasi_inverse: RustQuasiInverse) -> Self { + Self(quasi_inverse) + } + + /// Borrow the underlying upstream `QuasiInverse`. Exposed `pub(crate)` + /// so sibling binding modules can store it (e.g. + /// `FreeModuleHomomorphism.set_quasi_inverse`). + pub(crate) fn as_rust(&self) -> &RustQuasiInverse { + &self.0 + } + } + + #[pyclass(name = "Subquotient")] + pub struct PySubquotient(RustSubquotient); + + impl PySubquotient { + /// Wrap an owned upstream `Subquotient` into the bound pyclass. Exposed + /// `pub(crate)` so sibling binding modules (e.g. `sseq_py`) can return + /// subquotients they own (e.g. `Sseq.page_data`). + pub(crate) fn from_rust(subquotient: RustSubquotient) -> Self { + Self(subquotient) + } + } + + #[pyclass(name = "AffineSubspace")] + struct PyAffineSubspace(RustAffineSubspace); + + /// Lazy iterator over every vector in a subspace. + /// + /// The upstream `Subspace::iter_all_vectors` iterator borrows the subspace, + /// so it cannot be stored alongside an owned subspace in a `#[pyclass]` + /// without a self-referential struct. Instead we own a clone of the + /// subspace and an index counter, regenerating the i-th vector on each + /// `__next__` from the base-`p` decomposition of the index. This keeps + /// iteration lazy (O(1) memory) while yielding the same owned `FpVector`s + /// in the same order as the eager version. + #[pyclass(name = "SubspaceVectorIterator")] + pub struct PySubspaceVectorIterator { + subspace: RustSubspace, + index: u128, + total: u128, + } + + fn valid_prime(p: u32) -> PyResult { + if p < 2 || p >= MAX_VALID_PRIME { + 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"))) + } + + fn table_prime(p: u32) -> PyResult { + if fp::PRIMES.contains(&p) { + valid_prime(p) + } else { + Err(PyValueError::new_err(format!( + "{p} is not a supported table prime" + ))) + } + } + + /// Build a `SmallFq` from a Python-supplied prime and degree. + /// + /// We validate the prime ourselves (upstream `SmallFq` takes an already-valid + /// `Prime`), then delegate the degree/field-size checks to upstream + /// [`SmallFq::try_new`](RustSmallFq::try_new). Both + /// [`SmallFqError`](fp::field::SmallFqError) variants map to the same + /// `ValueError` messages (`"degree must be greater than 1"` / `"field is too + /// large"`) this helper used to raise by hand, avoiding the panic that + /// `SmallFq::new` would otherwise raise across the PyO3 boundary. + fn small_fq(p: u32, degree: u32) -> PyResult { + let p = valid_prime(p)?; + DynSmallFq::try_new(p, degree).map_err(|e| PyValueError::new_err(e.to_string())) + } + + 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_index(index: usize, len: usize) -> PyResult { + if index < len { + Ok(index) + } else { + Err(PyIndexError::new_err(format!( + "index {index} out of range for vector of length {len}" + ))) + } + } + + fn py_index(index: isize, len: usize) -> PyResult { + let index = if index < 0 { + len as isize + index + } else { + index + }; + if index >= 0 && (index as usize) < len { + Ok(index as usize) + } else { + Err(PyIndexError::new_err(format!( + "index {index} out of range for vector of length {len}" + ))) + } + } + + 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}" + ))) + } + } + + 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). + pub(crate) 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(" both in degree 0, f(g) = a.""" + source = free_gen_in_degree(alg, "F1", 0) + target = free_gen_in_degree(alg, "F0", 0) + hom = algebra.FreeModuleHomomorphismToFree(source, target, 0) + row = fp.FpVector(2, target.dimension(0)) # target dim in degree 0 is 1 + row[0] = 1 + hom.add_generators_from_rows(0, [row]) + return hom + + +# --- construction / accessors --------------------------------------------- + + +def test_construct_and_invariants(): + hom = free_to_free_id(milnor(2)) + assert isinstance(hom.prime, int) + assert hom.prime == 2 + assert hom.degree_shift == 0 + assert hom.min_degree == 0 + assert hom.next_degree == 1 + assert repr(hom).startswith("FreeModuleHomomorphismToFree(") + + +def test_source_and_target_are_both_free_modules(): + hom = free_to_free_id(milnor(2)) + source = hom.source + target = hom.target + assert isinstance(source, algebra.FreeModule) + assert isinstance(target, algebra.FreeModule) + assert source.number_of_gens_in_degree(0) == 1 + assert target.number_of_gens_in_degree(0) == 1 + assert source.prime == target.prime == 2 + + +def test_construct_requires_same_algebra(): + a1 = milnor(2) + a2 = milnor(2) # distinct algebra object + source = free_gen_in_degree(a1, "F1", 0) + target = free_gen_in_degree(a2, "F0", 0) + with pytest.raises(ValueError): + algebra.FreeModuleHomomorphismToFree(source, target, 0) + + +# --- apply / apply_to_basis_element / apply_to_generator / output ---------- + + +def test_apply_to_basis_element_known_values(): + hom = free_to_free_id(milnor(2)) + # f(g) = a: basis element (degree 0, idx 0) -> [1]. + res = fp.FpVector(2, 1) + hom.apply_to_basis_element(res, 1, 0, 0) + assert res[0] == 1 + # f(Sq1 . g) = Sq1 . a = [1] in target degree 1 (dimension 1). + res1 = fp.FpVector(2, hom.target.dimension(1)) + hom.apply_to_basis_element(res1, 1, 1, 0) + assert res1[0] == 1 + + +def test_apply_general_element(): + hom = free_to_free_id(milnor(2)) + inp = fp.FpVector(2, 1) + inp[0] = 1 + res = fp.FpVector(2, 1) + hom.apply(res, 1, 0, inp) + assert res[0] == 1 + + +def test_apply_to_generator_and_output(): + hom = free_to_free_id(milnor(2)) + res = fp.FpVector(2, 1) + hom.apply_to_generator(res, 1, 0, 0) + assert res[0] == 1 + out = hom.output(0, 0) + assert isinstance(out, fp.FpVector) + assert out[0] == 1 + + +def test_apply_aliasing_input_and_target_raises(): + hom = free_to_free_id(milnor(2)) + v = fp.FpVector(2, 1) + v[0] = 1 + with pytest.raises(RuntimeError): + hom.apply(v, 1, 0, v) + + +# --- hom_k ----------------------------------------------------------------- + + +def test_hom_k_known_value(): + hom = free_to_free_id(milnor(2)) + # The dual of the iso F1 -> F0 in degree 0 is the 1x1 identity. + assert hom.hom_k(0) == [[1]] + # No target generators in degree 1 -> empty list. + assert hom.hom_k(1) == [] + + +def test_hom_k_source_above_max_computed_degree_no_panic(): + # Target has generators up to degree 5, but the source only up to degree 2 + # (degree_shift == 0). Upstream `hom_k` reads + # `source.number_of_gens_in_degree(t + shift)` before any early return, which + # panics for a degree above the source's computed range. The binding must + # instead return the correct upstream result: with no source generators in + # `t + shift`, `source_dim` is morally 0, so the dual matrix is `target_dim` + # rows of length 0. Here `target_dim == 1`, so the result is `[[]]`. + alg = milnor(2) + source = free_gen_in_degree(alg, "F1", 2) + target = free_gen_in_degree(alg, "F0", 5) + assert source.max_computed_degree == 2 + assert target.number_of_gens_in_degree(5) == 1 + hom = algebra.FreeModuleHomomorphismToFree(source, target, 0) + assert hom.hom_k(5) == [[]] + + +def test_hom_k_target_above_max_computed_degree_is_empty(): + # `t` above the target's computed range morally has 0 target generators, so + # the empty list is returned rather than panicking (matches upstream's + # `target_dim == 0 => vec![]`). + alg = milnor(2) + source = free_gen_in_degree(alg, "F1", 2) + target = free_gen_in_degree(alg, "F0", 5) + assert target.max_computed_degree == 5 + hom = algebra.FreeModuleHomomorphismToFree(source, target, 0) + assert hom.hom_k(6) == [] + + +def test_hom_k_undefined_outputs_raises(): + alg = milnor(2) + source = free_gen_in_degree(alg, "F1", 0) + target = free_gen_in_degree(alg, "F0", 0) + hom = algebra.FreeModuleHomomorphismToFree(source, target, 0) + # Outputs on the degree-0 generator are not yet defined -> ValueError. + with pytest.raises(ValueError): + hom.hom_k(0) + + +# --- auxiliary data: kernel / image / quasi_inverse ------------------------ + + +def test_auxiliary_data_dimensions_and_types(): + hom = free_to_free_id(milnor(2)) + hom.compute_auxiliary_data_through_degree(0) + image = hom.image(0) + kernel = hom.kernel(0) + qi = hom.quasi_inverse(0) + assert isinstance(image, fp.Subspace) + assert isinstance(kernel, fp.Subspace) + assert isinstance(qi, fp.QuasiInverse) + # f is an iso k -> k in degree 0. + assert image.dimension == 1 + assert kernel.dimension == 0 + + +def test_apply_quasi_inverse_round_trip(): + hom = free_to_free_id(milnor(2)) + hom.compute_auxiliary_data_through_degree(0) + inp = fp.FpVector(2, 1) + inp[0] = 1 + res = fp.FpVector(2, 1) + applied = hom.apply_quasi_inverse(res, 0, inp) + assert applied is True + assert res[0] == 1 + + +def test_apply_quasi_inverse_returns_false_when_uncomputed(): + hom = free_to_free_id(milnor(2)) + res = fp.FpVector(2, 1) + inp = fp.FpVector(2, 1) + assert hom.apply_quasi_inverse(res, 0, inp) is False + + +def test_uncomputed_aux_data_reads_none(): + hom = free_to_free_id(milnor(2)) + assert hom.kernel(7) is None + assert hom.image(7) is None + assert hom.quasi_inverse(7) is None + + +# --- get_partial_matrix ---------------------------------------------------- + + +def test_get_partial_matrix_in_range(): + hom = free_to_free_id(milnor(2)) + m = hom.get_partial_matrix(0, [0]) + assert isinstance(m, fp.Matrix) + assert m.to_vec() == [[1]] + + +def test_get_partial_matrix_out_of_range_target_is_zero_matrix(): + # source.min_degree = 0 but target.min_degree = 1, so the output degree + # 0 is below the target's range -> target dimension 0 -> the (1 x 0) zero + # matrix is returned rather than panicking. + alg = milnor(2) + source = free_gen_in_degree(alg, "F1", 0) + target = free_gen_in_degree(alg, "F0", 1, min_degree=1) + hom = algebra.FreeModuleHomomorphismToFree(source, target, 0) + m = hom.get_partial_matrix(0, [0]) + assert m.rows == 1 + assert m.columns == 0 + + +# --- guards: errors instead of panics -------------------------------------- + + +def test_apply_out_of_range_index_raises(): + hom = free_to_free_id(milnor(2)) + res = fp.FpVector(2, 1) + with pytest.raises(IndexError): + hom.apply_to_basis_element(res, 1, 0, 9) + + +def test_apply_length_and_prime_mismatch_raises(): + hom = free_to_free_id(milnor(2)) + bad_len = fp.FpVector(2, 3) + with pytest.raises(ValueError): + hom.apply_to_basis_element(bad_len, 1, 0, 0) + bad_prime = fp.FpVector(3, 1) + with pytest.raises(ValueError): + hom.apply_to_basis_element(bad_prime, 1, 0, 0) + + +def test_apply_below_min_degree_raises(): + hom = free_to_free_id(milnor(2)) + res = fp.FpVector(2, 1) + with pytest.raises(IndexError): + hom.apply_to_basis_element(res, 1, -1, 0) + + +def test_add_generators_from_rows_non_consecutive_raises(): + hom = free_to_free_id(milnor(2)) + row = fp.FpVector(2, 1) + with pytest.raises(ValueError): + hom.add_generators_from_rows(5, [row]) + + +def test_add_generators_from_matrix_rows(): + alg = milnor(2) + source = free_gen_in_degree(alg, "F1", 0) + target = free_gen_in_degree(alg, "F0", 0) + hom = algebra.FreeModuleHomomorphismToFree(source, target, 0) + matrix = fp.Matrix.from_vec(2, [[1]]) + hom.add_generators_from_matrix_rows(0, matrix) + res = fp.FpVector(2, 1) + hom.apply_to_basis_element(res, 1, 0, 0) + assert res[0] == 1 + + +def test_extend_by_zero_past_max_computed_degree_raises(): + hom = free_to_free_id(milnor(2)) + with pytest.raises(ValueError): + hom.extend_by_zero(50) + + +def test_set_kernel_non_consecutive_raises(): + hom = free_to_free_id(milnor(2)) + sub = fp.Subspace(2, 1) + with pytest.raises(ValueError): + hom.set_kernel(3, sub) + + +def test_compute_auxiliary_data_out_of_sync_raises(): + hom = free_to_free_id(milnor(2)) + hom.set_image(0, None) + with pytest.raises(ValueError): + hom.compute_auxiliary_data_through_degree(0) + + +def test_differential_density_known_and_undefined(): + hom = free_to_free_id(milnor(2)) + assert hom.differential_density(0) == pytest.approx(1.0) + with pytest.raises(ValueError): + hom.differential_density(9) + + +# --- degree_shift != 0 ----------------------------------------------------- + + +def c2_like_shift(alg): + """f: F1 -> F0 with degree_shift = 1, F1 = in degree 1, f(g) = a.""" + source = free_gen_in_degree(alg, "F1", 1) + target = free_gen_in_degree(alg, "F0", 0) + hom = algebra.FreeModuleHomomorphismToFree(source, target, 1) + row = fp.FpVector(2, target.dimension(0)) # lands in target degree 1-1=0 + row[0] = 1 + hom.add_generators_from_rows(1, [row]) + return hom + + +def test_degree_shift_invariants_and_apply(): + hom = c2_like_shift(milnor(2)) + assert hom.degree_shift == 1 + assert hom.min_degree == 1 + assert hom.next_degree == 2 + # output(1, 0) = a = [1] in target degree 0. + assert hom.output(1, 0)[0] == 1 + # apply_to_basis_element(degree 1, idx 0) = f(g) = a = [1] in target deg 0. + res = fp.FpVector(2, 1) + hom.apply_to_basis_element(res, 1, 1, 0) + assert res[0] == 1 + + +def test_degree_shift_hom_k_known_value(): + hom = c2_like_shift(milnor(2)) + # f*: source generators in degree t + shift map to target gens in degree t. + # In t = 0: target has gen a in degree 0, source has g in degree 1. + assert hom.hom_k(0) == [[1]] + + +# --- state sharing --------------------------------------------------------- + + +def test_target_state_is_shared_not_snapshotted(): + alg = milnor(2) + source = free_gen_in_degree(alg, "F1", 0) + target = free_gen_in_degree(alg, "F0", 0) + hom = algebra.FreeModuleHomomorphismToFree(source, target, 0) + row = fp.FpVector(2, 1) + row[0] = 1 + hom.add_generators_from_rows(0, [row]) + del target + gc.collect() + t = hom.target + assert t.number_of_gens_in_degree(0) == 1 + assert t.dimension(0) == 1 diff --git a/ext_py/tests/test_full_module_homomorphism.py b/ext_py/tests/test_full_module_homomorphism.py new file mode 100644 index 0000000000..441c421cc0 --- /dev/null +++ b/ext_py/tests/test_full_module_homomorphism.py @@ -0,0 +1,445 @@ +import pytest + +from ext import algebra, fp + + +# The C2 module: x0 in degree 0, x1 in degree 1, with Sq1 x0 = x1. +C2_JSON = { + "p": 2, + "type": "finite dimensional module", + "gens": {"x0": 0, "x1": 1}, + "actions": ["Sq1 x0 = x1"], +} + + +def milnor(p=2): + return algebra.SteenrodAlgebra.milnor(p) + + +def c2_module(alg): + return algebra.SteenrodModule.from_spec(C2_JSON, alg) + + +def free_module_one_generator(alg): + """A FreeModule with a single generator in degree 0 (unbounded above).""" + b = algebra.FPModuleBuilder(alg, "F", 0) + b.add_generators(0, ["g"]) + b.add_relations(0, []) + return b.build().generators() + + +# --- binding presence ------------------------------------------------------ + + +def test_full_module_homomorphism_in_module(): + assert "FullModuleHomomorphism" in dir(algebra) + + +# --- construction / accessors ---------------------------------------------- + + +def test_zero_construct_and_invariants(): + alg = milnor(2) + m = c2_module(alg) + hom = algebra.FullModuleHomomorphism(m, m, 0) + assert isinstance(hom.prime, int) + assert hom.prime == 2 + assert hom.degree_shift == 0 + assert hom.min_degree == 0 + assert repr(hom).startswith("FullModuleHomomorphism(") + + +def test_source_and_target_types_and_state(): + alg = milnor(2) + m = c2_module(alg) + hom = algebra.FullModuleHomomorphism(m, m, 0) + source = hom.source + target = hom.target + assert isinstance(source, algebra.SteenrodModule) + assert isinstance(target, algebra.SteenrodModule) + assert source.dimension(0) == 1 + assert source.dimension(1) == 1 + assert target.dimension(1) == 1 + assert source.prime == target.prime == 2 + + +def test_construct_requires_same_algebra(): + a1 = milnor(2) + a2 = milnor(2) # distinct algebra object + m1 = c2_module(a1) + m2 = c2_module(a2) + with pytest.raises(ValueError): + algebra.FullModuleHomomorphism(m1, m2, 0) + + +# --- zero / identity static constructors ----------------------------------- + + +def test_zero_maps_everything_to_zero(): + alg = milnor(2) + m = c2_module(alg) + hom = algebra.FullModuleHomomorphism.zero(m, m, 0) + assert isinstance(hom, algebra.FullModuleHomomorphism) + res = fp.FpVector(2, 1) + hom.apply_to_basis_element(res, 1, 0, 0) + assert res[0] == 0 + res1 = fp.FpVector(2, 1) + hom.apply_to_basis_element(res1, 1, 1, 0) + assert res1[0] == 0 + + +def test_identity_is_identity(): + alg = milnor(2) + m = c2_module(alg) + hom = algebra.FullModuleHomomorphism.identity(m) + assert hom.degree_shift == 0 + # identity: x0 -> x0 (degree 0), x1 -> x1 (degree 1). + res0 = fp.FpVector(2, 1) + hom.apply_to_basis_element(res0, 1, 0, 0) + assert res0[0] == 1 + res1 = fp.FpVector(2, 1) + hom.apply_to_basis_element(res1, 1, 1, 0) + assert res1[0] == 1 + + +def test_identity_requires_bounded_module(): + alg = milnor(2) + free = free_module_one_generator(alg) + free.compute_basis(2) + unbounded = free.into_steenrod_module() + with pytest.raises(ValueError): + algebra.FullModuleHomomorphism.identity(unbounded) + + +# --- from_matrices --------------------------------------------------------- + + +def from_matrices_bottom_cell(alg): + """f: C2 -> C2, degree_shift 0: identity on the bottom cell (degree 0), + zero on the top cell (degree 1).""" + m = c2_module(alg) + m0 = fp.Matrix.from_vec(2, [[1]]) + m1 = fp.Matrix.from_vec(2, [[0]]) + return algebra.FullModuleHomomorphism.from_matrices(m, m, [m0, m1], 0) + + +def test_from_matrices_known_values(): + alg = milnor(2) + hom = from_matrices_bottom_cell(alg) + # apply_to_basis_element: degree 0 -> [1], degree 1 -> [0]. + r0 = fp.FpVector(2, 1) + hom.apply_to_basis_element(r0, 1, 0, 0) + assert r0[0] == 1 + r1 = fp.FpVector(2, 1) + hom.apply_to_basis_element(r1, 1, 1, 0) + assert r1[0] == 0 + # apply on a general element [1] in degree 0 agrees. + inp = fp.FpVector(2, 1) + inp[0] = 1 + r2 = fp.FpVector(2, 1) + hom.apply(r2, 1, 0, inp) + assert r2[0] == 1 + + +def test_from_matrices_dimension_mismatch_raises(): + alg = milnor(2) + m = c2_module(alg) + # Degree 0 of C2 has dimension 1, so a 2-column matrix is wrong. + bad = fp.Matrix.from_vec(2, [[1, 0]]) + with pytest.raises(ValueError): + algebra.FullModuleHomomorphism.from_matrices(m, m, [bad], 0) + + +def test_from_matrices_prime_mismatch_raises(): + alg = milnor(2) + m = c2_module(alg) + bad = fp.Matrix.from_vec(3, [[1]]) + with pytest.raises(ValueError): + algebra.FullModuleHomomorphism.from_matrices(m, m, [bad], 0) + + +# --- auxiliary data: kernel / image / quasi_inverse ------------------------ + + +def test_auxiliary_data_dimensions_and_types(): + alg = milnor(2) + hom = from_matrices_bottom_cell(alg) + hom.compute_auxiliary_data_through_degree(1) + + image0 = hom.image(0) + kernel0 = hom.kernel(0) + qi0 = hom.quasi_inverse(0) + assert isinstance(image0, fp.Subspace) + assert isinstance(kernel0, fp.Subspace) + assert isinstance(qi0, fp.QuasiInverse) + # degree 0 is an iso k -> k. + assert image0.dimension == 1 + assert kernel0.dimension == 0 + # degree 1 is the zero map k -> k. + assert hom.image(1).dimension == 0 + assert hom.kernel(1).dimension == 1 + + +def test_apply_quasi_inverse_round_trip(): + alg = milnor(2) + hom = from_matrices_bottom_cell(alg) + hom.compute_auxiliary_data_through_degree(1) + # qi(x0) recovers x0 = [1] in the source degree 0. + inp = fp.FpVector(2, 1) + inp[0] = 1 + res = fp.FpVector(2, 1) + applied = hom.apply_quasi_inverse(res, 0, inp) + assert applied is True + assert res[0] == 1 + + +def test_apply_quasi_inverse_returns_false_when_uncomputed(): + alg = milnor(2) + hom = from_matrices_bottom_cell(alg) + res = fp.FpVector(2, 1) + inp = fp.FpVector(2, 1) + assert hom.apply_quasi_inverse(res, 0, inp) is False + + +def test_get_partial_matrix(): + alg = milnor(2) + hom = from_matrices_bottom_cell(alg) + m = hom.get_partial_matrix(0, [0]) + assert isinstance(m, fp.Matrix) + assert m.rows == 1 + assert m.columns == 1 + assert m.to_vec() == [[1]] + + +# --- guards: errors instead of panics -------------------------------------- + + +def test_uncomputed_aux_data_reads_none(): + alg = milnor(2) + hom = from_matrices_bottom_cell(alg) + assert hom.kernel(7) is None + assert hom.image(7) is None + assert hom.quasi_inverse(7) is None + + +def test_apply_out_of_range_index_raises(): + alg = milnor(2) + hom = algebra.FullModuleHomomorphism.identity(c2_module(alg)) + res = fp.FpVector(2, 1) + with pytest.raises(IndexError): + hom.apply_to_basis_element(res, 1, 0, 9) + + +def test_apply_length_and_prime_mismatch_raises(): + alg = milnor(2) + hom = algebra.FullModuleHomomorphism.identity(c2_module(alg)) + bad_len = fp.FpVector(2, 3) + with pytest.raises(ValueError): + hom.apply_to_basis_element(bad_len, 1, 0, 0) + bad_prime = fp.FpVector(3, 1) + with pytest.raises(ValueError): + hom.apply_to_basis_element(bad_prime, 1, 0, 0) + + +def test_apply_aliasing_input_and_target_raises(): + alg = milnor(2) + hom = algebra.FullModuleHomomorphism.identity(c2_module(alg)) + v = fp.FpVector(2, 1) + v[0] = 1 + # Same object as both input and mutable target -> RuntimeError. + with pytest.raises(RuntimeError): + hom.apply(v, 1, 0, v) + + +# --- from_matrices min_degree guard ---------------------------------------- + + +def test_from_matrices_rejects_min_degree_below_target_min(): + alg = milnor(2) + m = c2_module(alg) + target_min = m.min_degree + assert target_min == 0 + # Upstream builds the kernels/images/quasi_inverses tables starting at + # target.min_degree, so matrices recorded below it would never get + # auxiliary data -> rejected with a clear ValueError. + with pytest.raises(ValueError): + algebra.FullModuleHomomorphism.from_matrices( + m, m, [], 0, min_degree=target_min - 1 + ) + + +def test_from_matrices_min_degree_at_target_min_and_default_ok(): + alg = milnor(2) + m = c2_module(alg) + target_min = m.min_degree + # min_degree == target.min_degree is accepted. + hom = algebra.FullModuleHomomorphism.from_matrices( + m, m, [], 0, min_degree=target_min + ) + assert isinstance(hom, algebra.FullModuleHomomorphism) + # The default (None) path is unaffected. + hom_default = algebra.FullModuleHomomorphism.from_matrices(m, m, [], 0) + assert isinstance(hom_default, algebra.FullModuleHomomorphism) + + +def test_from_matrices_explicit_min_degree_multi_degree_apply(): + """`matrices[i]` is the matrix in output degree `min_degree + i`.""" + alg = milnor(2) + m = c2_module(alg) + # degree_shift 0; zero on the bottom cell (output degree 0), identity on the + # top cell (output degree 1). If the ordering were reversed, the asserts + # below would flip. + m0 = fp.Matrix.from_vec(2, [[0]]) # output degree 0 + m1 = fp.Matrix.from_vec(2, [[1]]) # output degree 1 + hom = algebra.FullModuleHomomorphism.from_matrices( + m, m, [m0, m1], 0, min_degree=m.min_degree + ) + r0 = fp.FpVector(2, 1) + hom.apply_to_basis_element(r0, 1, 0, 0) + assert r0[0] == 0 + r1 = fp.FpVector(2, 1) + hom.apply_to_basis_element(r1, 1, 1, 0) + assert r1[0] == 1 + + +# --- degree_shift != 0 ----------------------------------------------------- + + +def shift_one_top_to_bottom(alg): + """f: C2 -> C2 with degree_shift 1, so output_degree = input_degree - 1. + + `matrices[0]` is the matrix in output degree min_degree (= 0); its rows + index the source basis in degree output_degree + degree_shift = 1 and its + columns index the target basis in degree 0. The single matrix [[1]] sends + the source top cell x1 (degree 1) to the target bottom cell x0 (degree 0). + """ + m = c2_module(alg) + m0 = fp.Matrix.from_vec(2, [[1]]) + return algebra.FullModuleHomomorphism.from_matrices(m, m, [m0], 1) + + +def test_shift_apply_lands_in_shifted_degree(): + alg = milnor(2) + hom = shift_one_top_to_bottom(alg) + assert hom.degree_shift == 1 + # input_degree 1 -> output_degree 0; result lives in target.dim(0) == 1. + # Per upstream apply_to_basis_element: result += matrices.get(0).row(0) = [1]. + res = fp.FpVector(2, 1) + hom.apply_to_basis_element(res, 1, 1, 0) + assert res[0] == 1 + # apply on the general top-cell element [1] in degree 1 agrees. + inp = fp.FpVector(2, 1) + inp[0] = 1 + res2 = fp.FpVector(2, 1) + hom.apply(res2, 1, 1, inp) + assert res2[0] == 1 + # input_degree 0 -> output_degree -1 (target.dim == 0): no matrix recorded + # there, so the map contributes nothing (length-0 result, no panic). + res_empty = fp.FpVector(2, 0) + hom.apply_to_basis_element(res_empty, 1, 0, 0) + + +def test_shift_get_partial_matrix_success_and_guard(): + alg = milnor(2) + hom = shift_one_top_to_bottom(alg) + # Success case: target.dim(1) == target.dim(0) == 1. + gm = hom.get_partial_matrix(1, [0]) + assert isinstance(gm, fp.Matrix) + assert gm.rows == 1 + assert gm.columns == 1 + assert gm.to_vec() == [[1]] + # Guard case: target.dim(0) == 1 != target.dim(-1) == 0 -> ValueError. + with pytest.raises(ValueError): + hom.get_partial_matrix(0, [0]) + + +def test_shift_get_partial_matrix_out_of_range_degree(): + alg = milnor(2) + hom = shift_one_top_to_bottom(alg) + # Degree beyond the (FD) source's range has dimension 0, so any input index + # is out of range -> clean IndexError (no panic). + with pytest.raises((IndexError, ValueError)): + hom.get_partial_matrix(50, [0]) + + +def test_shift_from_matrices_row_dimension_validation(): + alg = milnor(2) + m = c2_module(alg) + # For output degree 0 and degree_shift 1, rows must equal + # source.dim(0 + 1) == 1. A 2-row matrix is rejected. + bad = fp.Matrix.from_vec(2, [[1], [1]]) + with pytest.raises(ValueError): + algebra.FullModuleHomomorphism.from_matrices(m, m, [bad], 1) + + +# --- empty-matrices aux-data no-op ----------------------------------------- + + +def test_empty_new_aux_data_noop(): + alg = milnor(2) + m = c2_module(alg) + hom = algebra.FullModuleHomomorphism(m, m, 0) + # No matrices were recorded, so the upstream computation is a no-op and must + # not panic; nothing is cached, so reads return None. + hom.compute_auxiliary_data_through_degree(2) + assert hom.kernel(0) is None + assert hom.image(0) is None + assert hom.quasi_inverse(0) is None + + +def test_empty_zero_aux_data_noop(): + alg = milnor(2) + m = c2_module(alg) + hom = algebra.FullModuleHomomorphism.zero(m, m, 0) + hom.compute_auxiliary_data_through_degree(2) + assert hom.kernel(0) is None + assert hom.image(0) is None + assert hom.quasi_inverse(0) is None + + +# --- apply_quasi_inverse aliasing ------------------------------------------ + + +def test_apply_quasi_inverse_aliasing_raises(): + alg = milnor(2) + hom = from_matrices_bottom_cell(alg) + hom.compute_auxiliary_data_through_degree(1) + v = fp.FpVector(2, 1) + v[0] = 1 + # Same object as both input and mutable result -> RuntimeError. + with pytest.raises(RuntimeError): + hom.apply_quasi_inverse(v, 0, v) + + +# --- apply degree-range guards --------------------------------------------- + + +def test_apply_below_min_degree_raises(): + alg = milnor(2) + hom = algebra.FullModuleHomomorphism.identity(c2_module(alg)) + res = fp.FpVector(2, 1) + with pytest.raises(IndexError): + hom.apply_to_basis_element(res, 1, -1, 0) + + +def test_apply_above_range_raises(): + alg = milnor(2) + hom = algebra.FullModuleHomomorphism.identity(c2_module(alg)) + res = fp.FpVector(2, 1) + # Degree above the FD source's range has dimension 0 -> index out of range. + with pytest.raises((IndexError, ValueError)): + hom.apply_to_basis_element(res, 1, 50, 0) + + +# --- overflow guard -------------------------------------------------------- + + +def test_apply_output_degree_overflow_raises(): + alg = milnor(2) + m = c2_module(alg) + # degree_shift = i32::MIN; output_degree = input_degree - degree_shift then + # overflows i32 for any in-range input_degree -> clean ValueError, no panic. + hom = algebra.FullModuleHomomorphism(m, m, -2147483648) + res = fp.FpVector(2, 1) + with pytest.raises(ValueError): + hom.apply_to_basis_element(res, 1, 0, 0) diff --git a/ext_py/tests/test_hom_pullback.py b/ext_py/tests/test_hom_pullback.py new file mode 100644 index 0000000000..0110c9effc --- /dev/null +++ b/ext_py/tests/test_hom_pullback.py @@ -0,0 +1,371 @@ +"""Tests for `HomPullback`: the induced map `Hom(B, X) -> Hom(A, X)` of a +free -> free map `map: A -> B`. + +The end-to-end example mirrors upstream `hom_pullback.rs::test_pullback_id` +(`NUM_GENS = [1]`, `SHIFT = 0`): `map` is the iso `F1 -> F0` matching the single +generators, so the pullback is the identity in every degree. Expected values are +derived from that upstream test (which asserts the matrix equals the identity). + +Note: the two Hom modules must share the *identical* target module `X` (the +upstream constructor asserts `Arc::ptr_eq`). Because the dynamic monomorphisation +wraps `X` behind a per-instance outer `Arc`, the second Hom module must be built +with `HomModule.with_source` (reusing the first's `X`), not an independent +`HomModule(f1, X)` construction. +""" + +import pytest + +from ext import algebra, fp + + +def milnor(p=2): + return algebra.SteenrodAlgebra.milnor(p) + + +def make_c2(alg): + """The bounded module X = C2: x0 in degree 0, x1 in degree 1, Sq1 x0 = x1.""" + m = algebra.FDModuleBuilder(alg, "C2", [1, 1]) + m.set_action(1, 0, 0, 0, [1]) + return m.build() + + +def free_one_gen(alg, name): + """A FreeModule with a single generator in degree 0.""" + b = algebra.FPModuleBuilder(alg, name, 0) + b.add_generators(0, [name + "g"]) + b.add_relations(0, []) + f = b.build().generators() + f.compute_basis(4) + return f + + +def free_gen_in_degree(alg, name, gen_degree, min_degree=0): + """A FreeModule with a single generator in `gen_degree` and the given + `min_degree` (empty generators are added at the intervening degrees).""" + b = algebra.FPModuleBuilder(alg, name, min_degree) + for d in range(min_degree, gen_degree): + b.add_generators(d, []) + b.add_generators(gen_degree, [name + "g"]) + b.add_relations(min_degree, []) + f = b.build().generators() + f.compute_basis(6) + return f + + +def identity_pullback(alg): + """The identity pullback Hom(F0, C2) -> Hom(F1, C2) of d: F1 -> F0, d(b)=a.""" + f0 = free_one_gen(alg, "F0") + f1 = free_one_gen(alg, "F1") + d = algebra.FreeModuleHomomorphismToFree(f1, f0, 0) + row = fp.FpVector(2, f0.dimension(0)) # f0.dimension(0) == 1 + row[0] = 1 + d.add_generators_from_rows(0, [row]) + + x = make_c2(alg) + source = algebra.HomModule(f0, x) # Hom(F0, C2) + target = source.with_source(f1) # Hom(F1, C2), sharing X + pb = algebra.HomPullback(source, target, d) + return pb, source, target, f0, f1, d, x + + +# --- construction / accessors --------------------------------------------- + + +def test_construct_and_invariants(): + pb, source, target, _f0, _f1, _d, _x = identity_pullback(milnor(2)) + assert isinstance(pb.prime, int) + assert pb.prime == 2 + assert pb.degree_shift == 0 + # Hom(F, C2).min_degree = 0 - C2.max_degree(=1) = -1. + assert pb.min_degree == -1 + assert repr(pb).startswith("HomPullback(") + + +def test_source_target_roundtrip(): + pb, _source, _target, _f0, _f1, _d, _x = identity_pullback(milnor(2)) + s = pb.source + t = pb.target + assert isinstance(s, algebra.HomModule) + assert isinstance(t, algebra.HomModule) + assert s.min_degree == -1 + assert t.min_degree == -1 + s.compute_basis(0) + t.compute_basis(0) + # Hom(F, C2) dims: dim(-1) = C2.dim(1) = 1, dim(0) = C2.dim(0) = 1. + assert s.dimension(-1) == 1 + assert s.dimension(0) == 1 + assert t.dimension(-1) == 1 + assert t.dimension(0) == 1 + + +# --- known values (identity pullback) ------------------------------------- + + +def test_identity_partial_matrices(): + pb, _source, _target, _f0, _f1, _d, _x = identity_pullback(milnor(2)) + # The pullback of the iso d is the identity in every degree. + assert pb.get_partial_matrix(-1, [0]).to_vec() == [[1]] + assert pb.get_partial_matrix(0, [0]).to_vec() == [[1]] + + +def test_apply_to_basis_element_known_value(): + pb, _source, _target, _f0, _f1, _d, _x = identity_pullback(milnor(2)) + # apply_to_basis_element(-1, 0): identity -> [1] in target degree -1. + res = fp.FpVector(2, 1) + pb.apply_to_basis_element(res, 1, -1, 0) + assert res[0] == 1 + res0 = fp.FpVector(2, 1) + pb.apply_to_basis_element(res0, 1, 0, 0) + assert res0[0] == 1 + + +def test_apply_general_element(): + pb, _source, _target, _f0, _f1, _d, _x = identity_pullback(milnor(2)) + inp = fp.FpVector(2, 1) + inp[0] = 1 + res = fp.FpVector(2, 1) + pb.apply(res, 1, -1, inp) + assert res[0] == 1 + + +def test_apply_aliasing_raises_runtimeerror(): + pb, _source, _target, _f0, _f1, _d, _x = identity_pullback(milnor(2)) + v = fp.FpVector(2, 1) + v[0] = 1 + with pytest.raises(RuntimeError): + pb.apply(v, 1, -1, v) + + +# --- auxiliary data (genuinely computed, not trivial defaults) ------------- + + +def test_auxiliary_data_dimensions(): + pb, _source, _target, _f0, _f1, _d, _x = identity_pullback(milnor(2)) + pb.compute_auxiliary_data_through_degree(0) + # Identity is an iso in each degree: image dim 1, kernel dim 0. + for deg in (-1, 0): + image = pb.image(deg) + kernel = pb.kernel(deg) + assert isinstance(image, fp.Subspace) + assert isinstance(kernel, fp.Subspace) + assert image.dimension == 1 + assert kernel.dimension == 0 + assert pb.quasi_inverse(0) is not None + + +def test_apply_quasi_inverse_round_trip(): + pb, _source, _target, _f0, _f1, _d, _x = identity_pullback(milnor(2)) + pb.compute_auxiliary_data_through_degree(0) + inp = fp.FpVector(2, 1) + inp[0] = 1 + res = fp.FpVector(2, 1) + applied = pb.apply_quasi_inverse(res, 0, inp) + assert applied is True + assert res[0] == 1 + + +def test_uncomputed_aux_data_reads_none(): + pb, _source, _target, _f0, _f1, _d, _x = identity_pullback(milnor(2)) + assert pb.kernel(7) is None + assert pb.image(7) is None + assert pb.quasi_inverse(7) is None + # apply_quasi_inverse with no computed data -> False (not an error). + res = fp.FpVector(2, 1) + inp = fp.FpVector(2, 1) + assert pb.apply_quasi_inverse(res, 7, inp) is False + + +# --- get_partial_matrix guards -------------------------------------------- + + +def test_get_partial_matrix_out_of_range_is_zero_matrix(): + pb, _source, _target, _f0, _f1, _d, _x = identity_pullback(milnor(2)) + # Degree 5 is above the target Hom module's computed range -> dimension 0, + # so the (0 x 0) zero matrix is returned (no panic). + m = pb.get_partial_matrix(5, []) + assert m.columns == 0 + + +def test_get_partial_matrix_below_min_degree_raises(): + pb, _source, _target, _f0, _f1, _d, _x = identity_pullback(milnor(2)) + # min_degree() == -1, so degree -5 is below the source. + with pytest.raises(IndexError): + pb.get_partial_matrix(-5, [0]) + + +def test_apply_length_and_prime_mismatch_raises(): + pb, _source, _target, _f0, _f1, _d, _x = identity_pullback(milnor(2)) + bad_len = fp.FpVector(2, 3) + with pytest.raises(ValueError): + pb.apply_to_basis_element(bad_len, 1, -1, 0) + bad_prime = fp.FpVector(3, 1) + with pytest.raises(ValueError): + pb.apply_to_basis_element(bad_prime, 1, -1, 0) + + +def test_apply_out_of_range_index_raises(): + pb, _source, _target, _f0, _f1, _d, _x = identity_pullback(milnor(2)) + res = fp.FpVector(2, 1) + with pytest.raises(IndexError): + pb.apply_to_basis_element(res, 1, -1, 9) + + +# --- construction assertion guards (ValueError, not panic) ----------------- + + +def test_assertion_target_source_mismatch_raises(): + # target.source must equal map.source (= f1); passing Hom(f0, X) as the + # target violates this (its source is f0). + alg = milnor(2) + f0 = free_one_gen(alg, "F0") + f1 = free_one_gen(alg, "F1") + d = algebra.FreeModuleHomomorphismToFree(f1, f0, 0) + row = fp.FpVector(2, 1) + row[0] = 1 + d.add_generators_from_rows(0, [row]) + x = make_c2(alg) + source = algebra.HomModule(f0, x) + bad_target = source.with_source(f0) # Hom(f0, X), wrong source + with pytest.raises(ValueError): + algebra.HomPullback(source, bad_target, d) + + +def test_assertion_source_source_mismatch_raises(): + # source.source must equal map.target (= f0); passing Hom(f1, X) as the + # source violates this (its source is f1). + alg = milnor(2) + f0 = free_one_gen(alg, "F0") + f1 = free_one_gen(alg, "F1") + d = algebra.FreeModuleHomomorphismToFree(f1, f0, 0) + row = fp.FpVector(2, 1) + row[0] = 1 + d.add_generators_from_rows(0, [row]) + x = make_c2(alg) + target = algebra.HomModule(f1, x) + bad_source = target.with_source(f1) # Hom(f1, X), wrong source + with pytest.raises(ValueError): + algebra.HomPullback(bad_source, target, d) + + +def test_assertion_distinct_X_raises(): + # source.target must equal target.target: two independently built Hom + # modules over distinct (even if equal) X objects fail the identity check. + alg = milnor(2) + f0 = free_one_gen(alg, "F0") + f1 = free_one_gen(alg, "F1") + d = algebra.FreeModuleHomomorphismToFree(f1, f0, 0) + row = fp.FpVector(2, 1) + row[0] = 1 + d.add_generators_from_rows(0, [row]) + source = algebra.HomModule(f0, make_c2(alg)) + target = algebra.HomModule(f1, make_c2(alg)) # distinct X object + with pytest.raises(ValueError): + algebra.HomPullback(source, target, d) + + +# --- nonzero degree_shift -------------------------------------------------- + + +def shifted_pullback(alg, shift=1): + """A pullback of a map with a nonzero `degree_shift`. + + `map: A -> B` with `A = f1 = ` in degree `shift`, `B = f0 = ` in + degree 0, `map(g) = a` (so `map.degree_shift == shift`). The pullback + `Hom(B, X) -> Hom(A, X)` then has `degree_shift == -shift`. + """ + f0 = free_gen_in_degree(alg, "F0", 0, min_degree=0) + f1 = free_gen_in_degree(alg, "F1", shift, min_degree=shift) + d = algebra.FreeModuleHomomorphismToFree(f1, f0, shift) + row = fp.FpVector(2, f0.dimension(0)) # lands in f0 degree 0 + row[0] = 1 + d.add_generators_from_rows(shift, [row]) + x = make_c2(alg) + source = algebra.HomModule(f0, x) # Hom(B, X) + target = source.with_source(f1) # Hom(A, X), sharing X + pb = algebra.HomPullback(source, target, d) + return pb, source, target + + +def test_nonzero_degree_shift_invariants_and_apply(): + pb, source, target = shifted_pullback(milnor(2), shift=1) + # HomPullback.degree_shift == -map.degree_shift == -1. + assert pb.degree_shift == -1 + source.compute_basis(2) + target.compute_basis(2) + # apply at input degree -1: output_degree = -1 - (-1) = 0, both dim 1. + res = fp.FpVector(2, target.dimension(0)) + pb.apply_to_basis_element(res, 1, -1, 0) + assert res[0] == 1 + # apply at input degree 0: output_degree = 1, both dim 1. + res1 = fp.FpVector(2, target.dimension(1)) + pb.apply_to_basis_element(res1, 1, 0, 0) + assert res1[0] == 1 + # get_partial_matrix sizes its columns by target.dimension(degree) (the + # upstream convention). At degree 0 that equals target.dimension(0) == 1 and + # matches the output degree's dimension, so the induced map reads [[1]]. + assert pb.get_partial_matrix(0, [0]).to_vec() == [[1]] + # At degree -1 the source has a basis element but target.dimension(-1) == 0, + # so get_partial_matrix returns a 1 x 0 matrix (no panic). (We avoid + # `.to_vec()` here: it independently panics on any 0-column matrix — a + # pre-existing PyMatrix issue unrelated to this fix.) + m_lo = pb.get_partial_matrix(-1, [0]) + assert m_lo.rows == 1 + assert m_lo.columns == 0 + + +# --- misaligned map min-degrees (Fix 2 reachability) ----------------------- + + +def test_misaligned_map_min_degree_apply_no_panic(): + """`map.target.min_degree + degree_shift > map.source.min_degree`. + + Here `B = map.target` has `min_degree == 1` while `A = map.source` has + `min_degree == 0` and a generator in degree 0, with `degree_shift == 0`, so + `map.min_degree == max(0, 1) == 1` and A's degree-0 generator lives below + `map.min_degree`. Upstream `map.output(..)` asserts + `generator_degree >= map.min_degree`, which would panic if the pullback's + per-call filter admitted that generator. It does not: the filter's lower + bound is `>= B.min_degree + degree_shift == map.min_degree`, so the + bad generator is excluded and `apply` is safe (produces zero / valid output, + never panics). This documents that the assert is unreachable. + """ + alg = milnor(2) + f0 = free_gen_in_degree(alg, "F0", 1, min_degree=1) # B, min_degree 1 + f1 = free_gen_in_degree(alg, "F1", 0, min_degree=0) # A, gen in degree 0 + d = algebra.FreeModuleHomomorphismToFree(f1, f0, 0) + assert d.min_degree == 1 # max(A.min=0, B.min+shift=1) + x = make_c2(alg) + source = algebra.HomModule(f0, x) # Hom(B, X) + target = source.with_source(f1) # Hom(A, X), sharing X + pb = algebra.HomPullback(source, target, d) + source.compute_basis(3) + target.compute_basis(3) + # Apply across every computed degree: no panic despite A's degree-0 gen + # sitting below map.min_degree. + for deg in range(pb.min_degree, 3): + out_deg = deg - pb.degree_shift + res = fp.FpVector(2, target.dimension(out_deg)) + for idx in range(source.dimension(deg)): + pb.apply_to_basis_element(res, 1, deg, idx) + + +def test_independent_hommodules_over_same_x_still_mismatch(): + # Even over the *same* SteenrodModule object, two independent HomModule(...) + # constructions wrap X in distinct outer Arcs and fail the identity check; + # `with_source` is the supported way to build a compatible pair. + alg = milnor(2) + f0 = free_one_gen(alg, "F0") + f1 = free_one_gen(alg, "F1") + d = algebra.FreeModuleHomomorphismToFree(f1, f0, 0) + row = fp.FpVector(2, 1) + row[0] = 1 + d.add_generators_from_rows(0, [row]) + x = make_c2(alg) + source = algebra.HomModule(f0, x) + target = algebra.HomModule(f1, x) # same x, but independent outer Arc + with pytest.raises(ValueError): + algebra.HomPullback(source, target, d) + # The supported construction (with_source) succeeds. + target_ok = source.with_source(f1) + pb = algebra.HomPullback(source, target_ok, d) + assert pb.get_partial_matrix(0, [0]).to_vec() == [[1]] diff --git a/ext_py/tests/test_matrix.py b/ext_py/tests/test_matrix.py new file mode 100644 index 0000000000..f67e018b8e --- /dev/null +++ b/ext_py/tests/test_matrix.py @@ -0,0 +1,394 @@ +import pytest + +from ext import fp + + +def test_matrix_construction_and_queries(): + m = fp.Matrix(7, 2, 3) + assert m.prime == 7 + assert m.rows == 2 + assert m.columns == 3 + assert m.is_zero + assert len(m) == 2 + assert m.to_vec() == [[0, 0, 0], [0, 0, 0]] + assert repr(m).startswith("Matrix(7, ") + + +def test_matrix_to_vec_zero_dimensions(): + # Regression: a matrix with zero columns and nonzero rows used to panic in + # upstream `Matrix::to_vec` (`itertools::chunks(0)` -> "size != 0") across + # the FFI boundary. The mathematically correct value is one empty row per + # row of the matrix. + assert fp.Matrix(2, 1, 0).to_vec() == [[]] + assert fp.Matrix(2, 3, 0).to_vec() == [[], [], []] + # Zero rows (with or without columns) is the empty list of rows. + assert fp.Matrix(2, 0, 3).to_vec() == [] + assert fp.Matrix(2, 0, 0).to_vec() == [] + # Sibling row-materializing access must also not panic on zero columns. + assert fp.Matrix(2, 1, 0).rows == 1 + assert fp.Matrix(2, 1, 0).columns == 0 + assert list(fp.Matrix(2, 1, 0).row(0)) == [] + + +def test_matrix_from_vec_and_identity(): + m = fp.Matrix.from_vec(7, [[1, 3, 6], [0, 3, 4]]) + assert m.to_vec() == [[1, 3, 6], [0, 3, 4]] + assert not m.is_zero + + ident = fp.Matrix.identity(5, 3) + assert ident.to_vec() == [[1, 0, 0], [0, 1, 0], [0, 0, 1]] + + +def test_matrix_from_rows_and_from_row(): + r0 = fp.FpVector.from_slice(5, [1, 2, 3]) + r1 = fp.FpVector.from_slice(5, [4, 0, 1]) + m = fp.Matrix.from_rows(5, [r0, r1], 3) + assert m.to_vec() == [[1, 2, 3], [4, 0, 1]] + + single = fp.Matrix.from_row(5, r0, 3) + assert single.to_vec() == [[1, 2, 3]] + + +def test_matrix_augmented_from_vec(): + first_source, m = fp.Matrix.augmented_from_vec(7, [[1, 3, 6], [0, 3, 4]]) + assert first_source >= 3 + assert m.rows == 2 + + +def test_prime_is_int(): + m = fp.Matrix(5, 1, 1) + assert isinstance(m.prime, int) + + +def test_invalid_prime_and_dims(): + with pytest.raises(ValueError): + fp.Matrix(1, 2, 2) + with pytest.raises(ValueError): + fp.Matrix.from_vec(4, [[1, 2]]) + with pytest.raises(ValueError): + fp.Matrix.from_vec(7, [[1, 2], [3]]) + + +def test_row_access_and_getitem(): + m = fp.Matrix.from_vec(5, [[1, 2, 3], [4, 0, 1]]) + row = m.row(1) + assert row.prime == 5 + assert len(row) == 3 + assert row.entry(0) == 4 + assert row[2] == 1 + assert row[-1] == 1 + assert not row.is_zero + assert row.first_nonzero == (0, 4) + assert list(row.iter()) == [4, 0, 1] + assert row.iter_nonzero() == [(0, 4), (2, 1)] + assert m[0].to_owned().prime == 5 + assert list(m[0].iter()) == [1, 2, 3] + + with pytest.raises(IndexError): + m.row(2) + with pytest.raises(IndexError): + row.entry(3) + + +def test_row_mut_reflects_in_parent(): + m = fp.Matrix.from_vec(5, [[1, 2, 3], [4, 0, 1]]) + rm = m.row_mut(0) + rm.set_entry(0, 9) + assert m.to_vec()[0] == [4, 2, 3] + rm[1] = 3 + assert m.row(0)[1] == 3 + rm.scale(2) + assert m.to_vec()[0] == [3, 1, 1] + rm.set_to_zero() + assert m.to_vec()[0] == [0, 0, 0] + rm.add_basis_element(2, 1) + assert m.to_vec()[0] == [0, 0, 1] + + with pytest.raises(IndexError): + rm.set_entry(3, 1) + + +def test_row_mut_add_slice(): + m = fp.Matrix.from_vec(5, [[1, 2, 3]]) + other = fp.FpVector.from_slice(5, [1, 1, 1]) + m.row_mut(0).add(other.slice(0, 3), 2) + assert m.to_vec()[0] == [3, 4, 0] + + +def test_iter_mut_writes_through_and_counts_rows(): + m = fp.Matrix.from_vec(5, [[1, 2, 3], [4, 0, 1]]) + rows = list(m.iter_mut()) + assert len(rows) == m.rows == 2 + assert type(rows[0]) is type(m.row_mut(0)) + for i, out in enumerate(rows): + out.set_entry(i, 3) + assert m.to_vec() == [[3, 2, 3], [4, 3, 1]] + # A zero-row matrix yields an empty iterator. + assert list(fp.Matrix(5, 0, 3).iter_mut()) == [] + + +def test_mutators(): + m = fp.Matrix.from_vec(5, [[1, 2], [3, 4]]) + m.swap_rows(0, 1) + assert m.to_vec() == [[3, 4], [1, 2]] + with pytest.raises(IndexError): + m.swap_rows(0, 2) + + m.safe_row_op(0, 1, 1) + assert m.to_vec() == [[4, 1], [1, 2]] + with pytest.raises(ValueError): + m.safe_row_op(0, 0, 1) + + m.set_to_zero() + assert m.is_zero + + +def test_add_row_extends_matrix(): + m = fp.Matrix(5, 1, 2) + new = m.add_row() + assert m.rows == 2 + new.set_entry(0, 3) + assert m.to_vec()[1] == [3, 0] + + +def test_assign_requires_matching_shape(): + m = fp.Matrix.from_vec(5, [[1, 2], [3, 4]]) + other = fp.Matrix.from_vec(5, [[1, 1], [1, 1]]) + m.assign(other) + assert m.to_vec() == [[1, 1], [1, 1]] + + mismatch = fp.Matrix(5, 3, 2) + with pytest.raises(ValueError): + m.assign(mismatch) + diff_prime = fp.Matrix(7, 2, 2) + with pytest.raises(ValueError): + m.assign(diff_prime) + + +def test_row_reduce_rank(): + m = fp.Matrix.from_vec(2, [[1, 1, 0], [0, 1, 1], [1, 0, 1]]) + assert m.row_reduce() == 2 + + pivots = m.pivots + assert isinstance(pivots, list) + + +def test_extend_columns_and_pivots(): + m = fp.Matrix(5, 2, 2) + m.extend_column_dimension(4) + assert m.columns == 4 + m.initialize_pivots() + assert m.pivots == [-1, -1, -1, -1] + + +def test_trim_and_rotate(): + m = fp.Matrix.from_vec(5, [[1, 2, 3], [4, 0, 1], [2, 2, 2]]) + m.trim(0, 2, 1) + assert m.to_vec() == [[2, 3], [0, 1]] + + n = fp.Matrix.from_vec(5, [[1, 0], [2, 0], [3, 0]]) + n.rotate_down(0, 3, 1) + assert n.to_vec() == [[3, 0], [1, 0], [2, 0]] + + +def test_bytes_roundtrip(): + m = fp.Matrix.from_vec(5, [[1, 2, 3], [4, 0, 2]]) + data = m.to_bytes() + n = fp.Matrix.from_bytes(5, 2, 3, data) + assert n.to_vec() == m.to_vec() + + +def test_stale_row_handle_after_trim_raises(): + m = fp.Matrix.from_vec(5, [[1, 2, 3], [4, 0, 1], [2, 2, 2]]) + row = m.row(2) + assert row.entry(0) == 2 + m.trim(0, 1, 0) + assert m.rows == 1 + with pytest.raises(IndexError): + row.entry(0) + with pytest.raises(IndexError): + row[0] + + +def test_stale_row_mut_handle_after_trim_raises(): + m = fp.Matrix.from_vec(5, [[1, 2, 3], [4, 0, 1], [2, 2, 2]]) + rm = m.row_mut(2) + rm.set_entry(0, 1) + m.trim(0, 1, 0) + with pytest.raises(IndexError): + rm.set_entry(0, 1) + + +def test_row_returns_same_type_as_vector_slice(): + m = fp.Matrix.from_vec(5, [[1, 2, 3]]) + v = fp.FpVector.from_slice(5, [1, 2, 3]) + assert type(m.row(0)) is type(v.slice(0, 3)) + assert type(m.row_mut(0)) is type(v.slice_mut(0, 3)) + assert type(m[0]) is type(v.slice(0, 3)) + + +def test_row_slice_restrict_and_to_owned(): + m = fp.Matrix.from_vec(5, [[1, 2, 3, 4]]) + row = m.row(0) + sub = row.restrict(1, 3) + assert len(sub) == 2 + assert [sub[i] for i in range(len(sub))] == [2, 3] + owned = row.to_owned() + assert isinstance(owned, fp.FpVector) + assert repr(owned) == "FpVector(5, [1, 2, 3, 4])" + assert repr(row).startswith("FpSlice(5, ") + + +def test_row_mut_to_owned_and_slice_mut(): + m = fp.Matrix.from_vec(5, [[1, 2, 3, 4]]) + rm = m.row_mut(0) + owned = rm.to_owned() + assert owned.prime == 5 + sub = rm.slice_mut(0, 2) + sub.scale(2) + assert m.to_vec()[0] == [2, 4, 3, 4] + assert repr(rm).startswith("FpSliceMut(5, ") + + +def test_row_len_revalidates_after_column_shrink(): + # `trim` with col_start > 0 reduces the number of columns, so a previously + # created row slice whose `end` exceeds the new column count is stale. + m = fp.Matrix.from_vec(5, [[1, 2, 3, 4], [5, 6, 7, 8]]) + row = m.row(0) + rm = m.row_mut(1) + assert len(row) == 4 + assert len(rm) == 4 + + # Drop two leading columns: columns goes 4 -> 2. + m.trim(0, 2, 2) + assert m.columns == 2 + + with pytest.raises(IndexError): + len(row) + with pytest.raises(IndexError): + row.entry(0) + with pytest.raises(IndexError): + row[0] + with pytest.raises(IndexError): + len(rm) + with pytest.raises(IndexError): + rm.set_entry(0, 1) + + +def test_content_shift_staleness_after_trim_is_documented_behavior(): + # A sub-slice that survives a `col_start > 0` trim (its `end` stays within + # the new column count) does NOT raise: revalidation guards parent + # dimensions only, not logical-coordinate remapping. The slice now reads the + # shifted columns. This pins the documented semantics. + m = fp.Matrix.from_vec(5, [[1, 2, 3, 4]]) + # restrict to absolute indices 1..3, i.e. logical columns [2, 3]. + sub = m.row(0).restrict(1, 3) + assert [sub[i] for i in range(len(sub))] == [2, 3] + + # Trim one leading column: columns goes 4 -> 3, data shifts left by one. + # Row was [1, 2, 3, 4] -> [2, 3, 4]. The sub-slice's range 1..3 still fits. + m.trim(0, 1, 1) + assert m.columns == 3 + assert m.to_vec()[0] == [2, 3, 4] + + # The handle does not raise; it now reads the remapped indices 1..3 -> [3, 4] + # instead of the original [2, 3]. Length is unchanged but data has shifted. + assert len(sub) == 2 + assert [sub[i] for i in range(len(sub))] == [3, 4] + + +def test_interacting_mutable_slices_over_same_parent(): + # Borrow conflicts cannot arise from holding two slice handles: each call + # reconstructs and re-borrows the parent for the duration of that single + # call, and no parent borrow is held across re-entry into Python. So two + # FpSliceMut handles over the same matrix interleave safely rather than + # panicking. We assert the safe interleaving here; a genuine borrow conflict + # is not reachable from Python with this design. + m = fp.Matrix.from_vec(5, [[1, 2], [3, 4]]) + a = m.row_mut(0) + b = m.row_mut(1) + + a.set_entry(0, 0) + b.set_entry(1, 0) + a.scale(2) + + assert m.to_vec() == [[0, 4], [3, 0]] + + +# --- compute_kernel / compute_image ---------------------------------------- + +# Example from the Rust `compute_kernel`/`compute_image` docs at p = 3. +_KI_ROWS = [ + [1, 2, 1, 1, 0], + [1, 0, 2, 1, 1], + [2, 2, 0, 2, 1], +] + + +def test_compute_kernel_dimensions_and_membership(): + padded_cols, m = fp.Matrix.augmented_from_vec(3, _KI_ROWS) + m.row_reduce() + ker = m.compute_kernel(padded_cols) + assert ker.prime == 3 + # The kernel here is the left null space (combinations of the 3 rows that + # vanish), so it is ambient-dimension 3 and one-dimensional. + assert ker.ambient_dimension == len(_KI_ROWS) + assert ker.dimension == 1 + basis = ker.basis + assert len(basis) == 1 + # Kernel basis row from the upstream doc example. + assert list(basis[0]) == [1, 1, 2] + # Verify each kernel row r satisfies sum_i r[i] * row_i == 0. + orig = _KI_ROWS + ncols = len(orig[0]) + for row in basis: + product = fp.FpVector.new(3, ncols) + for i in range(len(orig)): + c = row.entry(i) + if c: + for j in range(ncols): + product.set_entry(j, (product.entry(j) + c * orig[i][j]) % 3) + assert product.is_zero + + +def test_compute_image_dimensions_and_membership(): + padded_cols, m = fp.Matrix.augmented_from_vec(3, _KI_ROWS) + m.row_reduce() + img = m.compute_image(len(_KI_ROWS[0]), padded_cols) + assert img.prime == 3 + assert img.ambient_dimension == len(_KI_ROWS[0]) + # Image of a rank-3 map into a 5-dim target has dimension 3... but the + # doc example restricts to the target block; the image basis matches the + # upstream doc rows. + rows = [list(r) for r in img.basis] + assert [1, 0, 2, 1, 1] in rows + assert [0, 1, 1, 0, 1] in rows + # The recorded image rows are members of the image subspace. + for r in img.basis: + assert img.contains(r) + + +def test_compute_kernel_out_of_range(): + _, m = fp.Matrix.augmented_from_vec(3, [[1, 0, 1], [0, 1, 1]]) + m.row_reduce() + with pytest.raises(IndexError): + m.compute_kernel(999) + + +def test_compute_image_out_of_range(): + _, m = fp.Matrix.augmented_from_vec(3, [[1, 0, 1], [0, 1, 1]]) + m.row_reduce() + with pytest.raises(IndexError): + m.compute_image(999, 999) + + +def test_compute_kernel_requires_row_reduce(): + padded_cols, m = fp.Matrix.augmented_from_vec(3, [[1, 0, 1], [0, 1, 1]]) + with pytest.raises(ValueError): + m.compute_kernel(padded_cols) + + +def test_compute_image_requires_row_reduce(): + padded_cols, m = fp.Matrix.augmented_from_vec(3, [[1, 0, 1], [0, 1, 1]]) + with pytest.raises(ValueError): + m.compute_image(2, padded_cols) diff --git a/ext_py/tests/test_matrix_slice_mut.py b/ext_py/tests/test_matrix_slice_mut.py new file mode 100644 index 0000000000..dcfc1d4a85 --- /dev/null +++ b/ext_py/tests/test_matrix_slice_mut.py @@ -0,0 +1,229 @@ +import pytest + +from ext import fp + + +def make_matrix(rows): + """Build an F5 matrix from a list-of-lists via row_mut.set_entry.""" + p = 5 + m = fp.Matrix(p, len(rows), len(rows[0])) + for i, row in enumerate(rows): + rm = m.row_mut(i) + for j, v in enumerate(row): + rm.set_entry(j, v) + return m + + +def test_slice_mut_construction_and_queries(): + m = make_matrix([[1, 2, 3, 4], [0, 1, 2, 3], [4, 3, 2, 1]]) + rect = m.slice_mut(0, 2, 1, 3) + # prime returned as a plain int. + assert rect.prime == 5 + assert rect.rows == 2 + assert rect.columns == 2 + assert repr(rect) == "MatrixSliceMut(5, 2x2)" + + +def test_row_and_row_slice_read(): + m = make_matrix([[1, 2, 3, 4], [0, 1, 2, 3], [4, 3, 2, 1]]) + rect = m.slice_mut(0, 3, 1, 3) + # row(i) is the column-shifted view into the rectangle. + row0 = rect.row(0) + assert isinstance(row0, fp.FpSlice) + assert len(row0) == 2 + assert row0[0] == 2 + assert row0[1] == 3 + + # row_slice restricts the row range, keeping the columns. + sub = rect.row_slice(1, 3) + assert sub.rows == 2 + assert sub.columns == 2 + assert sub.row(0)[0] == 1 # original row 1, column 1 + + +def test_row_mut_writes_through_to_parent(): + m = make_matrix([[1, 2, 3, 4], [0, 1, 2, 3]]) + rect = m.slice_mut(0, 2, 1, 4) + rm = rect.row_mut(0) + assert isinstance(rm, fp.FpSliceMut) + rm.set_entry(0, 0) # rectangle column 0 == matrix column 1 + assert m.to_vec()[0] == [1, 0, 3, 4] + + +def test_iter_mut_reflects_in_parent(): + m = make_matrix([[1, 1, 1, 1], [2, 2, 2, 2]]) + rect = m.slice_mut(0, 2, 0, 4) + # iter yields read-only row handles. + for r in rect.iter(): + assert isinstance(r, fp.FpSlice) + # iter_mut yields mutable handles that write through. + for r in rect.iter_mut(): + r.set_entry(0, 0) + assert m.to_vec() == [[0, 1, 1, 1], [0, 2, 2, 2]] + + +def test_add_identity(): + m = fp.Matrix(3, 2, 4) + rect = m.slice_mut(0, 2, 2, 4) + rect.add_identity() + assert m.to_vec() == [[0, 0, 1, 0], [0, 0, 0, 1]] + + # Non-square rectangle raises ValueError. + wide = m.slice_mut(0, 2, 0, 4) + with pytest.raises(ValueError): + wide.add_identity() + + +def test_add_masked(): + m = fp.Matrix(3, 2, 2) + other = fp.Matrix.from_vec(3, [[1, 2], [0, 1]]) + rect = m.slice_mut(0, 2, 0, 2) + rect.add_masked(other, [0, 1]) + assert m.to_vec() == [[1, 2], [0, 1]] + + # Mask length must equal the rectangle's column count. + with pytest.raises(ValueError): + rect.add_masked(other, [0]) + # Mask index out of range for `other` raises IndexError. + with pytest.raises(IndexError): + rect.add_masked(other, [0, 5]) + # Row-count mismatch raises ValueError. + bad_rows = fp.Matrix.from_vec(3, [[1, 2]]) + with pytest.raises(ValueError): + rect.add_masked(bad_rows, [0, 1]) + # Prime mismatch raises ValueError. + bad_prime = fp.Matrix.from_vec(5, [[1, 2], [0, 1]]) + with pytest.raises(ValueError): + rect.add_masked(bad_prime, [0, 1]) + + +def test_add_masked_self_raises_runtime_error(): + # Passing the slice's own parent matrix as `other` keeps an immutable + # borrow of that object alive while the rectangle tries to borrow it + # mutably, so the binding raises RuntimeError rather than aliasing. + m = make_matrix([[1, 2], [0, 1]]) + rect = m.slice_mut(0, 2, 0, 2) + with pytest.raises(RuntimeError): + rect.add_masked(m, [0, 1]) + + +def test_invalid_rectangle_raises(): + m = make_matrix([[1, 2, 3], [4, 0, 1]]) + with pytest.raises(IndexError): + m.slice_mut(0, 5, 0, 1) # too many rows + with pytest.raises(IndexError): + m.slice_mut(0, 1, 0, 9) # too many columns + with pytest.raises(IndexError): + m.slice_mut(2, 1, 0, 1) # inverted row range + + +def test_out_of_range_row_index_raises(): + m = make_matrix([[1, 2, 3], [4, 0, 1]]) + rect = m.slice_mut(0, 2, 0, 3) + with pytest.raises(IndexError): + rect.row(2) + with pytest.raises(IndexError): + rect.row_mut(2) + + +def test_stale_handle_after_parent_shrinks_raises(): + m = make_matrix([[1, 1, 0], [0, 1, 1]]) + rect = m.slice_mut(0, 2, 0, 3) + # A square sub-rectangle, valid before the parent shrinks. + square = m.slice_mut(0, 2, 0, 2) + # Trim the parent to a single row; the 2-row rectangles are now stale. + m.trim(0, 1, 0) + with pytest.raises(IndexError): + rect.rows + # The square rectangle passes its shape check but fails revalidation. + with pytest.raises(IndexError): + square.add_identity() + # A row handle taken before the shrink also raises on use. + rm = m.slice_mut(0, 1, 0, 3).row_mut(0) + # (still valid: row 0 survives) + rm.set_entry(0, 1) + + +def test_augmented_segment_mutates_and_reads_back(): + m = fp.AugmentedMatrix2(2, 2, [2, 2]) + seg = m.segment(1, 1) + assert isinstance(seg, fp.MatrixSliceMut) + assert seg.rows == 2 + assert seg.columns == 2 + seg.add_identity() + start1 = m.segment_starts[1] + rows = m.to_vec() + assert rows[0][start1] == 1 + assert rows[1][start1 + 1] == 1 + + +def test_augmented_row_segment_mut_writes_through(): + m = fp.AugmentedMatrix2(3, 2, [2, 2]) + row = m.row_segment_mut(0, 0, 0) + assert isinstance(row, fp.FpSliceMut) + row.set_entry(0, 2) + assert m.to_vec()[0][0] == 2 + + +def test_augmented3_segment_and_row_segment_mut_write_through(): + # Exercises the MatrixParent::Augmented3 arm: take a segment / row segment + # from an AugmentedMatrix3, mutate through it, and observe the change via + # the augmented matrix (to_vec / row_segment). + m = fp.AugmentedMatrix3(3, 2, [2, 2, 2]) + seg = m.segment(1, 1) + assert isinstance(seg, fp.MatrixSliceMut) + assert seg.rows == 2 + assert seg.columns == 2 + seg.add_identity() + start1 = m.segment_starts[1] + rows = m.to_vec() + assert rows[0][start1] == 1 + assert rows[1][start1 + 1] == 1 + + row = m.row_segment_mut(0, 0, 0) + assert isinstance(row, fp.FpSliceMut) + row.set_entry(0, 2) + assert m.to_vec()[0][0] == 2 + # Observable via the read-only row_segment accessor too. + assert list(m.row_segment(0, 0, 0))[0] == 2 + + +def test_stale_slice_mut_repr_raises(): + # __repr__ revalidates via with_slice_mut, so a stale handle raises rather + # than returning a string. + m = make_matrix([[1, 1, 0], [0, 1, 1]]) + rect = m.slice_mut(0, 2, 0, 3) + m.trim(0, 1, 0) + with pytest.raises(IndexError): + repr(rect) + + +def test_augmented_segment_builds_nontrivial_compute_values(): + # Mirrors the F3 doctest of Matrix::compute_image / compute_quasi_inverse, + # but built entirely through the Python segment-mut API: place A in segment + # 0 entry by entry, the identity in segment 1, then row reduce and verify + # the committed expected image and preimage. This closes the earlier gap + # where the Python layer could not set interior augmented-matrix entries. + a = [ + [1, 2, 1, 1, 0], + [1, 0, 2, 1, 1], + [2, 2, 0, 2, 1], + ] + m = fp.AugmentedMatrix2(3, 3, [5, 3]) + # Fill A (segment 0) via row_segment_mut. + for i, arow in enumerate(a): + rm = m.row_segment_mut(i, 0, 0) + for j, v in enumerate(arow): + rm.set_entry(j, v) + # Identity into segment 1 via segment(...). + m.segment(1, 1).add_identity() + m.row_reduce() + + image = m.compute_image() + assert image.dimension == 2 + image_rows = [list(v) for v in image.iter()] + assert image_rows == [[1, 0, 2, 1, 1], [0, 1, 1, 0, 1]] + + qi = m.compute_quasi_inverse() + assert qi.source_dimension == 3 + assert qi.preimage.to_vec() == [[0, 1, 0], [0, 2, 2]] diff --git a/ext_py/tests/test_milnor_algebra.py b/ext_py/tests/test_milnor_algebra.py new file mode 100644 index 0000000000..074eb4fb74 --- /dev/null +++ b/ext_py/tests/test_milnor_algebra.py @@ -0,0 +1,362 @@ +import pytest + +from ext import algebra, fp + + +def make_algebra(p=2, degree=8): + a = algebra.MilnorAlgebra(p) + a.compute_basis(degree) + return a + + +def test_construction_valid_and_invalid_prime(): + a = algebra.MilnorAlgebra(2) + assert a.prime == 2 + assert algebra.MilnorAlgebra(3).prime == 3 + + # A non-prime must raise ValueError, never panic. + with pytest.raises(ValueError): + algebra.MilnorAlgebra(4) + with pytest.raises(ValueError): + algebra.MilnorAlgebra(0) + with pytest.raises(ValueError): + algebra.MilnorAlgebra(1) + + +def test_prime_is_plain_int(): + a = algebra.MilnorAlgebra(2) + p = a.prime + assert isinstance(p, int) + assert p == 2 + + +def test_compute_basis_and_dimension(): + a = make_algebra(2, 8) + assert a.dimension(0) == 1 + assert a.dimension(1) == 1 + assert a.dimension(2) == 1 + assert a.dimension(3) == 2 + assert a.dimension(4) == 2 + # Negative degree is empty, not an error. + assert a.dimension(-2) == 0 + + +def test_generic_q_and_profile(): + a = algebra.MilnorAlgebra(2) + assert a.generic() is False + assert a.q() == 1 + + a3 = algebra.MilnorAlgebra(3) + assert a3.generic() is True + assert a3.q() == 4 + + profile = a.profile() + assert isinstance(profile, algebra.MilnorProfile) + assert profile.is_trivial() + assert profile.truncated is False + + +def test_multiply_basis_elements_known_results(): + a = make_algebra(2, 8) + + # Sq^1 * Sq^1 = 0 in degree 2. + v = fp.FpVector(2, a.dimension(2)) + a.multiply_basis_elements(v, 1, 1, 0, 1, 0) + assert list(v) == [0] + + # Sq^2 * Sq^2 = P(1, 1) in degree 4. + v = fp.FpVector(2, a.dimension(4)) + a.multiply_basis_elements(v, 1, 2, 0, 2, 0) + assert list(v) == [0, 1] + + # Sq^2 * Sq^1 in degree 3. + v = fp.FpVector(2, a.dimension(3)) + a.multiply_basis_elements(v, 1, 2, 0, 1, 0) + assert list(v) == [1, 1] + + +def test_multiply_basis_elements_accumulates_into_result(): + a = make_algebra(2, 8) + # multiply_* adds into the result, so doing Sq^2 * Sq^2 = [0, 1] twice at + # p = 2 cancels to zero. + v = fp.FpVector(2, a.dimension(4)) + a.multiply_basis_elements(v, 1, 2, 0, 2, 0) + assert list(v) == [0, 1] + a.multiply_basis_elements(v, 1, 2, 0, 2, 0) + assert list(v) == [0, 0] + + +def test_multiply_element_families_via_fpvector(): + a = make_algebra(2, 8) + + # multiply_element_by_element using full FpVector inputs. + r = fp.FpVector.from_slice(2, [1]) # Sq^2 (degree 2, dim 1) + s = fp.FpVector.from_slice(2, [1]) # Sq^2 + out = fp.FpVector(2, a.dimension(4)) + a.multiply_element_by_element(out, 1, 2, r, 2, s) + assert list(out) == [0, 1] + + # multiply_basis_element_by_element with an FpVector element. + s = fp.FpVector.from_slice(2, [1]) # Sq^1 + out = fp.FpVector(2, a.dimension(3)) + a.multiply_basis_element_by_element(out, 1, 2, 0, 1, s) + assert list(out) == [1, 1] + + +def test_multiply_accepts_fpslice_and_fpslicemut(): + a = make_algebra(2, 8) + + # Pass an FpSlice as an input element, and an FpSliceMut as the result. + s_vec = fp.FpVector.from_slice(2, [1]) + s_slice = s_vec.slice(0, 1) + + result_vec = fp.FpVector(2, a.dimension(3)) + result_slice = result_vec.slice_mut(0, a.dimension(3)) + a.multiply_basis_element_by_element(result_slice, 1, 2, 0, 1, s_slice) + assert list(result_vec) == [1, 1] + + +def test_multiply_milnor_basis_elements(): + a = make_algebra(2, 8) + m1 = a.basis_element_from_index(2, 0) + m2 = a.basis_element_from_index(2, 0) + out = fp.FpVector(2, a.dimension(4)) + a.multiply(out, 1, m1, m2) + assert list(out) == [0, 1] + + +def test_multiply_prime_and_length_errors(): + a = make_algebra(2, 8) + + # Prime mismatch on the result. + wrong_prime = fp.FpVector(3, a.dimension(2)) + with pytest.raises(ValueError): + a.multiply_basis_elements(wrong_prime, 1, 1, 0, 1, 0) + + # Result too short. + short = fp.FpVector(2, 0) + with pytest.raises(ValueError): + a.multiply_basis_elements(short, 1, 2, 0, 2, 0) + + # Out-of-range basis index. + ok = fp.FpVector(2, a.dimension(2)) + with pytest.raises(IndexError): + a.multiply_basis_elements(ok, 1, 1, 99, 1, 0) + + +def test_basis_element_to_from_string_roundtrip(): + a = make_algebra(2, 6) + for d in range(7): + for i in range(a.dimension(d)): + s = a.basis_element_to_string(d, i) + assert a.basis_element_from_string(s) == (d, i) + + with pytest.raises(ValueError): + a.basis_element_from_string("not a valid element ###") + + +def test_basis_element_from_string_absent_names_raise(): + # Parseable-but-absent / out-of-range names used to panic across the PyO3 + # boundary (upstream `beps_pn(..).unwrap()` / `basis_element_to_index`). + # They must now raise a normal ValueError, never a PanicException. + a = make_algebra(2, 8) + for name in ("Sq0", "P0", "Q_5"): + with pytest.raises(ValueError): + a.basis_element_from_string(name) + + # An out-of-profile name on a profiled algebra also raises rather than + # panicking. With profile p_part=[1], Sq^2 = P(2) is not present. + profile = algebra.MilnorProfile(truncated=True, q_part=0xFFFFFFFF, p_part=[1]) + pa = algebra.MilnorAlgebra.new_with_profile(2, profile) + pa.compute_basis(8) + with pytest.raises(ValueError): + pa.basis_element_from_string("Sq2") + + # Valid names still round-trip correctly. + for d in range(7): + for i in range(a.dimension(d)): + s = a.basis_element_to_string(d, i) + assert a.basis_element_from_string(s) == (d, i) + + +def test_decompose_degree_zero_unit_raises(): + # The degree-0 unit is indecomposable; decomposing it used to underflow + # and panic (`p_part[0..len - 1]` with len == 0). It must raise ValueError. + a = make_algebra(2, 8) + with pytest.raises(ValueError): + a.decompose_basis_element(0, 0) + + # A non-trivial decompose still works and returns a list of triples. + decomp = a.decompose_basis_element(4, 1) + assert isinstance(decomp, list) + assert all(len(t) == 3 for t in decomp) + + +def test_decompose_q0_generator_raises_odd_prime(): + # Q_0 (the Bockstein, degree 1, idx 0) is an in-range generator at odd + # primes. Upstream `decompose_basis_element_qpart` computes + # `prime().pow(i - 1)` with i == 0, underflowing and panicking. The + # generators-based guard must turn this into a ValueError. + a = make_algebra(3, 8) + assert 0 in a.generators(1) + with pytest.raises(ValueError): + a.decompose_basis_element(1, 0) + + +def test_decompose_generator_raises_p2(): + # P(2) (= Sq^2) is a generator in degree 2 (idx 0): indecomposable, so it + # must raise ValueError, matching the Adem variant rather than returning a + # degenerate self-term. + a = make_algebra(2, 8) + assert 0 in a.generators(2) + with pytest.raises(ValueError): + a.decompose_basis_element(2, 0) + # A non-generator decomposable element still returns a decomposition. + assert isinstance(a.decompose_basis_element(3, 0), list) + + +def test_multiply_large_coeff_does_not_overflow(): + # Upstream computes `coeff * v` before reducing mod p, overflowing for + # large coeff (panics in debug). The binding reduces coeff mod p first. + a = make_algebra(2, 8) + + # Reference: Sq^2 * Sq^2 = P(1, 1) = [0, 1] with coeff 1. + ref = fp.FpVector(2, a.dimension(4)) + a.multiply_basis_elements(ref, 1, 2, 0, 2, 0) + assert list(ref) == [0, 1] + + # coeff near u32::MAX. 0xFFFFFFFF % 2 == 1, so result matches coeff 1. + big = fp.FpVector(2, a.dimension(4)) + a.multiply_basis_elements(big, 0xFFFFFFFF, 2, 0, 2, 0) + assert list(big) == [0, 1] + + # An even large coeff reduces to 0 mod 2 -> no contribution. + even = fp.FpVector(2, a.dimension(4)) + a.multiply_basis_elements(even, 0xFFFFFFFE, 2, 0, 2, 0) + assert list(even) == [0, 0] + + # Odd prime: coeff >= p reduces correctly. At p = 3, Sq-analog product + # scaled by coeff 7 == coeff 1 (7 % 3 == 1). + a3 = make_algebra(3, 32) + base = fp.FpVector(3, a3.dimension(8)) + a3.multiply_basis_elements(base, 1, 4, 0, 4, 0) + scaled = fp.FpVector(3, a3.dimension(8)) + a3.multiply_basis_elements(scaled, 7, 4, 0, 4, 0) + assert list(scaled) == list(base) + + # And via the MilnorBasisElement multiply entry point. + m = a.basis_element_from_index(2, 0) + out = fp.FpVector(2, a.dimension(4)) + a.multiply(out, 0xFFFFFFFF, m, m) + assert list(out) == [0, 1] + + +def test_element_to_string(): + a = make_algebra(2, 6) + v = fp.FpVector.from_slice(2, [1, 1]) # P(3) + P(0, 1) in degree 3 + text = a.element_to_string(3, v) + assert "P(3)" in text + assert "P(0, 1)" in text + + # Length mismatch raises. + with pytest.raises(ValueError): + a.element_to_string(3, fp.FpVector(2, 5)) + + +def test_basis_element_index_roundtrip(): + a = make_algebra(2, 6) + for d in range(7): + for i in range(a.dimension(d)): + elt = a.basis_element_from_index(d, i) + assert isinstance(elt, algebra.MilnorBasisElement) + assert a.basis_element_to_index(elt) == i + assert a.try_basis_element_to_index(elt) == i + + with pytest.raises(IndexError): + a.basis_element_from_index(2, 99) + + +def test_basis_element_to_index_not_found(): + a = make_algebra(2, 6) + bogus = algebra.MilnorBasisElement([99], q_part=0, degree=2) + assert a.try_basis_element_to_index(bogus) is None + with pytest.raises(ValueError): + a.basis_element_to_index(bogus) + + +def test_milnor_basis_element_fields(): + elt = algebra.MilnorBasisElement([2, 1], q_part=3, degree=10) + assert elt.p_part == [2, 1] + assert elt.q_part == 3 + assert elt.degree == 10 + + elt.p_part = [1] + elt.q_part = 0 + assert elt.p_part == [1] + assert elt.q_part == 0 + + # compute_degree fills in the degree from the parts. + e = algebra.MilnorBasisElement([1]) + e.compute_degree(2) + assert e.degree == 1 + + assert algebra.MilnorBasisElement([1]) == algebra.MilnorBasisElement([1]) + + +def test_milnor_profile_fields_and_methods(): + profile = algebra.MilnorProfile(truncated=True, q_part=0b1111, p_part=[3, 2, 1]) + assert profile.truncated is True + assert profile.q_part == 0b1111 + assert profile.p_part == [3, 2, 1] + assert profile.get_p_part(0) == 3 + assert profile.is_valid() + + default = algebra.MilnorProfile() + assert default.is_trivial() + assert default.q_part == 0xFFFFFFFF + + +def test_new_with_profile_valid_and_invalid(): + profile = algebra.MilnorProfile(truncated=True, q_part=0xFFFFFFFF, p_part=[2, 1]) + a = algebra.MilnorAlgebra.new_with_profile(2, profile) + a.compute_basis(8) + assert a.prime == 2 + + # An invalid profile raises ValueError instead of panicking. + bad = algebra.MilnorProfile(truncated=True, q_part=0xFFFFFFFF, p_part=[1, 5]) + if not bad.is_valid(): + with pytest.raises(ValueError): + algebra.MilnorAlgebra.new_with_profile(2, bad) + + +def test_generated_algebra_surface(): + a = make_algebra(2, 8) + assert a.generators(2) == [0] + assert isinstance(a.generator_to_string(2, 0), str) + assert isinstance(a.decompose_basis_element(4, 1), list) + assert isinstance(a.generating_relations(4), list) + assert a.decompose(2, 0) == [(2, 0)] + + +def test_default_filtration_one_products(): + a = make_algebra(2, 8) + products = a.default_filtration_one_products + assert all(len(triple) == 3 for triple in products) + assert all(isinstance(name, str) for name, _, _ in products) + + +def test_coproduct_p2_and_odd_prime_error(): + a = make_algebra(2, 8) + assert a.coproduct(2, 0) == [(0, 0, 2, 0), (1, 0, 1, 0), (2, 0, 0, 0)] + + a3 = make_algebra(3, 32) + with pytest.raises(ValueError): + a3.coproduct(4, 0) + + +def test_beps_pn_and_ppart_table(): + a = make_algebra(2, 8) + assert a.beps_pn(0, 1) == (1, 0) + assert a.ppart_table(0) == [[]] + with pytest.raises(IndexError): + a.ppart_table(-1) diff --git a/ext_py/tests/test_modules.py b/ext_py/tests/test_modules.py new file mode 100644 index 0000000000..0a044a6788 --- /dev/null +++ b/ext_py/tests/test_modules.py @@ -0,0 +1,547 @@ +import pytest + +from ext import algebra, fp + + +# The C2 module: a generator x0 in degree 0 and x1 in degree 1 with Sq1 x0 = x1. +C2_JSON = { + "p": 2, + "type": "finite dimensional module", + "gens": {"x0": 0, "x1": 1}, + "actions": ["Sq1 x0 = x1"], +} + + +def milnor(p=2): + return algebra.SteenrodAlgebra.milnor(p) + + +def make_c2_fdmodule(): + """Build the C2 module by hand via an FDModuleBuilder and set its action. + + Returns the (pre-build) builder; read-only query methods stay available on + it for inspection during construction. + """ + m = algebra.FDModuleBuilder(milnor(2), "C2", [1, 1]) + # Sq1 is the algebra operation (degree 1, index 0). + m.set_action(1, 0, 0, 0, [1]) + return m + + +# --- FDModuleBuilder ------------------------------------------------------ + + +def test_fdmodule_basic_invariants(): + m = make_c2_fdmodule() + assert isinstance(m.prime, int) + assert m.prime == 2 + assert m.min_degree == 0 + assert m.dimension(0) == 1 + assert m.dimension(1) == 1 + assert m.dimension(2) == 0 + assert m.dimension(-1) == 0 + assert m.max_degree == 1 + assert m.total_dimension == 2 + + +def test_fdmodule_act_on_basis_known_value(): + m = make_c2_fdmodule() + # Sq1 . x0 = x1: result lands in degree 1, which has dimension 1. + res = fp.FpVector(2, m.dimension(1)) + m.act_on_basis(res, 1, 1, 0, 0, 0) + assert res[0] == 1 + assert sum(res) == 1 + + +def test_fdmodule_act_with_element_input(): + m = make_c2_fdmodule() + # act with an input vector equal to x0 (degree 0). + inp = fp.FpVector(2, m.dimension(0)) + inp[0] = 1 + res = fp.FpVector(2, m.dimension(1)) + m.act(res, 1, 1, 0, 0, inp) + assert res[0] == 1 + + +def test_fdmodule_act_by_element(): + m = make_c2_fdmodule() + # op = Sq1 as an algebra element in degree 1 (dimension 1). + op = fp.FpVector(2, 1) + op[0] = 1 + inp = fp.FpVector(2, m.dimension(0)) + inp[0] = 1 + res = fp.FpVector(2, m.dimension(1)) + m.act_by_element(res, 1, 1, op, 0, inp) + assert res[0] == 1 + + +def test_fdmodule_act_input_target_aliasing_raises_runtimeerror(): + # Sq1 . x0 = x1: input degree 0 (dim 1) and output degree 1 (dim 1) both + # have length 1, so a single length-1 vector is shape-valid as both the + # input and the mutable result. Aliasing them must raise RuntimeError + # (borrow conflict), NOT the generic ValueError. + m = make_c2_fdmodule() + v = fp.FpVector(2, 1) + v[0] = 1 + with pytest.raises(RuntimeError): + m.act(v, 1, 1, 0, 0, v) + with pytest.raises(Exception) as excinfo: + m.act(v, 1, 1, 0, 0, v) + assert not isinstance(excinfo.value, ValueError) + + # act_by_element with the result aliased as the input. + op = fp.FpVector(2, 1) + op[0] = 1 + with pytest.raises(RuntimeError): + m.act_by_element(v, 1, 1, op, 0, v) + + +def test_fdmodule_act_wrong_type_is_valueerror(): + m = make_c2_fdmodule() + inp = fp.FpVector(2, m.dimension(0)) + inp[0] = 1 + res = fp.FpVector(2, m.dimension(1)) + with pytest.raises(ValueError): + m.act(123, 1, 1, 0, 0, inp) + with pytest.raises(ValueError): + m.act(res, 1, 1, 0, 0, 123) + + +def test_fdmodule_act_distinct_objects_regression(): + m = make_c2_fdmodule() + inp = fp.FpVector(2, m.dimension(0)) + inp[0] = 1 + res = fp.FpVector(2, m.dimension(1)) + m.act(res, 1, 1, 0, 0, inp) + assert res[0] == 1 + + +def test_fdmodule_action_getter_and_string(): + m = make_c2_fdmodule() + assert list(m.action(1, 0, 0, 0)) == [1] + # FDModuleBuilder auto-names basis elements `x{degree}_{index}`. + assert m.basis_element_to_string(0, 0) == "x0_0" + assert m.string_to_basis_element("x1_0") == (1, 0) + assert m.string_to_basis_element("nope") is None + + +def test_fdmodule_set_action_invalid_raises(): + m = algebra.FDModuleBuilder(milnor(2), "C2", [1, 1]) + # Output degree 2 is empty -> length mismatch raises (not panic). + with pytest.raises((ValueError, IndexError)): + m.set_action(2, 0, 0, 0, [1]) + # Out-of-range module index raises. + with pytest.raises((ValueError, IndexError)): + m.set_action(1, 0, 0, 9, [1]) + + +def test_fdmodule_act_out_of_range_raises(): + m = make_c2_fdmodule() + res = fp.FpVector(2, m.dimension(1)) + # Module index out of range. + with pytest.raises((ValueError, IndexError)): + m.act_on_basis(res, 1, 1, 0, 0, 9) + # Negative operation degree. + with pytest.raises((ValueError, IndexError)): + m.act_on_basis(res, 1, -1, 0, 0, 0) + + +def test_fdmodule_build(): + m = make_c2_fdmodule() + sm = m.build() + assert isinstance(sm, algebra.SteenrodModule) + assert sm.prime == m.prime + assert sm.dimension(0) == m.dimension(0) + assert sm.dimension(1) == m.dimension(1) + # The algebra accessor returns a SteenrodAlgebra at the same prime. + assert sm.algebra.prime == 2 + + +# --- FDModuleBuilder algebra-argument acceptance -------------------------- + + +@pytest.mark.parametrize( + "make_alg, expected_type", + [ + # The union SteenrodAlgebra, in both bases. + (lambda: algebra.SteenrodAlgebra.adem(2), algebra.AlgebraType.Adem), + (lambda: algebra.SteenrodAlgebra.milnor(2), algebra.AlgebraType.Milnor), + # The concrete variant pyclasses: these used to be rejected (the + # constructor only accepted SteenrodAlgebra), and are reconstructed into + # the matching SteenrodAlgebra variant. + (lambda: algebra.AdemAlgebra(2, False), algebra.AlgebraType.Adem), + (lambda: algebra.MilnorAlgebra(2, False), algebra.AlgebraType.Milnor), + ], +) +def test_fdmodule_accepts_all_algebra_types(make_alg, expected_type): + alg = make_alg() + m = algebra.FDModuleBuilder(alg, "C2", [1, 1]) + # Building C2 with Sq1 x0 = x1 must work regardless of how the algebra was + # supplied: the reconstructed algebra has identical prime/basis indexing. + m.set_action(1, 0, 0, 0, [1]) + assert m.prime == 2 + # The builder's algebra is the matching SteenrodAlgebra variant. + assert m.algebra.algebra_type() == expected_type + sm = m.build() + res = fp.FpVector(2, sm.dimension(1)) + sm.act_on_basis(res, 1, 1, 0, 0, 0) + assert res[0] == 1 + + +def test_fdmodule_accepts_milnor_algebra_with_profile(): + # A profile-restricted MilnorAlgebra is accepted and reconstructed as Milnor. + alg = algebra.MilnorAlgebra(2, False) + m = algebra.FDModuleBuilder(alg, "", [1, 1]) + assert m.algebra.algebra_type() == algebra.AlgebraType.Milnor + + +def test_fdmodule_rejects_non_algebra_argument(): + with pytest.raises(TypeError): + algebra.FDModuleBuilder("not an algebra", "", [1]) + with pytest.raises(TypeError): + algebra.FDModuleBuilder(2, "", [1]) + + +def test_fdmodule_to_json(): + # to_json mirrors upstream FiniteDimensionalModule::to_json: name/type/gens/ + # actions, and NOT the prime p (the caller adds that separately). + m = algebra.FDModuleBuilder(milnor(2), "C2", [1, 1]) + m.set_action(1, 0, 0, 0, [1]) + j = m.to_json() + assert j["name"] == "C2" + assert j["type"] == "finite dimensional module" + assert j["gens"] == {"x0_0": 0, "x1_0": 1} + assert j["actions"] == ["Sq1 x0_0 = x1_0"] + assert "p" not in j + # Still available after build() (it is a read-only query). + m.build() + assert m.to_json()["name"] == "C2" + + +# --- from_spec ------------------------------------------------------------ + + +def test_from_spec_c2(): + sm = algebra.SteenrodModule.from_spec(C2_JSON, milnor(2)) + assert isinstance(sm, algebra.SteenrodModule) + assert sm.prime == 2 + assert sm.min_degree == 0 + assert sm.dimension(0) == 1 + assert sm.dimension(1) == 1 + assert sm.dimension(2) == 0 + assert sm.basis_element_to_string(0, 0) == "x0" + + +def test_from_spec_action_known_value(): + sm = algebra.SteenrodModule.from_spec(C2_JSON, milnor(2)) + # Sq1 . x0 = x1. + res = fp.FpVector(2, sm.dimension(1)) + sm.act_on_basis(res, 1, 1, 0, 0, 0) + assert res[0] == 1 + + +def test_from_spec_bad_spec_raises(): + with pytest.raises(ValueError): + algebra.SteenrodModule.from_spec({"p": 2}, milnor(2)) # missing type + with pytest.raises(ValueError): + algebra.SteenrodModule.from_spec({"p": 2, "type": "bogus"}, milnor(2)) + + +@pytest.mark.parametrize("name", ["milnor", "adem"]) +def test_from_spec_string_algebra(name): + sm = algebra.SteenrodModule.from_spec(C2_JSON, name) + assert isinstance(sm, algebra.SteenrodModule) + assert sm.prime == 2 + assert sm.dimension(0) == 1 + assert sm.dimension(1) == 1 + + +def test_from_spec_string_algebra_case_insensitive(): + sm = algebra.SteenrodModule.from_spec(C2_JSON, "Milnor") + assert isinstance(sm, algebra.SteenrodModule) + assert sm.prime == 2 + assert sm.dimension(0) == 1 + assert sm.dimension(1) == 1 + + +def test_from_spec_string_algebra_unknown_raises(): + with pytest.raises(ValueError): + algebra.SteenrodModule.from_spec(C2_JSON, "foo") + + +def test_from_spec_string_algebra_missing_prime_raises(): + spec = {k: v for k, v in C2_JSON.items() if k != "p"} + with pytest.raises(ValueError): + algebra.SteenrodModule.from_spec(spec, "milnor") + + +# --- FreeModule ----------------------------------------------------------- + + +def make_free(gen_degrees=(0,)): + # `FreeModule` is query-only (no Python mutators). Obtain a populated + # FreeModule via the remaining path: build an FPModule whose generators + # live in the requested degrees, then take its `generators()` FreeModule. + b = algebra.FPModuleBuilder(milnor(2), "F", 0) + for d in gen_degrees: + b.add_generators(d, [f"x{d}"]) + m = b.build().generators() + m.compute_basis(6) + return m + + +def test_freemodule_basic_invariants(): + m = make_free() + assert m.prime == 2 + assert m.min_degree == 0 + assert m.number_of_gens_in_degree(0) == 1 + # dimension(t) tracks the algebra dimension for a single degree-0 generator. + assert m.dimension(0) == 1 + assert m.dimension(1) == 1 + assert m.dimension(2) == 1 + assert m.dimension(3) == 2 + + +def test_freemodule_index_to_op_gen(): + m = make_free() + opgen = m.index_to_op_gen(1, 0) + assert isinstance(opgen, algebra.OperationGeneratorPair) + assert opgen.generator_degree == 0 + assert opgen.generator_index == 0 + assert opgen.operation_degree == 1 + assert opgen.operation_index == 0 + + +def test_freemodule_index_to_op_gen_out_of_range(): + m = make_free() + with pytest.raises((ValueError, IndexError)): + m.index_to_op_gen(1, 9) + + +def test_freemodule_operation_generator_to_index(): + m = make_free() + # Sq1 (op degree 1, index 0) applied to gen (0,0) lands at index 0 in deg 1. + idx = m.operation_generator_to_index(1, 0, 0, 0) + assert idx == 0 + + +def test_freemodule_offsets_and_iter_gens(): + m = make_free() + assert m.generator_offset(1, 0, 0) == 0 + assert m.iter_gens(3) == [(0, 0)] + names = m.gen_names + assert len(names) >= 1 + + +def test_freemodule_act_on_basis(): + m = make_free() + # Sq1 . (gen in degree 0) = the Sq1-generator basis element in degree 1. + res = fp.FpVector(2, m.dimension(1)) + m.act_on_basis(res, 1, 1, 0, 0, 0) + assert res[0] == 1 + + +def test_freemodule_into_steenrod_module(): + m = make_free() + sm = m.into_steenrod_module() + assert isinstance(sm, algebra.SteenrodModule) + assert sm.prime == m.prime + assert sm.dimension(1) == m.dimension(1) + + +def test_freemodule_total_dimension_unbounded_raises(): + m = make_free() + # FreeModule is unbounded above -> total_dimension raises, never panics. + with pytest.raises(ValueError): + m.total_dimension + + +def test_freemodule_number_of_gens_in_degree_above_range_returns_zero(): + # Fresh module: no generators added anywhere yet -> 0, never panics. + fresh = algebra.FreeModule(milnor(2), "F", 0) + assert fresh.number_of_gens_in_degree(0) == 0 + assert fresh.number_of_gens_in_degree(5) == 0 + assert fresh.number_of_gens_in_degree(-1) == 0 + # After adding degree-0 generators, degrees above the populated range + # still read 0 rather than panicking. + m = make_free() + assert m.number_of_gens_in_degree(0) == 1 + assert m.number_of_gens_in_degree(1) == 0 + assert m.number_of_gens_in_degree(99) == 0 + + +def test_freemodule_generator_offset_out_of_range_raises(): + m = make_free() + # gen_degree above the populated range raises IndexError, not panic. + with pytest.raises(IndexError): + m.generator_offset(4, 3, 0) + # gen_index out of range in a populated degree raises IndexError. + with pytest.raises(IndexError): + m.generator_offset(1, 0, 5) + + +def test_freemodule_operation_generator_to_index_out_of_range_raises(): + m = make_free() + with pytest.raises(IndexError): + m.operation_generator_to_index(0, 0, 3, 0) + with pytest.raises(IndexError): + m.operation_generator_to_index(0, 0, 0, 5) + + +def test_freemodule_has_no_python_mutators(): + # FreeModule is query-only: the consecutiveness guard that used to live on + # FreeModule.add_generators (former test_freemodule_add_generators_ + # consecutive_only) now lives on FPModuleBuilder.add_generators, tested in + # test_fp_module.py. Confirm the mutators are gone here. + m = algebra.FreeModule(milnor(2), "F", 0) + assert not hasattr(m, "add_generators") + assert not hasattr(m, "extend_by_zero") + + +def test_freemodule_iter_gens_below_min_degree_empty(): + m = make_free(gen_degrees=(0, 1)) + # Below min_degree must be empty, not "all generators". + assert m.iter_gens(-1) == [] + assert len(m.iter_gens(1)) == 2 + + +def test_fdmodule_set_action_out_of_range_output_degree_raises(): + m = algebra.FDModuleBuilder(milnor(2), "C2", [1, 1]) + # op_degree 5 lands in output degree 5 (above max_degree 1); an empty + # output used to slip past the length check and panic. + with pytest.raises(ValueError): + m.set_action(5, 0, 0, 0, []) + # Valid set_action still works. + m.set_action(1, 0, 0, 0, [1]) + assert list(m.action(1, 0, 0, 0)) == [1] + + +def test_fdmodule_build_shares_state_and_locks_mutation(): + m = algebra.FDModuleBuilder(milnor(2), "C2", [1, 1]) + # Pre-build mutation works and is reflected in the built module (shared + # state via Arc). + m.set_action(1, 0, 0, 0, [1]) + sm = m.build() + res = fp.FpVector(2, sm.dimension(1)) + sm.act_on_basis(res, 1, 1, 0, 0, 0) + assert res[0] == 1 + # After build() the builder is locked: the `built` flag is checked first in + # every mutator, so they raise RuntimeError even with valid arguments. + with pytest.raises(RuntimeError): + m.set_action(1, 0, 0, 0, [0]) + with pytest.raises(RuntimeError): + m.add_generator(0, "x") + with pytest.raises(RuntimeError): + m.extend_actions(0, 1) + with pytest.raises(RuntimeError): + m.set_basis_element_name(0, 0, "y") + + +def test_fdmodule_build_lock_is_checked_before_validation(): + # The build-lock (the `built` flag) is checked FIRST, before any argument + # validation, so even arguments that would otherwise raise ValueError/ + # IndexError raise a clean RuntimeError after build(). + m = algebra.FDModuleBuilder(milnor(2), "C2", [1, 1]) + m.set_action(1, 0, 0, 0, [1]) + m.build() + # op_degree 99 lands in an empty output degree (would be ValueError before + # build); out-of-range indices (would be IndexError). Both must surface as + # RuntimeError because the build-lock fires first. + with pytest.raises(RuntimeError): + m.set_action(99, 0, 0, 0, []) + with pytest.raises(RuntimeError): + m.set_action(1, 0, 0, 9, [1]) + with pytest.raises(RuntimeError): + m.set_basis_element_name(0, 9, "y") + + +def test_fdmodule_build_callable_multiple_times(): + m = algebra.FDModuleBuilder(milnor(2), "C2", [1, 1]) + m.set_action(1, 0, 0, 0, [1]) + sm1 = m.build() + sm2 = m.build() + # Both handles share the same finished module. + assert isinstance(sm1, algebra.SteenrodModule) + assert isinstance(sm2, algebra.SteenrodModule) + assert sm1.dimension(1) == sm2.dimension(1) == 1 + # The builder stays locked regardless of how many handles are alive or + # dropped: the `built` flag is the primary gate. + import gc + + del sm1 + del sm2 + gc.collect() + with pytest.raises(RuntimeError): + m.set_action(1, 0, 0, 0, [0]) + + +def test_fdmodule_build_result_usable_by_consumers(): + # A TensorModule built from FDModuleBuilder(...).build() still works (the + # consumer that previously used into_steenrod_module()). + m = algebra.FDModuleBuilder(milnor(2), "C2", [1, 1]) + m.set_action(1, 0, 0, 0, [1]) + sm = m.build() + t = algebra.TensorModule(sm, sm) + t.compute_basis(2) + assert t.dimension(0) == 1 + + +def test_fdmodulebuilder_present_fdmodule_absent(): + names = dir(algebra) + assert "FDModuleBuilder" in names + assert "FDModule" not in names + + +# --- invalid construction ------------------------------------------------- + + +def test_module_from_json_prime_mismatch(): + # Passing a p=3 algebra to a p=2 spec must error, not panic. + with pytest.raises((ValueError, RuntimeError)): + algebra.SteenrodModule.from_spec(C2_JSON, milnor(3)) + + +# --- FDModuleBuilder.from_tensor_module ----------------------------------- + + +def make_c2_tensor_c2(): + """Build TensorModule(C2, C2) from two C2 modules over the same algebra. + + TensorModule requires both factors to share the *same* algebra object + (checked via Arc::ptr_eq), so both are built from a single `alg`. + """ + alg = algebra.SteenrodAlgebra.adem(2) + left = algebra.SteenrodModule.from_spec(C2_JSON, alg) + right = algebra.SteenrodModule.from_spec(C2_JSON, alg) + return algebra.TensorModule(left, right) + + +def test_from_tensor_module_to_json_sensible(): + fd = algebra.FDModuleBuilder.from_tensor_module(make_c2_tensor_c2()) + j = fd.to_json() + assert j["type"] == "finite dimensional module" + # C2 (x) C2 has dimensions 1, 2, 1 in degrees 0, 1, 2. + assert fd.min_degree == 0 + assert [fd.dimension(t) for t in range(3)] == [1, 2, 1] + # gens map names to degrees; there should be 4 of them. + assert isinstance(j["gens"], dict) + assert len(j["gens"]) == 4 + assert "p" not in j + + +def test_from_tensor_module_name_roundtrips(): + fd = algebra.FDModuleBuilder.from_tensor_module(make_c2_tensor_c2()) + # name is readable and settable. + fd.name = "" + assert fd.name == "" + assert "name" not in fd.to_json() # empty name is omitted from json + fd.name = "C2 (x) C2" + assert fd.name == "C2 (x) C2" + assert fd.to_json()["name"] == "C2 (x) C2" + + +def test_from_tensor_module_name_setter_locked_after_build(): + fd = algebra.FDModuleBuilder.from_tensor_module(make_c2_tensor_c2()) + fd.build() + with pytest.raises(RuntimeError): + fd.name = "nope" diff --git a/ext_py/tests/test_quasi_inverse.py b/ext_py/tests/test_quasi_inverse.py new file mode 100644 index 0000000000..cb59af4e8b --- /dev/null +++ b/ext_py/tests/test_quasi_inverse.py @@ -0,0 +1,257 @@ +import pytest + +from ext import fp + + +def make_qi(): + # Mirrors the Rust `test_stream_qi` example at p = 2. + preimage = fp.Matrix.from_vec( + 2, + [ + [1, 0, 1, 1], + [1, 1, 0, 0], + [0, 1, 0, 1], + [1, 1, 1, 0], + ], + ) + return fp.QuasiInverse([0, -1, 1, -1, 2, 3], preimage) + + +def test_import_and_construction(): + qi = make_qi() + assert qi.prime == 2 + assert qi.image_dimension == 4 + assert qi.source_dimension == 4 + assert qi.target_dimension == 6 + assert "QuasiInverse(2" in repr(qi) + + +def test_pivots_and_preimage(): + qi = make_qi() + assert qi.pivots == [0, -1, 1, -1, 2, 3] + assert qi.preimage.to_vec() == [ + [1, 0, 1, 1], + [1, 1, 0, 0], + [0, 1, 0, 1], + [1, 1, 1, 0], + ] + + +def test_apply_known_example(): + qi = make_qi() + v = fp.FpVector.from_slice(2, [1, 1, 0, 0, 1, 0]) + out = fp.FpVector(2, 4) + qi.apply(out, 1, v) + assert list(out) == [1, 1, 1, 0] + + +def test_apply_accepts_slices(): + qi = make_qi() + v = fp.FpVector.from_slice(2, [1, 1, 0, 0, 1, 0]) + out = fp.FpVector(2, 4) + qi.apply(out.slice_mut(0, 4), 1, v.slice(0, 6)) + assert list(out) == [1, 1, 1, 0] + + +def test_apply_dimension_and_prime_mismatch(): + qi = make_qi() + out = fp.FpVector(2, 4) + # Wrong input length (target_dimension is 6). + bad_input = fp.FpVector(2, 5) + with pytest.raises(ValueError): + qi.apply(out, 1, bad_input) + # Wrong output length (source_dimension is 4). + good_input = fp.FpVector(2, 6) + with pytest.raises(ValueError): + qi.apply(fp.FpVector(2, 3), 1, good_input) + # Prime mismatch on input. + with pytest.raises(ValueError): + qi.apply(out, 1, fp.FpVector(3, 6)) + + +def test_apply_wrong_type(): + qi = make_qi() + with pytest.raises(ValueError): + qi.apply(123, 1, fp.FpVector(2, 6)) + + +def make_identity_qi(): + # Square identity preimage at p=3, so source==target==3 and a single + # length-3 vector is a valid argument both as input and as output target. + preimage = fp.Matrix.from_vec(3, [[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + return fp.QuasiInverse([0, 1, 2], preimage) + + +def test_apply_input_target_aliasing_raises_runtimeerror(): + # Passing the SAME bare FpVector as both the mutable target and the input + # is an aliasing conflict: it must raise RuntimeError (borrow conflict), + # NOT the generic ValueError reserved for wrong-type arguments. + qi = make_identity_qi() + v = fp.FpVector.from_slice(3, [1, 2, 1]) + with pytest.raises(RuntimeError): + qi.apply(v, 1, v) + with pytest.raises(Exception) as excinfo: + qi.apply(v, 1, v) + assert not isinstance(excinfo.value, ValueError) + + +def test_apply_wrong_type_is_valueerror_not_runtimeerror(): + # A genuine wrong-type argument still raises ValueError (not RuntimeError). + qi = make_identity_qi() + good = fp.FpVector(3, 3) + with pytest.raises(ValueError): + qi.apply(123, 1, good) + with pytest.raises(ValueError): + qi.apply(fp.Matrix.from_vec(3, [[1, 0, 0]]), 1, good) + + +def test_apply_distinct_objects_regression(): + # The normal distinct-objects call still produces the known value. + qi = make_identity_qi() + v = fp.FpVector.from_slice(3, [1, 2, 1]) + out = fp.FpVector(3, 3) + qi.apply(out, 2, v) + assert list(out) == [2, 1, 2] + + +def test_bytes_roundtrip(): + qi = make_qi() + data = qi.to_bytes() + assert isinstance(data, bytes) + restored = fp.QuasiInverse.from_bytes(2, data) + assert restored.source_dimension == qi.source_dimension + assert restored.target_dimension == qi.target_dimension + assert restored.image_dimension == qi.image_dimension + + v = fp.FpVector.from_slice(2, [1, 1, 0, 0, 1, 0]) + out = fp.FpVector(2, 4) + restored.apply(out, 1, v) + assert list(out) == [1, 1, 1, 0] + + +def test_from_bytes_malformed(): + with pytest.raises(RuntimeError): + fp.QuasiInverse.from_bytes(2, b"\x00\x01\x02") + + +def test_compute_quasi_inverse_from_matrix(): + # Example from the Rust `compute_quasi_inverse` doc at p = 3. + rows = [ + [1, 2, 1, 1, 0], + [1, 0, 2, 1, 1], + [2, 2, 0, 2, 1], + ] + padded_cols, m = fp.Matrix.augmented_from_vec(3, rows) + m.row_reduce() + qi = m.compute_quasi_inverse(len(rows[0]), padded_cols) + assert qi.prime == 3 + assert qi.source_dimension == 3 + assert qi.preimage.to_vec() == [[0, 1, 0], [0, 2, 2]] + + +def test_compute_quasi_inverse_out_of_range(): + _, m = fp.Matrix.augmented_from_vec(3, [[1, 0, 1], [0, 1, 1]]) + # Row reduce first so we exercise the column-range check rather than the + # not-row-reduced guard (which would otherwise fire first). + m.row_reduce() + with pytest.raises(IndexError): + m.compute_quasi_inverse(2, 999) + + +def test_compute_quasi_inverse_requires_row_reduce(): + # Without row_reduce the matrix has uninitialized (empty) pivots, which + # upstream would slice out of bounds and panic. The guard turns that into + # a clean ValueError. + padded_cols, m = fp.Matrix.augmented_from_vec(3, [[1, 0, 1], [0, 1, 1]]) + with pytest.raises(ValueError): + m.compute_quasi_inverse(2, padded_cols) + + +def test_inconsistent_image_raises(): + # preimage has 4 rows; supply an image with 5 non-negative pivots. + preimage = fp.Matrix.from_vec( + 2, + [ + [1, 0, 1, 1], + [1, 1, 0, 0], + [0, 1, 0, 1], + [1, 1, 1, 0], + ], + ) + with pytest.raises(ValueError): + fp.QuasiInverse([0, 1, 2, 3, 0], preimage) + + +def test_pivot_out_of_range_raises(): + # preimage has 4 rows; a non-negative pivot of 4 is an invalid row index. + preimage = fp.Matrix.from_vec( + 2, + [ + [1, 0, 1, 1], + [1, 1, 0, 0], + [0, 1, 0, 1], + [1, 1, 1, 0], + ], + ) + with pytest.raises(ValueError): + fp.QuasiInverse([0, 1, 2, 4], preimage) + + +def test_apply_with_coeff_odd_prime(): + # At p = 3, identity-style preimage so the result is `coeff * input`. + preimage = fp.Matrix.from_vec( + 3, + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ], + ) + qi = fp.QuasiInverse([0, 1, 2], preimage) + v = fp.FpVector.from_slice(3, [1, 2, 1]) + + out = fp.FpVector(3, 3) + qi.apply(out, 2, v) + # 2 * [1, 2, 1] mod 3 = [2, 1, 2] + assert list(out) == [2, 1, 2] + + # A large coeff must reduce mod p and not overflow/panic. + out2 = fp.FpVector(3, 3) + qi.apply(out2, 0xFFFF_FFFF, v) + # 0xFFFFFFFF mod 3 == 0, so result is the zero vector. + assert list(out2) == [0, 0, 0] + + out3 = fp.FpVector(3, 3) + qi.apply(out3, 2 + 3 * 10, v) # coeff = 32 ≡ 2 mod 3 + assert list(out3) == [2, 1, 2] + + +def test_none_image_construction_and_roundtrip(): + preimage = fp.Matrix.from_vec( + 2, + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ], + ) + qi = fp.QuasiInverse(None, preimage) + assert qi.pivots is None + # With a None (identity) image, target_dimension == image_dimension. + assert qi.image_dimension == 3 + assert qi.source_dimension == 3 + assert qi.target_dimension == 3 + + v = fp.FpVector.from_slice(2, [1, 0, 1]) + out = fp.FpVector(2, 3) + qi.apply(out, 1, v) + assert list(out) == [1, 0, 1] + + # A None image is serialized as an explicit identity pivot list and so + # round-trips to Some([0, 1, 2, ...]) rather than None. + restored = fp.QuasiInverse.from_bytes(2, qi.to_bytes()) + assert restored.pivots == [0, 1, 2] + assert restored.target_dimension == 3 + out2 = fp.FpVector(2, 3) + restored.apply(out2, 1, v) + assert list(out2) == [1, 0, 1] diff --git a/ext_py/tests/test_quotient_hom_modules.py b/ext_py/tests/test_quotient_hom_modules.py new file mode 100644 index 0000000000..52723222ca --- /dev/null +++ b/ext_py/tests/test_quotient_hom_modules.py @@ -0,0 +1,351 @@ +import pytest + +from ext import algebra, fp + + +def milnor(p=2): + return algebra.SteenrodAlgebra.milnor(p) + + +def make_c2(alg): + """Build a C2 SteenrodModule over the given algebra via an FDModuleBuilder.""" + m = algebra.FDModuleBuilder(alg, "C2", [1, 1]) + m.set_action(1, 0, 0, 0, [1]) # Sq1 x0 = x1 + return m.build() + + +def free_one_gen(alg): + """A FreeModule with a single generator in degree 0. + + `FreeModule` is query-only (no Python mutators), so build it through the + remaining path: an FPModule's `generators()` FreeModule. + """ + b = algebra.FPModuleBuilder(alg, "F", 0) + b.add_generators(0, ["x0"]) + f = b.build().generators() + f.compute_basis(4) + return f + + +# --- QuotientModule ------------------------------------------------------- + + +def test_quotient_module_basic_dimensions(): + alg = milnor(2) + q = algebra.QuotientModule(make_c2(alg), 1) + q.compute_basis(2) + assert isinstance(q.prime, int) + assert q.prime == 2 + assert q.min_degree == 0 + assert q.truncation == 1 + # Nothing quotiented yet: same dims as C2 ([1, 1]). + assert q.dimension(0) == 1 + assert q.dimension(1) == 1 + assert q.dimension(2) == 0 + assert q.max_degree == 1 + assert q.total_dimension == 2 + + +def test_quotient_module_truncation_zeroes_above(): + alg = milnor(2) + # Truncate C2 at degree 0: degree 1 is quotiented away entirely. + q = algebra.QuotientModule(make_c2(alg), 0) + q.compute_basis(2) + assert q.dimension(0) == 1 + assert q.dimension(1) == 0 + assert q.max_degree == 0 + assert q.total_dimension == 1 + + +def test_quotient_module_quotient_basis_elements(): + alg = milnor(2) + q = algebra.QuotientModule(make_c2(alg), 1) + q.compute_basis(2) + # Quotient out x1 (the unique basis element in degree 1). + q.quotient_basis_elements(1, [0]) + assert q.dimension(1) == 0 + assert q.dimension(0) == 1 + assert q.total_dimension == 1 + + +def test_quotient_module_quotient_vector_and_reduce(): + alg = milnor(2) + q = algebra.QuotientModule(make_c2(alg), 1) + q.compute_basis(2) + # Quotient out x1 by supplying its coefficient vector in degree 1. + elt = fp.FpVector(2, q.dimension(1)) # original dim in degree 1 is 1 + elt[0] = 1 + q.quotient(1, elt) + assert q.dimension(1) == 0 + # Reducing [1] in degree 1 now projects onto the (empty) complement -> 0. + v = fp.FpVector(2, 1) + v[0] = 1 + q.reduce(1, v) + assert v[0] == 0 + + +def test_quotient_module_reduce_above_truncation_zeroes(): + alg = milnor(2) + q = algebra.QuotientModule(make_c2(alg), 0) + q.compute_basis(2) + # Degree 1 is above the truncation: reduce zeroes any vector. + v = fp.FpVector(2, 1) + v[0] = 1 + q.reduce(1, v) + assert v[0] == 0 + + +def test_quotient_module_old_basis_to_new(): + alg = milnor(2) + q = algebra.QuotientModule(make_c2(alg), 1) + q.compute_basis(2) + q.quotient_basis_elements(1, [0]) + # In degree 0, x0 survives, so the original [1] maps to the new [1]. + new = fp.FpVector(2, q.dimension(0)) + old = fp.FpVector(2, 1) + old[0] = 1 + q.old_basis_to_new(0, new, old) + assert new[0] == 1 + + +def test_quotient_module_old_basis_to_new_aliasing_raises_runtimeerror(): + # In degree 0 the original and quotient dimensions are both 1, so a single + # length-1 vector is shape-valid as both `old` (input) and `new` (target). + # Aliasing them must raise RuntimeError (borrow conflict), NOT ValueError. + alg = milnor(2) + q = algebra.QuotientModule(make_c2(alg), 1) + q.compute_basis(2) + q.quotient_basis_elements(1, [0]) + v = fp.FpVector(2, q.dimension(0)) + v[0] = 1 + with pytest.raises(RuntimeError): + q.old_basis_to_new(0, v, v) + with pytest.raises(Exception) as excinfo: + q.old_basis_to_new(0, v, v) + assert not isinstance(excinfo.value, ValueError) + + +def test_quotient_module_old_basis_to_new_wrong_type_is_valueerror(): + alg = milnor(2) + q = algebra.QuotientModule(make_c2(alg), 1) + q.compute_basis(2) + q.quotient_basis_elements(1, [0]) + new = fp.FpVector(2, q.dimension(0)) + old = fp.FpVector(2, 1) + old[0] = 1 + with pytest.raises(ValueError): + q.old_basis_to_new(0, 123, old) + with pytest.raises(ValueError): + q.old_basis_to_new(0, new, 123) + + +def test_quotient_module_action_is_reduced(): + alg = milnor(2) + q = algebra.QuotientModule(make_c2(alg), 1) + q.compute_basis(2) + # Before quotienting: Sq1 . x0 = x1. + res = fp.FpVector(2, q.dimension(1)) + q.act_on_basis(res, 1, 1, 0, 0, 0) + assert res[0] == 1 + # After quotienting out x1, Sq1 . x0 reduces to 0 in the quotient. + q.quotient_basis_elements(1, [0]) + assert q.dimension(1) == 0 + res = fp.FpVector(2, q.dimension(1)) # length 0 + q.act_on_basis(res, 1, 1, 0, 0, 0) + assert sum(res) == 0 + + +def test_quotient_module_out_of_range_raises(): + alg = milnor(2) + q = algebra.QuotientModule(make_c2(alg), 1) + q.compute_basis(2) + # Degree outside [min_degree, truncation] raises for the subspace setters. + with pytest.raises(IndexError): + q.quotient_basis_elements(5, [0]) + with pytest.raises(IndexError): + q.quotient_all(-1) + # Out-of-range basis index raises. + with pytest.raises(IndexError): + q.quotient_basis_elements(0, [9]) + # reduce below min_degree raises. + v = fp.FpVector(2, 1) + with pytest.raises(IndexError): + q.reduce(-1, v) + + +def test_quotient_module_construct_below_min_degree_raises(): + alg = milnor(2) + with pytest.raises(ValueError): + algebra.QuotientModule(make_c2(alg), -5) + + +def test_quotient_module_free_inner_uncomputed_algebra_no_panic(): + # A FreeModule inner whose Steenrod algebra has NOT been computed: a + # truncation above the algebra's computed degree used to make upstream + # `module.compute_basis(truncation)` index past the empty algebra basis + # and panic. `QuotientModule.new` now pre-extends the inner module. + alg = milnor(2) + # One generator in degree 0; the algebra is NOT computed up to truncation. + b = algebra.FPModuleBuilder(alg, "F", 0) + b.add_generators(0, ["x0"]) + f = b.build().generators() + q = algebra.QuotientModule(f.into_steenrod_module(), 20) + assert q.prime == 2 + assert q.truncation == 20 + assert q.min_degree == 0 + # F over A: dim in degree t equals the algebra dimension in t. + assert q.dimension(0) == 1 # 1 (unit) + assert q.dimension(1) == 1 # Sq1 + assert q.dimension(2) == 1 # Sq2 + assert q.dimension(3) == 2 # Sq3, Sq2 Sq1 + + +def test_quotient_module_mutation_works_again_after_box_dropped(): + # The mutation lock is only active while a boxed SteenrodModule from this + # module is alive; dropping it restores unique ownership and mutation. + alg = milnor(2) + q = algebra.QuotientModule(make_c2(alg), 1) + q.compute_basis(2) + boxed = q.into_steenrod_module() + with pytest.raises(RuntimeError): + q.quotient_basis_elements(1, [0]) + # Drop the only outstanding box; the Arc is unique again. + del boxed + q.quotient_basis_elements(1, [0]) + assert q.dimension(1) == 0 + + +def test_quotient_module_into_steenrod_module_roundtrip_and_locks(): + alg = milnor(2) + q = algebra.QuotientModule(make_c2(alg), 1) + q.compute_basis(2) + boxed = q.into_steenrod_module() + assert boxed.prime == q.prime + assert boxed.dimension(0) == q.dimension(0) + assert boxed.dimension(1) == q.dimension(1) + # After boxing the Arc is shared, so mutation now raises RuntimeError. + with pytest.raises(RuntimeError): + q.quotient_basis_elements(0, [0]) + + +def test_quotient_module_out_of_range_dimension_no_panic(): + alg = milnor(2) + q = algebra.QuotientModule(make_c2(alg), 1) + assert q.dimension(-5) == 0 + assert q.dimension(100) == 0 + with pytest.raises(IndexError): + q.basis_element_to_string(-1, 0) + + +# --- HomModule ------------------------------------------------------------ + + +def test_hom_module_dimensions(): + alg = milnor(2) + source = free_one_gen(alg) + target = make_c2(alg) + hom = algebra.HomModule(source, target) + assert isinstance(hom.prime, int) + assert hom.prime == 2 + # min_degree = source.min_degree - target.max_degree = 0 - 1. + assert hom.min_degree == -1 + hom.compute_basis(0) + # Hom(F, C2) graded opposite: dim in degree d = target.dim(-d). + assert hom.dimension(-1) == 1 # target.dim(1) + assert hom.dimension(0) == 1 # target.dim(0) + assert hom.dimension(1) == 0 # target.dim(-1) + + +def test_hom_module_source_target_roundtrip(): + alg = milnor(2) + source = free_one_gen(alg) + target = make_c2(alg) + hom = algebra.HomModule(source, target) + s = hom.source + assert s.min_degree == 0 + assert s.number_of_gens_in_degree(0) == 1 + t = hom.target + assert t.dimension(0) == 1 + assert t.dimension(1) == 1 + + +def test_hom_module_scalar_action(): + alg = milnor(2) + hom = algebra.HomModule(free_one_gen(alg), make_c2(alg)) + hom.compute_basis(0) + # The field unit (op degree 0, index 0) fixes a basis element. + res = fp.FpVector(2, hom.dimension(-1)) + hom.act_on_basis(res, 1, 0, 0, -1, 0) + assert res[0] == 1 + + +def test_hom_module_basis_element_to_string(): + alg = milnor(2) + hom = algebra.HomModule(free_one_gen(alg), make_c2(alg)) + hom.compute_basis(0) + assert isinstance(hom.basis_element_to_string(-1, 0), str) + assert isinstance(hom.is_unit, bool) + + +def test_hom_module_prime_mismatch_raises(): + source = free_one_gen(milnor(2)) + target = make_c2(milnor(3)) + with pytest.raises(ValueError): + algebra.HomModule(source, target) + + +def test_hom_module_distinct_algebra_raises(): + # Same prime but two distinct algebra objects are incompatible. + source = free_one_gen(milnor(2)) + target = make_c2(milnor(2)) + with pytest.raises(ValueError): + algebra.HomModule(source, target) + + +def test_hom_module_unbounded_target_raises(): + alg = milnor(2) + source = free_one_gen(alg) + # A FreeModule is unbounded above; Hom requires a bounded target. + unbounded = free_one_gen(alg).into_steenrod_module() + with pytest.raises(ValueError): + algebra.HomModule(source, unbounded) + + +def test_hom_module_out_of_range_no_panic(): + alg = milnor(2) + hom = algebra.HomModule(free_one_gen(alg), make_c2(alg)) + assert hom.dimension(-5) == 0 + assert hom.dimension(5) == 0 + with pytest.raises(IndexError): + hom.basis_element_to_string(-5, 0) + + +def test_hom_module_overflow_degree_no_panic(): + # target.max_degree == 1, so the upstream compute_basis would add + # i32::MAX + 1 and overflow. The degree-touching methods short-circuit + # cleanly instead of panicking. + alg = milnor(2) + hom = algebra.HomModule(free_one_gen(alg), make_c2(alg)) + IMAX = 2147483647 + assert hom.dimension(IMAX) == 0 + with pytest.raises(IndexError): + hom.basis_element_to_string(IMAX, 0) + res = fp.FpVector(2, 0) + with pytest.raises((IndexError, ValueError)): + hom.act_on_basis(res, 1, 0, 0, IMAX, 0) + + +def test_hom_module_total_dimension_unbounded_raises(): + alg = milnor(2) + hom = algebra.HomModule(free_one_gen(alg), make_c2(alg)) + # Hom over a free source is unbounded above. + with pytest.raises(ValueError): + hom.total_dimension + + +def test_hom_module_has_no_into_steenrod_module(): + # HomModule is over the ground field, not the Steenrod algebra, so it is + # deliberately not a SteenrodModule and exposes no into_steenrod_module(). + alg = milnor(2) + hom = algebra.HomModule(free_one_gen(alg), make_c2(alg)) + assert not hasattr(hom, "into_steenrod_module") diff --git a/ext_py/tests/test_quotient_homomorphisms.py b/ext_py/tests/test_quotient_homomorphisms.py new file mode 100644 index 0000000000..d6603e5126 --- /dev/null +++ b/ext_py/tests/test_quotient_homomorphisms.py @@ -0,0 +1,383 @@ +import pytest + +from ext import algebra, fp + + +# The C2 module: x0 in degree 0, x1 in degree 1, with Sq1 x0 = x1. +C2_JSON = { + "p": 2, + "type": "finite dimensional module", + "gens": {"x0": 0, "x1": 1}, + "actions": ["Sq1 x0 = x1"], +} + + +def milnor(p=2): + return algebra.SteenrodAlgebra.milnor(p) + + +def c2_module(alg): + return algebra.SteenrodModule.from_spec(C2_JSON, alg) + + +def identity_and_quotient(alg): + """The identity FullModuleHomomorphism on a single C2 object plus a + QuotientModule view of the *same* module object (so the Arc-identity guard + in the quotient-homomorphism constructors is satisfied).""" + m = c2_module(alg) + f = algebra.FullModuleHomomorphism.identity(m) + q = algebra.QuotientModule(m, 1) + return m, f, q + + +# --- binding presence ------------------------------------------------------ + + +def test_classes_in_module(): + assert "QuotientHomomorphism" in dir(algebra) + assert "QuotientHomomorphismSource" in dir(algebra) + assert "GenericZeroHomomorphism" in dir(algebra) + + +# --- QuotientHomomorphism -------------------------------------------------- + + +def test_quotient_hom_construct_and_invariants(): + alg = milnor(2) + _m, f, q = identity_and_quotient(alg) + qh = algebra.QuotientHomomorphism(f, q, q) + assert isinstance(qh.prime, int) + assert qh.prime == 2 + assert qh.degree_shift == 0 + assert qh.min_degree == 0 + assert repr(qh).startswith("QuotientHomomorphism(") + # source / target are the bound QuotientModule, sharing state. + assert isinstance(qh.source, algebra.QuotientModule) + assert isinstance(qh.target, algebra.QuotientModule) + assert qh.source.dimension(0) == 1 + assert qh.target.dimension(1) == 1 + + +def test_quotient_hom_identity_known_values(): + alg = milnor(2) + _m, f, q = identity_and_quotient(alg) + qh = algebra.QuotientHomomorphism(f, q, q) + # induced identity: x0 -> [1] in degree 0, x1 -> [1] in degree 1. + r0 = fp.FpVector(2, 1) + qh.apply_to_basis_element(r0, 1, 0, 0) + assert r0[0] == 1 + r1 = fp.FpVector(2, 1) + qh.apply_to_basis_element(r1, 1, 1, 0) + assert r1[0] == 1 + # apply on a general element [1] in degree 0 agrees. + inp = fp.FpVector(2, 1) + inp[0] = 1 + r2 = fp.FpVector(2, 1) + qh.apply(r2, 1, 0, inp) + assert r2[0] == 1 + + +def test_quotient_hom_target_quotiented_is_zero(): + alg = milnor(2) + m = c2_module(alg) + f = algebra.FullModuleHomomorphism.identity(m) + q_src = algebra.QuotientModule(m, 1) + q_tgt = algebra.QuotientModule(m, 1) + # Quotient out x1 in the target before building the homomorphism. + q_tgt.quotient_basis_elements(1, [0]) + assert q_tgt.dimension(1) == 0 + qh = algebra.QuotientHomomorphism(f, q_src, q_tgt) + # x1 lives in source degree 1; its image lands in the now-zero target + # degree 1. A length-0 result is accepted and left untouched (no panic). + r = fp.FpVector(2, 0) + qh.apply_to_basis_element(r, 1, 1, 0) + assert len(r) == 0 + # A wrong (nonzero) result length raises rather than panicking. + bad = fp.FpVector(2, 1) + with pytest.raises(ValueError): + qh.apply_to_basis_element(bad, 1, 1, 0) + + +def test_quotient_hom_rejects_foreign_modules(): + alg = milnor(2) + m = c2_module(alg) + n = c2_module(alg) + f = algebra.FullModuleHomomorphism.identity(m) + # A quotient of a *different* module object is rejected. + q_other = algebra.QuotientModule(n, 1) + with pytest.raises(ValueError): + algebra.QuotientHomomorphism(f, q_other, q_other) + + +def test_quotient_hom_guards_raise_not_panic(): + alg = milnor(2) + _m, f, q = identity_and_quotient(alg) + qh = algebra.QuotientHomomorphism(f, q, q) + # Out-of-range source index -> IndexError. + res = fp.FpVector(2, 1) + with pytest.raises(IndexError): + qh.apply_to_basis_element(res, 1, 0, 9) + # Wrong result length -> ValueError. + bad = fp.FpVector(2, 3) + with pytest.raises(ValueError): + qh.apply_to_basis_element(bad, 1, 0, 0) + # Prime mismatch -> ValueError. + badp = fp.FpVector(3, 1) + with pytest.raises(ValueError): + qh.apply_to_basis_element(badp, 1, 0, 0) + # Below source min_degree -> IndexError. + with pytest.raises(IndexError): + qh.apply_to_basis_element(res, 1, -1, 0) + + +def test_quotient_hom_no_auxiliary_data(): + alg = milnor(2) + _m, f, q = identity_and_quotient(alg) + qh = algebra.QuotientHomomorphism(f, q, q) + # No auxiliary data is ever stored for a quotient homomorphism. + qh.compute_auxiliary_data_through_degree(1) + assert qh.kernel(0) is None + assert qh.image(0) is None + assert qh.quasi_inverse(0) is None + res = fp.FpVector(2, 1) + inp = fp.FpVector(2, 1) + assert qh.apply_quasi_inverse(res, 0, inp) is False + + +def test_quotient_hom_get_partial_matrix(): + alg = milnor(2) + _m, f, q = identity_and_quotient(alg) + qh = algebra.QuotientHomomorphism(f, q, q) + gm = qh.get_partial_matrix(0, [0]) + assert isinstance(gm, fp.Matrix) + assert gm.rows == 1 + assert gm.columns == 1 + assert gm.to_vec() == [[1]] + + +def test_quotient_hom_apply_aliasing_raises(): + alg = milnor(2) + _m, f, q = identity_and_quotient(alg) + qh = algebra.QuotientHomomorphism(f, q, q) + v = fp.FpVector(2, 1) + v[0] = 1 + with pytest.raises(RuntimeError): + qh.apply(v, 1, 0, v) + + +def test_quotient_hom_partial_matrix_below_target_min(): + # Finding 1: source/target quotients with asymmetric min_degree + # (src_min = 0 < target_min = 2). Querying get_partial_matrix at a degree in + # [src_min, target_min) used to panic in the trait-default's + # target.dimension(degree) (QuotientModule subspaces BiVec assert); it now + # returns a correctly-shaped (len(inputs) x 0) zero matrix. + alg = milnor(2) + sb = algebra.FDModuleBuilder(alg, "s", [1], 0) + source = sb.build() + tb = algebra.FDModuleBuilder(alg, "t", [1], 2) + target = tb.build() + f = algebra.FullModuleHomomorphism(source, target, 0) + q_src = algebra.QuotientModule(source, 3) + q_tgt = algebra.QuotientModule(target, 3) + qh = algebra.QuotientHomomorphism(f, q_src, q_tgt) + assert qh.source.min_degree == 0 + assert qh.target.min_degree == 2 + gm = qh.get_partial_matrix(0, [0]) + assert isinstance(gm, fp.Matrix) + assert gm.rows == 1 + assert gm.columns == 0 + + +def test_quotient_hom_partial_matrix_degree_shift_guard(): + # With degree_shift != 0, get_partial_matrix raises ValueError when + # target.dimension(degree) != target.dimension(degree - degree_shift) (both + # nonzero). dim(0) = 1, dim(1) = 2 makes the two differ. + alg = milnor(2) + mb = algebra.FDModuleBuilder(alg, "m", [1, 2], 0) + m = mb.build() + f = algebra.FullModuleHomomorphism(m, m, 1) + q = algebra.QuotientModule(m, 3) + qh = algebra.QuotientHomomorphism(f, q, q) + assert qh.degree_shift == 1 + with pytest.raises(ValueError): + qh.get_partial_matrix(1, [0]) + + +# --- QuotientHomomorphismSource -------------------------------------------- + + +def test_quotient_hom_source_construct_and_types(): + alg = milnor(2) + _m, f, q = identity_and_quotient(alg) + qhs = algebra.QuotientHomomorphismSource(f, q) + assert qhs.prime == 2 + assert qhs.degree_shift == 0 + assert qhs.min_degree == 0 + assert repr(qhs).startswith("QuotientHomomorphismSource(") + # source is the quotient; target is the plain SteenrodModule. + assert isinstance(qhs.source, algebra.QuotientModule) + assert isinstance(qhs.target, algebra.SteenrodModule) + assert qhs.source.dimension(0) == 1 + assert qhs.target.dimension(1) == 1 + + +def test_quotient_hom_source_known_values(): + alg = milnor(2) + _m, f, q = identity_and_quotient(alg) + qhs = algebra.QuotientHomomorphismSource(f, q) + # Identity into the un-quotiented target: x0 -> [1], x1 -> [1]. + r0 = fp.FpVector(2, 1) + qhs.apply_to_basis_element(r0, 1, 0, 0) + assert r0[0] == 1 + r1 = fp.FpVector(2, 1) + qhs.apply_to_basis_element(r1, 1, 1, 0) + assert r1[0] == 1 + + +def test_quotient_hom_source_rejects_foreign_module(): + alg = milnor(2) + m = c2_module(alg) + n = c2_module(alg) + f = algebra.FullModuleHomomorphism.identity(m) + q_other = algebra.QuotientModule(n, 1) + with pytest.raises(ValueError): + algebra.QuotientHomomorphismSource(f, q_other) + + +def test_quotient_hom_source_no_auxiliary_data(): + alg = milnor(2) + _m, f, q = identity_and_quotient(alg) + qhs = algebra.QuotientHomomorphismSource(f, q) + assert qhs.kernel(0) is None + assert qhs.image(0) is None + assert qhs.quasi_inverse(0) is None + + +def test_quotient_hom_source_get_partial_matrix(): + # The induced identity into the un-quotiented target is the 1x1 [[1]]. + alg = milnor(2) + _m, f, q = identity_and_quotient(alg) + qhs = algebra.QuotientHomomorphismSource(f, q) + gm = qhs.get_partial_matrix(0, [0]) + assert isinstance(gm, fp.Matrix) + assert gm.rows == 1 + assert gm.columns == 1 + assert gm.to_vec() == [[1]] + + +# --- GenericZeroHomomorphism ----------------------------------------------- + + +def test_generic_zero_construct_and_invariants(): + alg = milnor(2) + m = c2_module(alg) + z = algebra.GenericZeroHomomorphism(m, m, 0) + assert z.prime == 2 + assert z.degree_shift == 0 + assert z.min_degree == 0 + assert repr(z).startswith("GenericZeroHomomorphism(") + assert isinstance(z.source, algebra.SteenrodModule) + assert isinstance(z.target, algebra.SteenrodModule) + assert z.source.dimension(0) == 1 + assert z.target.dimension(1) == 1 + + +def test_generic_zero_maps_everything_to_zero(): + alg = milnor(2) + m = c2_module(alg) + z = algebra.GenericZeroHomomorphism(m, m, 0) + # apply_to_basis_element adds nothing. + r = fp.FpVector(2, 1) + r[0] = 1 + z.apply_to_basis_element(r, 1, 0, 0) + assert r[0] == 1 # unchanged (added 0) + # apply on a general element adds nothing. + inp = fp.FpVector(2, 1) + inp[0] = 1 + out = fp.FpVector(2, 1) + z.apply(out, 1, 0, inp) + assert out[0] == 0 + + +def test_generic_zero_default_degree_shift(): + alg = milnor(2) + m = c2_module(alg) + z = algebra.GenericZeroHomomorphism(m, m) + assert z.degree_shift == 0 + + +def test_generic_zero_get_partial_matrix_is_zero(): + alg = milnor(2) + m = c2_module(alg) + z = algebra.GenericZeroHomomorphism(m, m, 0) + gm = z.get_partial_matrix(0, [0]) + assert isinstance(gm, fp.Matrix) + assert gm.rows == 1 + assert gm.columns == 1 + assert gm.to_vec() == [[0]] + + +def test_generic_zero_no_auxiliary_data(): + alg = milnor(2) + m = c2_module(alg) + z = algebra.GenericZeroHomomorphism(m, m, 0) + z.compute_auxiliary_data_through_degree(1) + assert z.kernel(0) is None + assert z.image(0) is None + assert z.quasi_inverse(0) is None + res = fp.FpVector(2, 1) + inp = fp.FpVector(2, 1) + assert z.apply_quasi_inverse(res, 0, inp) is False + + +def test_generic_zero_requires_same_algebra(): + a1 = milnor(2) + a2 = milnor(2) # distinct algebra object + m1 = c2_module(a1) + m2 = c2_module(a2) + with pytest.raises(ValueError): + algebra.GenericZeroHomomorphism(m1, m2, 0) + + +def test_generic_zero_guards_raise_not_panic(): + alg = milnor(2) + m = c2_module(alg) + z = algebra.GenericZeroHomomorphism(m, m, 0) + # Out-of-range source index -> IndexError. + res = fp.FpVector(2, 1) + with pytest.raises(IndexError): + z.apply_to_basis_element(res, 1, 0, 9) + # Wrong result length -> ValueError. + bad = fp.FpVector(2, 3) + with pytest.raises(ValueError): + z.apply_to_basis_element(bad, 1, 0, 0) + + +def test_generic_zero_apply_aliasing_raises(): + alg = milnor(2) + m = c2_module(alg) + z = algebra.GenericZeroHomomorphism(m, m, 0) + v = fp.FpVector(2, 1) + v[0] = 1 + with pytest.raises(RuntimeError): + z.apply(v, 1, 0, v) + + +def test_generic_zero_partial_matrix_beyond_target_range(): + # Finding 2: degree_shift > 0 with a FreeModule-backed target. The binding + # only ensured the target through the (smaller) output degree, so the + # trait-default's target.dimension(degree) used to reach beyond the + # FreeModule's computed range and trip its "not computed through degree" + # assert. It now returns a clean (len(inputs) x 0) zero matrix. + alg = milnor(2) + source = c2_module(alg) + # An empty FreeModule (no generators) over the same algebra object. + free = algebra.FPModuleBuilder(alg, "t", 0).build().generators() + target = free.into_steenrod_module() + z = algebra.GenericZeroHomomorphism(source, target, 1) + assert z.degree_shift == 1 + # Source (C2) has dimension 1 in degree 1, so input index 0 is valid. + gm = z.get_partial_matrix(1, [0]) + assert isinstance(gm, fp.Matrix) + assert gm.rows == 1 + assert gm.columns == 0 diff --git a/ext_py/tests/test_resolution.py b/ext_py/tests/test_resolution.py new file mode 100644 index 0000000000..6f79e57fdb --- /dev/null +++ b/ext_py/tests/test_resolution.py @@ -0,0 +1,412 @@ +"""Tests for `Resolution` (Nassau vs standard dispatch) and `SecondaryResolution`. + +`Resolution(spec, algorithm)` selects an algorithm at runtime: ``None``/``"auto"`` +(try Nassau, fall back to the general algorithm), ``"nassau"`` (force Nassau, error +if ineligible), or ``"standard"`` (force the general algorithm). + +Expected values are not hardcoded: the resolution of ``S_2`` through a fixed small +range must be identical regardless of algorithm, so the auto/nassau results are +validated against the standard result via `graded_dimension_string()` agreement. + +`SecondaryResolution` is only supported over the *standard* backend; a Nassau-backed +`Resolution` is rejected with a clean `ValueError` (Nassau stores quasi-inverses on +disk and needs a save directory, which the binding does not provide). See the +maintainer decision rejecting Nassau-backed secondary resolutions. + +Ranges are kept small (`Bidegree.n_s(8, 4)`) so the suite stays fast (~0.05s total +on the dev machine). +""" + +from itertools import islice + +import pytest + +import ext +from ext import algebra, sseq + +# Small target bidegree: resolving S_2 through this stem is ~10ms per algorithm. +SMALL = sseq.Bidegree.n_s(8, 4) + + +def resolve(algorithm, max=SMALL): + r = ext.Resolution("S_2", algorithm) + r.compute_through_stem(max) + return r + + +# --- algorithm dispatch ---------------------------------------------------- + + +def test_auto_resolves_and_dimension_string_nonempty(): + s = resolve(None).graded_dimension_string() + assert isinstance(s, str) + assert len(s) > 0 + + +def test_auto_equals_standard(): + # auto picks Nassau for the sphere; both algorithms resolve the same object, so + # the graded dimensions over the same range must agree exactly. + assert resolve("auto").graded_dimension_string() == resolve("standard").graded_dimension_string() + + +def test_nassau_equals_standard(): + assert resolve("nassau").graded_dimension_string() == resolve("standard").graded_dimension_string() + + +def test_standard_resolves(): + assert len(resolve("standard").graded_dimension_string()) > 0 + + +def test_nassau_resolves(): + assert len(resolve("nassau").graded_dimension_string()) > 0 + + +# --- error taxonomy -------------------------------------------------------- + + +def test_bogus_algorithm_raises_valueerror(): + with pytest.raises(ValueError): + ext.Resolution("S_2", "bogus") + + +def test_nassau_on_ineligible_spec_raises_valueerror(): + # Odd-prime sphere is ineligible for Nassau (requires p = 2). This is a + # bad-argument condition, so it must be a clean ValueError, not a panic. + with pytest.raises(ValueError): + ext.Resolution("S_3", "nassau") + + +# --- construct() spec forms ------------------------------------------------ +# +# `ext.Resolution.construct(spec, save_dir, algorithm)` accepts the same `spec` forms the +# upstream `Config` does: a string, or a `(spec, algebra)` tuple where `spec` is +# a string or a module-JSON dict and `algebra` is an `AlgebraType`/"adem"/"milnor". + + +def test_construct_dict_tuple_spec(): + # (module-JSON dict, AlgebraType) tuple, forced standard backend. + cfg = {"p": 2, "type": "real projective space", "min": -3} + r = ext.Resolution.construct((cfg, algebra.AlgebraType.Milnor), None, "standard") + r.compute_through_stem(sseq.Bidegree.n_s(3, 2)) + assert isinstance(r, ext.Resolution) + assert len(r.graded_dimension_string()) > 0 + + +def test_construct_str_tuple_spec(): + # (module-name string, "milnor") tuple agrees with the bare-string form. + r = ext.Resolution.construct(("S_2", "milnor"), None, "standard") + r.compute_through_stem(SMALL) + plain = ext.Resolution.construct("S_2@milnor", None, "standard") + plain.compute_through_stem(SMALL) + assert r.graded_dimension_string() == plain.graded_dimension_string() + + +def test_construct_bare_dict_rejected(): + # A bare dict (no algebra) is not a valid Config; reject it cleanly. + cfg = {"p": 2, "type": "real projective space", "min": -3} + with pytest.raises((TypeError, ValueError)): + ext.Resolution.construct(cfg, None, "standard") + + +# --- compute_through_stem guard -------------------------------------------- + + +def test_negative_s_bidegree_raises_valueerror(): + # Pre-fix this panicked across the FFI boundary (negative s over-allocates / + # overflows in the resolve loop). The guard now raises ValueError first. + r = ext.Resolution("S_2", "standard") + with pytest.raises(ValueError): + r.compute_through_stem(sseq.Bidegree.n_s(0, -1)) + + +# --- SecondaryResolution --------------------------------------------------- + + +def test_secondary_over_standard_backend(): + r = resolve("standard") + sec = ext.SecondaryResolution(r) + sec.extend_all() + assert isinstance(sec.underlying, ext.Resolution) + + +def test_secondary_over_nassau_backend_raises_valueerror(): + # Nassau-backed secondary resolution is rejected up front (it would otherwise + # panic in extend_all because Nassau quasi-inverses live on disk only). + r = ext.Resolution("S_2", "nassau") + with pytest.raises(ValueError): + ext.SecondaryResolution(r) + + +# --- DoubleChainComplex (port of sq0.rs `mod double`) ---------------------- + + +def test_double_chain_complex_construct_and_compute(): + # The doubled chain complex of a standard-backend resolution constructs and + # computes through a bidegree without error. + r = resolve("standard") + d = ext.DoubleChainComplex(r) + d.compute_through_bidegree( + sseq.Bidegree.s_t(r.next_homological_degree - 1, 0) + ) + + +def test_double_chain_complex_over_nassau_backend_raises_valueerror(): + # Only the standard backend is bound; Nassau resolves over a distinct + # concrete algebra and is rejected up front. + r = ext.Resolution("S_2", "nassau") + with pytest.raises(ValueError): + ext.DoubleChainComplex(r) + + +# --- FreeChainComplex method set (§7.2) ------------------------------------ +# +# Known low-dimensional Ext of the sphere S_2 over the mod-2 Steenrod algebra, +# in Adams indexing (s, t) [stem n = t - s]. These are standard, textbook, +# algorithm-independent values: +# (0,0) = 1 (the unit), h_0 = (1,1), h_1 = (1,2), h_2 = (1,4), +# h_0^2 = (2,2), h_0^3 = (3,3); the gaps (1,3) = 0. +# They are cross-checked below against `graded_dimension_string`/the other +# backend, so they are validated rather than merely asserted. +KNOWN_NONZERO = {(0, 0): 1, (1, 1): 1, (1, 2): 1, (1, 4): 1, (2, 2): 1, (3, 3): 1} +KNOWN_ZERO = [(1, 3), (2, 1)] + + +@pytest.mark.parametrize("algorithm", ["standard", "nassau"]) +def test_compute_through_bidegree_and_number_of_gens(algorithm): + r = ext.Resolution("S_2", algorithm) + r.compute_through_bidegree(sseq.Bidegree.s_t(4, 12)) + for (s, t), n in KNOWN_NONZERO.items(): + assert r.number_of_gens_in_bidegree(sseq.Bidegree.s_t(s, t)) == n + for s, t in KNOWN_ZERO: + assert r.number_of_gens_in_bidegree(sseq.Bidegree.s_t(s, t)) == 0 + + +def test_known_values_agree_across_backends(): + # The two algorithms resolve the same object; their Ext dimensions over a + # shared range must agree, which validates the hardcoded KNOWN_* tables. + a = resolve("standard") + b = resolve("nassau") + for s, t in list(KNOWN_NONZERO) + KNOWN_ZERO: + bd = sseq.Bidegree.s_t(s, t) + assert a.number_of_gens_in_bidegree(bd) == b.number_of_gens_in_bidegree(bd) + + +def test_number_of_gens_guards(): + r = resolve("standard") + # Negative s -> clean ValueError, never a panic. + with pytest.raises(ValueError): + r.number_of_gens_in_bidegree(sseq.Bidegree.s_t(-1, 0)) + # Negative t is legitimate (modules with negative min_degree have generators + # in negative internal degrees). For S_2 (min_degree 0) a negative t is below + # min_degree, so it clamps to 0 rather than raising. + assert r.number_of_gens_in_bidegree(sseq.Bidegree.s_t(0, -1)) == 0 + # Far outside the computed range -> 0, never a panic. + assert r.number_of_gens_in_bidegree(sseq.Bidegree.s_t(100, 200)) == 0 + + +# --- negative internal degree t (RP_{-k}) ---------------------------------- +# +# Stunted projective spaces RP_{-k}^inf have their bottom cell in internal +# degree t = -k, so their resolutions carry generators in NEGATIVE internal +# degree. The stable Resolution query methods must accept negative t (while +# still rejecting negative homological degree s). + + +def _rp_resolution(k): + cfg = {"p": 2, "type": "real projective space", "min": -k} + r = ext.Resolution.construct( + (cfg, algebra.AlgebraType.Milnor), None, "standard" + ) + r.compute_through_stem(sseq.Bidegree.n_s(3, 2)) + return r + + +def test_rp_has_computed_bidegree_negative_t_no_raise(): + k = 3 + r = _rp_resolution(k) + # The bottom class lives at (s, t) = (0, -k); querying a negative t must + # return a bool, not raise. + result = r.has_computed_bidegree(sseq.Bidegree.s_t(0, -k)) + assert isinstance(result, bool) + assert result is True + + +def test_rp_number_of_gens_negative_t(): + k = 3 + r = _rp_resolution(k) + # The bottom generator sits at (0, -k); there is at least one generator. + assert r.number_of_gens_in_bidegree(sseq.Bidegree.s_t(0, -k)) >= 1 + # A t below min_degree (-k) clamps to 0 rather than raising. + assert r.number_of_gens_in_bidegree(sseq.Bidegree.s_t(0, -k - 5)) == 0 + + +def test_rp_negative_s_still_rejected(): + k = 3 + r = _rp_resolution(k) + # Negative homological degree is still invalid, regardless of t. + with pytest.raises(ValueError): + r.number_of_gens_in_bidegree(sseq.Bidegree.s_t(-1, 0)) + with pytest.raises(ValueError): + r.has_computed_bidegree(sseq.Bidegree.s_t(-1, 0)) + + +def test_module_standard_shares_arc(): + r = resolve("standard") + m0 = r.module(0) + # C_0 is free on one generator in degree 0. + assert m0.dimension(0) == 1 + assert m0.prime == 2 + # Negative / out-of-range s -> ValueError. + with pytest.raises(ValueError): + r.module(-1) + with pytest.raises(ValueError): + r.module(r.next_homological_degree) + + +def test_target_standard_chain_complex(): + # target() is the augmentation target: the chain complex being resolved. + # For S_2 that is the trivial complex with the base module in degree 0. + r = resolve("standard") + cc = r.target + assert isinstance(cc, ext.ChainComplex) + assert cc.prime == 2 + # max_s == 1: only C_0 is (potentially) nonzero for the sphere. + assert cc.max_s == 1 + m0 = cc.module(0) + assert m0.is_unit + + +def test_target_nassau_unsupported(): + # Nassau resolves a monomorphised complex type the ChainComplex pyclass + # (CCC) cannot represent. + r = resolve("nassau") + with pytest.raises(ValueError): + r.target + + +def test_module_nassau_unsupported(): + # Nassau resolves over the concrete MilnorAlgebra; the FreeModule pyclass + # (over the SteenrodAlgebra union) cannot represent its modules. + r = resolve("nassau") + with pytest.raises(ValueError): + r.module(0) + + +@pytest.mark.parametrize("algorithm", ["standard", "nassau"]) +def test_iter_nonzero_stem(algorithm): + r = resolve(algorithm) + # The iterator is bounded but exposed lazily; slice it with islice. + seen = [(b.n, b.s) for b in islice(r.iter_nonzero_stem(), 8)] + # Every yielded bidegree is nonzero. + for n, s in seen: + assert r.number_of_gens_in_bidegree(sseq.Bidegree.n_s(n, s)) > 0 + # The unit and the start of the h_0-tower are present. + assert (0, 0) in seen + assert (0, 1) in seen + + +def test_iter_stem_yields_bidegrees(): + r = resolve("standard") + first = next(iter(r.iter_stem())) + assert isinstance(first, sseq.Bidegree) + + +def test_filtration_one_products_h0(): + r = resolve("standard") + # h_0 is the filtration-one product of the degree-1 operation Sq^1. + prod = r.filtration_one_products(1, 0) + assert prod.b.s == 1 + assert prod.b.n == 0 + # The product matrix out of the unit (0,0) is [[1]] (h_0 hits h_0). + m = r.filtration_one_product(1, 0, sseq.Bidegree.s_t(0, 0)) + assert m == [[1]] + with pytest.raises(ValueError): + r.filtration_one_products(-1, 0) + + +@pytest.mark.parametrize("algorithm", ["standard", "nassau"]) +def test_filtration_one_products_uncomputed_no_panic(algorithm): + # A freshly constructed resolution has no modules; upstream's unconditional + # module(0) would panic. The binding returns the empty product instead. + r = ext.Resolution("S_2", algorithm) + prod = r.filtration_one_products(1, 0) + assert list(prod.matrices) == [] + # h_0 still lives in (n, s) = (0, 1). + assert prod.b.s == 1 + assert prod.b.n == 0 + + +@pytest.mark.parametrize("algorithm", ["standard", "nassau"]) +def test_filtration_one_op_idx_out_of_range_raises(algorithm): + # op_deg = 1 has a single operation (Sq^1) at p = 2, so op_idx = 999 is out + # of range. Both methods must raise IndexError, not panic or read garbage. + r = resolve(algorithm) + with pytest.raises(IndexError): + r.filtration_one_products(1, 999) + with pytest.raises(IndexError): + r.filtration_one_product(1, 999, sseq.Bidegree.s_t(0, 0)) + + +def test_boundary_string_guards(): + r = resolve("standard") + g = sseq.BidegreeGenerator.s_t(0, 0, 0) + assert isinstance(r.boundary_string(g), str) + # idx beyond the generators at (0,0) -> ValueError. + with pytest.raises(ValueError): + r.boundary_string(sseq.BidegreeGenerator.s_t(0, 0, 5)) + + +def test_callback_records_bidegrees(): + r = ext.Resolution("S_2", "standard") + visited = [] + r.compute_through_bidegree_with_callback(sseq.Bidegree.s_t(3, 6), visited.append) + assert len(visited) > 0 + assert all(isinstance(b, sseq.Bidegree) for b in visited) + + +def test_callback_exception_propagates(): + r = ext.Resolution("S_2", "standard") + + def boom(b): + raise ValueError("boom") + + with pytest.raises(ValueError): + r.compute_through_stem_with_callback(sseq.Bidegree.n_s(4, 4), boom) + + +def test_callback_unsupported_on_nassau(): + r = ext.Resolution("S_2", "nassau") + with pytest.raises(ValueError): + r.compute_through_bidegree_with_callback(sseq.Bidegree.s_t(2, 2), lambda b: None) + + +def test_name_is_property_returning_str(): + # `name` is bound as a getter (property), reading the wrapper-level override + # if set, else the underlying resolution's upstream name. + r = resolve("standard") + assert isinstance(r.name, str) + + +def test_set_name_overrides_name(): + # `set_name` sets a wrapper-level override returned by `name`. This works on + # the frozen, Arc-shared pyclass via interior mutability (a Mutex), without + # mutating the shared upstream resolution. + r = resolve("standard") + default = r.name + assert isinstance(default, str) + r.set_name("custom-name") + assert r.name == "custom-name" + + +@pytest.mark.parametrize("algorithm", ["standard", "nassau"]) +def test_algebra_returns_steenrod_algebra(algorithm): + # algebra() yields a SteenrodAlgebra on both backends: the standard backend + # shares its union algebra directly; the Nassau backend (which resolves over + # a bare MilnorAlgebra) rebuilds the equivalent Milnor variant. + r = resolve(algorithm) + alg = r.algebra + assert isinstance(alg, ext.algebra.SteenrodAlgebra) + assert alg.prime == 2 + assert alg.algebra_type() == ext.algebra.AlgebraType.Milnor + # The accessor an example relies on (chart.py) must work off it. + assert alg.default_filtration_one_products diff --git a/ext_py/tests/test_resolution_homomorphism.py b/ext_py/tests/test_resolution_homomorphism.py new file mode 100644 index 0000000000..0cadb978aa --- /dev/null +++ b/ext_py/tests/test_resolution_homomorphism.py @@ -0,0 +1,428 @@ +"""Tests for the `ResolutionHomomorphism` pyclass (`ext::resolution_homomorphism`). + +A `ResolutionHomomorphism` is a lifted chain map between two free resolutions — +the resolution-level realisation of a map of `Ext` modules. Only the +*standard*-backend Standard->Standard instantiation is bound (both source and +target are `Resolution(..., "standard")`); a Nassau-backed resolution is rejected +with a clean `ValueError`, because Nassau resolves over the concrete +`MilnorAlgebra` whose maps the bound homomorphism pyclasses cannot represent +(mirroring the standard-only `Resolution.module`/`chain_complex`). + +The canonical known value: `from_class` with shift `(0, 0)` and class `[1]` is the +identity chain map lifting the identity on the augmentation, so its `s`-th map +sends generator `idx` to the corresponding basis element (a single `1` at +`operation_generator_to_index(0, 0, t, idx)`). This is exactly the invariant +`ext/tests/extend_identity.rs` asserts for `from_module_homomorphism(identity)`. + +All bad degree/index/bidegree inputs are pre-checked and raise +`ValueError`/`IndexError` rather than panicking across the FFI boundary. +""" + +import pytest + +import ext +from ext import algebra, fp, sseq + +Bidegree = sseq.Bidegree +BidegreeGenerator = sseq.BidegreeGenerator +FpVector = fp.FpVector + + +def s2_rect(max_st=8): + """A standard-backend resolution of S_2 computed through the full bidegree + rectangle (max_st, max_st), so an extend over the same range is fully + resolved.""" + r = ext.Resolution("S_2", "standard") + r.compute_through_bidegree(Bidegree.s_t(max_st, max_st)) + return r + + +def identity_hom(r, max_st=8): + hom = ext.ResolutionHomomorphism.from_class("id", r, r, Bidegree.s_t(0, 0), [1]) + hom.extend(Bidegree.s_t(max_st, max_st)) + return hom + + +# --- construction & accessors --------------------------------------------- + + +def test_new_accessors_roundtrip(): + r = s2_rect(4) + hom = ext.ResolutionHomomorphism("f", r, r, Bidegree.s_t(1, 1)) + assert hom.name == "f" + assert hom.prime == 2 + assert hom.shift.s == 1 + assert hom.shift.t == 1 + # source()/target() share the underlying resolution. + assert hom.source.prime == 2 + assert hom.target.prime == 2 + assert hom.source.graded_dimension_string() == r.graded_dimension_string() + # A freshly constructed hom defines no maps yet. + assert hom.next_homological_degree == 1 # = shift.s + assert hom.save_dir is None + assert hom.algebra.prime == 2 + + +# --- known value: from_class([1]) at (0,0) is the identity chain map ------ + + +def test_from_class_identity_matches_basis(): + r = s2_rect(8) + hom = identity_hom(r, 8) + assert hom.name == "id" + assert hom.shift.s == 0 and hom.shift.t == 0 + + for s in range(0, 9): + m = hom.get_map(s) + src = r.module(s) + for t in range(0, 9): + for idx in range(src.number_of_gens_in_degree(t)): + out = m.output(t, idx) + j = src.operation_generator_to_index(0, 0, t, idx) + # Identity: a single 1 at the generator's own basis index. + for i in range(len(out)): + assert out[i] == (1 if i == j else 0), ( + f"identity mismatch at s={s} t={t} idx={idx} i={i}" + ) + + +def test_get_map_is_free_to_free_homomorphism(): + r = s2_rect(4) + hom = identity_hom(r, 4) + m = hom.get_map(1) + # source = r.module(1), target = r.module(0) (output_s = input_s - shift.s = 1). + assert m.degree_shift == 0 + assert m.prime == 2 + + +# --- extend_all ----------------------------------------------------------- + + +def test_extend_all_then_get_map(): + r = s2_rect(6) + hom = ext.ResolutionHomomorphism.from_class("id", r, r, Bidegree.s_t(0, 0), [1]) + hom.extend_all() + out = hom.get_map(0).output(0, 0) + assert out[0] == 1 + + +def test_extend_through_stem(): + r = ext.Resolution("S_2", "standard") + r.compute_through_stem(Bidegree.n_s(8, 4)) + hom = ext.ResolutionHomomorphism.from_class("id", r, r, Bidegree.s_t(0, 0), [1]) + hom.extend_through_stem(Bidegree.n_s(4, 4)) + # The s=0 map is the identity on the unit. + assert hom.get_map(0).output(0, 0)[0] == 1 + + +# --- extend_step_raw ------------------------------------------------------ + + +def test_extend_step_raw_seed_then_extend_all(): + # Seed the identity map at (0,0) with extra_images=[ [1] ] (sending the + # unit generator to itself), then fill in the rest by exactness. + r = s2_rect(6) + hom = ext.ResolutionHomomorphism("id", r, r, Bidegree.s_t(0, 0)) + rng = hom.extend_step_raw(Bidegree.s_t(0, 0), [FpVector(2, 1)]) + # Returns the (start, end) half-open range of touched degrees. + assert isinstance(rng, tuple) and len(rng) == 2 + assert rng[0] <= rng[1] + hom.extend_all() + assert hom.get_map(0).output(0, 0)[0] == 0 # FpVector(2,1) is the zero seed + + +def test_extend_step_raw_extra_images_none_runs(): + r = s2_rect(4) + hom = ext.ResolutionHomomorphism("id", r, r, Bidegree.s_t(0, 0)) + rng = hom.extend_step_raw(Bidegree.s_t(0, 0)) + assert isinstance(rng, tuple) and len(rng) == 2 + + +def test_extend_step_raw_uncomputed_bidegree_raises_value_error(): + # Guard: an input bidegree outside the computed range raises ValueError, + # never a panic across FFI. + r = s2_rect(4) + hom = ext.ResolutionHomomorphism("id", r, r, Bidegree.s_t(0, 0)) + with pytest.raises(ValueError): + hom.extend_step_raw(Bidegree.s_t(50, 500), [FpVector(2, 1)]) + # Negative bidegree is a ValueError too. + with pytest.raises(ValueError): + hom.extend_step_raw(Bidegree.s_t(-1, 0)) + # Input below the shift's homological degree is rejected. + shifted = ext.ResolutionHomomorphism("f", r, r, Bidegree.s_t(1, 1)) + with pytest.raises(ValueError): + shifted.extend_step_raw(Bidegree.s_t(0, 0)) + + +# --- act ------------------------------------------------------------------ + + +def test_act_identity_picks_out_generator(): + # For the identity resolution homomorphism, acting on a target generator g + # at (s, t) writes the indicator of that generator into the result vector of + # length = number of source generators at (s, t) (shift = 0). + r = s2_rect(6) + hom = identity_hom(r, 6) + # h_0 lives at (n, s) = (0, 1), i.e. (s, t) = (1, 1), with one generator. + b = Bidegree.s_t(1, 1) + n_src = r.number_of_gens_in_bidegree(b) + assert n_src == 1 + result = FpVector(2, n_src) + hom.act(result, 1, BidegreeGenerator.s_t(1, 1, 0)) + # Identity acts as the identity on Ext: the generator maps to itself. + assert result[0] == 1 + + +def test_act_guards(): + r = s2_rect(6) + hom = identity_hom(r, 6) + result = FpVector(2, 1) + # Negative generator -> ValueError. + with pytest.raises(ValueError): + hom.act(result, 1, BidegreeGenerator.s_t(-1, 0, 0)) + # Wrong result length -> ValueError. + bad = FpVector(2, 5) + with pytest.raises(ValueError): + hom.act(bad, 1, BidegreeGenerator.s_t(1, 1, 0)) + # Generator index out of range at the target bidegree -> IndexError. + with pytest.raises(IndexError): + hom.act(result, 1, BidegreeGenerator.s_t(1, 1, 99)) + + +def test_act_prime_mismatch_errors(): + # result over a different prime than the homomorphism -> ValueError (checked + # before the length/generator guards). + r = s2_rect(6) + hom = identity_hom(r, 6) + wrong_prime = FpVector(3, 1) + with pytest.raises(ValueError): + hom.act(wrong_prime, 1, BidegreeGenerator.s_t(1, 1, 0)) + + +def test_act_map_undefined_at_s_errors(): + # src_s = g.s + shift.s beyond where the chain map is defined -> ValueError. + r = s2_rect(6) + hom = identity_hom(r, 6) + result = FpVector(2, 1) + with pytest.raises(ValueError): + hom.act(result, 1, BidegreeGenerator.s_t(99, 99, 0)) + + +def test_act_map_not_extended_far_enough_errors(): + # The map at s exists but is not extended through src_t -> ValueError. + # Resolve the source over the full (6, 6) rectangle but extend the chain map + # only through t = 4, so get_map(2).next_degree == 5 and src_t = 5 is out + # of range. + r = s2_rect(6) + hom = ext.ResolutionHomomorphism.from_class("id", r, r, Bidegree.s_t(0, 0), [1]) + hom.extend(Bidegree.s_t(6, 4)) + result = FpVector(2, 1) + with pytest.raises(ValueError): + hom.act(result, 1, BidegreeGenerator.s_t(2, 5, 0)) + + +def test_act_target_s_out_of_range_errors(): + # g.s >= target.next_homological_degree. With shift >= 0 this guard is + # shadowed by the src_s ("map undefined") guard (src_s = g.s + shift.s >= + # g.s), so it cannot fire first from the bound API, but the call still + # raises ValueError. + r = s2_rect(6) + hom = identity_hom(r, 6) + assert hom.target.next_homological_degree == 7 + result = FpVector(2, 1) + with pytest.raises(ValueError): + hom.act(result, 1, BidegreeGenerator.s_t(7, 7, 0)) + + +def test_act_degree_overflow_errors(): + # g.s + shift.s / g.t + shift.t overflowing i32 is caught (checked_add) and + # raised as ValueError rather than wrapping. BidegreeGenerator imposes no + # upper bound, so i32::MAX is constructible from Python. + r = s2_rect(4) + hom = ext.ResolutionHomomorphism("f", r, r, Bidegree.s_t(1, 1)) + result = FpVector(2, 1) + with pytest.raises(ValueError): + hom.act(result, 1, BidegreeGenerator.s_t(2147483647, 0, 0)) + with pytest.raises(ValueError): + hom.act(result, 1, BidegreeGenerator.s_t(0, 2147483647, 0)) + + +# --- backend rejection ---------------------------------------------------- + + +def test_rejects_nassau_backend(): + standard = s2_rect(4) + nassau = ext.Resolution("S_2", "nassau") + nassau.compute_through_stem(Bidegree.n_s(8, 4)) + with pytest.raises(ValueError): + ext.ResolutionHomomorphism("f", nassau, standard, Bidegree.s_t(0, 0)) + with pytest.raises(ValueError): + ext.ResolutionHomomorphism("f", standard, nassau, Bidegree.s_t(0, 0)) + with pytest.raises(ValueError): + ext.ResolutionHomomorphism.from_class( + "f", nassau, nassau, Bidegree.s_t(0, 0), [1] + ) + + +# --- panic guards --------------------------------------------------------- + + +def test_new_prime_mismatch_errors(): + # source over p=2, target over p=3 -> ValueError (no computation required; + # the prime check fires first). + s2 = s2_rect(4) + s3 = ext.Resolution("S_3", "standard") + with pytest.raises(ValueError): + ext.ResolutionHomomorphism("f", s2, s3, Bidegree.s_t(0, 0)) + + +def test_from_class_prime_mismatch_errors(): + s2 = s2_rect(4) + s3 = ext.Resolution("S_3", "standard") + with pytest.raises(ValueError): + ext.ResolutionHomomorphism.from_class("f", s2, s3, Bidegree.s_t(0, 0), [1]) + + +def test_new_negative_shift_errors(): + # A negative homological-degree shift (shift.s < 0) is nonsensical and + # rejected, but a negative internal-degree shift (shift.t < 0) is legitimate + # (e.g. a map out of a stunted projective space RP_{-k}) and is allowed. + r = s2_rect(4) + with pytest.raises(ValueError): + ext.ResolutionHomomorphism("f", r, r, Bidegree.s_t(-1, 0)) + # shift.t < 0 is allowed. + hom = ext.ResolutionHomomorphism("f", r, r, Bidegree.s_t(0, -1)) + assert hom.shift.t == -1 + + +def rp_minus_k(k, max_st): + """A standard-backend resolution of the stunted projective space RP_{-k}^inf + (min_degree = -k), computed through the (max_st, max_st) rectangle.""" + spec = ({"p": 2, "type": "real projective space", "min": -k}, algebra.AlgebraType.Milnor) + r = ext.Resolution.construct(spec, None, "standard") + r.compute_through_bidegree(Bidegree.s_t(max_st, max_st)) + return r + + +def test_from_class_negative_t_shift_rp(): + # A map OUT OF RP_{-k} into S_2 uses the shift (s=0, t=-k); the binding must + # allow the negative internal degree once the source is resolved there. + k = 3 + rp = rp_minus_k(k, 6) + s2 = s2_rect(6) + hom = ext.ResolutionHomomorphism.from_class( + "bottom_cell", rp, s2, Bidegree.s_t(0, -k), [1] + ) + hom.extend_all() + assert hom.shift.s == 0 + assert hom.shift.t == -k + + +def test_from_class_negative_s_shift_still_rejected(): + # Guard: a negative homological-degree shift is still rejected. + r = s2_rect(4) + with pytest.raises(ValueError): + ext.ResolutionHomomorphism.from_class("f", r, r, Bidegree.s_t(-1, 0), [1]) + + +def test_from_class_guards(): + r = s2_rect(4) + # Wrong class length (source has 1 generator at (0,0)). + with pytest.raises(ValueError): + ext.ResolutionHomomorphism.from_class("id", r, r, Bidegree.s_t(0, 0), [1, 1]) + # Class at an uncomputed bidegree. + with pytest.raises(ValueError): + ext.ResolutionHomomorphism.from_class( + "id", r, r, Bidegree.s_t(100, 100), [] + ) + + +def test_get_map_out_of_range_errors(): + r = s2_rect(4) + hom = identity_hom(r, 4) + with pytest.raises(IndexError): + hom.get_map(-1) + with pytest.raises(IndexError): + hom.get_map(1000) + + +def test_extend_guards(): + r = s2_rect(4) + hom = ext.ResolutionHomomorphism.from_class("id", r, r, Bidegree.s_t(0, 0), [1]) + # Negative target. + with pytest.raises(ValueError): + hom.extend(Bidegree.s_t(-1, 0)) + # Beyond the resolved range (source only computed through (4, 4)). + with pytest.raises(ValueError): + hom.extend(Bidegree.s_t(100, 100)) + with pytest.raises(ValueError): + hom.extend_through_stem(Bidegree.n_s(100, 4)) + + +def test_extend_all_unresolved_errors(): + r = ext.Resolution("S_2", "standard") + hom = ext.ResolutionHomomorphism("f", r, r, Bidegree.s_t(0, 0)) + with pytest.raises(ValueError): + hom.extend_all() + + +# --- DoubleChainComplex target (the sq0 use case) ------------------------- + + +def test_double_target_sq0_action(): + """A ResolutionHomomorphism into a DoubleChainComplex realises Sq^0 on + Ext(S_2). Mirrors examples/sq0.py: seed the (0,0) step with [1], extend by + exactness, and read the action off get_map(s).output(...).""" + r = s2_rect(8) + doubled = ext.DoubleChainComplex(r) + doubled.compute_through_bidegree( + Bidegree.s_t(r.next_homological_degree - 1, 0) + ) + + hom = ext.ResolutionHomomorphism("Sq^0", r, doubled, Bidegree.zero()) + # target() hands back the DoubleChainComplex (shared Arc), not a Resolution. + assert isinstance(hom.target, ext.DoubleChainComplex) + assert hom.name == "Sq^0" + assert hom.prime == 2 + + hom.extend_step_raw(Bidegree.zero(), [FpVector.from_slice(r.prime, [1])]) + hom.extend_all() + + # Sq^0 fixes the generators on the 0- and 1-lines of Ext(S_2): for a + # generator x_(n, s) the action lands on the doubled bidegree (s, 2t). + results = {} + for b in r.iter_nonzero_stem(): + doubled_b = Bidegree.s_t(b.s, 2 * b.t) + if not r.has_computed_bidegree(doubled_b): + continue + source_num_gens = r.number_of_gens_in_bidegree(doubled_b) + module = r.module(b.s) + offset = module.generator_offset(b.t, b.t, 0) + m = hom.get_map(b.s) + for i in range(r.number_of_gens_in_bidegree(b)): + g = BidegreeGenerator(b, i) + row = [m.output(doubled_b.t, j).entry(offset + i) for j in range(source_num_gens)] + results[(b.n, b.s, i)] = row + + # Known values (cf. ext/examples/sq0.rs): Sq^0 is the identity on the + # bottom cells and the h-towers it reaches in this range. + assert results[(0, 0, 0)] == [1] + assert results[(1, 1, 0)] == [1] + assert results[(3, 1, 0)] == [1] + + +def test_double_target_extend_step_only_for_resolution(): + """extend_step (the augmentation-lifting variant) needs an AugmentedChainComplex + target, so it is rejected for a DoubleChainComplex target.""" + r = s2_rect(4) + doubled = ext.DoubleChainComplex(r) + doubled.compute_through_bidegree(Bidegree.s_t(r.next_homological_degree - 1, 0)) + hom = ext.ResolutionHomomorphism("Sq^0", r, doubled, Bidegree.zero()) + with pytest.raises(ValueError): + hom.extend_step(Bidegree.zero(), None) + + +def test_target_must_be_resolution_or_double(): + r = s2_rect(2) + with pytest.raises(TypeError): + ext.ResolutionHomomorphism("f", r, 42, Bidegree.zero()) diff --git a/ext_py/tests/test_run_examples.py b/ext_py/tests/test_run_examples.py new file mode 100644 index 0000000000..4ddb23b2df --- /dev/null +++ b/ext_py/tests/test_run_examples.py @@ -0,0 +1,215 @@ +"""Execute every ``examples/*.py`` script and record which actually run. + +Unlike :mod:`tests.test_examples` (which only statically checks that examples +reference bound symbols), this module *runs* each example in a subprocess and +asserts it exits 0. + +Interactive input +----------------- +The examples drive all of their prompts through :mod:`ext._query`, which +consumes answers from ``sys.argv[1:]`` first and only falls back to stdin when +the argument stream is exhausted (see ``ext/python/ext/_query.py``). We therefore +feed each script a curated answer sequence as command-line arguments, in the +exact left-to-right order the prompts fire, and connect stdin to ``/dev/null`` so +that a script asking for more input than we supplied fails fast (EOF) instead of +hanging. Parameters are kept small so the resolutions are cheap. + +Each script runs in its own temporary working directory because a few of them +(``d2_charts.py``, ``save_bruner.py``) write output files relative to the cwd. + +Expected failures +----------------- +Most ports were written against the aspirational API in ``API_PROPOSAL.md`` and +do not yet run end-to-end against the current bindings -- either because a +binding is still unbound, or because the default resolution backend (Nassau) +does not expose a method the example calls, or because of a genuine +example/binding mismatch. Each such script is marked ``xfail(strict=True)`` with +the observed reason, so that if a future binding change makes one start passing +(or breaks one of the currently-passing scripts) the suite fails loudly and +forces a conscious update to this table. +""" + +import pathlib +import subprocess +import sys + +import pytest + +EXAMPLES_DIR = pathlib.Path(__file__).resolve().parent.parent / "examples" + +# Per-example invocation table. +# name: the script filename in examples/ +# args: answers fed as argv (consumed by ext._query in prompt order) +# xfail: None if the script is expected to exit 0; otherwise a short reason +# string describing why it currently fails. +# +# Common answer prologue meanings: +# "S_2" -> module spec (the sphere at p=2) +# "" -> empty answer (e.g. an optional "save directory" -> None) +# then small Max n / Max s (or Max t / Max s) bounds. +EXAMPLES = [ + # --- Run to completion against the current bindings --- + {"name": "algebra_dim.py", "args": [], "xfail": None}, + {"name": "resolve.py", "args": ["S_2", "", "10", "5"], "xfail": None}, + { + "name": "resolve_through_stem.py", + "args": ["S_2", "", "8", "4"], + "xfail": None, + }, + # --- Genuine example/binding mismatches --- + {"name": "num_gens.py", "args": ["S_2", "", "8", "4"], "xfail": None}, + {"name": "differentials.py", "args": ["S_2", "", "8", "4"], "xfail": None}, + {"name": "filtration_one.py", "args": ["S_2", "", "8", "4"], "xfail": None}, + { + "name": "unstable_chart.py", + "args": ["S_2", "", "6", "3", "M"], + "xfail": None, + }, + { + # fd module, p=2, generators x0 (deg 0) and x1 (deg 1), finish (empty), + # confirm (y), then the action Sq1 x0 = x1. + "name": "define_module.py", + "args": ["fd", "2", "0", "x0", "1", "x1", "", "y", "x1"], + "xfail": None, + }, + { + "name": "lift_hom.py", + "args": ["S_2", "", "4", "2", "", "prod", "0", "0", "[1]"], + "xfail": None, + }, + # --- Nassau (default) backend lacks methods the example needs --- + { + "name": "resolution_size.py", + "args": ["S_2", "", "8", "4"], + "xfail": None, + }, + {"name": "chart.py", "args": ["S_2", "", "8", "4"], "xfail": None}, + { + "name": "save_bruner.py", + "args": ["S_2", "", "8", "4"], + "xfail": None, + }, + { + "name": "ext_m_n.py", + "args": ["S_2", "", "S_2", "6", "3"], + "xfail": None, + }, + { + "name": "sq0.py", + "args": ["S_2", "", "8", "4"], + "xfail": None, + }, + { + "name": "steenrod.py", + "args": ["S_2", "", "1", "1", "[1]"], + "xfail": "needs TensorChainComplex (ext.TensorChainComplex not bound)", + }, + { + "name": "yoneda.py", + "args": ["S_2", "", "0", "1", "[1]"], + "xfail": None, + }, + { + "name": "secondary.py", + "args": ["S_2", "", "8", "4"], + "xfail": None, + }, + { + "name": "d2_charts.py", + "args": ["S_2", "", "8", "4"], + "xfail": None, + }, + { + "name": "secondary_product.py", + "args": ["S_2", "", "8", "4", "prod", "0", "1", "[1]"], + "xfail": None, + }, + { + "name": "massey.py", + "args": ["S_2", "", "8", "4", "0", "1", "[1]", "0", "1", "[1]"], + "xfail": None, + }, + { + "name": "secondary_massey.py", + "args": ["S_2", "", "8", "4", "0", "1", "a", "[1]", "", + "1", "1", "b", "[1]", ""], + "xfail": None, + }, + # --- Unbound bindings (aspirational API) --- + { + "name": "tensor.py", + "args": ["S_2", "S_2"], + "xfail": None, + }, + { + "name": "resolve_unstable.py", + "args": ["S_2", "", "8", "4"], + "xfail": None, + }, + { + "name": "unstable_suspension.py", + "args": ["S_2", "", "6", "3"], + "xfail": None, + }, + { + "name": "bruner.py", + "args": [], + "xfail": "FiniteChainComplex is not bound (import fails)", + }, + { + "name": "mahowald_invariant.py", + "args": ["", "", "8"], + "xfail": None, + }, +] + + +def _runnable_example_files(): + """All examples/*.py except the ``_query`` shim (not a runnable example).""" + return sorted(p for p in EXAMPLES_DIR.glob("*.py") if p.name != "_query.py") + + +def test_examples_table_covers_every_script(): + """Every runnable example must appear in EXAMPLES exactly once, so a newly + added example cannot silently escape this runner.""" + on_disk = {p.name for p in _runnable_example_files()} + in_table = [e["name"] for e in EXAMPLES] + duplicates = {n for n in in_table if in_table.count(n) > 1} + assert not duplicates, f"duplicate EXAMPLES entries: {sorted(duplicates)}" + in_table_set = set(in_table) + missing = on_disk - in_table_set + stale = in_table_set - on_disk + assert not missing, f"examples not covered by the runner: {sorted(missing)}" + assert not stale, f"EXAMPLES entries with no script on disk: {sorted(stale)}" + + +def _params(): + return [ + pytest.param( + e, + id=e["name"], + marks=( + pytest.mark.xfail(reason=e["xfail"], strict=True) + if e["xfail"] + else () + ), + ) + for e in EXAMPLES + ] + + +@pytest.mark.parametrize("example", _params()) +def test_example_runs(example, tmp_path): + script = EXAMPLES_DIR / example["name"] + result = subprocess.run( + [sys.executable, str(script), *example["args"]], + cwd=tmp_path, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, ( + f"{example['name']} exited {result.returncode}\n" + f"--- stderr ---\n{result.stderr[-2000:]}" + ) diff --git a/ext_py/tests/test_secondary.py b/ext_py/tests/test_secondary.py new file mode 100644 index 0000000000..c64c63c70f --- /dev/null +++ b/ext_py/tests/test_secondary.py @@ -0,0 +1,262 @@ +"""Tests for the secondary (``Mod_{Cλ²}``) family: +``SecondaryResolutionHomomorphism`` and ``SecondaryChainHomotopy`` +(``ext::secondary``). + +These lift a ``ResolutionHomomorphism`` / ``ChainHomotopy`` to a map respecting +the secondary (``d₂``) structure, used to compute secondary products and Massey +products (see ``examples/secondary_product.rs`` / ``examples/secondary_massey.rs``, +the canonical constructions mirrored here). + +Only the standard backend is reachable: a ``SecondaryResolutionHomomorphism`` is +built from two ``SecondaryResolution``s (standard-only — Nassau is rejected at +their construction) and a ``ResolutionHomomorphism`` (also standard-only). + +Construction pre-checks every upstream ``Arc::ptr_eq`` ``assert!`` and raises +``ValueError`` rather than panicking across the FFI boundary; ``extend_all`` +checks the source/target secondary resolutions are extended far enough. + +A concrete secondary-product *value* is not derivable without the full +multi-step workflow (which lives in Python ``examples/``), so structural +invariants are asserted: shift relationships, source/target identities, name +bracketing, and that ``extend_all`` succeeds on fully-extended prerequisites. +""" + +import pytest + +import ext +from ext import sseq + +Bidegree = sseq.Bidegree + + +def sphere(max_st=8): + r = ext.Resolution("S_2", "standard") + r.compute_through_bidegree(Bidegree.s_t(max_st, max_st)) + return r + + +def h0(r, name, ext_deg=6): + """Multiplication by h0 = (n=0, s=1) = (s=1, t=1) as a ResolutionHomomorphism + r -> r, extended over the (ext_deg, ext_deg) rectangle.""" + hom = ext.ResolutionHomomorphism.from_class(name, r, r, Bidegree.s_t(1, 1), [1]) + hom.extend(Bidegree.s_t(ext_deg, ext_deg)) + return hom + + +# --- SecondaryResolutionHomomorphism: construction & accessors ------------- + + +def test_secondary_res_hom_construction_and_accessors(): + r = sphere(8) + res_lift = ext.SecondaryResolution(r) + hom = h0(r, "h0") + sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, hom) + assert sec.prime == 2 + # shift = underlying.shift + (1, 0) = (1, 1) + (1, 0) = (2, 1). + assert sec.shift.s == 2 + assert sec.shift.t == 1 + # name is bracketed to mark it as the secondary lift. + assert sec.name == "[h0]" + # underlying() shares the same ResolutionHomomorphism (same name). + assert isinstance(sec.underlying, ext.ResolutionHomomorphism) + assert sec.underlying.name == "h0" + # source()/target() are the underlying resolutions. + assert isinstance(sec.source, ext.Resolution) + assert sec.source.prime == 2 + assert sec.target.prime == 2 + assert sec.save_dir is None + + +def test_secondary_res_hom_extend_all_succeeds_when_extended(): + r = sphere(8) + res_lift = ext.SecondaryResolution(r) + res_lift.extend_all() + hom = h0(r, "h0") + sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, hom) + # Fully extended prerequisites -> extend_all succeeds (no panic). h0 is a + # permanent cycle supporting no d2, so the lift-validity assert does not fire. + sec.extend_all() + + +# --- SecondaryResolutionHomomorphism: guards ------------------------------- + + +def test_secondary_res_hom_rejects_mismatched_underlying_source(): + # Source secondary resolution over r1, but underlying hom over r2. + r1 = sphere(6) + r2 = sphere(6) + res_lift1 = ext.SecondaryResolution(r1) + hom = h0(r2, "h0") + with pytest.raises(ValueError): + ext.SecondaryResolutionHomomorphism(res_lift1, res_lift1, hom) + + +def test_secondary_res_hom_rejects_mismatched_underlying_target(): + r1 = sphere(6) + r2 = sphere(6) + res_lift1 = ext.SecondaryResolution(r1) + res_lift2 = ext.SecondaryResolution(r2) + # underlying maps r1 -> r1, but target secondary resolution is over r2. + hom = h0(r1, "h0") + with pytest.raises(ValueError): + ext.SecondaryResolutionHomomorphism(res_lift1, res_lift2, hom) + + +def test_secondary_res_hom_extend_all_rejects_unextended_secondary(): + # The underlying hom is extended (so the touched range is non-empty), but the + # source/target SecondaryResolution was never extend_all-ed: extend_all must + # raise a clean ValueError, not index an empty OnceBiVec / panic. + r = sphere(8) + res_lift = ext.SecondaryResolution(r) # NOT extended + hom = h0(r, "h0") + sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, hom) + with pytest.raises(ValueError): + sec.extend_all() + + +# --- SecondaryChainHomotopy: construction & accessors ---------------------- + + +def make_secondary_chain_homotopy(r, res_lift): + left = h0(r, "a") + right = h0(r, "b") + left_sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, left) + right_sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, right) + ch = ext.ChainHomotopy(left, right) + return ext.SecondaryChainHomotopy(left_sec, right_sec, ch) + + +def test_secondary_chain_homotopy_construction_and_accessors(): + r = sphere(8) + res_lift = ext.SecondaryResolution(r) + sec_ch = make_secondary_chain_homotopy(r, res_lift) + assert sec_ch.prime == 2 + assert sec_ch.source.prime == 2 + assert sec_ch.target.prime == 2 + # underlying() shares the ChainHomotopy. + assert isinstance(sec_ch.underlying, ext.ChainHomotopy) + assert sec_ch.underlying.prime == 2 + assert sec_ch.save_dir is None + # algebra() round-trips. + assert sec_ch.algebra.prime == 2 + + +# --- SecondaryChainHomotopy: guards ---------------------------------------- + + +def test_secondary_chain_homotopy_rejects_mismatched_underlying(): + # The ChainHomotopy is built from *different* homomorphism objects than the + # secondary lifts wrap, so the upstream Arc::ptr_eq assert would fail; the + # binding must reject with a ValueError, not panic. + r = sphere(8) + res_lift = ext.SecondaryResolution(r) + left = h0(r, "a") + right = h0(r, "b") + left_sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, left) + right_sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, right) + # Fresh homomorphisms (different Arcs) for the ChainHomotopy. + left2 = h0(r, "a2") + right2 = h0(r, "b2") + ch = ext.ChainHomotopy(left2, right2) + with pytest.raises(ValueError): + ext.SecondaryChainHomotopy(left_sec, right_sec, ch) + + +def test_secondary_chain_homotopy_rejects_bad_lambda_shift(): + # A left_lambda with the wrong shift must be rejected (upstream assert_eq). + r = sphere(8) + res_lift = ext.SecondaryResolution(r) + left = h0(r, "a") + right = h0(r, "b") + left_sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, left) + right_sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, right) + ch = ext.ChainHomotopy(left, right) + # left_lambda must have shift = left.shift + LAMBDA_BIDEGREE = (1,1)+(1,1) + # = (2,2); supply a wrong shift (3,3) to trigger the guard. + bad_lambda = ext.ResolutionHomomorphism.from_class( + "al", r, r, Bidegree.s_t(3, 3), [1] + ) + with pytest.raises(ValueError): + ext.SecondaryChainHomotopy( + left_sec, right_sec, ch, left_lambda=bad_lambda + ) + + +def test_secondary_chain_homotopy_rejects_lambda_source_target_mismatch(): + # left_lambda with the CORRECT shift ((1,1)+LAMBDA(1,1) = (2,2)) but built + # over a *different* resolution than underlying.left: only the source/ + # target Arc::ptr_eq branch can reject (distinct from the shift-mismatch + # branch exercised above). + r = sphere(8) + r2 = sphere(8) + res_lift = ext.SecondaryResolution(r) + left = h0(r, "a") + right = h0(r, "b") + left_sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, left) + right_sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, right) + ch = ext.ChainHomotopy(left, right) + bad_lambda = ext.ResolutionHomomorphism.from_class( + "al", r2, r2, Bidegree.s_t(2, 2), [1] + ) + with pytest.raises(ValueError): + ext.SecondaryChainHomotopy( + left_sec, right_sec, ch, left_lambda=bad_lambda + ) + + +def test_secondary_chain_homotopy_rejects_bad_right_lambda(): + # right_lambda with the wrong shift -> ValueError (exercises the right_lambda + # reject branch, the mirror of the left_lambda guard). + r = sphere(8) + res_lift = ext.SecondaryResolution(r) + left = h0(r, "a") + right = h0(r, "b") + left_sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, left) + right_sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, right) + ch = ext.ChainHomotopy(left, right) + bad_right_lambda = ext.ResolutionHomomorphism.from_class( + "br", r, r, Bidegree.s_t(3, 3), [1] + ) + with pytest.raises(ValueError): + ext.SecondaryChainHomotopy( + left_sec, right_sec, ch, right_lambda=bad_right_lambda + ) + + +def test_secondary_chain_homotopy_accepts_valid_lambdas(): + # Exercise the left_lambda AND right_lambda Some(..) *acceptance* paths: + # lambdas with the correct source/target identity and shift ((2,2)) + # construct cleanly. + r = sphere(8) + res_lift = ext.SecondaryResolution(r) + left = h0(r, "a") + right = h0(r, "b") + left_sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, left) + right_sec = ext.SecondaryResolutionHomomorphism(res_lift, res_lift, right) + ch = ext.ChainHomotopy(left, right) + left_lambda = ext.ResolutionHomomorphism.from_class( + "al", r, r, Bidegree.s_t(2, 2), [1] + ) + right_lambda = ext.ResolutionHomomorphism.from_class( + "bl", r, r, Bidegree.s_t(2, 2), [1] + ) + sec_ch = ext.SecondaryChainHomotopy( + left_sec, + right_sec, + ch, + left_lambda=left_lambda, + right_lambda=right_lambda, + ) + assert sec_ch.prime == 2 + assert isinstance(sec_ch.underlying, ext.ChainHomotopy) + + +# --- backend rejection (Nassau) -------------------------------------------- + + +def test_secondary_resolution_rejects_nassau(): + # SecondaryResolution (and hence the whole secondary family) is standard-only. + rn = ext.Resolution("S_2", "nassau") + rn.compute_through_bidegree(Bidegree.s_t(4, 4)) + with pytest.raises(ValueError): + ext.SecondaryResolution(rn) diff --git a/ext_py/tests/test_secondary_ext_algebra.py b/ext_py/tests/test_secondary_ext_algebra.py new file mode 100644 index 0000000000..37c5fcdc34 --- /dev/null +++ b/ext_py/tests/test_secondary_ext_algebra.py @@ -0,0 +1,301 @@ +"""Tests for the secondary (d2) layer ``SecondaryExtAlgebra`` and its +``SecondaryProduct`` results, plus ``ExtAlgebra.without_unit`` +(``ext::ext_algebra::secondary``). + +``SecondaryExtAlgebra`` composes an ``ExtAlgebra`` with the secondary +resolutions of ``M`` and the unit ``k`` and exposes the secondary differential +``d2`` (with the survival check ``survives``), the E3-page data +(``page_data``/``unit_page_data``), and the ``Mod_{Cλ²}`` secondary product +(``secondary_multiply_into``). It wraps ``SecondaryResolution`` / +``SecondaryResolutionHomomorphism``, so it is standard-backend only (Nassau is +rejected at the underlying construction). + +The KNOWN d2 values mirror upstream +``ext/src/ext_algebra/secondary.rs::tests::test_sphere_d2`` (the canonical +construction): over ``S_2`` resolved through stem ``(16, 6)``, + * ``h0 = (n=0, s=1)``, ``h1 = (n=1, s=1)``, ``h2 = (n=3, s=1)`` are permanent + cycles (``survives == True``, ``d2`` is the zero class), and + * the first Adams differential is ``d2(h4) = h0·h3²``, the nonzero generator + of ``Ext^{3,17}`` at ``(n=14, s=3)`` (dimension 1), so ``h4 = (n=15, s=1)`` + does not survive. + +Every uncomputed / negative / extend_all-not-called input is pre-checked and +raises ``ValueError`` rather than panicking across the FFI boundary. +""" + +import pytest + +import ext +from ext import fp, sseq + +Bidegree = sseq.Bidegree +BidegreeGenerator = sseq.BidegreeGenerator +BidegreeElement = sseq.BidegreeElement + + +def _resolution(n=16, s=6, backend="standard"): + r = ext.Resolution("S_2", backend) + return r + + +def build(n=16, s=6, via_without_unit=False): + """``SecondaryExtAlgebra`` of the mod-2 sphere, resolved through stem (n, s) + and fully extended (E3 pages built), exactly as upstream ``test_sphere_d2``. + """ + r = ext.Resolution("S_2", "standard") + if via_without_unit: + alg = ext.ExtAlgebra.without_unit(r) + else: + alg = ext.ExtAlgebra(r, r) + alg.compute_through_stem(Bidegree.n_s(n, s)) + sec = ext.SecondaryExtAlgebra(alg) + sec.extend_all() + return alg, sec + + +def gen(alg, n, s): + return alg.generator(BidegreeGenerator.n_s(n, s, 0)) + + +# --- construction & accessors --------------------------------------------- + + +def test_construct_and_prime(): + alg, sec = build(4, 4) + assert sec.prime == 2 + # ext_algebra() returns the bound ExtAlgebra (sharing resolutions). + e = sec.ext_algebra + assert isinstance(e, ext.ExtAlgebra) + assert e.prime == 2 + assert e.is_unit is True + + +def test_without_unit_builds_usable_d2_path(): + # without_unit(res) == new(res, res): is_unit True, and the d2 layer works. + alg, sec = build(16, 6, via_without_unit=True) + assert alg.is_unit is True + h0 = gen(alg, 0, 1) + assert sec.survives(h0) is True + + +def test_without_unit_rejects_nassau(): + r = ext.Resolution("S_2", "nassau") + with pytest.raises(ValueError): + ext.ExtAlgebra.without_unit(r) + + +# --- KNOWN d2 values (upstream test_sphere_d2) ----------------------------- + + +@pytest.mark.parametrize("n,s", [(0, 1), (1, 1), (3, 1)]) +def test_permanent_classes_survive(n, s): + # h0, h1, h2 are permanent cycles: survives == True and d2 is the zero class. + alg, sec = build(16, 6) + h = gen(alg, n, s) + assert sec.survives(h) is True + d = sec.d2(h) + assert d is not None + assert d.vec().is_zero + + +def test_h4_first_adams_differential(): + # d2(h4) = h0·h3², the nonzero generator of Ext^{3,17} at (n=14, s=3). + alg, sec = build(16, 6) + h4 = gen(alg, 15, 1) + d = sec.d2(h4) + assert d is not None + # target bidegree (n=14, s=3), dimension 1. + assert d.n == 14 + assert d.s == 3 + assert alg.dimension(Bidegree.n_s(14, 3)) == 1 + assert not d.vec().is_zero + # h4 does not survive. + assert sec.survives(h4) is False + + +# --- page_data / unit_page_data -------------------------------------------- + + +def test_page_data_returns_subquotient(): + alg, sec = build(16, 6) + sq = sec.page_data(Bidegree.n_s(0, 1)) + assert isinstance(sq, fp.Subquotient) + # h0 is a single surviving class at (0, 1). + assert sq.dimension == 1 + usq = sec.unit_page_data(Bidegree.n_s(0, 0)) + assert isinstance(usq, fp.Subquotient) + # The unit class survives at (0, 0). + assert usq.dimension == 1 + + +# --- SecondaryProduct round-trip ------------------------------------------- + + +def test_secondary_multiply_into_shapes(): + alg, sec = build(16, 6) + h0 = gen(alg, 0, 1) + # Multiply h0 into the unit at (0, 0): the unit class survives, so one product. + products = sec.secondary_multiply_into(h0, Bidegree.n_s(0, 0)) + assert isinstance(products, list) + assert len(products) == 1 + p = products[0] + assert isinstance(p, ext.SecondaryProduct) + assert isinstance(p.source, BidegreeElement) + assert isinstance(p.ext_part, fp.FpVector) + assert isinstance(p.lambda_part, fp.FpVector) + # source lives at the queried bidegree (0, 0). + assert p.source.n == 0 + assert p.source.s == 0 + + +def test_secondary_multiply_into_empty_when_none_survive(): + alg, sec = build(16, 6) + h0 = gen(alg, 0, 1) + # A computed bidegree with no surviving unit classes yields an empty list. + products = sec.secondary_multiply_into(h0, Bidegree.n_s(2, 1)) + assert isinstance(products, list) + + +# --- panic guards: extend_all not called ----------------------------------- + + +def _unextended(): + r = ext.Resolution("S_2", "standard") + alg = ext.ExtAlgebra(r, r) + alg.compute_through_stem(Bidegree.n_s(8, 6)) + sec = ext.SecondaryExtAlgebra(alg) # NOT extended + return alg, sec + + +def test_d2_before_extend_all_raises(): + alg, sec = _unextended() + h0 = gen(alg, 0, 1) + with pytest.raises(ValueError, match="extend_all"): + sec.d2(h0) + + +def test_survives_before_extend_all_raises(): + alg, sec = _unextended() + h0 = gen(alg, 0, 1) + with pytest.raises(ValueError, match="extend_all"): + sec.survives(h0) + + +def test_page_data_before_extend_all_raises(): + alg, sec = _unextended() + with pytest.raises(ValueError, match="extend_all"): + sec.page_data(Bidegree.n_s(0, 1)) + + +def test_unit_page_data_before_extend_all_raises(): + alg, sec = _unextended() + with pytest.raises(ValueError, match="extend_all"): + sec.unit_page_data(Bidegree.n_s(0, 0)) + + +def test_secondary_multiply_into_before_extend_all_raises(): + alg, sec = _unextended() + h0 = gen(alg, 0, 1) + with pytest.raises(ValueError, match="extend_all"): + sec.secondary_multiply_into(h0, Bidegree.n_s(0, 0)) + + +# --- panic guards: negative / malformed bidegrees -------------------------- + + +def test_page_data_negative_bidegree_raises(): + alg, sec = build(4, 4) + with pytest.raises(ValueError, match="s >= 0 and t >= 0"): + sec.page_data(Bidegree.s_t(-1, 0)) + + +def test_unit_page_data_negative_bidegree_raises(): + alg, sec = build(4, 4) + with pytest.raises(ValueError, match="s >= 0 and t >= 0"): + sec.unit_page_data(Bidegree.s_t(0, -1)) + + +def test_secondary_multiply_into_negative_bidegree_raises(): + alg, sec = build(4, 4) + h0 = gen(alg, 0, 1) + with pytest.raises(ValueError, match="s >= 0 and t >= 0"): + sec.secondary_multiply_into(h0, Bidegree.s_t(-1, 0)) + + +def test_compute_partial_negative_raises(): + alg, sec = _unextended() + with pytest.raises(ValueError, match="s >= 0"): + sec.compute_partial(-1) + + +def test_d2_uncomputed_element_raises(): + # An element at a bidegree the resolution has not computed is rejected. + alg, sec = build(4, 4) + # Construct an element far out of the computed range by hand. + far = Bidegree.n_s(100, 1) + vec = fp.FpVector.from_slice(2, [0]) + elem = BidegreeElement(far, vec) + with pytest.raises(ValueError): + sec.d2(elem) + + +def test_d2_uncomputed_target_returns_none(): + # A valid computed class whose d2 *target* is out of range yields None + # (uncomputed differential), not a panic. At the far edge of the computed + # stem the d2 target (n-1, s+2) is unresolved. + alg, sec = build(16, 6) + # (n=15, s=6) is computed; its d2 target (14, 8) is beyond the stem (16, 6). + if alg.dimension(Bidegree.n_s(15, 6)) > 0: + h = gen(alg, 15, 6) + # Either None (uncomputed target) or a computed class; must not raise. + sec.d2(h) + + +# --- d2-path element guards: prime / coord-count (check_res_element) -------- + + +def test_d2_cross_prime_operand_rejected(): + # SecondaryExtAlgebra.check_res_element reuses ExtAlgebra::check_element: an + # element whose underlying FpVector is over a different prime than the + # algebra (p=3 vs the p=2 SecondaryExtAlgebra) is rejected with ValueError, + # not a panic. (0, 1) IS computed, but the prime check precedes the + # uncomputed-bidegree check, so the prime branch is what fires. Mirrors + # test_ext_algebra.py::test_multiply_cross_prime_operand_rejected. + alg, sec = build(4, 4) + over_p3 = BidegreeElement(Bidegree.n_s(0, 1), fp.FpVector(3, 1)) + with pytest.raises(ValueError, match="over prime 3 but the ExtAlgebra is over prime 2"): + sec.d2(over_p3) + with pytest.raises(ValueError, match="over prime 3 but the ExtAlgebra is over prime 2"): + sec.survives(over_p3) + + +def test_d2_coord_count_mismatch_rejected(): + # check_res_element coord-count branch: a class at a computed bidegree whose + # vector length differs from the generator count there is rejected with + # ValueError. At (n=0, s=1) the dimension is 1 (h0); a length-2 vector over + # the right prime trips the coord-count check (which follows the + # has_computed_bidegree check, so the bidegree must be computed first). + alg, sec = build(4, 4) + assert alg.dimension(Bidegree.n_s(0, 1)) == 1 + wrong_len = BidegreeElement(Bidegree.n_s(0, 1), fp.FpVector.from_slice(2, [0, 0])) + with pytest.raises(ValueError, match="coordinate.*but there are 1 generator"): + sec.d2(wrong_len) + with pytest.raises(ValueError, match="coordinate.*but there are 1 generator"): + sec.survives(wrong_len) + + +# --- shared-instance identity (Arc storage) -------------------------------- + + +def test_ext_algebra_shares_instance(): + # The Arc-storage refactor makes SecondaryExtAlgebra hold the SAME ExtAlgebra + # instance it was built on, and ext_algebra() return it back with stable + # identity (shared resolution/unit Arcs and product cache). Confirm via + # observable equivalence: same prime / is_unit / dimension at a computed + # bidegree as the algebra passed to the constructor. + alg, sec = build(8, 6) + e = sec.ext_algebra + assert e.prime == alg.prime + assert e.is_unit == alg.is_unit + b = Bidegree.n_s(0, 1) + assert e.dimension(b) == alg.dimension(b) diff --git a/ext_py/tests/test_sseq.py b/ext_py/tests/test_sseq.py new file mode 100644 index 0000000000..8cb9b53f0f --- /dev/null +++ b/ext_py/tests/test_sseq.py @@ -0,0 +1,456 @@ +"""Tests for the §6.2 spectral-sequence bindings in ``sseq``. + +Covers the ``Sseq`` spectral sequence (monomorphized as ``Sseq<2, Adams>``), +the ``Differential`` and ``Product`` helper types, and the ``Adams`` / +``SseqProfile`` profile markers. Each guarded precondition is exercised to +confirm a clean exception is raised rather than a panic crossing the FFI +boundary. +""" + +import pytest + +from ext import fp, sseq + +Bidegree = sseq.Bidegree +BidegreeElement = sseq.BidegreeElement +FpVector = fp.FpVector +Matrix = fp.Matrix + + +def vec(p, entries): + return FpVector.from_slice(p, entries) + + +def elem(b, p, entries): + return BidegreeElement(b, vec(p, entries)) + + +# -------------------------------------------------------------------------- +# Adams / SseqProfile profile markers +# -------------------------------------------------------------------------- + + +def test_adams_profile_arithmetic(): + assert sseq.Adams.MIN_R == 2 + b = Bidegree.x_y(3, 1) + target = sseq.Adams.profile(2, b) + assert target == Bidegree.x_y(2, 3) + assert sseq.Adams.profile_inverse(2, target) == b + assert sseq.Adams.differential_length(Bidegree.x_y(-1, 2)) == 2 + + +def test_sseq_profile_default_is_adams(): + default = sseq.SseqProfile.default() + assert isinstance(default, sseq.Adams) + + +def test_bidegree_zero(): + z = Bidegree.zero() + assert z.s == 0 + assert z.t == 0 + assert z == Bidegree.s_t(0, 0) + + +# -------------------------------------------------------------------------- +# Sseq: construction and dimensions +# -------------------------------------------------------------------------- + + +def test_sseq_valid_and_invalid_prime(): + s = sseq.Sseq(2) + assert s.prime == 2 + with pytest.raises(ValueError): + sseq.Sseq(4) + with pytest.raises(ValueError): + sseq.Sseq(0) + + +def test_set_get_dimension(): + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(1, 0), 2) + assert s.dimension(Bidegree.x_y(1, 0)) == 2 + assert s.get_dimension(Bidegree.x_y(1, 0)) == 2 + assert s.defined(Bidegree.x_y(1, 0)) + + # Undefined bidegree: get_dimension is None, dimension raises. + assert s.get_dimension(Bidegree.x_y(9, 9)) is None + assert not s.defined(Bidegree.x_y(9, 9)) + with pytest.raises(IndexError): + s.dimension(Bidegree.x_y(9, 9)) + + # Re-defining a bidegree is a clean error, not a panic. + with pytest.raises(ValueError): + s.set_dimension(Bidegree.x_y(1, 0), 3) + + +def test_min_max_iter_degrees(): + s = sseq.Sseq(2) + for b in [Bidegree.x_y(0, 0), Bidegree.x_y(2, 1), Bidegree.x_y(1, 0)]: + s.set_dimension(b, 1) + assert s.min == Bidegree.x_y(0, 0) + assert s.max == Bidegree.x_y(2, 1) + degrees = s.iter_degrees() + assert len(degrees) == 3 + assert Bidegree.x_y(1, 0) in degrees + + +def test_clear(): + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(0, 0), 1) + s.add_permanent_class(elem(Bidegree.x_y(0, 0), 2, [1])) + s.clear() + # After clear, the bidegree is still defined but the page data is invalid. + assert s.defined(Bidegree.x_y(0, 0)) + assert s.invalid(Bidegree.x_y(0, 0)) + + +# -------------------------------------------------------------------------- +# Sseq: a small worked example (mirrors upstream test_sseq_differential_2) +# -------------------------------------------------------------------------- + + +def make_small_sseq(): + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(0, 0), 0) + s.set_dimension(Bidegree.x_y(1, 0), 2) + s.set_dimension(Bidegree.x_y(0, 1), 0) + s.set_dimension(Bidegree.x_y(0, 2), 2) + # d_2([1,0]) = [1,0], d_2([0,1]) = [1,1] from (1,0) to (0,2). + assert s.add_differential(2, elem(Bidegree.x_y(1, 0), 2, [1, 0]), vec(2, [1, 0])) + assert s.add_differential(2, elem(Bidegree.x_y(1, 0), 2, [0, 1]), vec(2, [1, 1])) + s.update() + return s + + +def test_add_differential_and_page_data(): + s = make_small_sseq() + # E_2 at (1,0) is the full 2-dimensional space; E_3 collapses to 0. + assert s.page_data(Bidegree.x_y(1, 0), 2).dimension == 2 + assert s.page_data(Bidegree.x_y(1, 0), 3).dimension == 0 + # (0,2) is killed too. + assert s.page_data(Bidegree.x_y(0, 2), 2).dimension == 2 + assert s.page_data(Bidegree.x_y(0, 2), 3).dimension == 0 + + +def test_differentials_and_hitting(): + s = make_small_sseq() + diffs = s.differentials(Bidegree.x_y(1, 0)) + assert len(diffs) >= 1 + assert isinstance(diffs[0], sseq.Differential) + # (0,2) is hit by the d_2 out of (1,0). + hitting = s.differentials_hitting(Bidegree.x_y(0, 2)) + assert any(r == 2 for (r, _d) in hitting) + + +def test_update_degree_returns_drawn_differentials(): + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(0, 0), 0) + s.set_dimension(Bidegree.x_y(1, 0), 2) + s.set_dimension(Bidegree.x_y(0, 1), 0) + s.set_dimension(Bidegree.x_y(0, 2), 2) + s.add_differential(2, elem(Bidegree.x_y(1, 0), 2, [1, 0]), vec(2, [1, 0])) + drawn = s.update_degree(Bidegree.x_y(1, 0)) + # A list (per page) of lists (per generator) of target coordinate lists. + assert isinstance(drawn, list) + + +def test_complete_and_inconsistent(): + s = make_small_sseq() + # complete returns a bool without panicking on a defined degree. + assert isinstance(s.complete(Bidegree.x_y(1, 0)), bool) + assert s.inconsistent(Bidegree.x_y(1, 0)) is False + + +def test_permanent_classes(): + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(0, 0), 2) + assert s.add_permanent_class(elem(Bidegree.x_y(0, 0), 2, [1, 0])) + # Adding the same class again is not new. + assert not s.add_permanent_class(elem(Bidegree.x_y(0, 0), 2, [1, 0])) + classes = s.permanent_classes(Bidegree.x_y(0, 0)) + assert isinstance(classes, fp.Subspace) + assert classes.dimension == 1 + assert classes.contains(vec(2, [1, 0])) + + +# -------------------------------------------------------------------------- +# Sseq: guarded preconditions raise clean exceptions +# -------------------------------------------------------------------------- + + +def test_add_differential_guards(): + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(1, 0), 2) + s.set_dimension(Bidegree.x_y(0, 2), 2) + src = elem(Bidegree.x_y(1, 0), 2, [1, 0]) + + # Page below MIN_R. + with pytest.raises(ValueError): + s.add_differential(1, src, vec(2, [1, 0])) + # Target length mismatch (target dim is 2). + with pytest.raises(ValueError): + s.add_differential(2, src, vec(2, [1, 0, 1])) + # Undefined source bidegree. + with pytest.raises(IndexError): + s.add_differential(2, elem(Bidegree.x_y(5, 0), 2, [1, 0]), vec(2, [1, 0])) + + +def test_add_permanent_class_guards(): + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(0, 0), 2) + # Undefined bidegree. + with pytest.raises(IndexError): + s.add_permanent_class(elem(Bidegree.x_y(9, 9), 2, [1, 0])) + # Length mismatch. + with pytest.raises(ValueError): + s.add_permanent_class(elem(Bidegree.x_y(0, 0), 2, [1, 0, 1])) + + +def test_page_data_out_of_range(): + s = make_small_sseq() + with pytest.raises(IndexError): + s.page_data(Bidegree.x_y(1, 0), 99) + with pytest.raises(IndexError): + s.page_data(Bidegree.x_y(9, 9), 2) + + +# -------------------------------------------------------------------------- +# Product and multiply +# -------------------------------------------------------------------------- + + +def test_product_construction_and_getters(): + m = Matrix.from_vec(2, [[1]]) + prod = sseq.Product(Bidegree.x_y(1, 1), True, [(Bidegree.x_y(0, 0), m)]) + assert prod.b == Bidegree.x_y(1, 1) + assert prod.left is True + mats = prod.matrices + assert len(mats) == 1 + assert mats[0][0] == Bidegree.x_y(0, 0) + assert prod.get_matrix(Bidegree.x_y(0, 0)) is not None + assert prod.get_matrix(Bidegree.x_y(5, 5)) is None + + # Duplicate bidegree -> ValueError. + with pytest.raises(ValueError): + sseq.Product( + Bidegree.x_y(1, 1), + True, + [(Bidegree.x_y(0, 0), Matrix.from_vec(2, [[1]])), + (Bidegree.x_y(0, 0), Matrix.from_vec(2, [[1]]))], + ) + + +def test_multiply(): + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(0, 0), 1) + s.set_dimension(Bidegree.x_y(1, 1), 1) + # Multiplication by a class in bidegree (1,1): 1x1 identity at (0,0). + prod = sseq.Product( + Bidegree.x_y(1, 1), True, [(Bidegree.x_y(0, 0), Matrix.from_vec(2, [[1]]))] + ) + result = s.multiply(elem(Bidegree.x_y(0, 0), 2, [1]), prod) + assert result is not None + assert result.degree == Bidegree.x_y(1, 1) + assert result.vec().entry(0) == 1 + + # No matrix at this source bidegree -> None. + assert s.multiply(elem(Bidegree.x_y(1, 1), 2, [1]), prod) is None + + +# -------------------------------------------------------------------------- +# Differential helper type +# -------------------------------------------------------------------------- + + +def test_differential_round_trip(): + d = sseq.Differential(2, 2, 1) + assert d.prime == 2 + assert d.source_dim == 2 + assert d.target_dim == 1 + + # d([1,0]) = [1]. + assert d.add(vec(2, [1, 0]), vec(2, [1])) + # Same differential again is not new. + assert not d.add(vec(2, [1, 0]), vec(2, [1])) + + # evaluate writes into the target. + out = FpVector(2, 1) + d.evaluate(vec(2, [1, 0]), out) + assert out.entry(0) == 1 + + pairs = d.get_source_target_pairs() + assert len(pairs) == 1 + src, tgt = pairs[0] + assert [src.entry(0), src.entry(1)] == [1, 0] + assert tgt.entry(0) == 1 + + # quasi_inverse returns a preimage of length source_dim. + preimage = d.quasi_inverse(vec(2, [1])) + assert preimage.len == 2 + check = FpVector(2, 1) + d.evaluate(preimage, check) + assert check.entry(0) == 1 + + +def test_differential_set_to_zero_and_inconsistent(): + d = sseq.Differential(2, 1, 1) + d.add(vec(2, [1]), vec(2, [1])) + assert d.get_source_target_pairs() + d.set_to_zero() + assert d.get_source_target_pairs() == [] + assert d.inconsistent is False + + +def test_differential_guards(): + d = sseq.Differential(2, 2, 1) + # Wrong source length. + with pytest.raises(ValueError): + d.add(vec(2, [1, 0, 1]), None) + # Wrong target length. + with pytest.raises(ValueError): + d.add(vec(2, [1, 0]), vec(2, [1, 1])) + # Prime mismatch. + with pytest.raises(ValueError): + d.add(vec(3, [1, 0]), None) + # quasi_inverse with wrong value length. + with pytest.raises(ValueError): + d.quasi_inverse(vec(2, [1, 1])) + + +def test_differential_invalid_prime(): + with pytest.raises(ValueError): + sseq.Differential(4, 1, 1) + + +# -------------------------------------------------------------------------- +# Leibniz rule +# -------------------------------------------------------------------------- + + +def test_leibniz_permanent_product(): + # A permanent class times a permanent product yields a new permanent class. + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(0, 0), 1) + s.set_dimension(Bidegree.x_y(1, 1), 1) + s.add_permanent_class(elem(Bidegree.x_y(0, 0), 2, [1])) + prod = sseq.Product( + Bidegree.x_y(1, 1), True, [(Bidegree.x_y(0, 0), Matrix.from_vec(2, [[1]]))] + ) + # r = i32::MAX signals a permanent source; target_product=None means the + # product is permanent too. + result = s.leibniz((1 << 31) - 1, elem(Bidegree.x_y(0, 0), 2, [1]), prod, None) + assert result is not None + r, new_class = result + assert new_class.degree == Bidegree.x_y(1, 1) + assert s.permanent_classes(Bidegree.x_y(1, 1)).dimension == 1 + + +def test_leibniz_guards(): + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(0, 0), 1) + prod = sseq.Product(Bidegree.x_y(1, 1), True, []) + # Undefined source bidegree. + with pytest.raises(IndexError): + s.leibniz(2, elem(Bidegree.x_y(9, 9), 2, [1]), prod, None) + + +def test_leibniz_product_prime_mismatch(): + # A product matrix over the wrong prime raises a clear ValueError + # ("product prime mismatch") rather than an opaque catch_unwind message. + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(0, 0), 1) + s.set_dimension(Bidegree.x_y(1, 1), 1) + # Matrix over F_3 but the Sseq is over F_2. + bad_prod = sseq.Product( + Bidegree.x_y(1, 1), True, [(Bidegree.x_y(0, 0), Matrix.from_vec(3, [[1]]))] + ) + with pytest.raises(ValueError, match="product prime mismatch"): + s.leibniz((1 << 31) - 1, elem(Bidegree.x_y(0, 0), 2, [1]), bad_prod, None) + # The source-product check also covers the differential (target) product. + good_prod = sseq.Product( + Bidegree.x_y(1, 1), True, [(Bidegree.x_y(0, 0), Matrix.from_vec(2, [[1]]))] + ) + with pytest.raises(ValueError, match="product prime mismatch"): + s.leibniz( + 2, + elem(Bidegree.x_y(0, 0), 2, [1]), + good_prod, + bad_prod, + ) + # The Sseq is untouched: the wrong-prime product was rejected before any + # mutation, so a subsequent read still works. + assert s.dimension(Bidegree.x_y(0, 0)) == 1 + + +# -------------------------------------------------------------------------- +# Fix 1: d_r for r >= 3 must guard *intermediate* target bidegrees +# -------------------------------------------------------------------------- + + +def test_add_differential_r3_undefined_intermediate_raises_index_error(): + # A d_3 out of (1,0) makes upstream index profile(2,(1,0)) = (0,2) and + # profile(3,(1,0)) = (0,3). With (0,2) undefined (but (0,3) defined!), the + # binding used to pass its single final-target check and then panic in + # MultiIndexed. It must now raise a clean IndexError naming (0, 2). + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(1, 0), 2) + # (0,2) deliberately NOT defined. + s.set_dimension(Bidegree.x_y(0, 3), 2) + + assert sseq.Adams.profile(2, Bidegree.x_y(1, 0)) == Bidegree.x_y(0, 2) + assert sseq.Adams.profile(3, Bidegree.x_y(1, 0)) == Bidegree.x_y(0, 3) + + src = elem(Bidegree.x_y(1, 0), 2, [1, 0]) + with pytest.raises(IndexError): + s.add_differential(3, src, vec(2, [1, 0])) + + +def test_add_differential_r3_all_defined_succeeds(): + # With every intermediate (profile(2,..)=(0,2)) and final + # (profile(3,..)=(0,3)) target defined, the d_3 is recorded and shows up + # as a differential hitting (0,3); the source class is killed on E_4. + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(1, 0), 1) + s.set_dimension(Bidegree.x_y(0, 2), 1) + s.set_dimension(Bidegree.x_y(0, 3), 1) + + assert s.add_differential(3, elem(Bidegree.x_y(1, 0), 2, [1]), vec(2, [1])) + s.update() + + hitting = s.differentials_hitting(Bidegree.x_y(0, 3)) + assert any(r == 3 for (r, _d) in hitting) + # E_3 at (1,0) still has the class; E_4 has it killed (it supports a d_3). + assert s.page_data(Bidegree.x_y(1, 0), 3).dimension == 1 + assert s.page_data(Bidegree.x_y(1, 0), 4).dimension == 0 + # (0,3) is a boundary on E_4. + assert s.page_data(Bidegree.x_y(0, 3), 4).dimension == 0 + + +# -------------------------------------------------------------------------- +# Other review gaps: shape-mismatched multiply, aliased evaluate +# -------------------------------------------------------------------------- + + +def test_multiply_shape_mismatch_raises(): + # A product matrix whose row/column count disagrees with the source/target + # dimensions must raise a clean error instead of panicking in Matrix::apply. + s = sseq.Sseq(2) + s.set_dimension(Bidegree.x_y(0, 0), 2) + s.set_dimension(Bidegree.x_y(1, 1), 2) + # Source dim is 2 but the matrix has a single row. + prod = sseq.Product( + Bidegree.x_y(1, 1), True, [(Bidegree.x_y(0, 0), Matrix.from_vec(2, [[1, 0]]))] + ) + with pytest.raises(ValueError): + s.multiply(elem(Bidegree.x_y(0, 0), 2, [1, 0]), prod) + + +def test_differential_evaluate_aliased_source_target_raises(): + # Passing the same FpVector as both source and target violates PyO3's + # borrow rules (one shared, one exclusive borrow of the same object) and + # must surface as a RuntimeError, not UB. + d = sseq.Differential(2, 2, 2) + d.add(vec(2, [1, 0]), vec(2, [1, 0])) + shared = vec(2, [1, 0]) + with pytest.raises(RuntimeError): + d.evaluate(shared, shared) diff --git a/ext_py/tests/test_steenrod_algebra.py b/ext_py/tests/test_steenrod_algebra.py new file mode 100644 index 0000000000..9011a0007d --- /dev/null +++ b/ext_py/tests/test_steenrod_algebra.py @@ -0,0 +1,343 @@ +import pytest + +from ext import algebra, fp + + +def make(variant, p=2, degree=8): + if variant == "adem": + a = algebra.SteenrodAlgebra.adem(p) + else: + a = algebra.SteenrodAlgebra.milnor(p) + a.compute_basis(degree) + return a + + +VARIANTS = ["adem", "milnor"] + + +def test_construction_and_variant(): + adem = algebra.SteenrodAlgebra.adem(2) + assert adem.prime == 2 + assert adem.algebra_type() == algebra.AlgebraType.Adem + + milnor = algebra.SteenrodAlgebra.milnor(3) + assert milnor.prime == 3 + assert milnor.algebra_type() == algebra.AlgebraType.Milnor + + +def test_invalid_prime_raises(): + for bad in (4, 0, 1): + with pytest.raises(ValueError): + algebra.SteenrodAlgebra.adem(bad) + with pytest.raises(ValueError): + algebra.SteenrodAlgebra.milnor(bad) + + +def test_prime_is_plain_int(): + a = algebra.SteenrodAlgebra.milnor(2) + p = a.prime + assert isinstance(p, int) + assert p == 2 + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_p_tilde_is_plain_int(variant): + a = make(variant, 2, 8) + pt = a.p_tilde() + assert isinstance(pt, int) + assert pt >= 0 + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_compute_basis_and_dimension(variant): + a = make(variant, 2, 8) + assert a.dimension(0) == 1 + assert a.dimension(1) == 1 + assert a.dimension(2) == 1 + assert a.dimension(3) == 2 + # Negative degree is empty, not an error. + assert a.dimension(-2) == 0 + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_multiply_basis_elements_known_results(variant): + a = make(variant, 2, 8) + + # Sq1 * Sq1 = 0 in degree 2. + v = fp.FpVector(2, a.dimension(2)) + a.multiply_basis_elements(v, 1, 1, 0, 1, 0) + assert list(v) == [0] + + # Sq1 * Sq2 = Sq3 in degree 3 (a single basis term in either basis). + deg3, sq3_idx = a.basis_element_from_string("Sq3") + assert deg3 == 3 + v = fp.FpVector(2, a.dimension(3)) + a.multiply_basis_elements(v, 1, 1, 0, 2, 0) + assert v[sq3_idx] == 1 + assert sum(v) == 1 + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_multiply_element_families_via_fpvector(variant): + a = make(variant, 2, 8) + + # multiply_element_by_element using full FpVector inputs: Sq1 * Sq2 = Sq3. + r = fp.FpVector.from_slice(2, [1]) # Sq1 (degree 1, dim 1) + s = fp.FpVector.from_slice(2, [1]) # Sq2 (degree 2, dim 1) + out = fp.FpVector(2, a.dimension(3)) + a.multiply_element_by_element(out, 1, 1, r, 2, s) + _, sq3_idx = a.basis_element_from_string("Sq3") + assert out[sq3_idx] == 1 + + # multiply_basis_element_by_element with an FpVector element. + s = fp.FpVector.from_slice(2, [1]) + out = fp.FpVector(2, a.dimension(3)) + a.multiply_basis_element_by_element(out, 1, 1, 0, 2, s) + assert out[sq3_idx] == 1 + + # multiply_element_by_basis_element. + r = fp.FpVector.from_slice(2, [1]) + out = fp.FpVector(2, a.dimension(3)) + a.multiply_element_by_basis_element(out, 1, 1, r, 2, 0) + assert out[sq3_idx] == 1 + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_multiply_accepts_fpslice_and_fpslicemut(variant): + a = make(variant, 2, 8) + + s_vec = fp.FpVector.from_slice(2, [1]) # Sq2 + s_slice = s_vec.slice(0, 1) + + result_vec = fp.FpVector(2, a.dimension(3)) + result_slice = result_vec.slice_mut(0, a.dimension(3)) + a.multiply_basis_element_by_element(result_slice, 1, 1, 0, 2, s_slice) + _, sq3_idx = a.basis_element_from_string("Sq3") + assert result_vec[sq3_idx] == 1 + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_multiply_prime_and_length_errors(variant): + a = make(variant, 2, 8) + + # Prime mismatch on the result. + wrong_prime = fp.FpVector(3, a.dimension(2)) + with pytest.raises(ValueError): + a.multiply_basis_elements(wrong_prime, 1, 1, 0, 1, 0) + + # Result too short. + short = fp.FpVector(2, 0) + with pytest.raises(ValueError): + a.multiply_basis_elements(short, 1, 2, 0, 2, 0) + + # Out-of-range basis index. + ok = fp.FpVector(2, a.dimension(2)) + with pytest.raises(IndexError): + a.multiply_basis_elements(ok, 1, 1, 99, 1, 0) + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_multiply_large_coeff_does_not_overflow(variant): + a = make(variant, 2, 8) + _, sq3_idx = a.basis_element_from_string("Sq3") + + big = fp.FpVector(2, a.dimension(3)) + a.multiply_basis_elements(big, 0xFFFFFFFF, 1, 0, 2, 0) # odd coeff -> 1 + assert big[sq3_idx] == 1 + + even = fp.FpVector(2, a.dimension(3)) + a.multiply_basis_elements(even, 0xFFFFFFFE, 1, 0, 2, 0) # even coeff -> 0 + assert list(even) == [0, 0] + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_basis_element_string_roundtrip(variant): + a = make(variant, 2, 8) + for d in range(9): + for i in range(a.dimension(d)): + s = a.basis_element_to_string(d, i) + assert a.basis_element_from_string(s) == (d, i) + + with pytest.raises(ValueError): + a.basis_element_from_string("not a valid element ###") + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_basis_element_from_string_absent_names_raise(variant): + # Parseable-but-absent names must raise ValueError, never panic across the + # FFI boundary. + a = make(variant, 2, 8) + with pytest.raises(ValueError): + a.basis_element_from_string("Sq0") + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_basis_element_to_string_out_of_range(variant): + a = make(variant, 2, 8) + with pytest.raises(IndexError): + a.basis_element_to_string(2, 99) + with pytest.raises(IndexError): + a.basis_element_to_string(-1, 0) + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_decompose_basis_element_guards(variant): + a = make(variant, 2, 8) + # The degree-0 unit is indecomposable -> ValueError, not a panic. + with pytest.raises(ValueError): + a.decompose_basis_element(0, 0) + # A non-generator decomposes into a list of triples. + decomp = a.decompose_basis_element(3, 0) + assert isinstance(decomp, list) + assert all(len(t) == 3 for t in decomp) + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_decompose_generator_raises_consistently(variant): + # Both variants reject generators identically: Sq^2 / P(2) is the degree-2 + # generator (idx 0). Previously the Milnor variant returned a degenerate + # self-term while Adem raised; the generators-based guard unifies them. + a = make(variant, 2, 8) + assert 0 in a.generators(2) + with pytest.raises(ValueError): + a.decompose_basis_element(2, 0) + # A non-generator decomposable element still decomposes. + assert isinstance(a.decompose_basis_element(3, 0), list) + + +def test_milnor_q0_decompose_raises(): + # Q_0 (degree 1, idx 0) at an odd prime used to underflow-panic through the + # union (`prime().pow(i - 1)` with i == 0). It must raise ValueError. + a = make("milnor", 3, 8) + assert 0 in a.generators(1) + with pytest.raises(ValueError): + a.decompose_basis_element(1, 0) + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_generated_algebra_surface(variant): + a = make(variant, 2, 8) + assert a.generators(2) == [0] + assert isinstance(a.generator_to_string(2, 0), str) + assert isinstance(a.generating_relations(4), list) + assert a.generators(-1) == [] + assert a.generating_relations(-1) == [] + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_default_filtration_one_products(variant): + a = make(variant, 2, 8) + products = a.default_filtration_one_products + assert all(len(triple) == 3 for triple in products) + assert all(isinstance(name, str) for name, _, _ in products) + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_element_to_string(variant): + a = make(variant, 2, 8) + v = fp.FpVector.from_slice(2, [1, 1]) # degree 3 has dim 2 + text = a.element_to_string(3, v) + assert isinstance(text, str) + + with pytest.raises(ValueError): + a.element_to_string(3, fp.FpVector(2, 5)) + + +def test_coproduct_p2(): + a = make("adem", 2, 8) + assert a.coproduct(2, 0) == [(0, 0, 2, 0), (1, 0, 1, 0), (2, 0, 0, 0)] + assert a.decompose(2, 0) == [(2, 0)] + + +def test_coproduct_milnor_odd_prime_raises(): + a = make("milnor", 3, 8) + with pytest.raises(ValueError): + a.coproduct(0, 0) + + +def test_from_json_constructs_known_algebra(): + spec = {"p": 2} + adem = algebra.SteenrodAlgebra.from_json(spec, algebra.AlgebraType.Adem, False) + assert adem.prime == 2 + assert adem.algebra_type() == algebra.AlgebraType.Adem + + milnor = algebra.SteenrodAlgebra.from_json(spec, algebra.AlgebraType.Milnor, False) + assert milnor.prime == 2 + assert milnor.algebra_type() == algebra.AlgebraType.Milnor + + # from_json defaults unstable to False. + again = algebra.SteenrodAlgebra.from_json(spec, algebra.AlgebraType.Adem) + assert again.prime == 2 + + # The "algebra" allow-list is respected by the underlying constructor; a + # spec listing only milnor falls back to milnor even if adem is requested. + listed = {"p": 2, "algebra": ["milnor"]} + fallback = algebra.SteenrodAlgebra.from_json( + listed, algebra.AlgebraType.Adem, False + ) + assert fallback.algebra_type() == algebra.AlgebraType.Milnor + + +def test_from_json_accepts_string_algebra_type(): + spec = {"p": 2} + + # Plain strings are accepted, case-insensitively, as an alternative to the + # AlgebraType enum. + for s in ("adem", "ADEM", "Adem"): + a = algebra.SteenrodAlgebra.from_json(spec, s) + assert a.algebra_type() == algebra.AlgebraType.Adem + + for s in ("milnor", "MILNOR", "Milnor"): + m = algebra.SteenrodAlgebra.from_json(spec, s) + assert m.algebra_type() == algebra.AlgebraType.Milnor + + # The enum still works exactly as before. + enum_adem = algebra.SteenrodAlgebra.from_json(spec, algebra.AlgebraType.Adem) + assert enum_adem.algebra_type() == algebra.AlgebraType.Adem + + +def test_from_json_rejects_invalid_algebra_string(): + with pytest.raises(ValueError): + algebra.SteenrodAlgebra.from_json({"p": 2}, "foo") + + +def test_from_json_rejects_non_string_non_enum_algebra_type(): + with pytest.raises(TypeError): + algebra.SteenrodAlgebra.from_json({"p": 2}, 42) + + +def test_from_json_bad_prime_raises(): + # Upstream returns an opaque anyhow::Error for a bad prime, which the + # binding maps to RuntimeError (documented behavior); the Python value + # itself converts fine, so this is not a ValueError. + with pytest.raises(RuntimeError): + algebra.SteenrodAlgebra.from_json({"p": 4}, algebra.AlgebraType.Adem, False) + + +def test_from_json_rejects_non_dict(): + # A list converts to JSON fine, but upstream rejects the shape -> RuntimeError. + with pytest.raises(RuntimeError): + algebra.SteenrodAlgebra.from_json([1, 2, 3], algebra.AlgebraType.Adem, False) + + +def test_from_json_large_int_in_spec(): + # py_to_json now accepts ints in (i64::MAX, u64::MAX] via the u64 path + # rather than raising OverflowError. Such a value is not a valid prime, so + # upstream rejects it with a RuntimeError (the int conversion succeeds). + big = 2**63 + 1 # > i64::MAX, <= u64::MAX + with pytest.raises(RuntimeError): + algebra.SteenrodAlgebra.from_json({"p": big}, algebra.AlgebraType.Adem, False) + + # An int outside [i64::MIN, u64::MAX] is rejected by py_to_json itself with + # a ValueError (taxonomy), not OverflowError. + too_big = 2**64 + with pytest.raises(ValueError): + algebra.SteenrodAlgebra.from_json( + {"p": too_big}, algebra.AlgebraType.Adem, False + ) + + +def test_repr(): + a = algebra.SteenrodAlgebra.milnor(2) + assert isinstance(repr(a), str) + assert repr(a) diff --git a/ext_py/tests/test_steenrod_evaluator.py b/ext_py/tests/test_steenrod_evaluator.py new file mode 100644 index 0000000000..4d58898baa --- /dev/null +++ b/ext_py/tests/test_steenrod_evaluator.py @@ -0,0 +1,169 @@ +import pytest + +from ext import algebra, fp + + +def test_construction_valid_and_invalid_prime(): + ev = algebra.SteenrodEvaluator(2) + assert ev.prime == 2 + assert algebra.SteenrodEvaluator(3).prime == 3 + + # A non-prime must raise ValueError, never panic. + with pytest.raises(ValueError): + algebra.SteenrodEvaluator(4) + with pytest.raises(ValueError): + algebra.SteenrodEvaluator(0) + with pytest.raises(ValueError): + algebra.SteenrodEvaluator(1) + + +def test_evaluate_algebra_adem_known_value(): + ev = algebra.SteenrodEvaluator(2) + degree, vec = ev.evaluate_algebra_adem("Sq2 * Sq2") + assert isinstance(degree, int) + assert degree == 4 + assert isinstance(vec, fp.FpVector) + # Sq2 Sq2 = Sq3 Sq1 (one admissible monomial); the Adem element is nonzero. + a = algebra.AdemAlgebra(2) + a.compute_basis(degree) + assert a.element_to_string(degree, vec) == "Sq3 Sq1" + + +def test_evaluate_algebra_adem_zero(): + ev = algebra.SteenrodEvaluator(2) + # Sq1 Sq1 = 0 by the Adem relations, but it still lives in degree 2. + degree, vec = ev.evaluate_algebra_adem("Sq1 * Sq1") + assert degree == 2 + assert vec.is_zero + + +def test_evaluate_algebra_milnor_known_value(): + ev = algebra.SteenrodEvaluator(2) + degree, vec = ev.evaluate_algebra_milnor("Sq2 * Sq2") + assert degree == 4 + m = algebra.MilnorAlgebra(2) + m.compute_basis(degree) + assert m.element_to_string(degree, vec) == "P(1, 1)" + + +def test_evaluate_module_adem_returns_dict(): + ev = algebra.SteenrodEvaluator(2) + result = ev.evaluate_module_adem("Sq2 * x0 + x1") + assert isinstance(result, dict) + assert set(result) == {"x0", "x1"} + deg0, vec0 = result["x0"] + assert deg0 == 2 + assert isinstance(vec0, fp.FpVector) + deg1, vec1 = result["x1"] + assert deg1 == 0 + + +def test_adem_milnor_roundtrip(): + ev = algebra.SteenrodEvaluator(2) + degree, adem_vec = ev.evaluate_algebra_adem("Sq2 * Sq2") + milnor_vec = ev.adem_to_milnor(degree, adem_vec) + assert isinstance(milnor_vec, fp.FpVector) + m = algebra.MilnorAlgebra(2) + m.compute_basis(degree) + assert m.element_to_string(degree, milnor_vec) == "P(1, 1)" + + back = ev.milnor_to_adem(degree, milnor_vec) + a = algebra.AdemAlgebra(2) + a.compute_basis(degree) + assert a.element_to_string(degree, back) == "Sq3 Sq1" + + +def test_change_basis_validates(): + ev = algebra.SteenrodEvaluator(2) + # Negative degree raises. + with pytest.raises(Exception): + ev.adem_to_milnor(-1, fp.FpVector(2, 0)) + # Length mismatch raises ValueError. + with pytest.raises(ValueError): + ev.adem_to_milnor(4, fp.FpVector(2, 99)) + # Prime mismatch raises ValueError. + with pytest.raises(ValueError): + ev.adem_to_milnor(4, fp.FpVector(3, 1)) + + +def test_malformed_input_raises_value_error(): + ev = algebra.SteenrodEvaluator(2) + with pytest.raises(ValueError): + ev.evaluate_algebra_adem("Sqx") + with pytest.raises(ValueError): + ev.evaluate_algebra_adem("Sq2 +") + with pytest.raises(ValueError): + ev.evaluate_algebra_milnor("not an expression!!") + with pytest.raises(ValueError): + ev.evaluate_module_adem("x0 + ") + + +def test_parse_algebra_tree(): + node = algebra.parse_algebra("Sq2 * Sq2") + assert node.kind() == "Product" + left = node.left + right = node.right + assert left.kind() == "BasisElt" + assert right.kind() == "BasisElt" + be = left.basis_element() + assert be.kind() == "P" + assert be.p() == 2 + # Wrong-shape accessors raise. + with pytest.raises(ValueError): + be.q() + with pytest.raises(ValueError): + node.scalar() + assert repr(node) + + +def test_parse_algebra_scalar_and_qlist(): + node = algebra.parse_algebra("3") + assert node.kind() == "Scalar" + assert node.scalar() == 3 + + q = algebra.parse_algebra("Q2") + be = q.basis_element() + assert be.kind() == "Q" + assert be.q() == 2 + + plist = algebra.parse_algebra("P(1, 0)") + be = plist.basis_element() + assert be.kind() == "PList" + assert be.p_list() == [1, 0] + + +def test_parse_module_tree(): + tree = algebra.parse_module("Sq2 * x0 + x1") + assert isinstance(tree, list) + assert len(tree) == 2 + node0, gen0 = tree[0] + assert gen0 == "x0" + assert node0.kind() == "BasisElt" + node1, gen1 = tree[1] + assert gen1 == "x1" + + +def test_parse_errors_raise(): + with pytest.raises(ValueError): + algebra.parse_algebra("Sq2 +") + with pytest.raises(ValueError): + algebra.parse_module("x0 + ") + + +def test_bockstein_or_sq_variants(): + node = algebra.parse_algebra("A(2 b 5)") + be = node.basis_element() + assert be.kind() == "AList" + items = be.a_list() + assert len(items) == 3 + assert all(isinstance(x, algebra.BocksteinOrSq) for x in items) + # Sq(2), Bockstein, Sq(5) + assert isinstance(items[0], algebra.BocksteinOrSq.Sq) + assert items[0]._0 == 2 + assert isinstance(items[1], algebra.BocksteinOrSq.Bockstein) + assert isinstance(items[2], algebra.BocksteinOrSq.Sq) + assert items[2]._0 == 5 + + # Variants are directly constructible (mirroring PorBockstein). + assert isinstance(algebra.BocksteinOrSq.Sq(7), algebra.BocksteinOrSq) + assert isinstance(algebra.BocksteinOrSq.Bockstein(), algebra.BocksteinOrSq) diff --git a/ext_py/tests/test_subquotient.py b/ext_py/tests/test_subquotient.py new file mode 100644 index 0000000000..a2e8575053 --- /dev/null +++ b/ext_py/tests/test_subquotient.py @@ -0,0 +1,228 @@ +import pytest + +from ext import fp + + +def test_construction_and_queries(): + sq = fp.Subquotient(3, 5) + assert sq.prime == 3 + assert isinstance(sq.prime, int) + assert sq.ambient_dimension == 5 + assert sq.dimension == 0 + assert len(sq) == 0 + assert sq.is_empty + assert repr(sq) == "Subquotient(3, dim=0, ambient=5)" + + +def test_new_full(): + sq = fp.Subquotient.new_full(2, 4) + assert sq.dimension == 4 + assert sq.ambient_dimension == 4 + assert sq.quotient_dimension == 4 + assert len(sq.gens) == 4 + + +def test_invalid_prime_raises(): + with pytest.raises(ValueError): + fp.Subquotient(4, 3) + with pytest.raises(ValueError): + fp.Subquotient.new_full(4, 3) + + +def test_add_gen_quotient_reduce_and_gens(): + # Mirrors the upstream `test_add_gen` example at p = 3, dim = 5. + sq = fp.Subquotient(3, 5) + sq.quotient(fp.FpVector.from_slice(3, [1, 1, 0, 0, 1])) + sq.quotient(fp.FpVector.from_slice(3, [0, 2, 0, 0, 1])) + sq.add_gen(fp.FpVector.from_slice(3, [1, 1, 0, 0, 0])) + sq.add_gen(fp.FpVector.from_slice(3, [0, 1, 0, 0, 0])) + + assert sq.dimension == 1 + gens = sq.gens + assert len(gens) == 1 + assert list(gens[0]) == [0, 0, 0, 0, 1] + + zeros = sq.zeros() + assert isinstance(zeros, fp.Subspace) + assert zeros.dimension == 2 + + # reduce returns the coefficients w.r.t. the generators and mutates the + # vector in place. + elt = fp.FpVector.from_slice(3, [2, 0, 0, 0, 0]) + assert sq.reduce(elt) == [2] + + # complement + quotient + gens cover the ambient space. + assert ( + sq.zeros().dimension + len(sq.gens) + len(sq.complement_pivots) + == sq.ambient_dimension + ) + + +def test_subspace_gens_quotient_pivots_and_dimension(): + # Non-trivial subquotient (non-empty quotient and one generator), mirroring + # the upstream `test_add_gen` example at p = 3, dim = 5. After the calls the + # upstream `Display` is: + # Generators: [0, 0, 0, 0, 1] + # Zeros: [1, 0, 0, 0, 2] + # [0, 1, 0, 0, 2] + sq = fp.Subquotient(3, 5) + sq.quotient(fp.FpVector.from_slice(3, [1, 1, 0, 0, 1])) + sq.quotient(fp.FpVector.from_slice(3, [0, 2, 0, 0, 1])) + sq.add_gen(fp.FpVector.from_slice(3, [1, 1, 0, 0, 0])) + sq.add_gen(fp.FpVector.from_slice(3, [0, 1, 0, 0, 0])) + + # dimension is the subspace-part generator count; quotient (zeros) dim is 2. + assert sq.dimension == 1 + assert sq.zeros().dimension == 2 + + # subspace_dimension == self.dimension + quotient.dimension per upstream + # `subquotient.rs::subspace_dimension`. + assert sq.subspace_dimension == sq.dimension + sq.zeros().dimension + assert sq.subspace_dimension == 3 + + # subspace_gens chains gens() with the quotient's basis vectors (upstream + # `subspace_gens` = `gens().chain(quotient.iter())`). + subspace_gens = [list(v) for v in sq.subspace_gens] + assert subspace_gens == [ + [0, 0, 0, 0, 1], + [1, 0, 0, 0, 2], + [0, 1, 0, 0, 2], + ] + + # quotient_pivots is the quotient subspace's pivot table: pivots[col] = row + # index of the pivot in that column, else -1. Quotient pivots are in cols 0,1. + assert sq.quotient_pivots == [0, 1, -1, -1, -1] + + +def test_clear_gens_keeps_quotient(): + sq = fp.Subquotient(3, 5) + sq.quotient(fp.FpVector.from_slice(3, [1, 1, 0, 0, 1])) + sq.add_gen(fp.FpVector.from_slice(3, [0, 1, 0, 0, 0])) + assert sq.dimension >= 1 + sq.clear_gens() + assert sq.dimension == 0 + assert sq.zeros().dimension == 1 + + +def test_set_to_full(): + sq = fp.Subquotient(2, 3) + sq.set_to_full() + # `set_to_full` makes the gens the entire space and clears the quotient, + # but (matching upstream) does not update the cached `dimension` counter. + assert sq.zeros().dimension == 0 + assert len(sq.gens) == 3 + + # Stale-`dimension` quirk: `set_to_full` makes gens the entire space and + # clears the quotient, but upstream does NOT update the cached `dimension` + # counter. So on a fresh Subquotient these are inconsistent today: + # dimension()/len(sq) report 0 (stale), while gens() actually has 3 rows. + # Pin the surprising current behavior so a future upstream fix (syncing the + # cached dimension) trips this test and prompts a revisit. + assert sq.dimension == 0 + assert len(sq) == 0 + assert len(sq.gens) == 3 + + +def test_from_parts(): + sub = fp.Subspace(2, 3) + sub.add_vector(fp.FpVector.from_slice(2, [1, 0, 0])) + sub.add_vector(fp.FpVector.from_slice(2, [0, 1, 0])) + quot = fp.Subspace(2, 3) + quot.add_vector(fp.FpVector.from_slice(2, [1, 0, 0])) + + sq = fp.Subquotient.from_parts(sub, quot) + assert sq.dimension == 1 + assert sq.ambient_dimension == 3 + + +def test_from_parts_mismatch_raises(): + sub = fp.Subspace(2, 3) + bad = fp.Subspace(2, 4) + with pytest.raises(ValueError): + fp.Subquotient.from_parts(sub, bad) + other_prime = fp.Subspace(3, 3) + with pytest.raises(ValueError): + fp.Subquotient.from_parts(sub, other_prime) + + +def test_invalid_vector_inputs_raise(): + sq = fp.Subquotient(3, 3) + with pytest.raises(ValueError): + sq.quotient(fp.FpVector.from_slice(5, [1, 0, 0])) + with pytest.raises(ValueError): + sq.add_gen(fp.FpVector.from_slice(3, [1, 0])) + with pytest.raises(ValueError): + sq.reduce(fp.FpVector.from_slice(3, [1, 0])) + + +def test_reduce_by_quotient(): + sq = fp.Subquotient(3, 3) + sq.quotient(fp.FpVector.from_slice(3, [1, 0, 0])) + v = fp.FpVector.from_slice(3, [1, 1, 0]) + sq.reduce_by_quotient(v) + assert list(v) == [0, 1, 0] + + +def test_reduce_by_quotient_slice_mut(): + sq = fp.Subquotient(3, 3) + sq.quotient(fp.FpVector.from_slice(3, [1, 0, 0])) + # Full-row slice: reduction must write through to the matrix. + m = fp.Matrix.from_vec(3, [[1, 1, 0]]) + sq.reduce_by_quotient(m.row_mut(0)) + assert list(m.row(0)) == [0, 1, 0] + # Sub-range slice: only the targeted columns are reduced in place. + m2 = fp.Matrix.from_vec(3, [[2, 1, 1, 0]]) + row = m2.row_mut(0) + sq.reduce_by_quotient(row.slice_mut(1, 4)) + assert list(m2.row(0)) == [2, 0, 1, 0] + + +def test_reduce_matrix(): + source = fp.Subquotient.new_full(3, 2) + target = fp.Subquotient.new_full(3, 2) + # identity matrix maps source ambient (rows) to target ambient (cols). + m = fp.Matrix.from_vec(3, [[1, 0], [0, 1]]) + result = fp.Subquotient.reduce_matrix(m, source, target) + assert len(result) == source.dimension + + +def test_reduce_matrix_values_with_nontrivial_quotient(): + # source = full space of dim 2 at p = 3, so gens() = [1, 0] and [0, 1]. + source = fp.Subquotient.new_full(3, 2) + + # target has a non-trivial quotient: quotient kills coordinate 1, generator + # is [1, 0]. So target.reduce projects out column 1 and reads coeff at col 0. + target = fp.Subquotient(3, 2) + target.quotient(fp.FpVector.from_slice(3, [0, 1])) + target.add_gen(fp.FpVector.from_slice(3, [1, 0])) + + # Non-identity matrix. `Matrix.apply` computes (input row-vector) * matrix: + # result = sum_i input[i] * row(i). So gen [1,0] -> row 0 = [2, 1]; + # gen [0,1] -> row 1 = [0, 1]. + m = fp.Matrix.from_vec(3, [[2, 1], [0, 1]]) + + # Reducing images in target: drop col 1 then read coeff at col 0. + # [2, 1] -> quotient -> [2, 0] -> coeff [2] + # [0, 1] -> quotient -> [0, 0] -> coeff [0] + result = fp.Subquotient.reduce_matrix(m, source, target) + assert result == [[2], [0]] + + +def test_reduce_matrix_dimension_mismatches_raise(): + source = fp.Subquotient.new_full(3, 2) + target = fp.Subquotient.new_full(3, 2) + + # rows != source.ambient_dimension (3 rows vs ambient 2). + bad_rows = fp.Matrix.from_vec(3, [[1, 0], [0, 1], [0, 0]]) + with pytest.raises(ValueError): + fp.Subquotient.reduce_matrix(bad_rows, source, target) + + # columns != target.ambient_dimension (3 cols vs ambient 2). + bad_cols = fp.Matrix.from_vec(3, [[1, 0, 0], [0, 1, 0]]) + with pytest.raises(ValueError): + fp.Subquotient.reduce_matrix(bad_cols, source, target) + + # prime mismatch between matrix and subquotients. + bad_prime = fp.Matrix.from_vec(5, [[1, 0], [0, 1]]) + with pytest.raises(ValueError): + fp.Subquotient.reduce_matrix(bad_prime, source, target) diff --git a/ext_py/tests/test_subspace.py b/ext_py/tests/test_subspace.py new file mode 100644 index 0000000000..dc540d21e2 --- /dev/null +++ b/ext_py/tests/test_subspace.py @@ -0,0 +1,173 @@ +import pytest + +from ext import fp + + +def test_subspace_construction_and_queries(): + s = fp.Subspace(3, 3) + assert s.prime == 3 + assert s.ambient_dimension == 3 + assert s.dimension == 0 + assert len(s) == 0 + assert repr(s) == "Subspace(3, dim=0, ambient=3)" + + +def test_subspace_entire_space(): + s = fp.Subspace.entire_space(2, 3) + assert s.dimension == 3 + assert s.ambient_dimension == 3 + + +def test_subspace_from_matrix(): + m = fp.Matrix.from_vec(3, [[1, 0, 0], [0, 1, 2]]) + s = fp.Subspace.from_matrix(m) + assert s.dimension == 2 + assert s.ambient_dimension == 3 + + +def test_add_vector_and_contains(): + s = fp.Subspace(3, 3) + v = fp.FpVector.from_slice(3, [1, 0, 0]) + assert s.add_vector(v) == 1 + assert s.contains(v) + assert v in s + + w = fp.FpVector.from_slice(3, [0, 1, 0]) + assert not s.contains(w) + assert w not in s + + # A scalar multiple is still contained. + twice = fp.FpVector.from_slice(3, [2, 0, 0]) + assert s.contains(twice) + + +def test_contains_space_and_sum(): + a = fp.Subspace(3, 3) + a.add_vector(fp.FpVector.from_slice(3, [1, 0, 0])) + b = fp.Subspace(3, 3) + b.add_vector(fp.FpVector.from_slice(3, [0, 1, 0])) + + assert not a.contains_space(b) + + # Incompatible prime/ambient dimension raise ValueError rather than panic. + with pytest.raises(ValueError): + a.contains_space(fp.Subspace(5, 3)) + with pytest.raises(ValueError): + a.contains_space(fp.Subspace(3, 4)) + + # The sum of two complementary lines is their 2-dimensional span. + s = a.sum(b) + assert s.dimension == 2 + assert s.contains_space(a) + assert s.contains_space(b) + assert s.contains(fp.FpVector.from_slice(3, [1, 0, 0])) + assert s.contains(fp.FpVector.from_slice(3, [0, 1, 0])) + assert s.dimension <= s.ambient_dimension + + # Overlapping subspaces: the sum's dimension is the union's rank. + c = fp.Subspace(3, 3) + c.add_vector(fp.FpVector.from_slice(3, [1, 0, 0])) + overlap = a.sum(c) + assert overlap.dimension == 1 + assert overlap.contains_space(a) + + +def test_reduce_in_place(): + s = fp.Subspace(3, 3) + s.add_vector(fp.FpVector.from_slice(3, [1, 0, 0])) + + v = fp.FpVector.from_slice(3, [2, 1, 0]) + s.reduce(v) + assert v[0] == 0 + assert v[1] == 1 + + +def test_set_to_zero_and_entire(): + s = fp.Subspace.entire_space(2, 3) + assert s.dimension == 3 + s.set_to_zero() + assert s.dimension == 0 + s.set_to_entire() + assert s.dimension == 3 + + +def test_iter_returns_basis_vectors(): + m = fp.Matrix.from_vec(3, [[1, 0, 0], [0, 1, 2]]) + s = fp.Subspace.from_matrix(m) + basis = s.iter() + assert [list(v) for v in basis] == [[1, 0, 0], [0, 1, 2]] + + +def test_basis_matches_iter(): + m = fp.Matrix.from_vec(3, [[1, 0, 0], [0, 1, 2]]) + s = fp.Subspace.from_matrix(m) + assert [list(v) for v in s.basis] == [[1, 0, 0], [0, 1, 2]] + assert [list(v) for v in s.basis] == [list(v) for v in s.iter()] + + +def test_contains_accepts_slice(): + m = fp.Matrix.from_vec(3, [[1, 0, 0], [0, 1, 2]]) + s = fp.Subspace.from_matrix(m) + v = fp.FpVector.from_slice(3, [1, 0, 0]) + # An owned FpVector and an FpSlice over it behave identically. + assert s.contains(v) + assert s.contains(v.slice(0, 3)) + w = fp.FpVector.from_slice(3, [0, 0, 1]) + assert not s.contains(w.slice(0, 3)) + # Slice of the wrong ambient dimension is rejected. + with pytest.raises(ValueError): + s.contains(w.slice(0, 2)) + + +def test_iter_all_vectors(): + m = fp.Matrix.from_vec(3, [[1, 0, 0], [0, 1, 2]]) + s = fp.Subspace.from_matrix(m) + + # iter_all_vectors returns a lazy iterator, not a list. + it = s.iter_all_vectors() + assert not isinstance(it, list) + assert iter(it) is it + + # Iterating via a for-loop yields every vector in the subspace. + collected = [] + for v in s.iter_all_vectors(): + collected.append(list(v)) + assert len(collected) == 9 + + # list(...) over the iterator yields the same set/count. + vectors = sorted(list(v) for v in s.iter_all_vectors()) + assert len(vectors) == 9 + assert sorted(collected) == vectors + assert [0, 0, 0] in vectors + assert [1, 1, 2] in vectors + + +def test_bytes_roundtrip(): + s = fp.Subspace(3, 3) + s.add_vector(fp.FpVector.from_slice(3, [1, 0, 0])) + data = s.to_bytes() + assert isinstance(data, bytes) + restored = fp.Subspace.from_bytes(3, data) + assert restored.dimension == 1 + assert restored.contains(fp.FpVector.from_slice(3, [1, 0, 0])) + + +def test_invalid_inputs_raise(): + s = fp.Subspace(3, 3) + + with pytest.raises(ValueError): + s.contains(fp.FpVector.from_slice(5, [1, 0, 0])) + + with pytest.raises(ValueError): + s.add_vector(fp.FpVector.from_slice(3, [1, 0])) + + with pytest.raises(ValueError): + fp.Subspace(1, 3) + + +def test_bytes_error_taxonomy_is_consistent(): + # Subspace mirrors FpVector/Matrix: both to_bytes and from_bytes map + # serialization failures to RuntimeError, so malformed bytes raise + # RuntimeError (not ValueError). + with pytest.raises(RuntimeError): + fp.Subspace.from_bytes(3, b"\x00\x01\x02") diff --git a/ext_py/tests/test_unstable.py b/ext_py/tests/test_unstable.py new file mode 100644 index 0000000000..55fcc34248 --- /dev/null +++ b/ext_py/tests/test_unstable.py @@ -0,0 +1,448 @@ +"""Tests for the unstable (``U = true``) family: the ``UnstableResolution`` +pyclass, the ``construct_unstable`` pyfunction, and the pure-Python +``query_unstable_module`` / ``query_unstable_module_only`` I/O helpers. + +The unstable construct path monomorphises upstream ``construct_standard::`` (general algorithm only -- there is no Nassau analogue). The smallest +meaningful unstable resolution here is the (unsuspended) sphere ``S_2``, mirror- +ing ``examples/resolve_unstable.py`` and ``ext/examples/resolve_unstable.rs``. + +Unstable Ext differs from stable Ext, so we assert structural invariants plus a +derived known invariant (the Adem/Milnor charts must agree -- the upstream +``ext/tests/milnor_vs_adem.rs::compare_unstable`` invariant), not the stable +``h_i`` pattern. +""" + +import itertools + +import pytest + +import ext +from ext import _query, algebra, fp, sseq + + +def _ns(n, s): + return sseq.Bidegree.n_s(n, s) + + +def _st(s, t): + return sseq.Bidegree.s_t(s, t) + + +def _unstable_s2(spec="S_2", n=8, s=4): + r = ext.construct_unstable(spec) + r.compute_through_stem(_ns(n, s)) + return r + + +# --- UnstableResolution: construction + structural invariants ------------ + + +def test_unstable_resolution_basic_invariants(): + r = _unstable_s2() + assert r.prime == 2 + assert r.min_degree == 0 + assert r.next_homological_degree > 0 + # The unit: exactly one generator at (0, 0). + assert r.number_of_gens_in_bidegree(_st(0, 0)) == 1 + # graded_dimension_string is nonempty/consistent. + assert r.graded_dimension_string().strip() != "" + + +def test_unstable_resolution_constructor_matches_pyfunction(): + # The UnstableResolution(spec) constructor and the construct_unstable + # pyfunction agree on the chart. + a = ext.UnstableResolution("S_2") + a.compute_through_stem(_ns(6, 3)) + b = ext.construct_unstable("S_2") + b.compute_through_stem(_ns(6, 3)) + assert a.graded_dimension_string() == b.graded_dimension_string() + + +def test_unstable_resolution_load_quasi_inverse_option(): + # The load_quasi_inverse keyword (default True) is accepted by both the + # constructor and the construct_unstable pyfunction, and either setting + # resolves to the same chart over a small range. + default = ext.UnstableResolution("S_2[5]") + default.compute_through_stem(_ns(6, 3)) + no_qi = ext.UnstableResolution("S_2[5]", load_quasi_inverse=False) + no_qi.compute_through_stem(_ns(6, 3)) + fn_no_qi = ext.construct_unstable("S_2[5]", load_quasi_inverse=False) + fn_no_qi.compute_through_stem(_ns(6, 3)) + assert default.graded_dimension_string() == no_qi.graded_dimension_string() + assert default.graded_dimension_string() == fn_no_qi.graded_dimension_string() + + +def _nonblank_glyphs(chart): + # Upstream renders an all-zero bidegree as a space (unicode_num(0) == ' ') + # and separates entries with spaces/newlines, so the count of non-whitespace + # characters is the number of populated bidegrees ("dots") on the chart. + return sum(1 for c in chart if not c.isspace()) + + +def test_unstable_adem_milnor_charts_agree(): + # Derived known invariant: the unstable Ext chart is independent of the + # Steenrod-algebra basis (cf. ext/tests/milnor_vs_adem.rs::compare_unstable, + # which uses *suspended* spheres precisely to get non-trivial charts). + # + # The unsuspended S_2 unstable chart has only the single (0,0) unit glyph, so + # its Adem-vs-Milnor agreement is near-vacuous. The suspension S_2[5] has a + # genuinely non-trivial chart, so we also assert it has more than one glyph. + a = _unstable_s2("S_2[5]@adem") + b = _unstable_s2("S_2[5]@milnor") + chart_a = a.graded_dimension_string() + chart_b = b.graded_dimension_string() + assert chart_a == chart_b + assert _nonblank_glyphs(chart_a) > 1, ( + f"suspended-sphere unstable chart should be non-trivial, got:\n{chart_a}" + ) + + +def test_unstable_vs_stable_s3_charts_differ(): + # THE core semantic proof that the unstable binding produces genuinely + # unstable data and is not silently the stable resolution: resolve the + # 3-sphere S^3 = S_2[3] BOTH stably and unstably over the same range and + # assert the charts DIFFER. + # + # We use the *standard* stable backend (not the default Nassau backend, + # which renormalises min_degree to 0): standard keeps min_degree == 3, the + # same coordinate frame as the unstable resolution, so the comparison is a + # genuine apples-to-apples per-bidegree contrast rather than a coordinate + # artifact. + rng = _ns(10, 6) + stable = ext.Resolution.construct("S_2[3]", algorithm="standard") + stable.compute_through_stem(rng) + unstable = ext.construct_unstable("S_2[3]") + unstable.compute_through_stem(rng) + + assert stable.min_degree == 3 + assert unstable.min_degree == 3 + + chart_stable = stable.graded_dimension_string() + chart_unstable = unstable.graded_dimension_string() + assert chart_stable != chart_unstable, ( + "unstable S^3 chart must differ from the stable S^3 chart:\n" + f"STABLE:\n{chart_stable}\nUNSTABLE:\n{chart_unstable}" + ) + + # Document the first divergence: the unstable resolution truncates/kills a + # class that survives stably. In these (shared) coordinates the first such + # bidegree is (n=6, s=1): the stable resolution has one generator there, the + # unstable one has none (the unstable conditions kill it). + assert stable.number_of_gens_in_bidegree(_ns(6, 1)) == 1 + assert unstable.number_of_gens_in_bidegree(_ns(6, 1)) == 0 + + # Sanity: both charts are non-trivial (so "differ" is not a trivial + # empty-vs-empty difference), and the unstable resolution exposes genuine + # unstable generator data at more than one bidegree. + populated_unstable = sum( + 1 + for n in range(0, 11) + for s in range(0, 7) + if unstable.number_of_gens_in_bidegree(_ns(n, s)) > 0 + ) + assert populated_unstable > 1, "unstable S^3 chart should be non-trivial" + + +def test_unstable_to_sseq_returns_sseq(): + r = _unstable_s2() + ss = r.to_unstable_sseq() + assert isinstance(ss, sseq.Sseq) + assert ss.prime == 2 + + +def test_unstable_filtration_one_products(): + # The unstable analogue of test_filtration_one_products_h0: the degree-1 + # operation Sq^1 induces the filtration-one product living in (n, s) = (0, 1). + r = _unstable_s2() + prod = r.filtration_one_products(1, 0) + assert isinstance(prod, sseq.Product) + assert prod.b.s == 1 + assert prod.b.n == 0 + # Negative op_deg is a ValueError (mirrors the stable binding). + with pytest.raises(ValueError): + r.filtration_one_products(-1, 0) + # op_deg = 1 has a single operation (Sq^1) at p = 2, so op_idx = 9999 is out + # of range and must raise IndexError, not panic. + with pytest.raises(IndexError): + r.filtration_one_products(1, 9999) + + +def test_unstable_iter_nonzero_stem_islice(): + r = _unstable_s2() + # The iterator is bounded; islice a few entries. Every yielded bidegree + # must be nonzero. + seen = list(itertools.islice(r.iter_nonzero_stem(), 6)) + assert seen, "expected at least one nonzero unstable bidegree" + for b in seen: + assert r.number_of_gens_in_bidegree(b) > 0 + # The unit (n=0, s=0) is among the nonzero entries. + assert any(b.n == 0 and b.s == 0 for b in seen) + + +def test_unstable_iter_stem_terminates(): + r = _unstable_s2(n=4, s=2) + # Bounded by the resolved range, so list() terminates. + allb = list(r.iter_stem()) + assert len(allb) > 0 + + +# --- Panic guards: no panic across FFI ----------------------------------- + + +def test_unstable_negative_bidegree_raises_value_error(): + r = _unstable_s2() + with pytest.raises(ValueError): + r.number_of_gens_in_bidegree(_st(-1, 0)) + with pytest.raises(ValueError): + r.number_of_gens_in_bidegree(_st(0, -1)) + with pytest.raises(ValueError): + r.compute_through_stem(_st(-1, 0)) + with pytest.raises(ValueError): + r.compute_through_bidegree(_st(0, -1)) + with pytest.raises(ValueError): + r.has_computed_bidegree(_st(-1, 0)) + + +def test_unstable_out_of_range_bidegree_is_zero_no_panic(): + r = _unstable_s2() + # Far outside the computed range -> 0, never a panic. + assert r.number_of_gens_in_bidegree(_st(100, 100)) == 0 + assert r.number_of_gens_in_bidegree(_st(1, 10_000)) == 0 + # Uncomputed (but in-axis) bidegree is reported as not computed. + assert r.has_computed_bidegree(_st(0, 0)) is True + + +def test_unstable_boundary_string_guards(): + r = _unstable_s2() + # The unit generator at (0,0,0) is valid. + assert isinstance( + r.boundary_string(sseq.BidegreeGenerator.s_t(0, 0, 0)), str + ) + # Out-of-range generator idx / negative -> ValueError, no panic. + with pytest.raises(ValueError): + r.boundary_string(sseq.BidegreeGenerator.s_t(0, 0, 9)) + with pytest.raises(ValueError): + r.boundary_string(sseq.BidegreeGenerator.s_t(-1, 0, 0)) + + +def test_unstable_construct_bad_spec_raises_value_error(): + with pytest.raises(ValueError): + ext.construct_unstable("definitely_not_a_module") + + +@pytest.mark.parametrize("spec", ["C9", "C4"]) +def test_unstable_construct_cofiber_spec_raises_value_error(spec): + # A cofiber-bearing spec is stable-only: upstream construct_standard:: + # builds the algebra/module then trips assert!(!U, "Cofiber not supported for + # unstable resolution"). The binding must contain that panic and surface a + # clean ValueError (NOT a PanicException, which is a BaseException) on both + # construct_unstable and the UnstableResolution(spec) constructor. + with pytest.raises(ValueError, match="cofiber"): + ext.construct_unstable(spec) + with pytest.raises(ValueError, match="cofiber"): + ext.UnstableResolution(spec) + + +def test_unstable_construct_save_dir_is_file_raises(tmp_path): + f = tmp_path / "afile" + f.write_text("not a directory") + with pytest.raises(ValueError): + ext.construct_unstable("S_2", save_dir=str(f)) + + +def test_unstable_construct_save_dir_round_trip(tmp_path): + save = str(tmp_path) + r1 = ext.construct_unstable("S_2", save_dir=save) + r1.compute_through_stem(_ns(6, 3)) + chart1 = r1.graded_dimension_string() + assert any(p.is_file() for p in tmp_path.rglob("*")), "expected save files" + + r2 = ext.construct_unstable("S_2", save_dir=save) + r2.compute_through_stem(_ns(6, 3)) + assert r2.graded_dimension_string() == chart1 + + +# --- UnstableResolutionHomomorphism.extend_step_raw ---------------------- + + +def _suspension_module(spec="S_2", shift_t=0): + """The (shift_t-suspended) module for `spec` over an unstable Milnor + algebra -- mirrors the setup of examples/unstable_suspension.py.""" + module_json = ext.parse_module_name(spec) + alg = algebra.SteenrodAlgebra.from_json( + module_json, algebra.AlgebraType.Milnor, True + ) + module = algebra.SteenrodModule.from_spec(module_json, alg) + return algebra.SuspensionModule(module, shift_t) + + +def _suspension_resolution(spec="S_2", shift_t=0, rng=_ns(6, 3)): + """An unstable resolution of the shift_t-suspension of `spec`, computed + through stem `rng` (plus the shift).""" + res = ext.UnstableResolution( + ext.ChainComplex.ccdz(_suspension_module(spec, shift_t)) + ) + res.compute_through_stem(rng) + return res + + +def test_unstable_extend_step_raw_suspension_runs(): + # Mirror examples/unstable_suspension.py: build the resolution of the + # 0-suspension (res_a) and the 1-suspension (res_b) of S_2, then the + # (0,1) suspension homomorphism res_b -> res_a, seed it with + # extend_step_raw, extend by exactness, and read off a hom_k matrix. + max = _ns(6, 3) + min_degree = _st(0, 0) + suspension_shift = _st(0, 1) + + res_a = _suspension_resolution("S_2", 0, max) + res_b = ext.UnstableResolution( + ext.ChainComplex.ccdz(_suspension_module("S_2", 1)) + ) + res_b.compute_through_stem(max + suspension_shift) + + hom = ext.UnstableResolutionHomomorphism( + "suspension", res_b, res_a, suspension_shift + ) + prime = res_b.prime + rng = hom.extend_step_raw( + min_degree + suspension_shift, + [fp.FpVector.from_slice(prime, [1])], + ) + # The return is the half-open (start, end) range of touched degrees. + assert isinstance(rng, tuple) and len(rng) == 2 + assert rng[0] <= rng[1] + + hom.extend_all() + + # A defined map can be read back. The s=0 map is the suspension on the + # unit: it sends the single (0,0) generator to the (0,1) generator. + m = hom.get_map(0) + out = m.output(suspension_shift.t, 0) + assert out[0] == 1 + + +def test_unstable_hom_k_suspension_matrix(): + # After building and seeding the suspension hom (exactly as + # examples/unstable_suspension.py does), get_map(s).hom_k(t) must return a + # list-of-lists of ints whose outer length is the target generator count in + # degree t, and uncomputed/out-of-range degrees must read empty (never + # panic across FFI). + max = _ns(6, 3) + min_degree = _st(0, 0) + suspension_shift = _st(0, 1) + + res_a = _suspension_resolution("S_2", 0, max) + res_b = ext.UnstableResolution( + ext.ChainComplex.ccdz(_suspension_module("S_2", 1)) + ) + res_b.compute_through_stem(max + suspension_shift) + + hom = ext.UnstableResolutionHomomorphism( + "suspension", res_b, res_a, suspension_shift + ) + prime = res_b.prime + hom.extend_step_raw( + min_degree + suspension_shift, + [fp.FpVector.from_slice(prime, [1])], + ) + hom.extend_all() + + # s=0: target is res_a's module S_2; degree t = 1 (= suspension_shift.t). + m = hom.get_map(0) + t = suspension_shift.t + target = m.target + expected_rows = target.number_of_gens_in_degree(t) + mat = m.hom_k(t) + assert isinstance(mat, list) + assert len(mat) == expected_rows + for row in mat: + assert isinstance(row, list) + assert all(isinstance(x, int) for x in row) + + # A degree just above the computed range has no target generators and reads + # as an empty matrix, never a panic. (Avoid a wildly large degree: hom_k + # ensures the module basis up to t first, so probing e.g. 10_000 would force + # a huge basis computation.) + above_range = target.max_computed_degree + 5 + assert m.hom_k(above_range) == [] + # A negative degree is cheap (no basis computation) and also empty. + assert m.hom_k(-5) == [] + + +def test_unstable_extend_step_raw_extra_images_none_runs(): + # extra_images=None is the zero-map seed and must also run without panic. + max = _ns(4, 2) + res = _suspension_resolution("S_2", 0, max) + hom = ext.UnstableResolutionHomomorphism( + "id", res, res, _st(0, 0) + ) + rng = hom.extend_step_raw(_st(0, 0)) + assert isinstance(rng, tuple) and len(rng) == 2 + + +def test_unstable_extend_step_raw_uncomputed_bidegree_raises_value_error(): + # Guard test: an input bidegree the resolutions have NOT computed must + # raise a clean ValueError, not panic/crash across FFI. + res = _suspension_resolution("S_2", 0, _ns(4, 2)) + hom = ext.UnstableResolutionHomomorphism("id", res, res, _st(0, 0)) + # Far outside the computed range. + with pytest.raises(ValueError): + hom.extend_step_raw(_st(50, 500), [fp.FpVector.from_slice(2, [1])]) + # Negative bidegree is also a ValueError, never a panic. + with pytest.raises(ValueError): + hom.extend_step_raw(_st(-1, 0)) + # An input below the shift's homological degree is rejected. + shifted = ext.UnstableResolutionHomomorphism("f", res, res, _st(1, 1)) + with pytest.raises(ValueError): + shifted.extend_step_raw(_st(0, 0)) + + +# --- query_unstable_module* (pure-Python I/O) ---------------------------- + + +@pytest.fixture +def feed(monkeypatch): + def _feed(answers): + _query._reset_args(list(answers)) + + yield _feed + _query._reset_args() + + +def test_query_unstable_module_only_builds_sphere(feed): + # Answers: module spec, then (empty) save directory. + feed(["S_2", ""]) + res = ext.query_unstable_module_only("Module") + res.compute_through_stem(_ns(4, 2)) + assert res.number_of_gens_in_bidegree(_st(0, 0)) == 1 + + +def test_query_unstable_module_only_explicit_save_dir_skips_prompt(feed, tmp_path): + # Only the module spec is consumed; save_dir supplied -> no save-dir prompt. + feed(["S_2"]) + res = ext.query_unstable_module_only("Module", save_dir=str(tmp_path)) + res.compute_through_stem(_ns(4, 2)) + assert res.number_of_gens_in_bidegree(_st(0, 0)) == 1 + + +def test_query_unstable_module_resolves_through_stem(feed): + # module spec, save dir (empty), Max n, Max s. + feed(["S_2", "", "6", "3"]) + res = ext.query_unstable_module() + assert res.number_of_gens_in_bidegree(_st(0, 0)) == 1 + # Resolved through the requested stem. + assert res.next_homological_degree > 0 + + +def test_query_unstable_module_save_dir_round_trip(feed, tmp_path): + feed(["S_2", str(tmp_path), "6", "3"]) + res = ext.query_unstable_module() + chart = res.graded_dimension_string() + assert any(p.is_file() for p in tmp_path.rglob("*")), "expected save files" + + # Fresh build from the same directory loads the saved data. + feed(["S_2", str(tmp_path), "6", "3"]) + res2 = ext.query_unstable_module() + assert res2.graded_dimension_string() == chart diff --git a/ext_py/tests/test_unstable_homomorphism.py b/ext_py/tests/test_unstable_homomorphism.py new file mode 100644 index 0000000000..1373ddbc46 --- /dev/null +++ b/ext_py/tests/test_unstable_homomorphism.py @@ -0,0 +1,392 @@ +"""Tests for the unstable (``U = true``) resolution-homomorphism family: + + - ``ext.UnstableResolutionHomomorphism`` (the unstable analogue of + ``ResolutionHomomorphism``), + - ``algebra.UnstableFreeModuleHomomorphism`` (its ``get_map(s)`` return — + the unstable free -> free chain map), + - ``algebra.UnstableFreeModule`` (the unstable resolution's modules, also + handed back by ``UnstableResolution.module(s)``). + +These mirror the *stable* family bound in ``test_resolution_homomorphism.py`` +but live over ``MuFreeModule`` / ``MuFreeModuleHomomorphism`` +— distinct const-generic monomorphisations from the stable ``U = false`` types, +so they need their own pyclasses. + +Known value: ``from_class`` with shift ``(0, 0)`` and class ``[1]`` is the +identity chain map lifting the identity on the augmentation, so its ``s``-th map +sends generator ``idx`` to the indicator at +``operation_generator_to_index(0, 0, t, idx)`` — exactly the invariant +``ext/tests/extend_identity.rs`` asserts (and the same invariant the stable +``test_from_class_identity_matches_basis`` checks), here for the unstable +resolution of ``S_2``. + +The unsuspended unstable ``S_2`` chart is trivial (only the ``(0, 0)`` unit), so +``act`` is exercised on that unit; the structural / guard tests cover the rest. + +All bad degree/index/bidegree inputs are pre-checked and raise +``ValueError``/``IndexError`` rather than panicking across the FFI boundary. +""" + +import pytest + +import ext +from ext import algebra, fp, sseq + +Bidegree = sseq.Bidegree +BidegreeGenerator = sseq.BidegreeGenerator +FpVector = fp.FpVector + + +def us2_rect(max_st=6): + """An unstable resolution of S_2 computed through the full bidegree + rectangle (max_st, max_st), so an extend over the same range is fully + resolved.""" + r = ext.construct_unstable("S_2") + r.compute_through_bidegree(Bidegree.s_t(max_st, max_st)) + return r + + +def identity_hom(r, max_st=6): + hom = ext.UnstableResolutionHomomorphism.from_class( + "id", r, r, Bidegree.s_t(0, 0), [1] + ) + hom.extend(Bidegree.s_t(max_st, max_st)) + return hom + + +# --- construction & accessors --------------------------------------------- + + +def test_new_accessors_roundtrip(): + r = us2_rect(4) + hom = ext.UnstableResolutionHomomorphism("f", r, r, Bidegree.s_t(1, 1)) + assert hom.name == "f" + assert hom.prime == 2 + assert hom.shift.s == 1 + assert hom.shift.t == 1 + assert hom.source.prime == 2 + assert hom.target.prime == 2 + assert hom.source.graded_dimension_string() == r.graded_dimension_string() + # A freshly constructed hom defines no maps yet (next_hom_degree == shift.s). + assert hom.next_homological_degree == 1 + assert hom.save_dir is None + assert hom.algebra.prime == 2 + + +# --- known value: from_class([1]) at (0,0) is the identity chain map ------ + + +def test_from_class_identity_matches_basis(): + # The basis-indicator invariant from ext/tests/extend_identity.rs: the + # from_class([1]) identity chain map sends generator (t, idx) of module(s) + # to the indicator vector with a single 1 at + # operation_generator_to_index(0, 0, t, idx). + # + # NOTE on the unstable sphere: from_class([1]) at shift (0,0) requires a + # degree-0 unit (the augmentation must be 1-dimensional in degree 0) AND a + # source generator at bidegree (0,0). The only unstable resolution meeting + # both is the *unsuspended* sphere S_2, whose chart is the single (0,0) unit + # -- so this invariant necessarily visits exactly one generator here. The + # suspended sphere S^3 = S_2[3] HAS a non-trivial unstable chart, but its + # bottom cell sits in degree 3 (no (0,0) generator and no degree-0 unit), so + # from_class cannot express its identity at all; the genuine + # unstable-vs-stable contrast lives in test_unstable.py's + # test_unstable_vs_stable_s3_charts_differ instead. + r = us2_rect(6) + hom = identity_hom(r, 6) + assert hom.name == "id" + assert hom.shift.s == 0 and hom.shift.t == 0 + + visited = 0 + for s in range(0, 7): + m = hom.get_map(s) + assert isinstance(m, algebra.UnstableFreeModuleHomomorphism) + src = r.module(s) + assert isinstance(src, algebra.UnstableFreeModule) + for t in range(0, 7): + for idx in range(src.number_of_gens_in_degree(t)): + visited += 1 + out = m.output(t, idx) + j = src.operation_generator_to_index(0, 0, t, idx) + for i in range(len(out)): + assert out[i] == (1 if i == j else 0), ( + f"identity mismatch at s={s} t={t} idx={idx} i={i}" + ) + # The unstable S_2 chart is exactly the (0,0) unit (see the docstring note). + assert visited == 1 + + +def test_get_map_is_unstable_free_to_free(): + r = us2_rect(4) + hom = identity_hom(r, 4) + m = hom.get_map(0) + assert m.degree_shift == 0 + assert m.prime == 2 + # source()/target() are unstable free modules sharing the resolution's Arc. + assert isinstance(m.source, algebra.UnstableFreeModule) + assert isinstance(m.target, algebra.UnstableFreeModule) + assert m.source.prime == 2 + assert m.next_degree > 0 + # s=0 map is the identity on the unit: output(0,0) == [1]. + assert list(m.output(0, 0)) == [1] + + +# --- extend_all / extend_through_stem ------------------------------------- + + +def test_extend_all_then_get_map(): + r = us2_rect(6) + hom = ext.UnstableResolutionHomomorphism.from_class( + "id", r, r, Bidegree.s_t(0, 0), [1] + ) + hom.extend_all() + assert hom.get_map(0).output(0, 0)[0] == 1 + + +def test_extend_through_stem(): + r = ext.construct_unstable("S_2") + r.compute_through_stem(Bidegree.n_s(8, 4)) + hom = ext.UnstableResolutionHomomorphism.from_class( + "id", r, r, Bidegree.s_t(0, 0), [1] + ) + hom.extend_through_stem(Bidegree.n_s(4, 4)) + assert hom.get_map(0).output(0, 0)[0] == 1 + + +# --- act ------------------------------------------------------------------ + + +def test_act_identity_picks_out_unit(): + # The unsuspended unstable S_2 chart is trivial (only the (0,0) unit), so + # the identity hom acts as the identity on that single generator. + r = us2_rect(6) + hom = identity_hom(r, 6) + b = Bidegree.s_t(0, 0) + n_src = r.number_of_gens_in_bidegree(b) + assert n_src == 1 + result = FpVector(2, n_src) + hom.act(result, 1, BidegreeGenerator.s_t(0, 0, 0)) + assert result[0] == 1 + + +def test_act_guards(): + r = us2_rect(6) + hom = identity_hom(r, 6) + result = FpVector(2, 1) + # Negative generator -> ValueError. + with pytest.raises(ValueError): + hom.act(result, 1, BidegreeGenerator.s_t(-1, 0, 0)) + # Wrong result length -> ValueError. + bad = FpVector(2, 5) + with pytest.raises(ValueError): + hom.act(bad, 1, BidegreeGenerator.s_t(0, 0, 0)) + # Generator index out of range at the target bidegree -> IndexError. + with pytest.raises(IndexError): + hom.act(result, 1, BidegreeGenerator.s_t(0, 0, 99)) + + +def test_act_prime_mismatch_errors(): + r = us2_rect(6) + hom = identity_hom(r, 6) + wrong_prime = FpVector(3, 1) + with pytest.raises(ValueError): + hom.act(wrong_prime, 1, BidegreeGenerator.s_t(0, 0, 0)) + + +def test_act_map_undefined_at_s_errors(): + r = us2_rect(6) + hom = identity_hom(r, 6) + result = FpVector(2, 1) + with pytest.raises(ValueError): + hom.act(result, 1, BidegreeGenerator.s_t(99, 99, 0)) + + +def test_act_not_extended_far_enough_errors(): + # The `src_t >= map.next_degree` guard ("map not extended far enough"): + # the hom IS defined at homological degree src_s (so the src_s guard does + # NOT fire) and the source IS computed at the source bidegree, but the map + # has only been extended through a smaller t. Resolve S_2 through (6, 6) so + # the source bidegree (0, 3) is computed, but extend the identity hom only + # through (0, 0); then act at g = (0, 3, 0): src = (0, 3) with src_t = 3 >= + # map(0).next_degree == 1. + r = us2_rect(6) + hom = ext.UnstableResolutionHomomorphism.from_class( + "id", r, r, Bidegree.s_t(0, 0), [1] + ) + hom.extend(Bidegree.s_t(0, 0)) + assert hom.next_homological_degree == 1 # s = 0 map is defined ... + assert hom.get_map(0).next_degree == 1 # ... but only extended through t=0 + result = FpVector(2, 1) + with pytest.raises(ValueError, match="not extended through"): + hom.act(result, 1, BidegreeGenerator.s_t(0, 3, 0)) + + +def test_act_target_not_computed_guard_is_shadowed(): + # The `target.has_computed_bidegree(g.degree())` guard is intentionally + # over-strict relative to the stable ResolutionHomomorphism.act (see the + # NOTE at that guard in src/lib.rs): it is a conservative superset of the + # `src_t >= map.next_degree` guard and is UNREACHABLE from Python. The hom + # cannot be extended past the target's computed range (extend itself guards + # `target.has_computed_bidegree(input - shift)`), so whenever g.degree() is + # uncomputed in the target the earlier "not extended through" guard fires + # first. We therefore exercise the reachable path and assert that guard. + # + # Setup: source resolved further (through t = 9) than target (through t = 6), + # shift (0, 0). The hom can only be extended through (6, 6) (target's range), + # so act at g = (0, 7, 0) -- where the target is NOT computed but the source + # IS -- trips `src_t >= map.next_degree` rather than the target guard. + src = ext.construct_unstable("S_2") + src.compute_through_bidegree(Bidegree.s_t(6, 9)) + tgt = ext.construct_unstable("S_2") + tgt.compute_through_bidegree(Bidegree.s_t(6, 6)) + hom = ext.UnstableResolutionHomomorphism.from_class( + "id", src, tgt, Bidegree.s_t(0, 0), [1] + ) + hom.extend(Bidegree.s_t(6, 6)) + assert src.has_computed_bidegree(Bidegree.s_t(0, 7)) is True + assert tgt.has_computed_bidegree(Bidegree.s_t(0, 7)) is False + # g.s = 0 < tgt.next_homological_degree, so the target-s guard is passed; + # the "not extended through" guard is what actually fires. + result = FpVector(2, 1) + with pytest.raises(ValueError, match="not extended through"): + hom.act(result, 1, BidegreeGenerator.s_t(0, 7, 0)) + + +def test_act_degree_overflow_errors(): + r = us2_rect(4) + hom = ext.UnstableResolutionHomomorphism("f", r, r, Bidegree.s_t(1, 1)) + result = FpVector(2, 1) + with pytest.raises(ValueError): + hom.act(result, 1, BidegreeGenerator.s_t(2147483647, 0, 0)) + with pytest.raises(ValueError): + hom.act(result, 1, BidegreeGenerator.s_t(0, 2147483647, 0)) + + +# --- panic guards: construction ------------------------------------------- + + +def test_new_prime_mismatch_errors(): + s2 = us2_rect(4) + s3 = ext.construct_unstable("S_3") + with pytest.raises(ValueError): + ext.UnstableResolutionHomomorphism("f", s2, s3, Bidegree.s_t(0, 0)) + + +def test_from_class_prime_mismatch_errors(): + s2 = us2_rect(4) + s3 = ext.construct_unstable("S_3") + with pytest.raises(ValueError): + ext.UnstableResolutionHomomorphism.from_class( + "f", s2, s3, Bidegree.s_t(0, 0), [1] + ) + + +def test_new_negative_shift_errors(): + r = us2_rect(4) + with pytest.raises(ValueError): + ext.UnstableResolutionHomomorphism("f", r, r, Bidegree.s_t(-1, 0)) + with pytest.raises(ValueError): + ext.UnstableResolutionHomomorphism("f", r, r, Bidegree.s_t(0, -1)) + + +def test_from_class_guards(): + r = us2_rect(4) + # Wrong class length (source has 1 generator at (0,0)). + with pytest.raises(ValueError): + ext.UnstableResolutionHomomorphism.from_class( + "id", r, r, Bidegree.s_t(0, 0), [1, 1] + ) + # Class at an uncomputed bidegree. + with pytest.raises(ValueError): + ext.UnstableResolutionHomomorphism.from_class( + "id", r, r, Bidegree.s_t(100, 100), [] + ) + + +def test_get_map_out_of_range_errors(): + r = us2_rect(4) + hom = identity_hom(r, 4) + with pytest.raises(IndexError): + hom.get_map(-1) + with pytest.raises(IndexError): + hom.get_map(1000) + + +def test_extend_guards(): + r = us2_rect(4) + hom = ext.UnstableResolutionHomomorphism.from_class( + "id", r, r, Bidegree.s_t(0, 0), [1] + ) + with pytest.raises(ValueError): + hom.extend(Bidegree.s_t(-1, 0)) + # Beyond the resolved range (source only computed through (4, 4)). + with pytest.raises(ValueError): + hom.extend(Bidegree.s_t(100, 100)) + with pytest.raises(ValueError): + hom.extend_through_stem(Bidegree.n_s(100, 4)) + + +def test_extend_all_unresolved_errors(): + r = ext.construct_unstable("S_2") + hom = ext.UnstableResolutionHomomorphism("f", r, r, Bidegree.s_t(0, 0)) + with pytest.raises(ValueError): + hom.extend_all() + + +# --- UnstableFreeModule accessor guards ----------------------------------- + + +def test_unstable_resolution_module_accessor_and_guards(): + r = us2_rect(4) + m = r.module(0) + assert isinstance(m, algebra.UnstableFreeModule) + assert m.prime == 2 + assert m.min_degree == 0 + assert m.number_of_gens_in_degree(0) == 1 + assert m.dimension(0) == 1 + assert isinstance(m.basis_element_to_string(0, 0), str) + # module(s) out of range -> IndexError, no panic. + with pytest.raises(IndexError): + r.module(-1) + with pytest.raises(IndexError): + r.module(10_000) + + +def test_unstable_free_module_operation_generator_to_index_unstable_bound(): + # THE unstable-specific guard: the number of admissible operations on a + # degree-0 generator in op_degree 1 is dimension_unstable(1, 0) == 0 (Sq^1 + # has excess 1 > generator degree 0), so op_index 0 is OUT of range and must + # raise IndexError -- NOT silently read a neighbour's basis element as the + # stable `dimension(1) == 1` bound would permit. The identity operation + # (op_degree 0, op_index 0) is always valid. + r = us2_rect(6) + m = r.module(0) + assert m.operation_generator_to_index(0, 0, 0, 0) == 0 + with pytest.raises(IndexError): + m.operation_generator_to_index(1, 0, 0, 0) + # Negative op_degree -> IndexError (degree treated as out-of-range index). + with pytest.raises(IndexError): + m.operation_generator_to_index(-1, 0, 0, 0) + # Out-of-range generator index -> IndexError. + with pytest.raises(IndexError): + m.operation_generator_to_index(0, 0, 0, 5) + # Generator degree below min_degree -> ValueError. + with pytest.raises(ValueError): + m.operation_generator_to_index(0, 0, -1, 0) + + +def test_unstable_free_module_homomorphism_output_guards(): + r = us2_rect(4) + hom = identity_hom(r, 4) + m = hom.get_map(0) + # Valid generator output. + assert list(m.output(0, 0)) == [1] + # Generator degree beyond the defined range -> ValueError. + with pytest.raises(ValueError): + m.output(10_000, 0) + # Out-of-range generator index -> IndexError. + with pytest.raises(IndexError): + m.output(0, 99) + # Below min_degree -> IndexError. + with pytest.raises(IndexError): + m.output(-1, 0) diff --git a/ext_py/tests/test_utils.py b/ext_py/tests/test_utils.py new file mode 100644 index 0000000000..20bd03a4b9 --- /dev/null +++ b/ext_py/tests/test_utils.py @@ -0,0 +1,283 @@ +"""Tests for the pure-Python I/O util layer (``ext._query`` / ``ext.utils``) +and the ``ext.Resolution.construct`` staticmethod with ``save_dir`` support. + +The interactive ``query_resolution`` / ``query_n_s`` helpers consume a +module-level argument stream +(``ext._query._args``, built from ``sys.argv[1:]`` at import). We drive them +deterministically by monkeypatching that stream via the ``_reset_args`` hook, +which feeds a fixed answer sequence in the same left-to-right order the Rust +``query`` crate consumes ``std::env::args()``. +""" + +import pytest + +import ext +from ext import _query + + +@pytest.fixture +def feed(monkeypatch): + """Return a callable that loads a deterministic answer sequence into the + ``_query`` argument stream (so no prompt ever reads stdin).""" + + def _feed(answers): + _query._reset_args(list(answers)) + + yield _feed + # Restore the real argv-derived stream so we don't leak state across tests. + _query._reset_args() + + +def _bidegree(n, s): + return ext.sseq.Bidegree.n_s(n, s) + + +# --- query_resolution / query_n_s (Python I/O) --------------------------- + + +def test_query_resolution_builds_sphere(feed): + # Answers: module spec, then (empty) save directory. + feed(["S_2", ""]) + res = ext.query_resolution("Module") + res.compute_through_bidegree(ext.sseq.Bidegree.s_t(0, 0)) + assert res.number_of_gens_in_bidegree(ext.sseq.Bidegree.s_t(0, 0)) == 1 + + +def test_query_resolution_algorithm_selects_resolution_type(feed): + # The `algorithm` argument is forwarded to Resolution.construct and selects + # the resolution TYPE. "standard" yields the standard backend, on which + # standard-only methods like module() work (Nassau cannot provide them). + feed(["S_2", ""]) + res = ext.query_resolution("Module", algorithm="standard") + res.compute_through_bidegree(ext.sseq.Bidegree.s_t(0, 0)) + # module() is standard-backend-only; it must succeed here. + assert res.module(0).dimension(0) == 1 + + +def test_query_resolution_explicit_save_dir_skips_prompt(feed, tmp_path): + # Only the module spec is consumed; save_dir is supplied, so NO save-dir + # prompt is read (if it were, the stream would be exhausted -> EOF exit). + feed(["S_2"]) + res = ext.query_resolution("Module", save_dir=str(tmp_path)) + res.compute_through_bidegree(ext.sseq.Bidegree.s_t(0, 0)) + assert res.number_of_gens_in_bidegree(ext.sseq.Bidegree.s_t(0, 0)) == 1 + + +def test_query_n_s_returns_bidegree_and_caller_resolves(feed): + # query_n_s returns the target (n, s) Bidegree; the caller resolves. + feed(["S_2", "", "8", "4"]) + res = ext.query_resolution() + target = ext.query_n_s() + assert (target.n, target.s) == (8, 4) + res.compute_through_stem(target) + # Standard low-dimensional Ext of the sphere. + assert res.number_of_gens_in_bidegree(_bidegree(0, 0)) == 1 + assert res.number_of_gens_in_bidegree(_bidegree(0, 1)) == 1 # h_0 at (1,1) + assert res.number_of_gens_in_bidegree(_bidegree(1, 1)) == 1 # h_1 at (1,2) + + +def test_query_n_s_secondary_job_caps_max_s(feed, monkeypatch): + monkeypatch.setenv("SECONDARY_JOB", "2") + feed(["S_2", "", "8", "7"]) + res = ext.query_resolution() + # max_s is capped to min(2+1, 7) = 3. + target = ext.query_n_s() + assert target.s == 3 + res.compute_through_stem(target) + assert res.number_of_gens_in_bidegree(_bidegree(0, 0)) == 1 + assert res.number_of_gens_in_bidegree(_bidegree(0, 5)) == 0 + + +def test_query_n_s_secondary_job_too_large_raises(feed, monkeypatch): + monkeypatch.setenv("SECONDARY_JOB", "10") + feed(["8", "7"]) + with pytest.raises(ValueError): + ext.query_n_s() + + +# --- construct + save_dir round-trip ------------------------------------- + + +def test_construct_save_dir_round_trip(tmp_path): + save = str(tmp_path) + r1 = ext.Resolution.construct("S_2", save_dir=save) + r1.compute_through_stem(_bidegree(8, 4)) + chart1 = r1.graded_dimension_string() + + # Save files were written. + written = list(tmp_path.rglob("*")) + assert any(p.is_file() for p in written), "expected save files under tmp_path" + + # A fresh construct from the SAME directory loads the saved data. + r2 = ext.Resolution.construct("S_2", save_dir=save) + r2.compute_through_stem(_bidegree(8, 4)) + assert r2.graded_dimension_string() == chart1 + + +# --- construct error taxonomy / algorithm -------------------------------- + + +def test_construct_bad_spec_raises_value_error(): + with pytest.raises(ValueError): + ext.Resolution.construct("definitely_not_a_module") + + +def test_construct_nassau_eligible_module(): + r = ext.Resolution.construct("S_2", algorithm="nassau") + r.compute_through_bidegree(ext.sseq.Bidegree.s_t(0, 0)) + assert r.number_of_gens_in_bidegree(ext.sseq.Bidegree.s_t(0, 0)) == 1 + + +def test_construct_bad_algorithm_raises_value_error(): + with pytest.raises(ValueError): + ext.Resolution.construct("S_2", algorithm="bogus") + + +# --- import-surface regression ------------------------------------------- + + +def test_import_surface_intact(): + import ext as _e + + assert _e.Resolution is not None + from ext import algebra, fp, sseq # noqa: F401 + + import ext.ext as _compiled + + assert _compiled is _e.ext + + # The Python utils I/O helpers live at package level... + assert _e.query_resolution.__module__ == "ext.utils" + assert _e.query_n_s.__module__ == "ext.utils" + # ...while the lower-level Rust pyfunctions remain reachable on the compiled + # submodule under their original names. + assert callable(_compiled.query_module) + assert callable(_compiled.query_module_only) + # construct is the compiled (Rust) staticmethod on Resolution, not a + # top-level function. + assert not hasattr(_e, "construct") + assert callable(_e.Resolution.construct) + + +def test_query_primitives_exposed(): + for name in ("raw", "with_default", "optional", "yes_no", "vector"): + assert hasattr(ext, name) + + +# --- non-IO module utils: unicode_num / LAMBDA_BIDEGREE / parse_module_name / +# load_module_json / get_unit ------------------------------------------- + + +def test_unicode_num_exact_output(): + # Byte-for-byte match with ext::utils::unicode_num (ext/src/utils.rs). + assert ext.unicode_num(0) == " " + assert ext.unicode_num(1) == "·" + assert ext.unicode_num(2) == ":" + assert ext.unicode_num(3) == "∴" + assert ext.unicode_num(4) == "⁘" + assert ext.unicode_num(5) == "⁙" + assert ext.unicode_num(6) == "⠿" + assert ext.unicode_num(7) == "⡿" + assert ext.unicode_num(8) == "⣿" + assert ext.unicode_num(9) == "9" + # Anything >= 10 collapses to '*'. + assert ext.unicode_num(10) == "*" + assert ext.unicode_num(123) == "*" + + +def test_lambda_bidegree_value(): + # ext::secondary::LAMBDA_BIDEGREE == Bidegree::n_s(0, 1) -> n=0, s=1, t=1. + b = ext.LAMBDA_BIDEGREE + assert isinstance(b, ext.sseq.Bidegree) + assert b.n == 0 + assert b.s == 1 + assert b.t == 1 + # The compiled getter agrees with the package-level constant value. + assert ext.lambda_bidegree().coords == b.coords + + +def test_parse_module_name_valid(): + d = ext.parse_module_name("S_2") + assert isinstance(d, dict) + assert d["p"] == 2 + assert "type" in d + assert "gens" in d + + +def test_parse_module_name_with_shift(): + d = ext.parse_module_name("S_2[3]") + assert isinstance(d, dict) + assert d["shift"] == 3 + + +def test_parse_module_name_bad_name_raises(): + with pytest.raises(ValueError): + ext.parse_module_name("definitely_not_a_module") + + +def test_load_module_json_valid(): + d = ext.load_module_json("S_2") + assert isinstance(d, dict) + assert d["p"] == 2 + + +def test_load_module_json_unknown_name_raises(): + with pytest.raises(ValueError): + ext.load_module_json("definitely_not_a_module") + + +def test_load_module_json_malformed_raises_runtime_error(tmp_path, monkeypatch): + # A present-but-malformed module file is a genuine parse failure (NOT a bad + # name), so it maps to RuntimeError rather than ValueError. We isolate this + # in tmp_path (chdir) so nothing pollutes the repo / real cwd: upstream + # searches the current directory first for `.json`. + monkeypatch.chdir(tmp_path) + (tmp_path / "broken_module.json").write_text("{ this is not valid json ]") + with pytest.raises(RuntimeError): + ext.load_module_json("broken_module") + + +def test_get_unit_round_trip(): + res = ext.Resolution.construct("S_2", algorithm="standard") + res.compute_through_stem(_bidegree(8, 4)) + is_unit, unit = ext.get_unit(res) + # S_2 IS the unit, so it returns (True, the same resolution) via the cheap + # shared-Arc path -- no construction, no save_dir, no prompt. + assert is_unit is True + assert isinstance(unit, ext.Resolution) + assert unit.prime == 2 + + +def test_get_unit_nonunit_builds_unit_noninteractively(tmp_path): + # A shifted sphere S_2[2] is NOT the unit (its module sits in degree 2). + # The binding must NOT fall through to upstream's interactive + # `query::optional` (which would consume argv / block on stdin / exit). With + # NO argv fed and NO stdin available, this must return PROMPTLY (no hang), + # building a fresh unit resolution from the Python-provided save_dir. + res = ext.Resolution.construct("S_2[2]", algorithm="standard") + is_unit, unit = ext.get_unit(res, save_dir=str(tmp_path)) + assert is_unit is False + assert isinstance(unit, ext.Resolution) + assert unit.prime == 2 + # The constructed unit IS usable and IS the unit. + unit.compute_through_stem(_bidegree(4, 4)) + is_unit2, _ = ext.get_unit(unit) + assert is_unit2 is True + + +def test_get_unit_nonunit_no_save_dir(tmp_path): + # The save_dir is optional on the non-unit path too (None -> in-memory unit). + res = ext.Resolution.construct("S_2[2]", algorithm="standard") + is_unit, unit = ext.get_unit(res) + assert is_unit is False + assert unit.prime == 2 + + +def test_get_unit_nonunit_save_dir_is_file_raises(tmp_path): + # save_dir that is an existing FILE -> ValueError (mirrors construct), never + # a panic and never interactive I/O. + bad = tmp_path / "not_a_dir" + bad.write_text("x") + res = ext.Resolution.construct("S_2[2]", algorithm="standard") + with pytest.raises(ValueError): + ext.get_unit(res, save_dir=str(bad)) diff --git a/ext_py/tests/test_yoneda.py b/ext_py/tests/test_yoneda.py new file mode 100644 index 0000000000..2594ae3b67 --- /dev/null +++ b/ext_py/tests/test_yoneda.py @@ -0,0 +1,112 @@ +"""Tests for ``ext.yoneda_representative_element``. + +The Yoneda representative of an Ext class is a quasi-isomorphic finite quotient +of the resolution that the class factors through (the geometric representative). +We copy the canonical construction from the upstream ``examples/yoneda.rs`` / +``examples/steenrod.rs``: resolve ``S_2`` through a small stem, pick the class +``h_0`` at Adams bidegree ``(s, t) = (1, 1)`` with coordinate ``[1]``, and compute +its representative. + +Structural invariants asserted (derived from the upstream examples, which print +``module(s).total_dimension`` for ``s`` in ``0..=b.s()`` and the upstream +Euler-characteristic sanity assert ``euler_characteristic(t) == target_dim(t)``): + +* the result is a ``FiniteAugmentedChainComplex`` over ``p = 2``; +* it has ``b.s() + 1`` modules (``max_s() == 2`` for ``b.s() == 1``); +* its augmentation ``target()`` is the original ``S_2`` complex (1-dimensional + in internal degree 0); +* the bottom module ``C_0`` is the point in internal degree 0. + +Yoneda operates on the *standard* backend only; a Nassau-backed ``Resolution`` is +rejected with a ``ValueError`` (mirroring ``ResolutionHomomorphism`` / +``SecondaryResolution`` / ``chain_complex()``). +""" + +import pytest + +import ext +from ext import sseq + +# h_0 lives at Adams bidegree (s, t) = (1, 1); resolving through stem (8, 4) +# covers it comfortably and stays fast. +H0 = sseq.Bidegree.s_t(1, 1) + + +def standard_s2(): + r = ext.Resolution("S_2", "standard") + r.compute_through_stem(sseq.Bidegree.n_s(8, 4)) + return r + + +# --- the representative itself --------------------------------------------- + + +def test_h0_representative_structure(): + r = standard_s2() + y = ext.yoneda_representative_element(r, H0, [1]) + assert isinstance(y, ext.FiniteAugmentedChainComplex) + assert y.prime == 2 + # s_max = b.s() = 1, so modules C_0, C_1 => max_s() = modules.len = 2. + assert y.max_s == 2 + # The augmentation target is the original S_2 complex: 1-dimensional in + # internal degree 0. + target = y.target + assert target.prime == 2 + assert target.module(0).dimension(0) == 1 + # The bottom cell C_0 is a point in internal degree 0. + assert y.module(0).dimension(0) == 1 + # The s=0 augmentation chain map is defined. + assert y.chain_map(0).prime == 2 + + +def test_chain_map_index_out_of_range_raises_index_error(): + r = standard_s2() + y = ext.yoneda_representative_element(r, H0, [1]) + with pytest.raises(IndexError): + y.chain_map(2) + with pytest.raises(IndexError): + y.chain_map(-1) + + +# --- backend rejection ------------------------------------------------------ + + +def test_rejects_nassau_backend(): + r = ext.Resolution("S_2", "nassau") + r.compute_through_stem(sseq.Bidegree.n_s(8, 4)) + with pytest.raises(ValueError): + ext.yoneda_representative_element(r, H0, [1]) + + +# --- panic guards ----------------------------------------------------------- + + +def test_negative_bidegree_raises_value_error(): + r = standard_s2() + with pytest.raises(ValueError): + ext.yoneda_representative_element(r, sseq.Bidegree.s_t(-1, 0), [1]) + with pytest.raises(ValueError): + ext.yoneda_representative_element(r, sseq.Bidegree.s_t(0, -1), [1]) + + +def test_unresolved_bidegree_raises_value_error(): + r = standard_s2() + # Far outside the resolved region -> "resolution not resolved through ...". + with pytest.raises(ValueError): + ext.yoneda_representative_element(r, sseq.Bidegree.s_t(50, 50), []) + + +def test_wrong_class_length_raises_value_error(): + r = standard_s2() + # (1, 1) has exactly one generator. + with pytest.raises(ValueError): + ext.yoneda_representative_element(r, H0, [1, 0]) + with pytest.raises(ValueError): + ext.yoneda_representative_element(r, H0, []) + + +def test_class_entry_out_of_range_raises_value_error(): + r = standard_s2() + # Entry must be in [0, p) = [0, 2). + with pytest.raises(ValueError): + ext.yoneda_representative_element(r, H0, [2]) diff --git a/ext_py/uv.lock b/ext_py/uv.lock new file mode 100644 index 0000000000..57d3ecd922 --- /dev/null +++ b/ext_py/uv.lock @@ -0,0 +1,7 @@ +version = 1 +revision = 2 +requires-python = ">=3.8" + +[[package]] +name = "sseq-ext" +source = { editable = "." }