From ed9288dc316bccf28a3fbe5f04006032b65d7fad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 06:16:30 +0000 Subject: [PATCH 1/7] ext_algebra: let the Ext DGA carry a differential and compute cohomology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ExtAlgebra` models Ext as the cochain complex Hom(P•, k) = k^gens. Over a field with a minimal resolution its coboundary is identically zero, so Ext is just the generators. But a *deformation* (a motivic τ-Bockstein δ) or a *secondary* operation (an Adams d₂) makes the coboundary nonzero, and then the interesting object is its cohomology. This adds that differential as a pluggable piece: - `ExtDifferential`: a fixed bidegree `shift` plus, at each bidegree, the coboundary `matrix` in the generator bases. For graded coefficients (e.g. F₂[τ], graded by motivic weight) it also exposes `graded_dimension` / `matrix_capped`, so cohomology can be computed one graded slice at a time; the default (ungraded/field) makes those no-ops and recovers the old behavior exactly. - `ExtAlgebra::with_differential` / `differential` to attach and read it. - `cohomology_dimension` (+ `_capped`): dim H = gens − rank δ_out − rank δ_in. It's generic — nothing here is motivic. The two consumers are the motivic lift's δ and the secondary (d₂) machinery; the tests exercise it on the classical sphere with a mock differential (kernel/image bookkeeping) and confirm the zero-differential case is just the generators. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013ePtYD7Bt4iPeCtmqtqvZE --- ext/src/ext_algebra/mod.rs | 148 +++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/ext/src/ext_algebra/mod.rs b/ext/src/ext_algebra/mod.rs index f229886636..23a43d4353 100644 --- a/ext/src/ext_algebra/mod.rs +++ b/ext/src/ext_algebra/mod.rs @@ -35,6 +35,46 @@ use crate::{ utils::{QueryModuleResolution, get_unit}, }; +/// The differential of the Ext DGA: the coboundary on the cochain complex +/// $\Hom(P_\bullet, k) = k^{\text{gens}}$ whose cohomology is the "Ext part" — +/// the next page. +/// +/// It shifts bidegree by a fixed [`shift`](ExtDifferential::shift) and, at each +/// bidegree, gives its matrix in the generator bases. Over a field with a +/// *minimal* resolution this differential is identically zero — $d_s$ lands in +/// $\bar A \cdot P_{s-1}$, which every $\varphi\colon P_{s-1} \to k$ kills — so +/// $\Ext$ is just the generators and taking cohomology is a no-op. A deformation +/// (the motivic lift's $\delta$) or a secondary operation (the Adams $d_2$) is +/// what makes it nonzero and the cohomology nontrivial. +pub trait ExtDifferential: Send + Sync { + /// The fixed bidegree shift the differential applies: $\delta\colon \Ext_b \to + /// \Ext_{b + \mathrm{shift}}$. + fn shift(&self) -> Bidegree; + + /// The matrix of $\delta$ out of bidegree `b`: rows index the generators at + /// `b`, columns the generators at `b + shift`. `None` if the differential out + /// of `b` is out of the computed range; a computed-but-empty bidegree yields a + /// valid zero-size matrix, not `None`. + fn matrix(&self, b: Bidegree) -> Option; + + /// For a **graded** coefficient (e.g. $\mathbb{F}_2[\tau]$, graded by motivic + /// weight), the number of cochain generators at `b` whose grade is `≤ cap`. + /// The default — an ungraded (field) coefficient — returns `None`, meaning "no + /// grading", and the capped cohomology falls back to the full dimension. + fn graded_dimension(&self, b: Bidegree, cap: i32) -> Option { + let _ = (b, cap); + None + } + + /// The differential [`matrix`](Self::matrix) restricted to generators of grade + /// `≤ cap` at both ends (rows and columns compacted to the kept generators). + /// The default ignores `cap` (ungraded), returning the full matrix. + fn matrix_capped(&self, b: Bidegree, cap: i32) -> Option { + let _ = cap; + self.matrix(b) + } +} + /// $\Ext(M, k)$ as a bigraded module over the bigraded algebra $\Ext(k, k)$, backed by a /// resolution. See the [module-level documentation](self) for conventions. pub struct ExtAlgebra { @@ -45,6 +85,9 @@ pub struct ExtAlgebra { is_unit: bool, /// One multiplication map per generator of $\Ext(M, k)$, built and extended on demand. products: DashMap>>, + /// The DGA differential, if any. `None` is the field/minimal case (zero + /// coboundary), where the cohomology is just the generators. + differential: Option>, } impl ExtAlgebra { @@ -75,6 +118,7 @@ impl ExtAlgebra { resolution, unit, products: DashMap::new(), + differential: None, } } @@ -90,6 +134,58 @@ impl ExtAlgebra { Self::new(Arc::clone(&resolution), resolution) } + /// Attach a DGA differential, turning this into the Ext DGA whose cohomology + /// is the next page (see [`ExtDifferential`] and [`Self::cohomology_dimension`]). + /// Without one, the cohomology is the field/minimal case — just the generators. + #[must_use] + pub fn with_differential(mut self, differential: Arc) -> Self { + self.differential = Some(differential); + self + } + + /// The differential this DGA carries, if any. + pub fn differential(&self) -> Option<&Arc> { + self.differential.as_ref() + } + + /// The dimension of the DGA's cohomology at `b` — the "Ext part": + /// $\dim H_b = \dim\ker(\delta \text{ out of } b) - \mathrm{rank}(\delta \text{ into } b) + /// = \mathrm{gens}(b) - \mathrm{rank}\,\delta_{\text{out}}(b) - \mathrm{rank}\,\delta_{\text{in}}(b)$. + /// + /// With no differential (a field/minimal resolution, the zero coboundary) this + /// is exactly the generator count — the cohomology *is* $\Ext$, and "taking + /// cohomology" degenerates to reading generators. A nonzero differential (the + /// motivic $\delta$, an Adams $d_2$) makes it a genuine kernel-mod-image. + /// + /// Returns `None` if the outgoing differential at `b` is out of the computed + /// range; a missing incoming differential (no source bidegree, or empty) counts + /// as rank $0$. + pub fn cohomology_dimension(&self, b: Bidegree) -> Option { + self.cohomology_dimension_capped(b, i32::MAX) + } + + /// The dimension of the DGA's cohomology at `b` restricted to the coefficient's + /// weight slice `≤ cap` — for a graded coefficient like $\mathbb{F}_2[\tau]$ + /// this is a slice of the Ext *module*, and sweeping `cap` exposes the + /// $\tau$-torsion (dimension above the free/`cap = ∞` rank). For an ungraded + /// (field) coefficient the differential reports no grading and this is just + /// [`cohomology_dimension`](Self::cohomology_dimension) for every `cap`. + pub fn cohomology_dimension_capped(&self, b: Bidegree, cap: i32) -> Option { + let Some(d) = &self.differential else { + return Some(self.dimension(b)); + }; + let gens = d + .graded_dimension(b, cap) + .unwrap_or_else(|| self.dimension(b)); + let shift = d.shift(); + let source = Bidegree::n_s(b.n() - shift.n(), b.s() - shift.s()); + let rank_out = d.matrix_capped(b, cap)?.row_reduce(); + let rank_in = d + .matrix_capped(source, cap) + .map_or(0, |mut m| m.row_reduce()); + Some(gens - rank_out - rank_in) + } + pub fn resolution(&self) -> &Arc { &self.resolution } @@ -264,9 +360,61 @@ where #[cfg(test)] mod tests { + use fp::prime::TWO; + use super::*; use crate::utils::construct_standard; + #[test] + fn test_zero_differential_cohomology_is_generators() { + // The field/minimal case: with no differential the DGA cohomology is just + // the generators — "taking the Ext" is a no-op. + let res = Arc::new(construct_standard::("S_2", None).unwrap()); + res.compute_through_stem(Bidegree::n_s(8, 8)); + let alg = ExtAlgebra::new(Arc::clone(&res), res); + for s in 0..=8 { + for n in 0..=8 { + let b = Bidegree::n_s(n, s); + assert_eq!(alg.cohomology_dimension(b), Some(alg.dimension(b))); + } + } + } + + #[test] + fn test_differential_cohomology_kills_kernel_and_image() { + // A synthetic rank-1 differential (0,2) -> (0,1) must kill both ends in + // cohomology: h_0^2 by the outgoing rank, h_0 by the incoming rank. An + // untouched bidegree (h_1) is unchanged. + struct MockDiff; + impl ExtDifferential for MockDiff { + fn shift(&self) -> Bidegree { + Bidegree::n_s(0, -1) // lowers filtration: (0,2) -> (0,1) + } + fn matrix(&self, b: Bidegree) -> Option { + if b == Bidegree::n_s(0, 2) { + let mut m = Matrix::new(TWO, 1, 1); + m.row_mut(0).set_entry(0, 1); + Some(m) + } else { + Some(Matrix::new(TWO, 0, 0)) // rank 0 elsewhere + } + } + } + + let res = Arc::new(construct_standard::("S_2", None).unwrap()); + res.compute_through_stem(Bidegree::n_s(8, 8)); + let alg = ExtAlgebra::new(Arc::clone(&res), res).with_differential(Arc::new(MockDiff)); + + // Sanity: all three source bidegrees are 1-dimensional on the E-page. + assert_eq!(alg.dimension(Bidegree::n_s(0, 1)), 1); // h_0 + assert_eq!(alg.dimension(Bidegree::n_s(0, 2)), 1); // h_0^2 + assert_eq!(alg.dimension(Bidegree::n_s(1, 1)), 1); // h_1 + + assert_eq!(alg.cohomology_dimension(Bidegree::n_s(0, 2)), Some(0)); // outgoing rank 1 + assert_eq!(alg.cohomology_dimension(Bidegree::n_s(0, 1)), Some(0)); // incoming rank 1 + assert_eq!(alg.cohomology_dimension(Bidegree::n_s(1, 1)), Some(1)); // untouched + } + #[test] fn test_sphere_products() { let res = Arc::new(construct_standard::("S_2", None).unwrap()); From d465c711c6b16b23d85744808a91b4f23a220163 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 06:52:22 +0000 Subject: [PATCH 2/7] =?UTF-8?q?ext=5Falgebra:=20cohomology=5Fsubquotient?= =?UTF-8?q?=20+=20the=20Adams=20d=E2=82=82=20as=20an=20ExtDifferential?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate that the differential→cohomology abstraction serves a second consumer: the Adams d₂. Two additions, no behavior change to existing paths. - `ExtAlgebra::cohomology_subquotient(b)`: the cohomology at `b` as a `Subquotient` of the generators — ker(δ out) mod im(δ into), i.e. actual representatives of the surviving classes, not just the dimension. The natural companion to `cohomology_dimension`; the motivic side wants it (surviving-class reps) as much as the secondary side does. - `SecondaryCoboundary: ExtDifferential`: the Adams d₂ presented as a coboundary on the primary ExtAlgebra, shift (n,s) → (n-1,s+2), matrix read from the secondary resolution's homotopies. Its `matrix` sizes each end at its own bidegree so an `a×0` (empty target: a d₂-cycle) and a `0×b` (off-axis source, in-quadrant target: an ambient-`b` image) stay distinct; off the first quadrant Ext is a *known* zero, in-quadrant-but-unresolved is unknown (`None`). Gate test: attaching `SecondaryCoboundary` and computing the E₃ page via the shared `cohomology_subquotient` reproduces the spectral-sequence bookkeeping's `page_data` exactly across the sphere in a range (dimension at every bidegree, including the axes). This is the groundwork for routing the secondary layer's page/product machinery onto the shared path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013ePtYD7Bt4iPeCtmqtqvZE --- ext/src/ext_algebra/mod.rs | 46 +++++++++++- ext/src/ext_algebra/secondary.rs | 123 ++++++++++++++++++++++++++++++- 2 files changed, 166 insertions(+), 3 deletions(-) diff --git a/ext/src/ext_algebra/mod.rs b/ext/src/ext_algebra/mod.rs index 23a43d4353..2a1f3eba3f 100644 --- a/ext/src/ext_algebra/mod.rs +++ b/ext/src/ext_algebra/mod.rs @@ -25,7 +25,11 @@ pub mod secondary; use std::sync::Arc; use dashmap::DashMap; -use fp::{matrix::Matrix, prime::ValidPrime, vector::FpVector}; +use fp::{ + matrix::{AugmentedMatrix, Matrix, Subquotient, Subspace}, + prime::ValidPrime, + vector::FpVector, +}; use sseq::coordinates::{Bidegree, BidegreeElement, BidegreeGenerator}; pub use self::secondary::{SecondaryExtAlgebra, SecondaryProduct}; @@ -186,6 +190,46 @@ impl ExtAlgebra { Some(gens - rank_out - rank_in) } + /// The DGA's cohomology at `b` as a [`Subquotient`] of the generators — the + /// actual kernel-mod-image subspace, so callers get *representatives* of the + /// surviving classes, not just the [dimension](Self::cohomology_dimension). + /// The numerator is $\ker(\delta \text{ out of } b)$, the denominator is + /// $\operatorname{im}(\delta \text{ into } b)$. With no differential attached + /// every generator survives, so this is the full space. + /// + /// `None` if the outgoing differential at `b` is out of the computed range (as + /// with [`cohomology_dimension`](Self::cohomology_dimension)). + pub fn cohomology_subquotient(&self, b: Bidegree) -> Option { + let p = self.prime(); + let dim = self.dimension(b); + let Some(d) = &self.differential else { + return Some(Subquotient::new_full(p, dim)); + }; + + // Numerator: ker(δ out of b), via the standard augmented-identity kernel. + let out = d.matrix(b)?; + let target_dim = out.columns(); + let mut aug = AugmentedMatrix::<2>::new(p, dim, [target_dim, dim]); + aug.segment(1, 1).add_identity(); + for i in 0..dim { + aug.row_mut(i).slice_mut(0, target_dim).add(out.row(i), 1); + } + aug.row_reduce(); + let numerator = aug.compute_kernel(); + + // Denominator: im(δ into b) = row space of δ out of the source bidegree. A + // well-shaped differential lands in the gens(b)-space (`dim` columns), so its + // rows are vectors of the right ambient; a missing source is the zero image. + let shift = d.shift(); + let source = Bidegree::n_s(b.n() - shift.n(), b.s() - shift.s()); + let denominator = match d.matrix(source) { + Some(m) => Subspace::from_matrix(m), + None => Subspace::new(p, dim), + }; + + Some(Subquotient::from_parts(numerator, denominator)) + } + pub fn resolution(&self) -> &Arc { &self.resolution } diff --git a/ext/src/ext_algebra/secondary.rs b/ext/src/ext_algebra/secondary.rs index 983a7c60ac..001f27f577 100644 --- a/ext/src/ext_algebra/secondary.rs +++ b/ext/src/ext_algebra/secondary.rs @@ -16,10 +16,14 @@ use std::sync::{Arc, Mutex}; use algebra::pair_algebra::PairAlgebra; use dashmap::DashMap; -use fp::{matrix::Subquotient, prime::Prime, vector::FpVector}; +use fp::{ + matrix::{Matrix, Subquotient}, + prime::{Prime, ValidPrime}, + vector::FpVector, +}; use sseq::coordinates::{Bidegree, BidegreeElement}; -use super::ExtAlgebra; +use super::{ExtAlgebra, ExtDifferential}; use crate::{ chain_complex::FreeChainComplex, resolution_homomorphism::ResolutionHomomorphism, @@ -28,6 +32,70 @@ use crate::{ }, }; +/// The Adams $d_2$ presented as an [`ExtDifferential`] on the primary +/// [`ExtAlgebra`]: the same coboundary shape as the motivic $\delta$, only with the +/// Adams shift $(n, s) \mapsto (n-1, s+2)$. Its matrix out of a bidegree is exactly +/// the $d_2$ the secondary resolution's homotopies record. Attaching it makes +/// [`ExtAlgebra::cohomology_subquotient`] compute the $E_3$ page on the shared +/// kernel-mod-image path — the same object the spectral-sequence bookkeeping gives. +pub struct SecondaryCoboundary +where + CC::Algebra: PairAlgebra, +{ + res_lift: Arc>, + p: ValidPrime, +} + +impl ExtDifferential for SecondaryCoboundary +where + CC::Algebra: PairAlgebra, +{ + fn shift(&self) -> Bidegree { + Bidegree::n_s(-1, 2) + } + + fn matrix(&self, b: Bidegree) -> Option { + let res = self.res_lift.underlying(); + let target = b + self.shift(); + + // The shape must be exactly `gens(b) × gens(target)`: an `a × 0` (empty + // target — the whole source is a d2-cycle) and a `0 × b` (off-axis source, + // in-quadrant target — contributes to the ambient-`b` image) are genuinely + // different and both matter to `cohomology_subquotient`, so size each end at + // its own bidegree. Off the first quadrant Ext vanishes (0 generators, a + // *known* zero); in the first quadrant but unresolved is unknown (`None`). + let gens = |x: Bidegree| -> Option { + if x.n() < 0 || x.s() < 0 { + Some(0) + } else if res.has_computed_bidegree(x) { + Some(res.number_of_gens_in_bidegree(x)) + } else { + None + } + }; + let rows = gens(b)?; + let cols = gens(target)?; + + let mut mat = Matrix::new(self.p, rows, cols); + // Fill only when both ends carry generators; `m[i]` is the d2 of the i-th + // generator of `b`, as a vector at `target` — the same matrix + // `SecondaryResolution::e3_page` reads to install d2. + if rows > 0 && cols > 0 { + let m = self.res_lift.homotopy(b.s() + 2).homotopies.hom_k(b.t()); + if !m.is_empty() && !m[0].is_empty() { + for (i, row) in m.iter().enumerate() { + for (k, &v) in row.iter().enumerate() { + if v != 0 { + mat.row_mut(i).set_entry(k, v); + } + } + } + } + } + Some(mat) + } +} + /// A single secondary product `x · y` in $\Mod_{C\lambda^2}$, where `y` is an $E_3$-surviving /// class. See [`SecondaryExtAlgebra::secondary_multiply_into`]. pub struct SecondaryProduct { @@ -305,4 +373,55 @@ mod tests { let h4_survives = sec_e2.survives(&h4).expect("h4 should have a computed d2"); assert!(!h4_survives, "h4 should not survive d2"); } + + #[test] + fn d2_as_ext_differential_reproduces_the_e3_page() { + // The Adams d2 is an `ExtDifferential`: attaching `SecondaryCoboundary` to the + // primary ExtAlgebra makes the shared `cohomology_subquotient` path compute the + // exact E3 page the spectral-sequence bookkeeping (`page_data`) gives — same + // dimension at every bidegree, and the same d2-image quotient. + let res = Arc::new(construct_standard::("S_2", None).unwrap()); + res.compute_through_stem(Bidegree::n_s(16, 6)); + let e2 = Arc::new(ExtAlgebra::new(Arc::clone(&res), Arc::clone(&res))); + let sec = SecondaryExtAlgebra::new(Arc::clone(&e2)); + sec.extend_all(); + + let coboundary = Arc::new(SecondaryCoboundary { + res_lift: Arc::clone(&sec.res_lift), + p: e2.prime(), + }); + let e2_d2 = + ExtAlgebra::new(Arc::clone(&res), Arc::clone(&res)).with_differential(coboundary); + + let mut saw_nontrivial = false; + for n in 0..=15 { + for s in 1..=5 { + let b = Bidegree::n_s(n, s); + let Some(dim) = e2_d2.cohomology_dimension(b) else { + continue; + }; + let page = sec.page_data(b); + assert_eq!( + dim, + page.dimension(), + "E3 dimension mismatch at (n={n}, s={s})" + ); + // The subquotient's denominator is the d2-image: reducing any E2 vector + // by it must agree with the spectral sequence's page quotient. + let sq = e2_d2.cohomology_subquotient(b).unwrap(); + assert_eq!( + sq.dimension(), + page.dimension(), + "E3 subquotient dimension mismatch at (n={n}, s={s})" + ); + if page.dimension() != e2.dimension(b) { + saw_nontrivial = true; // d2 actually killed something here + } + } + } + assert!( + saw_nontrivial, + "expected d2 to be nontrivial somewhere in range (e.g. h4 at (15,1) → (14,3))" + ); + } } From 673d040eea738ecb9163b018d0c8c94a35be98fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 07:23:53 +0000 Subject: [PATCH 3/7] =?UTF-8?q?ext=5Falgebra:=20route=20the=20secondary=20?= =?UTF-8?q?d=E2=82=82/E=E2=82=83=20page=20onto=20the=20shared=20cohomology?= =?UTF-8?q?=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The secondary layer computed its E₃ pages by building a whole Sseq<2, Adams> per resolution and reading `page_data` off it. Now that the Adams d₂ is an `ExtDifferential`, `cohomology_subquotient` computes the same E₃ page directly (ker d₂ / im d₂), so the parallel bookkeeping is redundant. - `ExtAlgebra::cohomology_subquotient(b)`: the E₃ page (representatives, not just the dimension `cohomology_dimension` gives). - `SecondaryExtAlgebra` holds an Ext-with-d₂ for `M` and for the unit; its `page_data`/`unit_page_data` are now `cohomology_subquotient`, and `extend_all` no longer builds any Sseq. The `res_sseq`/`unit_sseq` fields, their construction, and the `e3_page_data` helper are gone. - `SecondaryResolutionHomomorphism::hom_k` took a whole `Sseq` only to read one page (the λ-part's d₂-image quotient). It now takes the E₃ page as a function of bidegree and queries it at its own `lambda_source` — no fragile reconstruction of that bidegree by the caller (the first, index-arithmetic attempt is what the new product smoke test caught). Hardening from review: - assert `ExtDifferential::matrix` shapes in `cohomology_subquotient` (gens(b) rows / gens(b) columns), so a nonconformant impl fails loudly; - debug_assert `rank_out + rank_in ≤ gens` before the cohomology subtraction; - document that graded impls must override `graded_dimension`/`matrix_capped` together; - the `MockDiff` test fixture now returns correctly-shaped zero matrices (via the resolution's generator counts), making it a valid reference for `cohomology_subquotient`, not just rank-only `cohomology_dimension`; - `SecondaryCoboundary` is `pub(crate)` and derives its prime from the resolution instead of storing it. Validated: `secondary-S_2` and `secondary-C2` benchmarks are byte-identical to golden; new tests cover the E₃-page agreement, the mock subquotient, and that the secondary product's Ext part is the primary product. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013ePtYD7Bt4iPeCtmqtqvZE --- ext/src/ext_algebra/mod.rs | 83 +++++++++++++++++--- ext/src/ext_algebra/secondary.rs | 120 ++++++++++++++++++----------- ext/src/resolution_homomorphism.rs | 24 +++--- 3 files changed, 163 insertions(+), 64 deletions(-) diff --git a/ext/src/ext_algebra/mod.rs b/ext/src/ext_algebra/mod.rs index 2a1f3eba3f..6f7e913a60 100644 --- a/ext/src/ext_algebra/mod.rs +++ b/ext/src/ext_algebra/mod.rs @@ -73,6 +73,13 @@ pub trait ExtDifferential: Send + Sync { /// The differential [`matrix`](Self::matrix) restricted to generators of grade /// `≤ cap` at both ends (rows and columns compacted to the kept generators). /// The default ignores `cap` (ungraded), returning the full matrix. + /// + /// A graded implementor **must override this together with + /// [`graded_dimension`](Self::graded_dimension)** and keep them consistent: the + /// capped matrix's row count must equal `graded_dimension(b, cap)` (and its + /// column count `graded_dimension(b + shift, cap)`). Otherwise + /// [`cohomology_dimension_capped`](ExtAlgebra::cohomology_dimension_capped) mixes + /// a capped generator count with an uncapped rank. fn matrix_capped(&self, b: Bidegree, cap: i32) -> Option { let _ = cap; self.matrix(b) @@ -187,6 +194,11 @@ impl ExtAlgebra { let rank_in = d .matrix_capped(source, cap) .map_or(0, |mut m| m.row_reduce()); + // ker ⊇ im requires d∘d = 0; a malformed differential could underflow here. + debug_assert!( + rank_out + rank_in <= gens, + "ExtDifferential violates d∘d=0 at {b:?}: rank_out={rank_out}, rank_in={rank_in}, gens={gens}" + ); Some(gens - rank_out - rank_in) } @@ -208,6 +220,12 @@ impl ExtAlgebra { // Numerator: ker(δ out of b), via the standard augmented-identity kernel. let out = d.matrix(b)?; + assert_eq!( + out.rows(), + dim, + "ExtDifferential::matrix({b:?}) must have gens(b) = {dim} rows, got {}", + out.rows() + ); let target_dim = out.columns(); let mut aug = AugmentedMatrix::<2>::new(p, dim, [target_dim, dim]); aug.segment(1, 1).add_identity(); @@ -223,7 +241,15 @@ impl ExtAlgebra { let shift = d.shift(); let source = Bidegree::n_s(b.n() - shift.n(), b.s() - shift.s()); let denominator = match d.matrix(source) { - Some(m) => Subspace::from_matrix(m), + Some(m) => { + assert_eq!( + m.columns(), + dim, + "ExtDifferential::matrix({source:?}) into {b:?} must have gens(b) = {dim} columns, got {}", + m.columns() + ); + Subspace::from_matrix(m) + } None => Subspace::new(p, dim), }; @@ -407,7 +433,7 @@ mod tests { use fp::prime::TWO; use super::*; - use crate::utils::construct_standard; + use crate::{chain_complex::ChainComplex, utils::construct_standard}; #[test] fn test_zero_differential_cohomology_is_generators() { @@ -429,25 +455,43 @@ mod tests { // A synthetic rank-1 differential (0,2) -> (0,1) must kill both ends in // cohomology: h_0^2 by the outgoing rank, h_0 by the incoming rank. An // untouched bidegree (h_1) is unchanged. - struct MockDiff; + // A differential shaped per the `matrix` contract: `gens(b)` rows, + // `gens(b + shift)` columns (0 off the first quadrant), with the single + // nonzero d2 entry d(h_0^2) = h_0 at (0,2) → (0,1). Sizing from the real + // generator counts keeps it a valid reference for `cohomology_subquotient`, + // not just the rank-only `cohomology_dimension`. + struct MockDiff { + dims: Arc usize + Send + Sync>, + } impl ExtDifferential for MockDiff { fn shift(&self) -> Bidegree { Bidegree::n_s(0, -1) // lowers filtration: (0,2) -> (0,1) } fn matrix(&self, b: Bidegree) -> Option { - if b == Bidegree::n_s(0, 2) { - let mut m = Matrix::new(TWO, 1, 1); + let rows = (self.dims)(b); + let cols = (self.dims)(b + self.shift()); + let mut m = Matrix::new(TWO, rows, cols); + if b == Bidegree::n_s(0, 2) && rows == 1 && cols == 1 { m.row_mut(0).set_entry(0, 1); - Some(m) - } else { - Some(Matrix::new(TWO, 0, 0)) // rank 0 elsewhere } + Some(m) } } let res = Arc::new(construct_standard::("S_2", None).unwrap()); res.compute_through_stem(Bidegree::n_s(8, 8)); - let alg = ExtAlgebra::new(Arc::clone(&res), res).with_differential(Arc::new(MockDiff)); + let dims: Arc usize + Send + Sync> = { + let res = Arc::clone(&res); + Arc::new(move |b: Bidegree| { + if b.n() >= 0 && b.s() >= 0 && res.has_computed_bidegree(b) { + res.number_of_gens_in_bidegree(b) + } else { + 0 + } + }) + }; + let alg = ExtAlgebra::new(Arc::clone(&res), Arc::clone(&res)) + .with_differential(Arc::new(MockDiff { dims })); // Sanity: all three source bidegrees are 1-dimensional on the E-page. assert_eq!(alg.dimension(Bidegree::n_s(0, 1)), 1); // h_0 @@ -457,6 +501,27 @@ mod tests { assert_eq!(alg.cohomology_dimension(Bidegree::n_s(0, 2)), Some(0)); // outgoing rank 1 assert_eq!(alg.cohomology_dimension(Bidegree::n_s(0, 1)), Some(0)); // incoming rank 1 assert_eq!(alg.cohomology_dimension(Bidegree::n_s(1, 1)), Some(1)); // untouched + + // The shape-correct mock drives `cohomology_subquotient` too: same answers, + // now with representatives. + assert_eq!( + alg.cohomology_subquotient(Bidegree::n_s(0, 2)) + .unwrap() + .dimension(), + 0 + ); + assert_eq!( + alg.cohomology_subquotient(Bidegree::n_s(0, 1)) + .unwrap() + .dimension(), + 0 + ); + assert_eq!( + alg.cohomology_subquotient(Bidegree::n_s(1, 1)) + .unwrap() + .dimension(), + 1 + ); } #[test] diff --git a/ext/src/ext_algebra/secondary.rs b/ext/src/ext_algebra/secondary.rs index 001f27f577..1ffad8164c 100644 --- a/ext/src/ext_algebra/secondary.rs +++ b/ext/src/ext_algebra/secondary.rs @@ -12,13 +12,13 @@ //! algebra is implemented here. The layer is split out from [`ExtAlgebra`] because the secondary //! machinery requires `CC::Algebra: PairAlgebra`, a bound the primary layer does not impose. -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use algebra::pair_algebra::PairAlgebra; use dashmap::DashMap; use fp::{ matrix::{Matrix, Subquotient}, - prime::{Prime, ValidPrime}, + prime::Prime, vector::FpVector, }; use sseq::coordinates::{Bidegree, BidegreeElement}; @@ -38,12 +38,11 @@ use crate::{ /// the $d_2$ the secondary resolution's homotopies record. Attaching it makes /// [`ExtAlgebra::cohomology_subquotient`] compute the $E_3$ page on the shared /// kernel-mod-image path — the same object the spectral-sequence bookkeeping gives. -pub struct SecondaryCoboundary +pub(crate) struct SecondaryCoboundary where CC::Algebra: PairAlgebra, { res_lift: Arc>, - p: ValidPrime, } impl ExtDifferential for SecondaryCoboundary @@ -56,6 +55,7 @@ where fn matrix(&self, b: Bidegree) -> Option { let res = self.res_lift.underlying(); + let p = res.prime(); let target = b + self.shift(); // The shape must be exactly `gens(b) × gens(target)`: an `a × 0` (empty @@ -76,7 +76,7 @@ where let rows = gens(b)?; let cols = gens(target)?; - let mut mat = Matrix::new(self.p, rows, cols); + let mut mat = Matrix::new(p, rows, cols); // Fill only when both ends carry generators; `m[i]` is the d2 of the i-th // generator of `b`, as a vector at `target` — the same matrix // `SecondaryResolution::e3_page` reads to install d2. @@ -118,15 +118,18 @@ where res_lift: Arc>, /// `Arc`-shared with `res_lift` when `M == k`. unit_lift: Arc>, - /// $E_3$ page of the resolution, filled by [`extend_all`](Self::extend_all). - res_sseq: Mutex>>>, - /// $E_3$ page of the unit, filled by [`extend_all`](Self::extend_all). - unit_sseq: Mutex>>>, + /// The primary Ext with the Adams $d_2$ ([`SecondaryCoboundary`]) attached: its + /// [`cohomology_subquotient`](ExtAlgebra::cohomology_subquotient) is the $E_3$ + /// page of $\Ext(M, k)$. The page is computed on demand from the extended + /// secondary homotopies — no separate spectral-sequence object. + alg_d2: ExtAlgebra, + /// The unit Ext with $d_2$ attached: the $E_3$ page of $\Ext(k, k)$. + unit_d2: ExtAlgebra, /// Secondary lift of the multiplication map, cached per multiplier class `(degree, coords)`. secondary_products: DashMap>>, } -impl SecondaryExtAlgebra +impl SecondaryExtAlgebra where CC::Algebra: PairAlgebra, { @@ -139,32 +142,36 @@ where } else { Arc::new(SecondaryResolution::new(Arc::clone(alg.unit()))) }; + // Ext-with-d2 objects whose `cohomology_subquotient` is the E3 page. The + // coboundary reads the secondary homotopies lazily, so building these before + // `extend_all` is cheap. + let alg_d2 = ExtAlgebra::new(Arc::clone(alg.resolution()), Arc::clone(alg.unit())) + .with_differential(Arc::new(SecondaryCoboundary { + res_lift: Arc::clone(&res_lift), + })); + let unit_d2 = ExtAlgebra::new(Arc::clone(alg.unit()), Arc::clone(alg.unit())) + .with_differential(Arc::new(SecondaryCoboundary { + res_lift: Arc::clone(&unit_lift), + })); Self { alg, res_lift, unit_lift, - res_sseq: Mutex::new(None), - unit_sseq: Mutex::new(None), + alg_d2, + unit_d2, secondary_products: DashMap::new(), } } - /// Extend the secondary resolutions as far as the underlying resolutions allow, then compute - /// the $E_3$ pages. Must be called before [`d2`](Self::d2), [`page_data`](Self::page_data) or - /// [`secondary_multiply_into`](Self::secondary_multiply_into). + /// Extend the secondary resolutions as far as the underlying resolutions allow. + /// Must be called before [`d2`](Self::d2), [`page_data`](Self::page_data) or + /// [`secondary_multiply_into`](Self::secondary_multiply_into); the $E_3$ pages are + /// then computed on demand from the extended homotopies. pub fn extend_all(&self) { self.res_lift.extend_all(); if !self.alg.is_unit() { self.unit_lift.extend_all(); } - - *self.res_sseq.lock().unwrap() = Some(Arc::new(self.res_lift.e3_page())); - let unit = if self.alg.is_unit() { - Arc::clone(self.res_sseq.lock().unwrap().as_ref().unwrap()) - } else { - Arc::new(self.unit_lift.e3_page()) - }; - *self.unit_sseq.lock().unwrap() = Some(unit); } /// Sharding entry point: compute only the secondary resolution data for filtration `s`, @@ -220,25 +227,25 @@ where self.d2(x).map(|d| d.vec().is_zero()) } - /// The $E_3$-page subquotient of $\Ext(M, k)$ at bidegree `b`. + /// The $E_3$-page subquotient of $\Ext(M, k)$ at bidegree `b` — the cohomology of + /// the primary Ext with the Adams $d_2$ attached, on the shared + /// [`cohomology_subquotient`](ExtAlgebra::cohomology_subquotient) path. pub fn page_data(&self, b: Bidegree) -> Subquotient { - let g = self.res_sseq.lock().unwrap(); - Self::e3_page_data(g.as_ref().expect("call extend_all() first"), b).clone() + self.alg_d2 + .cohomology_subquotient(b) + .expect("call extend_all() first (and query a computed bidegree)") } /// The $E_3$-page subquotient of the unit $\Ext(k, k)$ at bidegree `b`. pub fn unit_page_data(&self, b: Bidegree) -> Subquotient { - let g = self.unit_sseq.lock().unwrap(); - Self::e3_page_data(g.as_ref().expect("call extend_all() first"), b).clone() - } - - fn e3_page_data(sseq: &sseq::Sseq<2, sseq::Adams>, b: Bidegree) -> &Subquotient { - let d = sseq.page_data(b); - &d[std::cmp::min(3, d.len() - 1)] + self.unit_d2 + .cohomology_subquotient(b) + .expect("call extend_all() first (and query a computed bidegree)") } } -impl SecondaryExtAlgebra +impl + SecondaryExtAlgebra where CC::Algebra: PairAlgebra, { @@ -289,13 +296,11 @@ where ) -> Vec { let p = self.prime(); let shift = x.degree(); - let res_sseq = Arc::clone( - self.res_sseq - .lock() - .unwrap() - .as_ref() - .expect("call extend_all() first"), - ); + // `hom_k` reduces the λ-part of the product by the image of d2 at the λ-part's + // source. Rather than reconstruct that bidegree here, hand it the E3 page as a + // function of bidegree (from the shared cohomology path on the primary Ext, + // where the product lands) and let `hom_k` query it at the right place. + let lambda_page = |bd: Bidegree| self.alg_d2.cohomology_subquotient(bd); let ext_dim = self.alg.resolution().number_of_gens_in_bidegree(b + shift); let lambda_dim = self @@ -315,7 +320,7 @@ where let mut outputs = vec![FpVector::new(p, ext_dim + lambda_dim); n]; lift.hom_k( - Some(&res_sseq), + Some(&lambda_page), b, page.subspace_gens(), outputs.iter_mut().map(FpVector::as_slice_mut), @@ -388,7 +393,6 @@ mod tests { let coboundary = Arc::new(SecondaryCoboundary { res_lift: Arc::clone(&sec.res_lift), - p: e2.prime(), }); let e2_d2 = ExtAlgebra::new(Arc::clone(&res), Arc::clone(&res)).with_differential(coboundary); @@ -424,4 +428,34 @@ mod tests { "expected d2 to be nontrivial somewhere in range (e.g. h4 at (15,1) → (14,3))" ); } + + #[test] + fn secondary_product_runs_and_ext_part_is_the_primary_product() { + // End-to-end check of the product path after routing the E3 page onto the + // shared `cohomology_subquotient` (the λ-part reduce now reads it, not a + // separate Sseq): `secondary_multiply_into` runs, and every product's Ext part + // equals the primary Ext product x · source. + let res = Arc::new(construct_standard::("S_2", None).unwrap()); + res.compute_through_stem(Bidegree::n_s(10, 8)); + let e2 = Arc::new(ExtAlgebra::new(Arc::clone(&res), Arc::clone(&res))); + let sec = SecondaryExtAlgebra::new(Arc::clone(&e2)); + sec.extend_all(); + + // Multiply h0 into the classes at (0,1); the lone survivor is h0, so the Ext + // part must be the primary product h0 · h0 = h0². + let h0 = e2.generator(BidegreeGenerator::new(Bidegree::n_s(0, 1), 0)); + let products = sec.secondary_multiply_into(&h0, Bidegree::n_s(0, 1)); + assert!( + !products.is_empty(), + "expected a secondary product at (0,1)" + ); + for prod in &products { + let primary = e2.multiply(&h0, &prod.source); + assert_eq!( + prod.ext_part, + primary.vec().to_owned(), + "secondary product Ext part must equal the primary product" + ); + } + } } diff --git a/ext/src/resolution_homomorphism.rs b/ext/src/resolution_homomorphism.rs index 271c7318d3..b2c20bd070 100644 --- a/ext/src/resolution_homomorphism.rs +++ b/ext/src/resolution_homomorphism.rs @@ -506,7 +506,7 @@ pub(crate) mod secondary { }; use dashmap::DashMap; use fp::{ - matrix::Matrix, + matrix::{Matrix, Subquotient}, vector::{FpSlice, FpSliceMut, FpVector}, }; use itertools::Itertools; @@ -717,7 +717,7 @@ pub(crate) mod secondary { pub fn hom_k_with<'a>( &self, lambda_part: Option<&ResolutionHomomorphism>, - sseq: Option<&sseq::Sseq<2, sseq::Adams>>, + e3_page: Option<&dyn Fn(Bidegree) -> Option>, b: Bidegree, inputs: impl Iterator>, outputs: impl Iterator>, @@ -754,10 +754,9 @@ pub(crate) mod secondary { }; let filtration_one_sign = if (b.t() % 2) == 1 { p - 1 } else { 1 }; - let page_data = sseq.map(|sseq| { - let d = sseq.page_data(lambda_source); - &d[std::cmp::min(3, d.len() - 1)] - }); + // The E3 page at the λ-part's source; its quotient (the image of d₂) + // reduces the λ part of each output. + let lambda_page = e3_page.and_then(|f| f(lambda_source)); let mut scratch0: Vec = Vec::new(); for (input, mut out) in inputs.zip_eq(outputs) { @@ -778,8 +777,8 @@ pub(crate) mod secondary { out.slice_mut(source_num_gens, source_num_gens + lambda_num_gens) .add(mp.row(i), (extra * filtration_one_sign) % p); } - if let Some(page_data) = page_data { - page_data.reduce_by_quotient( + if let Some(lambda_page) = &lambda_page { + lambda_page.reduce_by_quotient( out.slice_mut(source_num_gens, source_num_gens + lambda_num_gens), ); } @@ -795,16 +794,17 @@ pub(crate) mod secondary { /// This reduces the λ part of the result by the image of d₂. /// /// # Arguments - /// - `sseq`: A sseq object that records the $d_2$ differentials. If present, reduce the value - /// of the map by the image of $d_2$. + /// - `e3_page`: the $E_3$ subquotient as a function of bidegree. If present, the + /// quotient (image of $d_2$) at the λ-part's source reduces the λ part of the + /// result. Passed as a function so the caller need not reconstruct that bidegree. pub fn hom_k<'a>( &self, - sseq: Option<&sseq::Sseq<2, sseq::Adams>>, + e3_page: Option<&dyn Fn(Bidegree) -> Option>, b: Bidegree, inputs: impl Iterator>, outputs: impl Iterator>, ) { - self.hom_k_with(None, sseq, b, inputs, outputs); + self.hom_k_with(None, e3_page, b, inputs, outputs); } /// Given an element b whose product with this is null, find the element whose $d_2$ hits the From 83ce2f2a8a5d20bbc891be556213d79d612bd34f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 07:32:42 +0000 Subject: [PATCH 4/7] =?UTF-8?q?ext=5Falgebra:=20fix=20CI=20=E2=80=94=20ada?= =?UTF-8?q?pt=20secondary=5Fmassey=20to=20the=20hom=5Fk=20page-fn=20API=20?= =?UTF-8?q?+=20nightly=20fmt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `hom_k`/`hom_k_with` signature change (Sseq → E₃-page function) missed a caller: `examples/secondary_massey.rs`, which only compiles under default features (my local runs used --no-default-features, and my grep for callers searched src/ only). Adapt its call to pass the E₃ page as a closure over the example's existing `get_page_data`. Also run nightly `cargo fmt` (the repo's rustfmt.toml enables unstable options — reorder_impl_items, group_imports, format_strings — that stable rustfmt silently skips), which the `lint` CI job checks. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013ePtYD7Bt4iPeCtmqtqvZE --- ext/examples/secondary_massey.rs | 3 ++- ext/src/ext_algebra/mod.rs | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ext/examples/secondary_massey.rs b/ext/examples/secondary_massey.rs index 9c8169a4fb..27bbf9063b 100644 --- a/ext/examples/secondary_massey.rs +++ b/ext/examples/secondary_massey.rs @@ -323,9 +323,10 @@ fn main() -> anyhow::Result<()> { target_all_gens + prod_all_gens, ); + let e3_page = |bd: Bidegree| Some(get_page_data(&unit_sseq, bd).clone()); b.hom_k_with( b_lambda.as_deref(), - Some(&unit_sseq), + Some(&e3_page as &dyn Fn(Bidegree) -> Option), c, e2_kernel.basis(), product_matrix diff --git a/ext/src/ext_algebra/mod.rs b/ext/src/ext_algebra/mod.rs index 6f7e913a60..6965b0e5d9 100644 --- a/ext/src/ext_algebra/mod.rs +++ b/ext/src/ext_algebra/mod.rs @@ -197,7 +197,8 @@ impl ExtAlgebra { // ker ⊇ im requires d∘d = 0; a malformed differential could underflow here. debug_assert!( rank_out + rank_in <= gens, - "ExtDifferential violates d∘d=0 at {b:?}: rank_out={rank_out}, rank_in={rank_in}, gens={gens}" + "ExtDifferential violates d∘d=0 at {b:?}: rank_out={rank_out}, rank_in={rank_in}, \ + gens={gens}" ); Some(gens - rank_out - rank_in) } @@ -245,7 +246,8 @@ impl ExtAlgebra { assert_eq!( m.columns(), dim, - "ExtDifferential::matrix({source:?}) into {b:?} must have gens(b) = {dim} columns, got {}", + "ExtDifferential::matrix({source:?}) into {b:?} must have gens(b) = {dim} \ + columns, got {}", m.columns() ); Subspace::from_matrix(m) @@ -467,6 +469,7 @@ mod tests { fn shift(&self) -> Bidegree { Bidegree::n_s(0, -1) // lowers filtration: (0,2) -> (0,1) } + fn matrix(&self, b: Bidegree) -> Option { let rows = (self.dims)(b); let cols = (self.dims)(b + self.shift()); From 5678bd7b43284b7a9acbbec9d39ae72d6d4c9c53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 08:19:08 +0000 Subject: [PATCH 5/7] ext_algebra: match the e3_page convention at the top edge of the resolved range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `secondary_product`/`secondary_massey` query the E3 page one row past the resolved filtration, where the outgoing d2 lands at an unresolved bidegree. The old Sseq path (SecondaryResolution::e3_page) treats an uncomputed outgoing differential as zero — those classes provisionally survive — so page_data was always defined there. cohomology_subquotient is stricter: a None outgoing matrix makes it return None, and unit_page_data's .expect() then panicked (caught by the C2 v_1 benchmark under `just benchmarks`). Reproduce the e3_page convention locally in SecondaryCoboundary::matrix by distinguishing the two ends: an in-quadrant-but-unresolved *source* still means the page is unknown (None), but an in-quadrant-but-unresolved *target* means the secondary resolution records no d2 there yet, so give a `rows × 0` matrix (the whole source is a provisional d2-cycle) instead of None. The generic cohomology_subquotient contract is unchanged. That `rows × 0` matrix is never consumed as an incoming differential, since the target bidegree's own subquotient short-circuits to None at its numerator. Verified byte-identical against the secondary-*, secondary_product-*, and secondary_massey-* golden benchmarks. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013ePtYD7Bt4iPeCtmqtqvZE --- ext/src/ext_algebra/secondary.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/ext/src/ext_algebra/secondary.rs b/ext/src/ext_algebra/secondary.rs index 1ffad8164c..f36463bf38 100644 --- a/ext/src/ext_algebra/secondary.rs +++ b/ext/src/ext_algebra/secondary.rs @@ -63,7 +63,22 @@ where // in-quadrant target — contributes to the ambient-`b` image) are genuinely // different and both matter to `cohomology_subquotient`, so size each end at // its own bidegree. Off the first quadrant Ext vanishes (0 generators, a - // *known* zero); in the first quadrant but unresolved is unknown (`None`). + // *known* zero). + // + // The source and target ends differ when the bidegree is in the first + // quadrant but unresolved: + // * Source `b` (rows): the page at `b` is then unknown, so the whole + // differential is unavailable — `None`. + // * Target (cols): the secondary resolution simply records no d2 landing + // there yet. The E3-page convention (`SecondaryResolution::e3_page`) + // treats an uncomputed outgoing differential as zero, so we give a + // `rows × 0` matrix — the whole source is a *provisional* d2-cycle — + // rather than `None`. (This `rows × 0` matrix is never consumed as an + // incoming differential: the target bidegree's own subquotient short- + // circuits to `None` at its numerator, since its rows are unresolved.) + // + // `gens` gives the generator count at an end, or `None` when the bidegree is + // in the first quadrant but unresolved (a known zero off the quadrant). let gens = |x: Bidegree| -> Option { if x.n() < 0 || x.s() < 0 { Some(0) @@ -73,8 +88,10 @@ where None } }; + // Source unresolved ⇒ unknown page ⇒ no differential; target unresolved ⇒ no + // d2 recorded yet ⇒ provisional cycle (`rows × 0`). let rows = gens(b)?; - let cols = gens(target)?; + let cols = gens(target).unwrap_or(0); let mut mat = Matrix::new(p, rows, cols); // Fill only when both ends carry generators; `m[i]` is the d2 of the i-th From b80d4e98ad8ee4bab4c6125508ecf54015df13f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 11:06:04 +0000 Subject: [PATCH 6/7] ext_algebra: assert capped matrix dimensions before using their ranks cohomology_dimension_capped mixed a capped generator count (graded_dimension) with ranks read off matrix_capped without checking the matrices are the right shape. An undersized capped matrix would understate a rank and silently overstate the cohomology. Assert the outgoing matrix has `gens` rows and the incoming matrix has `gens` columns before row-reducing, matching the shape checks already in cohomology_subquotient. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013ePtYD7Bt4iPeCtmqtqvZE --- ext/src/ext_algebra/mod.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/ext/src/ext_algebra/mod.rs b/ext/src/ext_algebra/mod.rs index 6965b0e5d9..a6c0109352 100644 --- a/ext/src/ext_algebra/mod.rs +++ b/ext/src/ext_algebra/mod.rs @@ -190,10 +190,30 @@ impl ExtAlgebra { .unwrap_or_else(|| self.dimension(b)); let shift = d.shift(); let source = Bidegree::n_s(b.n() - shift.n(), b.s() - shift.s()); - let rank_out = d.matrix_capped(b, cap)?.row_reduce(); - let rank_in = d - .matrix_capped(source, cap) - .map_or(0, |mut m| m.row_reduce()); + // The capped matrix must line up with the capped generator count `gens`, or + // an undersized matrix would understate a rank and overstate the cohomology + // (matching the shape checks in `cohomology_subquotient`). + let mut out = d.matrix_capped(b, cap)?; + assert_eq!( + out.rows(), + gens, + "ExtDifferential::matrix_capped({b:?}, {cap}) must have gens = {gens} rows, got {}", + out.rows() + ); + let rank_out = out.row_reduce(); + let rank_in = match d.matrix_capped(source, cap) { + Some(mut incoming) => { + assert_eq!( + incoming.columns(), + gens, + "ExtDifferential::matrix_capped({source:?}, {cap}) into {b:?} must have gens \ + = {gens} columns, got {}", + incoming.columns() + ); + incoming.row_reduce() + } + None => 0, + }; // ker ⊇ im requires d∘d = 0; a malformed differential could underflow here. debug_assert!( rank_out + rank_in <= gens, From e904c9e432a8fa786bd7cff41133e61d719dce34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 14:27:00 +0000 Subject: [PATCH 7/7] ext_algebra: reframe docs so ExtAlgebra reads as classical Ext(k,k) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lead ExtAlgebra and ExtDifferential with the classical story — Ext is the cohomology of Hom(P•,k); minimal resolution ⇒ generators, non-minimal ⇒ the canonical dualised differential Hom(d,k) — rather than the deformation story. The Adams d2 / motivic δ are named only as *also* using the pluggable coboundary, with the machinery kept in their own modules (the secondary layer); they no longer headline ExtAlgebra's interface. Generalize graded_dimension's wording from "motivic weight" to "a deformation base R" (F2[τ] as the example), since the cap-sweep is the general R-module shape, not motivic-specific. Docs only — no logic changes. Docs build clean under -D broken_intra_doc_links -D private_intra_doc_links. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013ePtYD7Bt4iPeCtmqtqvZE --- ext/src/ext_algebra/mod.rs | 57 +++++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/ext/src/ext_algebra/mod.rs b/ext/src/ext_algebra/mod.rs index a6c0109352..e54bda73c6 100644 --- a/ext/src/ext_algebra/mod.rs +++ b/ext/src/ext_algebra/mod.rs @@ -6,8 +6,7 @@ //! //! The goal is ergonomics: computing a product of Ext classes is a single [`ExtAlgebra::multiply`] //! call instead of the manual [`ResolutionHomomorphism`] + `extend` + `hom_k` plumbing that the -//! examples currently re-derive. This is the foundational layer; the secondary differential ($d_2$) -//! and Massey products are planned follow-ups. +//! examples currently re-derive. //! //! # Conventions //! A product is realised by a [`ResolutionHomomorphism`] built from a fixed multiplier class living @@ -16,8 +15,18 @@ //! map per *generator* of $\Ext(M, k)$ (keyed by [`BidegreeGenerator`]); a product by a general //! class is assembled at request time as the corresponding linear combination of generator maps. //! -//! The secondary differential ($d_2$) and the $\Mod_{C\lambda^2}$ secondary product live in the -//! [`secondary`] submodule ([`SecondaryExtAlgebra`]). +//! # Cohomology +//! $\Ext(M, k)$ is the cohomology of the cochain complex $\Hom(P_\bullet, k)$. For a **minimal** +//! resolution its coboundary is identically zero, so $\Ext$ is just the generators and taking +//! cohomology is a no-op; for a **non-minimal** resolution the coboundary is the canonical dualised +//! differential $\Hom(d, k)$. Either way this is classical homological algebra — see +//! [`ExtDifferential`] and [`ExtAlgebra::cohomology_subquotient`]. +//! +//! The same cochain complex can also host the connecting differential of a *deformation* — the +//! Adams $d_2$ or the motivic $\delta$ — but that story is deliberately kept out of [`ExtAlgebra`]'s +//! own interface: the differentials are supplied by their own modules. The $d_2$ and the +//! $\Mod_{C\lambda^2}$ secondary product live in the [`secondary`] submodule +//! ([`SecondaryExtAlgebra`]). pub mod massey; pub mod secondary; @@ -39,17 +48,24 @@ use crate::{ utils::{QueryModuleResolution, get_unit}, }; -/// The differential of the Ext DGA: the coboundary on the cochain complex -/// $\Hom(P_\bullet, k) = k^{\text{gens}}$ whose cohomology is the "Ext part" — -/// the next page. +/// The coboundary of the Ext cochain complex $\Hom(P_\bullet, k) = k^{\text{gens}}$, +/// whose cohomology is $\Ext$. /// /// It shifts bidegree by a fixed [`shift`](ExtDifferential::shift) and, at each -/// bidegree, gives its matrix in the generator bases. Over a field with a -/// *minimal* resolution this differential is identically zero — $d_s$ lands in -/// $\bar A \cdot P_{s-1}$, which every $\varphi\colon P_{s-1} \to k$ kills — so -/// $\Ext$ is just the generators and taking cohomology is a no-op. A deformation -/// (the motivic lift's $\delta$) or a secondary operation (the Adams $d_2$) is -/// what makes it nonzero and the cohomology nontrivial. +/// bidegree, gives its matrix in the generator bases. For a **minimal** resolution +/// this coboundary is identically zero — $d_s$ lands in $\bar A \cdot P_{s-1}$, +/// which every $\varphi\colon P_{s-1} \to k$ kills — so $\Ext$ is just the +/// generators and taking cohomology is a no-op (no differential is attached). For a +/// **non-minimal** resolution it is the canonical dualised differential +/// $\Hom(d, k)$. Both are classical homological algebra. +/// +/// The same trait is *also* how a **deformation** hangs its connecting differential +/// on this complex — the Adams $d_2$ ([`secondary`]) or the motivic $\delta$ — as +/// instances defined in their own modules; those are what +/// [`ExtAlgebra::cohomology_subquotient`] reads to compute the next page of a +/// deformation spectral sequence. That story is kept out of [`ExtAlgebra`]'s own +/// interface on purpose: here the differential is just a pluggable coboundary, +/// deformation or not. pub trait ExtDifferential: Send + Sync { /// The fixed bidegree shift the differential applies: $\delta\colon \Ext_b \to /// \Ext_{b + \mathrm{shift}}$. @@ -61,10 +77,12 @@ pub trait ExtDifferential: Send + Sync { /// valid zero-size matrix, not `None`. fn matrix(&self, b: Bidegree) -> Option; - /// For a **graded** coefficient (e.g. $\mathbb{F}_2[\tau]$, graded by motivic - /// weight), the number of cochain generators at `b` whose grade is `≤ cap`. - /// The default — an ungraded (field) coefficient — returns `None`, meaning "no - /// grading", and the capped cohomology falls back to the full dimension. + /// For a coefficient **graded by a deformation base** $R$ (e.g. + /// $\mathbb{F}_2[\tau]$ graded by motivic weight), the number of cochain + /// generators at `b` whose grade is `≤ cap`; sweeping `cap` walks up the + /// $R$-adic tower and exposes the $R$-torsion of the Ext module. The default — + /// an ungraded (field) coefficient — returns `None`, meaning "no grading", and + /// the capped cohomology falls back to the full dimension. fn graded_dimension(&self, b: Bidegree, cap: i32) -> Option { let _ = (b, cap); None @@ -165,8 +183,9 @@ impl ExtAlgebra { /// /// With no differential (a field/minimal resolution, the zero coboundary) this /// is exactly the generator count — the cohomology *is* $\Ext$, and "taking - /// cohomology" degenerates to reading generators. A nonzero differential (the - /// motivic $\delta$, an Adams $d_2$) makes it a genuine kernel-mod-image. + /// cohomology" degenerates to reading generators. A nonzero differential (a + /// non-minimal resolution's $\Hom(d, k)$, or a deformation's connecting map) + /// makes it a genuine kernel-mod-image. /// /// Returns `None` if the outgoing differential at `b` is out of the computed /// range; a missing incoming differential (no source bidegree, or empty) counts