From 9d14eade01576735864335ccf7568bb60575ce62 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 06:16:30 +0000 Subject: [PATCH 01/12] 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 9075958f5bfa639e4165d67c65af74c3b60cbd65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 06:52:22 +0000 Subject: [PATCH 02/12] =?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 a86b7744d2062a60c3ba84ab559a2e87ef7d80f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 07:23:53 +0000 Subject: [PATCH 03/12] =?UTF-8?q?ext=5Falgebra:=20route=20the=20secondary?= =?UTF-8?q?=20d=E2=82=82/E=E2=82=83=20page=20onto=20the=20shared=20cohomol?= =?UTF-8?q?ogy=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 9ea0e4dd68413bc7967524d25f395495681f91b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 07:32:42 +0000 Subject: [PATCH 04/12] =?UTF-8?q?ext=5Falgebra:=20fix=20CI=20=E2=80=94=20a?= =?UTF-8?q?dapt=20secondary=5Fmassey=20to=20the=20hom=5Fk=20page-fn=20API?= =?UTF-8?q?=20+=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 37918ece88872b8dd44d7ff4d8b17bc0011866d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 08:19:08 +0000 Subject: [PATCH 05/12] 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 503a8884db670ce2195b3fa4b0c74332b905b851 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 11:06:04 +0000 Subject: [PATCH 06/12] 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 439c23d3538ad2bc3e81c73b104182a74e145ea8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 14:27:00 +0000 Subject: [PATCH 07/12] 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 From 37d859c7df8dacf2b5a969d11d5a659f36cc62db Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 06:04:41 +0000 Subject: [PATCH 08/12] sseq: drive bidegrees whose dependency reaches the same internal degree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iter_s_t` runs `f(s, t)` once `f(s - 1, t')` is done for every `t' < t`. That is the right dependency for a minimal resolution: its differential lands in `Ā·P_{s-1}`, so every coefficient has positive degree and `f(s, t)` only reads generators of degree strictly below `t`. A non-minimal differential has an identity component, so `f(s, t)` can read the generator of degree exactly `t` one filtration down. That is a shift of the wavefront by one column, not a reason to serialise filtrations, so `iter_s_t_inclusive` is `iter_s_t` with the recursion advanced one step less: having finished `(s, t)` it releases `(s + 1, t')` for `t' < T` instead of `t' < T + 1`, where `T` is the contiguous computed prefix of filtration `s`. Filtrations still overlap in time. Both are wrappers over one engine, parameterised by that lag. The lag also settles the seeding, which is not a free choice: at lag 1 the dependency of `f(s, min_t)` is vacuous and every filtration can be seeded at once, which is what `iter_s_t` does; at lag 0 it is `f(s - 1, min_t)`, so only the bottom filtration may be seeded. The lag-1 path is textually the existing `iter_s_t`. The tests assert the contract from inside `f` — that every `(s - 1, t')` with `t' ≤ t` has completed before `(s, t)` runs — since under `concurrent` the bidegrees genuinely overlap and a recorded completion order proves nothing about happens-before. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LdymHggmtPKyEsPEbF7DWo --- ext/crates/sseq/src/coordinates/mod.rs | 165 ++++++++++++++++++++++--- 1 file changed, 150 insertions(+), 15 deletions(-) diff --git a/ext/crates/sseq/src/coordinates/mod.rs b/ext/crates/sseq/src/coordinates/mod.rs index c030db46b5..e62cc94796 100644 --- a/ext/crates/sseq/src/coordinates/mod.rs +++ b/ext/crates/sseq/src/coordinates/mod.rs @@ -62,6 +62,56 @@ pub fn iter_s_t( f: &(impl Fn(Bidegree) -> std::ops::Range + Sync), min: Bidegree, max: BidegreeRange, +) { + iter_s_t_with_lag(f, min, max, 1); +} + +/// [`iter_s_t`] for a complex whose dependency at `(s, t)` reaches `(s - 1, t)` at the **same** +/// `t`, rather than only `t' < t`. +/// +/// [`iter_s_t`] runs `f(s, t)` once `f(s - 1, t')` is done for every `t' < t`. That is exactly +/// right for a **minimal** resolution: `d` lands in $\bar A \cdot P_{s-1}$, so the coefficient of +/// every component has positive degree and `f(s, t)` only ever reads generators of degree *below* +/// `t`. A **non-minimal** differential has an identity (degree-zero) component, so `f(s, t)` can +/// read the generator of degree exactly `t` in filtration `s - 1`, and needs `f(s - 1, t)` itself. +/// +/// That is a shift of the wavefront by one column, *not* a reason to serialise filtrations. This +/// driver is [`iter_s_t`] with the recursion advanced one step less: having finished `f(s, t)`, it +/// releases `(s + 1, t')` for `t' < T` rather than `t' < T + 1`, where `T` is the extent of the +/// contiguous computed prefix of row `s` reported by `f`. Filtrations still overlap in time — row +/// `s + 1` works on low `t` while row `s` is still climbing — so the available parallelism is +/// [`iter_s_t`]'s, minus one diagonal. +/// +/// The bottom row is the only seed. [`iter_s_t`] additionally seeds `(s, min_t)` for every `s` at +/// once, which is sound only because `f(s, min_t)`'s dependency is vacuous there; here it is not — +/// it is `f(s - 1, min_t)` — so those must be reached through the recursion. +/// +/// # Arguments (matching [`iter_s_t`]): +/// - `max.s()`: exclusive +/// - `max.t(s)`: exclusive +pub fn iter_s_t_inclusive( + f: &(impl Fn(Bidegree) -> std::ops::Range + Sync), + min: Bidegree, + max: BidegreeRange, +) { + iter_s_t_with_lag(f, min, max, 0); +} + +/// The wavefront shared by [`iter_s_t`] and [`iter_s_t_inclusive`]. +/// +/// `lag` is how far filtration `s + 1` may run ahead of the contiguous computed prefix of +/// filtration `s`, and so encodes the dependency the caller has: `1` when `f(s, t)` reads only +/// `f(s - 1, t')` for `t' < t`, `0` when it also reads `t' = t`. +/// +/// It also settles the seeding, which is not a free choice. With `lag == 1`, `f(s, min_t)` has a +/// vacuous dependency, so every filtration can be seeded at `min_t` simultaneously; with +/// `lag == 0` it depends on `f(s - 1, min_t)`, so only the bottom filtration may be seeded and the +/// rest must be reached through the recursion. +fn iter_s_t_with_lag( + f: &(impl Fn(Bidegree) -> std::ops::Range + Sync), + min: Bidegree, + max: BidegreeRange, + lag: i32, ) { // Track `tracing` spans correctly let tracing_span = tracing::Span::current(); @@ -78,34 +128,42 @@ pub fn iter_s_t( f: &'a (impl Fn(Bidegree) -> std::ops::Range + Sync + 'a), max: BidegreeRange<'a, S>, current: Bidegree, + lag: i32, ) { let mut ret = f(current); if current.s() + 1 < max.s() { - ret.start += 1; - ret.end = std::cmp::min(ret.end + 1, max.t(current.s() + 1)); + // `ret` is `[t, T)` with `f(current.s(), t')` computed for every `t' < T`, so + // filtration `s + 1` is released up to `T - 1 + lag`. + ret.start += lag; + ret.end = std::cmp::min(ret.end + lag, max.t(current.s() + 1)); if !ret.is_empty() { // We spawn a new scope to avoid recursion, which may blow the stack scope.spawn(move |scope| { - ret.into_maybe_par_iter() - .for_each(|t| run(scope, f, max, Bidegree::s_t(current.s() + 1, t))); + ret.into_maybe_par_iter().for_each(|t| { + run(scope, f, max, Bidegree::s_t(current.s() + 1, t), lag) + }); }); } } } - maybe_rayon::join( - || { - (min.t()..max.t(min.s())) - .into_maybe_par_iter() - .for_each(|t| run(scope, f, max, Bidegree::s_t(min.s(), t))) - }, - || { + let seed_bottom_row = || { + (min.t()..max.t(min.s())) + .into_maybe_par_iter() + .for_each(|t| run(scope, f, max, Bidegree::s_t(min.s(), t), lag)) + }; + + if lag > 0 { + maybe_rayon::join(seed_bottom_row, || { (min.s() + 1..max.s()) .into_maybe_par_iter() - .for_each(|s| run(scope, f, max, Bidegree::s_t(s, min.t()))) - }, - ); + .for_each(|s| run(scope, f, max, Bidegree::s_t(s, min.t()), lag)) + }); + } else if min.s() < max.s() { + // `max.s()` is exclusive, so an empty filtration range visits nothing at all. + seed_bottom_row(); + } }); } @@ -113,7 +171,7 @@ pub fn iter_s_t( mod tests { use fp::{prime::ValidPrime, vector::FpVector}; - use super::{Bidegree, BidegreeElement, BidegreeGenerator}; + use super::{Bidegree, BidegreeElement, BidegreeGenerator, BidegreeRange}; #[test] fn test_bidegree_generator_try_from_element() { @@ -129,4 +187,81 @@ mod tests { h0_squared_i.try_into() ); } + + /// Run [`super::iter_s_t_inclusive`] over a rectangle, checking its contract *as it goes*: + /// before `f(s, t)` runs, `f(s - 1, t')` must already have completed for every `t' ≤ t`. The + /// check has to happen inside `f` rather than on a recorded order — under `concurrent` the + /// bidegrees genuinely overlap in time, so a completion list says nothing about happens-before. + /// + /// `f` reports the contiguous computed prefix of its own row, as the real callers do via + /// `OnceBiVec::push_ooo`. Returns every bidegree visited. + fn run_checked( + min: Bidegree, + max_s: i32, + max_t: &(dyn Fn(&(), i32) -> i32 + Sync), + ) -> Vec<(i32, i32)> { + use std::collections::{BTreeSet, HashMap}; + + let done: std::sync::Mutex>> = + std::sync::Mutex::new(HashMap::new()); + super::iter_s_t_inclusive( + &|b| { + let mut done = done.lock().unwrap(); + if b.s() > min.s() { + let below = done.get(&(b.s() - 1)).cloned().unwrap_or_default(); + for t in min.t()..=b.t() { + assert!( + below.contains(&t), + "({}, {}) ran before ({}, {})", + b.s(), + b.t(), + b.s() - 1, + t + ); + } + } + let row = done.entry(b.s()).or_default(); + assert!(row.insert(b.t()), "({}, {}) ran twice", b.s(), b.t()); + // The contiguous prefix of this row, i.e. what `push_ooo` would report. + let mut end = min.t(); + while row.contains(&end) { + end += 1; + } + b.t()..end + }, + min, + BidegreeRange::new(&(), max_s, max_t), + ); + + let done = done.into_inner().unwrap(); + let mut visited: Vec<(i32, i32)> = done + .iter() + .flat_map(|(s, ts)| ts.iter().map(move |t| (*s, *t))) + .collect(); + visited.sort_unstable(); + visited + } + + #[test] + fn iter_s_t_inclusive_covers_the_rectangle_and_respects_the_same_t_dependency() { + let visited = run_checked(Bidegree::s_t(0, 0), 4, &|(), _| 3); + let expected: Vec<(i32, i32)> = (0..4).flat_map(|s| (0..3).map(move |t| (s, t))).collect(); + assert_eq!(visited, expected); + } + + #[test] + fn iter_s_t_inclusive_handles_degenerate_ranges() { + // Empty `s` range, and an empty `t` range for every `s`. + assert!(run_checked(Bidegree::s_t(2, 0), 2, &|(), _| 5).is_empty()); + assert!(run_checked(Bidegree::s_t(0, 0), 3, &|(), _| 0).is_empty()); + + // A shrinking `t` bound is honoured per row, exactly as `iter_s_t` clamps it. + let visited = run_checked(Bidegree::s_t(0, 1), 4, &|(), s| 4 - s); + let mut per_row = [0usize; 4]; + for (s, t) in &visited { + assert!(*t >= 1 && *t < 4 - *s, "({s}, {t}) out of range"); + per_row[*s as usize] += 1; + } + assert_eq!(per_row, [3, 2, 1, 0]); + } } From 4ca55803cdb73bbcb6fe9afaafd3fb010265c0a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 06:04:41 +0000 Subject: [PATCH 09/12] secondary: allow the secondary machinery to run on a non-minimal resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HIT_GENERATOR` was a compile-time constant on `SecondaryLift`, so whether composites may hit same-degree generators was fixed per type. A `SecondaryResolution` over a non-minimal complex needs it per instance: such a differential has identity components, so `∂∂` genuinely lands on same-degree generators. Add a `hit_generator()` method defaulting to the constant, and `SecondaryResolution::new_with_hit_generator` to set it; `new` keeps the old value, so every existing lift is unchanged. `SecondaryComposite::act` also assumed a non-empty output block. With an identity action (`op_degree == 0`) on a same-degree generator the block is empty and `generator_offset` indexes out of range, so skip it before taking the offset. This cannot arise for a minimal resolution, whose differential has no identity component. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LdymHggmtPKyEsPEbF7DWo --- ext/src/resolution.rs | 20 ++++++++++++++++++++ ext/src/secondary.rs | 25 +++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/ext/src/resolution.rs b/ext/src/resolution.rs index d37edd20ed..59706e9349 100644 --- a/ext/src/resolution.rs +++ b/ext/src/resolution.rs @@ -1005,6 +1005,13 @@ pub(crate) mod secondary { /// s -> t -> idx -> homotopy pub(crate) homotopies: OnceBiVec>, intermediates: DashMap, + /// Whether composites may hit same-degree generators — see + /// [`SecondaryLift::hit_generator`]. `false` for a minimal resolution (the default, + /// and required when resolving to a stem); `true` for a **non-minimal** resolution such + /// as an untwisted `TensorResolution`, whose differential has identity components so + /// $\partial\partial$ genuinely lands on same-degree generators. `true` requires the + /// resolution be computed as a full rectangle, not truncated to a stem. + hit_generator: bool, } impl SecondaryLift for SecondaryResolution @@ -1016,6 +1023,10 @@ pub(crate) mod secondary { type Target = CC; type Underlying = CC; + fn hit_generator(&self) -> bool { + self.hit_generator + } + fn underlying(&self) -> Arc { Arc::clone(&self.underlying) } @@ -1088,6 +1099,14 @@ pub(crate) mod secondary { CC::Algebra: PairAlgebra, { pub fn new(cc: Arc) -> Self { + Self::new_with_hit_generator(cc, false) + } + + /// Like [`new`](Self::new) but for a **non-minimal** resolution: the composite + /// $\partial\partial$ may hit same-degree generators, so `hit_generator` must be `true` + /// (see [`SecondaryLift::hit_generator`]). The resolution must be computed as a full + /// rectangle, not truncated to a stem. + pub fn new_with_hit_generator(cc: Arc, hit_generator: bool) -> Self { if let Some(p) = cc.save_dir().write() { for subdir in SaveKind::secondary_data() { subdir.create_dir(p).unwrap(); @@ -1098,6 +1117,7 @@ pub(crate) mod secondary { underlying: cc, homotopies: OnceBiVec::new(2), intermediates: DashMap::new(), + hit_generator, } } diff --git a/ext/src/secondary.rs b/ext/src/secondary.rs index e04b852a36..d285ad077c 100644 --- a/ext/src/secondary.rs +++ b/ext/src/secondary.rs @@ -183,10 +183,20 @@ impl SecondaryComposite { continue; } + let len = algebra.dimension(module_op_deg + op_degree - 1); + // The output block is empty when `module_op_deg + op_degree - 1 < 0`, i.e. when the + // target generator sits above degree `self.degree + op_degree - 1`. This never + // happens for a minimal resolution (its differential has no identity component, so + // `op_degree ≥ 1` and same-degree generators are absent), but a non-minimal + // resolution with `hit_generator = true` can act by the identity (`op_degree = 0`) + // on a same-degree generator. Skip before taking the offset, which would otherwise + // index out of range. + if len == 0 { + continue; + } let offset = self.target .generator_offset(self.degree + op_degree - 1, gen_deg, gen_idx); - let len = algebra.dimension(module_op_deg + op_degree - 1); algebra.a_multiply( result.slice_mut(offset, offset + len), @@ -370,6 +380,16 @@ pub trait SecondaryLift: Sync + Sized { /// [`SecondaryResolution`], we need to ignore target generators of the same degree uniformly. const HIT_GENERATOR: bool = false; + /// Whether composites may hit same-degree generators, defaulting to the compile-time + /// [`HIT_GENERATOR`](Self::HIT_GENERATOR). Overridable at runtime by implementors that decide + /// it per instance — e.g. a [`SecondaryResolution`] of a *non-minimal* resolution, whose + /// differential has identity components so the composite $\partial\partial$ genuinely lands + /// on same-degree generators. When `true`, the underlying resolution must be computed as a + /// full rectangle (not truncated to a stem), since those same-degree generators are consulted. + fn hit_generator(&self) -> bool { + Self::HIT_GENERATOR + } + fn underlying(&self) -> Arc; fn algebra(&self) -> Arc; fn prime(&self) -> ValidPrime { @@ -395,12 +415,13 @@ pub trait SecondaryLift: Sync + Sized { let shift = self.shift(); let max = self.max(); + let hit_generator = self.hit_generator(); self.homotopies().extend(max.s() - 1, |s| { SecondaryHomotopy::new( self.source().module(s), self.target().module(s - shift.s()), shift.t(), - Self::HIT_GENERATOR, + hit_generator, ) }); } From a4292fb120cd746b398077c8c04de56ad8168fb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 06:04:41 +0000 Subject: [PATCH 10/12] ext_algebra: Ext of infinite modules via the untwisted tensor resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A minimal resolution engine resolves a finite module directly, and its generators already are Ext. It cannot resolve an infinite `M` such as `H^*(RP^∞)`. Nassau's workaround is to resolve the base field instead: minimally resolve `k` as `P• → k`, then tensor with `M`. Since `A` is a Hopf algebra `P_s ⊗_k M` is free, so `Q• = P• ⊗ M → M` is a free — but non-minimal — resolution of `M`. Untwisting the diagonal action through the antipode `χ` gives the coboundary of `Hom_A(Q•, k)` in closed form, δ_{(i,α),(l,γ)} = [m_α]( χ(a_li) · m_γ ), where `a_li` is the component of `d_P(z_l)` on the generator `x_i`. Because `δ` is closed-form, `Q•` is never materialised: every computation is cochain-sized, driven by the small resolution of `k` and the module action on `M`. `Q•` being non-minimal, `Ext_A(M, k)` is the genuine cohomology of `Hom_A(Q•, k)` rather than its generators, so `ExtAlgebra` gains a pluggable `CochainCup` alongside the `ExtDifferential` it already had, and products are transported between the cochain and cohomology bases. Massey products go through the cochain DGA: on a non-minimal resolution the chain-map/null-homotopy bracket degenerates, so `⟨a,b,c⟩` is `[a ∪ v]` with `δ_Q v = b ∪ c`. A minimal resolution attaches no cup and keeps the chain-map construction, where lift/project are identities — so the minimal path is unchanged throughout. `TensorResolutionSecondary` carries the same construction to the Adams `d₂`, and `δ_Q` matrices are cached under a new `SaveKind::TensorDifferential`. That header records only the algebra and bidegree, so two modules would collide by name and a shape check alone cannot separate them; each file therefore stores a fingerprint of `M` and refuses to load for another. Correctness is pinned against the direct minimal resolution wherever one exists: `C2` for the additive Ext, the products, the Massey products and the `d₂`, and `RP^∞` for the additive Ext and the products against the direct non-Nassau resolution. `tensor_d2_matches_direct_rp_inf` is ignored for a mathematical reason recorded in the attribute — `H^*(RP^∞)` admits several secondary structures and the machinery assumes the zero one, which the companion test shows fails identically on the direct minimal resolution. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LdymHggmtPKyEsPEbF7DWo --- ext/SAVE-FORMAT.md | 32 + ext/src/ext_algebra/massey.rs | 256 ++- ext/src/ext_algebra/mod.rs | 474 ++++- ext/src/ext_algebra/tensor_resolution.rs | 2445 ++++++++++++++++++++++ ext/src/save.rs | 7 + 5 files changed, 3160 insertions(+), 54 deletions(-) create mode 100644 ext/src/ext_algebra/tensor_resolution.rs diff --git a/ext/SAVE-FORMAT.md b/ext/SAVE-FORMAT.md index 83b75dfeb9..de6afff89b 100644 --- a/ext/SAVE-FORMAT.md +++ b/ext/SAVE-FORMAT.md @@ -259,3 +259,35 @@ u64, which is to be interpreted as follows: The pivot column and the image are expressed in terms of the original basis, while the lift is expressed in terms of the masked basis under the current signature. The latter measure is done in order to save space. + +### Tensor-resolution differentials + +This has magic `0xD1FF0002`. + +This is the closed-form coboundary `δ_Q` of an untwisted tensor resolution +`Q• = P• ⊗ M`, where `P•` is a minimal resolution of the base field and `M` is +an arbitrary (possibly infinite) module. `Q•` itself is never materialised; +only the cochain-level matrices of `δ_Q` are, so this kind stores one matrix +per bidegree and no chain map data. + +The header records only the kind, algebra and bidegree, so files for two +different modules over the same algebra collide by name. Each file therefore +opens with a fingerprint of `M` — an Adler-32 checksum of the module's +`Display` name, its minimum degree, and its dimensions in every degree up to +`t` — which the reader checks before anything else. A save directory must +still be dedicated to a single module; the fingerprint turns a violation into a +panic instead of a silently wrong `Ext`. + +```text +struct { + module_fingerprint: u32, + rows: u64, + cols: u64, + matrix: [[u64; num_limbs(cols)]; rows], +} +``` + +Unlike the other kinds, the shape is stored in the file rather than being +recovered from the resolution, since the cochain dimensions depend on `M`. The +reader checks it against the cochain basis it expects, so a truncated or +foreign file fails loudly. diff --git a/ext/src/ext_algebra/massey.rs b/ext/src/ext_algebra/massey.rs index d29acdc403..4b1ed8bb26 100644 --- a/ext/src/ext_algebra/massey.rs +++ b/ext/src/ext_algebra/massey.rs @@ -20,11 +20,12 @@ use std::sync::Arc; use fp::{ matrix::{AffineSubspace, AugmentedMatrix, Matrix, Subspace}, + prime::Prime, vector::{FpSlice, FpVector}, }; use sseq::coordinates::{Bidegree, BidegreeElement, BidegreeGenerator}; -use super::ExtAlgebra; +use super::{Cochain, CochainCup, ExtAlgebra}; use crate::{ chain_complex::{AugmentedChainComplex, ChainHomotopy, FreeChainComplex}, resolution_homomorphism::ResolutionHomomorphism, @@ -52,7 +53,7 @@ impl MasseyResult { impl ExtAlgebra where - CC: FreeChainComplex + AugmentedChainComplex, + CC: FreeChainComplex + AugmentedChainComplex + Sync, { /// The bidegree shift of $\langle a, b, -\rangle$: a class `c` produces a bracket in bidegree /// `c.degree() + a.degree() + b.degree() - (1, 0)`. @@ -86,7 +87,7 @@ where /// sign, so the same kernel). Returns `None` when the product bidegree `c_deg + b.degree()` is /// uncomputed, so callers never mistake an uncomputed product for a zero one; a computed but /// empty product bidegree correctly yields the full space. - fn massey_kernel(&self, b: &BidegreeElement, c_deg: Bidegree) -> Option { + pub(crate) fn massey_kernel(&self, b: &BidegreeElement, c_deg: Bidegree) -> Option { let p = self.prime(); let resolution = self.resolution(); @@ -94,8 +95,10 @@ where if !resolution.has_computed_bidegree(prod_deg) { return None; } - let num_gens = resolution.number_of_gens_in_bidegree(c_deg); - let product_num_gens = resolution.number_of_gens_in_bidegree(prod_deg); + // Work in the Ext (cohomology) basis: on the untwisted tensor resolution the complex is + // non-minimal, so its generator count is *not* the Ext dimension. + let num_gens = self.dimension(c_deg); + let product_num_gens = self.dimension(prod_deg); let mut product = AugmentedMatrix::<2>::new(p, num_gens, [product_num_gens, num_gens]); product.segment(1, 1).add_identity(); @@ -123,10 +126,19 @@ where fn massey_bracket_of( &self, a: &BidegreeElement, + b: &BidegreeElement, b_hom: Arc>, shift: Bidegree, c: &BidegreeElement, ) -> Option { + // On a non-minimal resolution (the untwisted tensor resolution attaches a cup product) the + // chain-map bracket below degenerates; dispatch to the cochain-DGA bracket instead. The DGA + // path reads its cup matrices off the shared per-generator self-map cache, so it needs `b` + // itself, not `b_hom`. + if let Some(cup) = self.cup() { + return self.massey_bracket_dga(&Arc::clone(cup), a, b, shift, c); + } + let p = self.prime(); let resolution = self.resolution(); let unit = self.unit(); @@ -136,6 +148,7 @@ where if !resolution.has_computed_bidegree(tot) { return None; } + // The bracket is read in the cochain (resolution-generator) basis, then projected to Ext. let target_num_gens = resolution.number_of_gens_in_bidegree(tot); // When `tot` is computed but empty the bracket lands in the zero group, so it is the @@ -150,7 +163,9 @@ where unit.module(a.degree().s()) .generator_offset(a.degree().t(), a.degree().t(), 0); let a_coords: Vec = a.vec().iter().collect(); - let c_coords: Vec = c.vec().iter().collect(); + // Realise `c` as a cocycle in the resolution's cochain basis (identity on a minimal + // resolution); `f_c` needs its cochain coordinates, not the Ext ones. + let c_coords: Vec = self.lift(c).vec().iter().collect(); let f_c = Arc::new(ResolutionHomomorphism::from_class( String::new(), @@ -177,6 +192,9 @@ where representative }; + // Project the cochain representative to its Ext class, so it lives in the same basis as the + // indeterminacy (identity on a minimal resolution). + let representative = self.project(&Cochain::new(tot, representative)).into_vec(); let indeterminacy = self.massey_indeterminacy(a, c, tot); Some(MasseyResult { degree: tot, @@ -184,6 +202,94 @@ where }) } + /// The bracket $\langle a, b, c\rangle$ via the **cochain DGA**, for a non-minimal resolution + /// carrying a [`CochainCup`]. Because the unit is minimal ($a\cdot b = 0$ at the cochain level, + /// so the $a\cup b$ null-homotopy $u = 0$), the bracket is $[\,a \cup v\,]$ with $\delta_Q v = + /// b \cup c$. Both cups ($b\cup c$ and $a\cup v$) are read via + /// [`cup_class_matrix`](Self::cup_class_matrix), so they draw on the *same* per-generator unit + /// self-map cache as the product path and the family sweep — no bracket-specific self-map is + /// ever built. $\delta_Q$ is the attached [`differential`](Self::differential). Returns `None` + /// if `b·c ≠ 0` or the range is uncomputed. + fn massey_bracket_dga( + &self, + cup: &Arc>, + a: &BidegreeElement, + b: &BidegreeElement, + shift: Bidegree, + c: &BidegreeElement, + ) -> Option { + let p = self.prime(); + let res = self.resolution(); + + let c_deg = c.degree(); + let tot = c_deg + shift; + let bc_deg = b.degree() + c_deg; + let v_deg = bc_deg + Bidegree::n_s(1, -1); + // Every cochain read (b∪c at `bc_deg`, its δ-preimage `v`, the bracket `tot`) must be in + // the computed box. `has_computed_bidegree` is panic-safe unlike + // `cohomology`/`cochain_dimension`. + for d in [bc_deg, v_deg, tot] { + if d.s() < 0 || d.t() < 0 || !res.has_computed_bidegree(d) { + return None; + } + } + self.cohomology(tot)?; + + // b∪c as a cochain: cup by b applied to the cocycle rep of c, through the shared cup cache. + let bc_mat = self.cup_class_matrix(cup, b, c_deg); + let lc = self.lift(c); + let mut bc = FpVector::new(p, bc_mat.columns()); + bc_mat.apply(bc.as_slice_mut(), 1, lc.vec()); + + // v with δ_Q v = b∪c; `None` iff b·c ≠ 0 (b∪c is not a coboundary). + let v = self.delta_preimage(v_deg, bc.as_slice())?; + + // a∪v, then project to an Ext class — again through the shared cup cache. + let av_mat = self.cup_class_matrix(cup, a, v_deg); + let mut av = FpVector::new(p, av_mat.columns()); + av_mat.apply(av.as_slice_mut(), 1, v.as_slice()); + + let representative = self.project(&Cochain::new(tot, av)).into_vec(); + let indeterminacy = self.massey_indeterminacy(a, c, tot); + Some(MasseyResult { + degree: tot, + coset: AffineSubspace::new(representative, indeterminacy), + }) + } + + /// A cochain `v` at `v_deg` with `δ_Q v = z` (the attached differential's matrix out of `v_deg` + /// lands at `z`'s bidegree), or `None` if `z ∉ im δ_Q` (so the bracket is undefined). Solved by + /// a quasi-inverse of `[δ_Q | I]`, with a `δ_Q v = z` post-check (the quasi-inverse silently + /// drops any component of `z` outside the image). + fn delta_preimage(&self, v_deg: Bidegree, z: FpSlice) -> Option { + let p = self.prime(); + let d = self.differential()?.matrix(v_deg)?; + let (r, c) = (d.rows(), d.columns()); + // A shape mismatch is a caller bug, never a mathematical answer — returning `None` here + // would be indistinguishable from "`b∪c` is not a coboundary" and would silently drop the + // bracket from the family. + assert_eq!( + z.len(), + c, + "delta_preimage at {v_deg:?}: cocycle has length {} but δ has {c} columns", + z.len() + ); + // The quasi-inverse of `δ_Q` out of `v_deg` is intrinsic — shared across every bracket that + // lands here — so it is row-reduced once and cached. + let qi = self.delta_quasi_inverse(v_deg, &d, r, c); + + let mut v = FpVector::new(p, r); + qi.apply(v.as_slice_mut(), 1, z); + + let mut back = FpVector::new(p, c); + d.apply(back.as_slice_mut(), 1, v.as_slice()); + back.as_slice_mut().add(z, p.as_u32() - 1); + if !back.is_zero() { + return None; + } + Some(v) + } + /// Compute a representative of a Massey product evaluated at `row` from the per-generator /// bracket matrix `answers`. Used by [`massey_iter_a`](Self::massey_iter_a), which builds one /// null-homotopy for fixed `b, c` and reads a whole family of first factors off `answers`. @@ -203,7 +309,10 @@ where row: FpSlice, tot: Bidegree, ) -> MasseyResult { + // `answers` is in the cochain basis; project the bracket representative to its Ext class so + // it matches the (cohomology-basis) indeterminacy. Identity on a minimal resolution. let representative = self.massey_representative(answers, row); + let representative = self.project(&Cochain::new(tot, representative)).into_vec(); let indeterminacy = self.massey_indeterminacy(a, c, tot); MasseyResult { degree: tot, @@ -213,27 +322,41 @@ where /// The indeterminacy $a \cdot \Ext^{|b| + |c| - (1,0)} + \Ext^{|a| + |b| - (1,0)} \cdot c$ at /// the bracket bidegree `tot`, as a subspace of $\Ext(M, k)$ at `tot`. - fn massey_indeterminacy( + pub(crate) fn massey_indeterminacy( &self, a: &BidegreeElement, c: &BidegreeElement, tot: Bidegree, ) -> Subspace { - let mut sub = Subspace::new(self.prime(), self.dimension(tot)); + let mut sub = self.massey_indeterminacy_a(a, tot); + self.massey_indeterminacy_c(c, tot, &mut sub); + sub + } + /// The $a \cdot \Ext^{|b| + |c| - (1,0)}$ half of the Massey indeterminacy at `tot`. This + /// depends only on `a` and `tot`, so a family sweep over the third factor computes it once per + /// `c_deg`. + fn massey_indeterminacy_a(&self, a: &BidegreeElement, tot: Bidegree) -> Subspace { + let mut sub = Subspace::new(self.prime(), self.dimension(tot)); // a · Ext(M, k)^{tot - a.degree()}, computed as y · a (equal up to sign). for y in self.basis(tot - a.degree()) { if let Some(prod) = self.try_multiply(&self.generator(y), a) { sub.add_vector(prod.vec()); } } + sub + } + + /// Add the $\Ext^{|a| + |b| - (1,0)} \cdot c$ half of the Massey indeterminacy at `tot` into an + /// existing subspace (typically one already carrying the `a`-half). This half depends on the + /// specific third factor `c`, so it is the per-row part of a family sweep. + fn massey_indeterminacy_c(&self, c: &BidegreeElement, tot: Bidegree, sub: &mut Subspace) { // Ext(k, k)^{tot - c.degree()} · c, computed as c · x (equal up to sign). for x in self.unit_basis(tot - c.degree()) { if let Some(prod) = self.try_multiply(c, &self.unit_generator(x)) { sub.add_vector(prod.vec()); } } - sub } /// Compute the family of Massey products $\langle a, b, -\rangle$ for fixed `a` and `b` and @@ -254,18 +377,121 @@ where b: &BidegreeElement, ) -> Vec<(BidegreeElement, MasseyResult)> { let shift = Self::massey_shift(a, b); + + // On the untwisted tensor resolution the bracket is the cochain-DGA formula, whose + // per-`c_deg` cup matrices and δ_Q quasi-inverse are independent of the specific kernel + // row; hoist them out of the row loop rather than rebuilding them per bracket via + // `massey_bracket_of`. The cup path reads its cup matrices off `cup_class_matrix`, so the + // chain-map `b_hom` is never needed. + if let Some(cup) = self.cup() { + return self.massey_iter_c_cup(&Arc::clone(cup), a, b, shift); + } + let b_hom = self.massey_b_hom(b, shift); + let mut results = Vec::new(); + // The third factor ranges over bidegrees where $\Ext(M, k)$ is nonzero. On a minimal + // resolution those are the resolution's generators (`iter_nonzero_stem`); on the untwisted + // tensor resolution the resolution is the small $P_\bullet$, so the Ext bidegrees are read + // off `dimension` (the cohomology of $\delta_Q$) instead — hence sweep `iter_stem` and + // filter. + for c_deg in self.resolution().iter_stem() { + if self.dimension(c_deg) == 0 { + continue; + } + let Some(kernel) = self.massey_kernel(b, c_deg) else { + continue; + }; + for row in kernel.iter() { + let c = BidegreeElement::new(c_deg, row.to_owned()); + let Some(result) = self.massey_bracket_of(a, b, Arc::clone(&b_hom), shift, &c) + else { + continue; + }; + if result.contains_zero() { + continue; + } + results.push((c, result)); + } + } + results + } + + /// The cochain-DGA specialisation of [`massey_iter_c`](Self::massey_iter_c): computes the + /// family $\langle a, b, -\rangle$ for an untwisted tensor resolution carrying a + /// [`CochainCup`]. For each third-factor bidegree `c_deg` it builds the cup-by-`b` matrix, the + /// cup-by-`a` matrix, the δ_Q quasi-inverse at `v_deg`, and the `a·Ext` half of the + /// indeterminacy **once**, then applies them to every kernel row — the only per-row work is + /// `lift(c)`, two matrix applications, the δ-preimage solve (against the shared quasi-inverse), + /// the projection, and the `Ext·c` half of the indeterminacy. This is the hoisted equivalent of + /// looping [`massey_bracket_dga`](Self::massey_bracket_dga) per row. + fn massey_iter_c_cup( + &self, + cup: &Arc>, + a: &BidegreeElement, + b: &BidegreeElement, + shift: Bidegree, + ) -> Vec<(BidegreeElement, MasseyResult)> { + let p = self.prime(); + let res = self.resolution(); + let b_deg = b.degree(); let mut results = Vec::new(); - for c_deg in self.resolution().iter_nonzero_stem() { + for c_deg in res.iter_stem() { + if self.dimension(c_deg) == 0 { + continue; + } let Some(kernel) = self.massey_kernel(b, c_deg) else { continue; }; + if kernel.dimension() == 0 { + continue; + } + + let tot = c_deg + shift; + let bc_deg = b_deg + c_deg; + let v_deg = bc_deg + Bidegree::n_s(1, -1); + // Every cochain read must lie in the computed box (mirrors `massey_bracket_dga`). + if [bc_deg, v_deg, tot] + .iter() + .any(|d| d.s() < 0 || d.t() < 0 || !res.has_computed_bidegree(*d)) + { + continue; + } + if self.cohomology(tot).is_none() { + continue; + } + + // Per-`c_deg` matrices, independent of the specific kernel row: cup-by-`b` (source + // `c_deg`), cup-by-`a` (source `v_deg`), and the `a·Ext` half of the indeterminacy. + // Both cup matrices go through `cup_class_matrix`, so their (expensive) per-generator + // builds are memoised in `cup_cache` and shared with the product path; the δ_Q + // quasi-inverse at `v_deg` is likewise memoised inside `delta_preimage`. + let bc_mat = self.cup_class_matrix(cup, b, c_deg); + let av_mat = self.cup_class_matrix(cup, a, v_deg); + let a_indet = self.massey_indeterminacy_a(a, tot); + for row in kernel.iter() { let c = BidegreeElement::new(c_deg, row.to_owned()); - let Some(result) = self.massey_bracket_of(a, Arc::clone(&b_hom), shift, &c) else { + + // b∪c as a cochain, then its δ_Q-preimage `v` (`None` iff b·c ≠ 0). + let lc = self.lift(&c); + let mut bc = FpVector::new(p, bc_mat.columns()); + bc_mat.apply(bc.as_slice_mut(), 1, lc.vec()); + let Some(v) = self.delta_preimage(v_deg, bc.as_slice()) else { continue; }; + + // a∪v, projected to an Ext class. + let mut av = FpVector::new(p, av_mat.columns()); + av_mat.apply(av.as_slice_mut(), 1, v.as_slice()); + let representative = self.project(&Cochain::new(tot, av)).into_vec(); + + let mut indeterminacy = a_indet.clone(); + self.massey_indeterminacy_c(&c, tot, &mut indeterminacy); + let result = MasseyResult { + degree: tot, + coset: AffineSubspace::new(representative, indeterminacy), + }; if result.contains_zero() { continue; } @@ -299,8 +525,10 @@ where let bc_shift = b.degree() + c.degree() - Bidegree::s_t(1, 0); // `f_c` realises `c` (resolution of `M` → unit); `f_b` is multiplication by `b` (in the - // unit). The single null-homotopy `s_bc` of `b ∘ c` is reused for every first factor. - let c_coords: Vec = c.vec().iter().collect(); + // unit). The single null-homotopy `s_bc` of `b ∘ c` is reused for every first factor. `c`'s + // cochain (cocycle) coordinates realise the chain map — the identity of its Ext coordinates + // on a minimal resolution, a genuine lift on the untwisted tensor resolution. + let c_coords: Vec = self.lift(c).vec().iter().collect(); let f_c = Arc::new(ResolutionHomomorphism::from_class( String::new(), Arc::clone(resolution), @@ -412,7 +640,7 @@ where _ => return None, } - self.massey_bracket_of(a, b_hom, shift, c) + self.massey_bracket_of(a, b, b_hom, shift, c) } } diff --git a/ext/src/ext_algebra/mod.rs b/ext/src/ext_algebra/mod.rs index e54bda73c6..cd09eb81d6 100644 --- a/ext/src/ext_algebra/mod.rs +++ b/ext/src/ext_algebra/mod.rs @@ -30,6 +30,7 @@ pub mod massey; pub mod secondary; +pub mod tensor_resolution; use std::sync::Arc; @@ -37,11 +38,17 @@ use dashmap::DashMap; use fp::{ matrix::{AugmentedMatrix, Matrix, Subquotient, Subspace}, prime::ValidPrime, - vector::FpVector, + vector::{FpSlice, FpVector}, }; use sseq::coordinates::{Bidegree, BidegreeElement, BidegreeGenerator}; -pub use self::secondary::{SecondaryExtAlgebra, SecondaryProduct}; +pub use self::{ + secondary::{SecondaryExtAlgebra, SecondaryProduct}, + tensor_resolution::{ + Antipode, TensorResolutionDifferential, tensor_resolution_ext, + tensor_resolution_ext_with_save_dir, tensor_resolution_products, + }, +}; use crate::{ chain_complex::{AugmentedChainComplex, FreeChainComplex}, resolution_homomorphism::ResolutionHomomorphism, @@ -81,7 +88,7 @@ pub trait ExtDifferential: Send + Sync { /// $\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 + /// an ungraded (tensor) 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); @@ -102,21 +109,125 @@ pub trait ExtDifferential: Send + Sync { let _ = cap; self.matrix(b) } + + /// The number of cochain generators at `b`, when the differential's cochain complex is *not* + /// the one read off the backing resolution's generators. + /// + /// The default `None` means "use the backing resolution's generator count" + /// ([`ExtAlgebra::dimension`]) — the right thing for the minimal/secondary case, where the + /// cochain generators *are* the resolution's generators. A differential whose complex lives + /// elsewhere overrides this: the untwisted tensor resolution coboundary + /// ([`tensor_resolution::TensorResolutionDifferential`]) resolves a *different* module `M` by + /// tensoring the backing $k$-resolution with `M`, so its cochain generators at + /// $(n, s)$ number $\sum_i \dim M_{t - d_i}$ (the indecomposables of $P_s \otimes M$), not the + /// $k$-resolution's `number_of_gens_in_bidegree`. + fn dimension(&self, b: Bidegree) -> Option { + let _ = b; + None + } +} + +/// A cochain-level **cup product** $x \cup -$ by a class $x \in \Ext(k, k)$, on the cochain complex +/// $\Hom_A(Q_\bullet, k)$. +/// +/// This is what lets [`ExtAlgebra::massey`] compute Massey products on a **non-minimal** +/// resolution: the chain-map/null-homotopy bracket degenerates there (the lifted product map can be +/// forced to zero), but the dual cochain-DGA formula $\langle a,b,c\rangle = [a \cup v]$, $\delta_Q +/// v = b\cup c$, stays correct precisely because $\delta_Q \neq 0$. It is attached by the untwisted +/// tensor resolution ([`tensor_resolution_products`]); a minimal resolution attaches none and +/// [`ExtAlgebra::massey`] falls back to the chain-map construction. Like [`ExtDifferential`], the +/// cup is a pluggable structure supplied by whoever builds the [`ExtAlgebra`]. +pub trait CochainCup: Send + Sync { + /// The matrix of $x \cup -\colon \Hom_A(Q_s, k)_t \to \Hom_A(Q_{s + \mathrm{shift}.s}, k)_{t + + /// \mathrm{shift}.t}$: rows index the source cochain basis at `(src_s, src_t)`, columns the + /// target at `(src_s + shift.s(), src_t + shift.t())`. `f_x` is the chain self-map of the unit + /// realising `x` (its [`shift`](ResolutionHomomorphism) is `x`'s bidegree), extended through + /// the target filtration. + fn cup_matrix( + &self, + f_x: &ResolutionHomomorphism, + src_s: i32, + src_t: i32, + shift: Bidegree, + ) -> Matrix; +} + +/// A cochain of the Ext cochain complex $\Hom_A(P_\bullet, k)$ at a bidegree: a vector over the +/// **cochain generators** (dual to the resolution's free generators). +/// +/// This is the native "cochain" world, distinct from [`BidegreeElement`], which `Ext*` interprets +/// as a cohomology **class**. The two coincide only when the coboundary vanishes (a minimal +/// resolution); in general convert with [`ExtAlgebra::lift`] (class → cocycle representative) and +/// [`ExtAlgebra::project`] (cochain → class). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cochain { + degree: Bidegree, + vec: FpVector, +} + +impl Cochain { + pub fn new(degree: Bidegree, vec: FpVector) -> Self { + Self { degree, vec } + } + + pub fn degree(&self) -> Bidegree { + self.degree + } + + pub fn vec(&self) -> FpSlice<'_> { + self.vec.as_slice() + } + + pub fn into_vec(self) -> FpVector { + self.vec + } } /// $\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. +/// +/// The unit resolution is held separately from the resolution of `M`, but both have the same type: +/// products and Massey maps always *target* the unit, so it must be an [`AugmentedChainComplex`], +/// while the resolution is only ever a *source*. The untwisted tensor resolution never needs them +/// to differ — its products go through the closed-form [`CochainCup`], which builds no chain map +/// out of the non-minimal $Q_ullet$ at all. pub struct ExtAlgebra { /// Resolution of `M`; products land in its Ext. resolution: Arc, - /// Resolution of the base field `k`. `Arc`-shared with `resolution` when `M == k`. + /// Resolution of the base field `k`. `Arc`-shared with `resolution` when `M == k` (only + /// possible when the two are the same `Arc`). unit: Arc, is_unit: bool, - /// One multiplication map per generator of $\Ext(M, k)$, built and extended on demand. + /// One multiplication map per generator of $\Ext(M, k)$, built and extended on demand. Used by + /// the chain-map product path (minimal resolutions). products: DashMap>>, - /// The DGA differential, if any. `None` is the field/minimal case (zero + /// One chain self-map of the unit per generator of $\Ext(k, k)$, built on demand. This is the + /// single self-map cache: the **cup** product path (the closed-form untwisted tensor + /// resolution) and *every* Massey bracket read their cup matrices off these maps (via + /// [`cup_class_matrix`](Self::cup_class_matrix)), realising a general class as a linear + /// combination of the per-generator maps rather than building a separate map per class. Because + /// they all share one source keyed by the generator, they are also unified on disk (saved once, + /// under the same `prod_{n}_{s}_{idx}` name as the unit's own product maps). + unit_maps: DashMap>>, + /// Cup matrices `{y_j ∪ -}` keyed by `(shift, b)` (the multiplier and multiplicand bidegrees). + /// They depend only on those two bidegrees, not on the multiplier's coordinates, so caching + /// them collapses the per-generator recomputation a Massey kernel would otherwise do. + cup_cache: DashMap<(Bidegree, Bidegree), Arc>>, + /// Quasi-inverse of the coboundary `δ_Q` out of each bidegree, memoised. The Massey + /// `δ`-preimage is intrinsic to `δ_Q` (not to the bracket factors), so this row-reduction is + /// shared across every third factor rather than repeated per bracket. + delta_qi_cache: DashMap>, + /// The DGA differential, if any. `None` is the minimal case (zero /// coboundary), where the cohomology is just the generators. differential: Option>, + /// The cochain cup product $x \cup -$, if any. Attached for a **non-minimal** resolution (the + /// untwisted tensor resolution) so [`massey`](Self::massey) uses the cochain-DGA bracket; + /// `None` for a minimal resolution, where [`massey`](Self::massey) uses the + /// chain-map/null-homotopy bracket. + cup: Option>>, + /// Memoised cohomology space (of the attached [`differential`](Self::differential)) at each + /// bidegree — the vector space whose basis `Ext*` exposes. See [`Self::cohomology`]. + cohomology_cache: DashMap>, } impl ExtAlgebra { @@ -139,7 +250,7 @@ impl ExtAlgebra { } impl ExtAlgebra { - /// Build an [`ExtAlgebra`] from an explicit `(resolution, unit)` pair. + /// Build an [`ExtAlgebra`] from an explicit `(resolution, unit)` pair of the **same** type. pub fn new(resolution: Arc, unit: Arc) -> Self { assert_eq!(resolution.prime(), unit.prime()); Self { @@ -147,7 +258,12 @@ impl ExtAlgebra { resolution, unit, products: DashMap::new(), + unit_maps: DashMap::new(), + cup_cache: DashMap::new(), + delta_qi_cache: DashMap::new(), differential: None, + cup: None, + cohomology_cache: DashMap::new(), } } @@ -162,13 +278,37 @@ impl ExtAlgebra { pub fn without_unit(resolution: Arc) -> Self { Self::new(Arc::clone(&resolution), resolution) } +} + +impl ExtAlgebra +where + CC: FreeChainComplex, +{ + /// Attach a cochain cup product, so [`massey`](Self::massey) uses the cochain-DGA bracket + /// (correct on a non-minimal resolution) instead of the chain-map/null-homotopy one. See + /// [`CochainCup`]. + #[must_use] + pub fn with_cup(mut self, cup: Arc>) -> Self { + self.cup = Some(cup); + self.cup_cache.clear(); + self + } + + /// The cochain cup product this carries, if any. + pub fn cup(&self) -> Option<&Arc>> { + self.cup.as_ref() + } /// 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. + /// Without one, the cohomology is the minimal case — just the generators. #[must_use] pub fn with_differential(mut self, differential: Arc) -> Self { self.differential = Some(differential); + // Every cache below is derived from the differential: the cohomology subquotients, and the + // memoised quasi-inverses of `δ` used to solve `δv = b∪c` (keyed by degree alone). + self.cohomology_cache.clear(); + self.delta_qi_cache.clear(); self } @@ -181,7 +321,7 @@ impl ExtAlgebra { /// $\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 + /// With no differential (a 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 (a /// non-minimal resolution's $\Hom(d, k)$, or a deformation's connecting map) @@ -198,15 +338,16 @@ impl ExtAlgebra { /// 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 + /// (tensor) 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)); + return Some(self.cochain_dimension(b)); }; let gens = d .graded_dimension(b, cap) - .unwrap_or_else(|| self.dimension(b)); + .or_else(|| d.dimension(b)) + .unwrap_or_else(|| self.cochain_dimension(b)); let shift = d.shift(); let source = Bidegree::n_s(b.n() - shift.n(), b.s() - shift.s()); // The capped matrix must line up with the capped generator count `gens`, or @@ -233,13 +374,14 @@ impl ExtAlgebra { } None => 0, }; - // 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) + // ker ⊇ im requires d∘d = 0. `ExtDifferential` is a public trait, so a malformed + // implementor can violate it; assert rather than wrap around to a ~2^64 "dimension". + Some(gens.checked_sub(rank_out + rank_in).unwrap_or_else(|| { + panic!( + "ExtDifferential violates d∘d=0 at {b:?}: rank_out={rank_out}, rank_in={rank_in}, \ + gens={gens}" + ) + })) } /// The DGA's cohomology at `b` as a [`Subquotient`] of the generators — the @@ -252,11 +394,32 @@ impl ExtAlgebra { /// `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 { + self.cohomology(b).map(|h| (*h).clone()) + } + + /// The cohomology space at `b` (of the attached [`differential`](Self::differential)) — the + /// vector space whose basis `Ext*` exposes as `Ext`, memoised and `Arc`-shared. This is the + /// canonical object behind [`dimension`](Self::dimension), [`basis`](Self::basis), + /// [`lift`](Self::lift) and [`project`](Self::project): its `dimension()` is the Ext dimension + /// and its `gens()` are cocycle representatives of the basis classes. + /// + /// `None` when the differential out of `b` is out of the computed range. + pub fn cohomology(&self, b: Bidegree) -> Option> { + if let Some(h) = self.cohomology_cache.get(&b) { + return Some(Arc::clone(&h)); + } + let h = Arc::new(self.compute_cohomology(b)?); + Some(Arc::clone( + self.cohomology_cache.entry(b).or_insert(h).value(), + )) + } + + fn compute_cohomology(&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)); + return Some(Subquotient::new_full(p, self.cochain_dimension(b))); }; + let dim = self.cochain_dimension(b); // Numerator: ker(δ out of b), via the standard augmented-identity kernel. let out = d.matrix(b)?; @@ -321,29 +484,121 @@ impl ExtAlgebra { } } - /// The dimension of $\Ext^{s,t}(M, k)$ at the given bidegree. + /// The dimension of $\Ext^{s,t}(M, k)$ at the given bidegree — the dimension of the + /// **cohomology** of the attached [`differential`](Self::differential) (its Ext part). With no + /// differential (a minimal resolution) this is the generator count, since every cochain is a + /// cocycle; with one (the untwisted tensor resolution, or a $d_2$ page) it is a genuine + /// kernel-mod-image. Returns `0` when out of the computed range. pub fn dimension(&self, b: Bidegree) -> usize { - self.resolution.number_of_gens_in_bidegree(b) + self.cohomology_dimension(b).unwrap_or(0) } - /// The basis generators of $\Ext(M, k)$ at the given bidegree. + /// The number of **cochain generators** at `b` — the ambient dimension of the cochain group + /// $\Hom_A(P_\bullet, k)_b$ that the cohomology is a subquotient of. This is the "generator" + /// world (as opposed to the cohomology-class world of [`dimension`](Self::dimension)); the two + /// coincide exactly when the coboundary is zero (a minimal resolution). + pub fn cochain_dimension(&self, b: Bidegree) -> usize { + self.differential + .as_ref() + .and_then(|d| d.dimension(b)) + .unwrap_or_else(|| { + // Panic-safe: an uncomputed bidegree has no cochains (rather than indexing past the + // resolution). This lets callers sweep `iter_stem` without a prior range check. + if b.s() >= 0 && self.resolution.has_computed_bidegree(b) { + self.resolution.number_of_gens_in_bidegree(b) + } else { + 0 + } + }) + } + + /// The basis classes of $\Ext(M, k)$ at the given bidegree — one [`BidegreeGenerator`] per + /// basis element of the cohomology. pub fn basis(&self, b: Bidegree) -> Vec { (0..self.dimension(b)) .map(|i| BidegreeGenerator::new(b, i)) .collect() } - /// A class in $\Ext(M, k)$ from its coordinates in the generator basis at bidegree `b`. + /// A class in $\Ext(M, k)$ from its coordinates in the **cohomology** basis at bidegree `b`. pub fn element(&self, b: Bidegree, coords: &[u32]) -> BidegreeElement { assert_eq!(self.dimension(b), coords.len()); BidegreeElement::new(b, FpVector::from_slice(self.prime(), coords)) } - /// A single generator of $\Ext(M, k)$ as a class. + /// A single basis class of $\Ext(M, k)$. pub fn generator(&self, g: BidegreeGenerator) -> BidegreeElement { let ambient = self.dimension(g.degree()); assert!(ambient > g.idx()); - g.into_element(self.prime(), self.dimension(g.degree())) + g.into_element(self.prime(), ambient) + } + + /// Lift a cohomology **class** to a **cocycle representative** — a [`Cochain`] whose class is + /// `x`. Concretely $\sum_i x_i \cdot g_i$ over the cocycle representatives + /// `cohomology(b).gens()`. Inverse to [`project`](Self::project) up to a coboundary; on a + /// minimal resolution (zero coboundary) it is the identity. + pub fn lift(&self, x: &BidegreeElement) -> Cochain { + let b = x.degree(); + let h = self + .cohomology(b) + .expect("lift: bidegree out of computed range"); + let mut vec = FpVector::new(self.prime(), h.ambient_dimension()); + for (i, c) in x.vec().iter_nonzero() { + vec.as_slice_mut() + .add(h.gens().nth(i).expect("class coordinate out of range"), c); + } + Cochain::new(b, vec) + } + + /// Project a **cochain** to its cohomology **class** by reducing modulo coboundaries and + /// reading coordinates in the cohomology basis. On a minimal resolution it is the identity. (A + /// cochain with a non-cocycle part is reduced regardless; pass a cocycle for a meaningful + /// class.) + pub fn project(&self, c: &Cochain) -> BidegreeElement { + let b = c.degree(); + let h = self + .cohomology(b) + .expect("project: bidegree out of computed range"); + let mut v = FpVector::new(self.prime(), h.ambient_dimension()); + v.as_slice_mut().add(c.vec(), 1); + let coords = h.reduce(v.as_slice_mut()); + BidegreeElement::new(b, FpVector::from_slice(self.prime(), &coords)) + } + + /// The quasi-inverse of the coboundary `δ_Q` out of `v_deg` (given its matrix `d`, `r × c`), + /// memoised. Used by the Massey `δ`-preimage; `δ_Q` is intrinsic, so the row reduction is done + /// once and reused across every bracket landing at `v_deg`. + pub(crate) fn delta_quasi_inverse( + &self, + v_deg: Bidegree, + d: &Matrix, + r: usize, + c: usize, + ) -> Arc { + if let Some(q) = self.delta_qi_cache.get(&v_deg) { + return Arc::clone(&q); + } + let mut aug = AugmentedMatrix::<2>::new(self.prime(), r, [c, r]); + for i in 0..r { + aug.row_mut(i).slice_mut(0, c).add(d.row(i), 1); + } + aug.segment(1, 1).add_identity(); + aug.row_reduce(); + let qi = Arc::new(aug.compute_quasi_inverse()); + Arc::clone(self.delta_qi_cache.entry(v_deg).or_insert(qi).value()) + } + + /// A [`Cochain`] from its coordinates in the cochain-generator basis at bidegree `b`. + pub fn cochain_element(&self, b: Bidegree, coords: &[u32]) -> Cochain { + assert_eq!(self.cochain_dimension(b), coords.len()); + Cochain::new(b, FpVector::from_slice(self.prime(), coords)) + } + + /// A single cochain generator (a basis element of the cochain group), as a [`Cochain`]. + pub fn cochain_generator(&self, g: BidegreeGenerator) -> Cochain { + let ambient = self.cochain_dimension(g.degree()); + assert!(ambient > g.idx()); + Cochain::new(g.degree(), g.into_element(self.prime(), ambient).into_vec()) } /// The dimension of $\Ext(k, k)$ at the given bidegree (the multiplicand/"scalar" side). @@ -379,6 +634,9 @@ where /// The multiplication map for a single generator `g` of $\Ext(M, k)$, built and cached on /// first use. The returned map is *not* guaranteed to be extended; [`ExtAlgebra::multiply_into`] /// extends it as needed. + /// + /// The map's source is the (possibly non-minimal) resolution of `M` and its target is the unit + /// unit resolution; only the latter must be an [`AugmentedChainComplex`]. When pub fn generator_product_map( &self, g: BidegreeGenerator, @@ -403,6 +661,92 @@ where Arc::clone(self.products.entry(g).or_insert(hom).value()) } + /// The chain self-map $f_y\colon P_\bullet \to P_\bullet$ of the unit realising a generator `g` + /// of $\Ext(k, k)$, built and cached on first use. Used by the closed-form cup product path. + /// + /// This is *the same map* the minimal product path builds for the unit's own generators via + /// [`generator_product_map`](Self::generator_product_map): both are `from_class` on the unit + /// realising `g`, so they must share the on-disk save name (`prod_{n}_{s}_{idx}`). That sharing + /// is what lets a module resolved by the untwisted tensor resolution reuse the unit's + /// already-computed self-maps: once the unit's product ring has been saved to disk, every + /// module's cup path *reloads* the lifts instead of recomputing them (the expensive, + /// `M`-independent part), leaving only the closed-form cup untwist per module. + pub(crate) fn unit_self_map( + &self, + g: BidegreeGenerator, + ) -> Arc> { + if let Some(map) = self.unit_maps.get(&g) { + return Arc::clone(&map); + } + let dim = self.unit.number_of_gens_in_bidegree(g.degree()); + let mut class = vec![0u32; dim]; + class[g.idx()] = 1; + let name = format!("prod_{}_{}_{}", g.n(), g.s(), g.idx()); + let hom = Arc::new(ResolutionHomomorphism::from_class( + name, + Arc::clone(&self.unit), + Arc::clone(&self.unit), + g.degree(), + &class, + )); + Arc::clone(self.unit_maps.entry(g).or_insert(hom).value()) + } + + /// The cup matrices `{y_j ∪ -}` for each generator `y_j` of $\Ext(k, k)$ at `b`, mapping + /// cochains at `shift` to cochains at `target = b + shift`. Cached by `(shift, b)` since they + /// are independent of the multiplier's coordinates. + fn cup_matrices( + &self, + cup: &Arc>, + src: Bidegree, + mult_deg: Bidegree, + target: Bidegree, + unit_dim: usize, + ) -> Arc> { + if let Some(m) = self.cup_cache.get(&(src, mult_deg)) { + return Arc::clone(&m); + } + let mats: Vec = (0..unit_dim) + .map(|j| { + let f_y = self.unit_self_map(BidegreeGenerator::new(mult_deg, j)); + f_y.extend_through_stem(target); + cup.cup_matrix(&f_y, src.s(), src.t(), mult_deg) + }) + .collect(); + Arc::clone( + self.cup_cache + .entry((src, mult_deg)) + .or_insert_with(|| Arc::new(mats)) + .value(), + ) + } + + /// The cup matrix `x ∪ -` for a general class `x` of $\Ext(k, k)$, mapping cochains at `src` to + /// cochains at `src + x.degree()`. Built by combining the cached per-generator cup matrices + /// (`x ∪ - = Σ x_j (y_j ∪ -)`), so the expensive cup-matrix builds are memoised in `cup_cache` + /// and shared with the product path (and, once persisted, with disk); only the linear combine + /// is per call. Used by the closed-form Massey sweep for both the `b ∪ c` and `a ∪ v` cochains. + pub(crate) fn cup_class_matrix( + &self, + cup: &Arc>, + x: &BidegreeElement, + src: Bidegree, + ) -> Matrix { + let x_deg = x.degree(); + let target = src + x_deg; + let unit_dim = self.unit.number_of_gens_in_bidegree(x_deg); + let mats = self.cup_matrices(cup, src, x_deg, target, unit_dim); + let rows = mats.first().map_or(0, Matrix::rows); + let cols = mats.first().map_or(0, Matrix::columns); + let mut out = Matrix::new(self.prime(), rows, cols); + for (j, coef) in x.vec().iter_nonzero() { + for i in 0..rows { + out.row_mut(i).add(mats[j].row(i), coef); + } + } + out + } + /// Left-multiplication by the class `x` (in $\Ext(M, k)$), applied to every basis generator of /// $\Ext(k, k)$ at bidegree `b`. /// @@ -419,24 +763,65 @@ where if !self.unit.has_computed_bidegree(b) || !self.resolution.has_computed_bidegree(target) { return None; } - + // Both ends of the transport must have a computable cohomology (they coincide with the + // cochain space, so are always available, on a minimal resolution). + self.cohomology(shift)?; + self.cohomology(target)?; + + // Realise `x` as a cocycle in the cochain basis; on a minimal resolution `lift` is the + // identity. + let x_cochain = self.lift(x); let unit_dim = self.unit.number_of_gens_in_bidegree(b); - let res_dim = self.resolution.number_of_gens_in_bidegree(target); - let mut matrix = Matrix::new(self.prime(), unit_dim, res_dim); - for (i, c) in x.vec().iter_nonzero() { + // Closed-form untwisted tensor resolution: read each product `x·y_j = project(y_j ∪ x)` off + // the cup, with no chain-map lift on a materialised `Q•` — cochain-sized work only. The cup + // matrices depend only on `(shift, b)`, so they are cached and shared across every + // multiplier at `shift`. + if let Some(cup) = &self.cup { + let cochain_cols = self.cochain_dimension(target); + let cohomology_cols = self.dimension(target); + let cup_mats = self.cup_matrices(cup, shift, b, target, unit_dim); + let mut matrix = Matrix::new(self.prime(), unit_dim, cohomology_cols); + for (j, cup_mat) in cup_mats.iter().enumerate() { + let mut out = FpVector::new(self.prime(), cochain_cols); + cup_mat.apply(out.as_slice_mut(), 1, x_cochain.vec()); + let class = self.project(&Cochain::new(target, out)); + matrix.row_mut(j).add(class.vec(), 1); + } + return Some(matrix); + } + + let cochain_cols = self.cochain_dimension(target); + // The products in the cochain (resolution-generator) basis of `target`. + let mut raw = Matrix::new(self.prime(), unit_dim, cochain_cols); + + for (i, c) in x_cochain.vec().iter_nonzero() { let map = self.generator_product_map(BidegreeGenerator::new(shift, i)); - map.extend_all(); + // Extend only as far as this product needs (`target`), not to the resolved edge. A + // Nassau substrate keeps its quasi-inverse one filtration *behind* the edge (the QI at + // filtration `s` inverts `d: s+1 → s`, which does not exist at the top), so + // `extend_all` would request a quasi-inverse the substrate never saved. `target` stays + // within the resolved-with-margin range, matching the closed-form cup path above. + map.extend_through_stem(target); // `hom_k(b.t())[j][k]`: `j` indexes the multiplicand generator of the unit at `b`, `k` - // indexes the result generator of the resolution at `target`. + // indexes the result cochain generator of the resolution at `target`. let hom_k = map.get_map(target.s()).hom_k(b.t()); for (j, row) in hom_k.iter().enumerate() { for (k, &v) in row.iter().enumerate() { - matrix.row_mut(j).add_basis_element(k, c * v); + raw.row_mut(j).add_basis_element(k, c * v); } } } + + // Project each product cochain to its cohomology class, so the returned matrix is in the + // Ext (cohomology) basis of `target`. + let cohomology_cols = self.dimension(target); + let mut matrix = Matrix::new(self.prime(), unit_dim, cohomology_cols); + for j in 0..unit_dim { + let class = self.project(&Cochain::new(target, raw.row(j).to_owned())); + matrix.row_mut(j).add(class.vec(), 1); + } Some(matrix) } @@ -478,7 +863,7 @@ mod tests { #[test] fn test_zero_differential_cohomology_is_generators() { - // The field/minimal case: with no differential the DGA cohomology is just + // The 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)); @@ -486,7 +871,10 @@ mod tests { 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))); + // No differential: cohomology = cochain generators, so the class world and the + // cochain world coincide. + assert_eq!(alg.cohomology_dimension(b), Some(alg.cochain_dimension(b))); + assert_eq!(alg.dimension(b), alg.cochain_dimension(b)); } } } @@ -535,10 +923,16 @@ mod tests { 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 - 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 + // Sanity: all three source bidegrees are 1-dimensional on the E-page (the cochain level; + // `dimension` itself is now the cohomology, computed below). + assert_eq!(alg.cochain_dimension(Bidegree::n_s(0, 1)), 1); // h_0 + assert_eq!(alg.cochain_dimension(Bidegree::n_s(0, 2)), 1); // h_0^2 + assert_eq!(alg.cochain_dimension(Bidegree::n_s(1, 1)), 1); // h_1 + + // `dimension` is the cohomology: the two killed classes drop to 0, h_1 survives. + assert_eq!(alg.dimension(Bidegree::n_s(0, 2)), 0); + assert_eq!(alg.dimension(Bidegree::n_s(0, 1)), 0); + assert_eq!(alg.dimension(Bidegree::n_s(1, 1)), 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 diff --git a/ext/src/ext_algebra/tensor_resolution.rs b/ext/src/ext_algebra/tensor_resolution.rs new file mode 100644 index 0000000000..c50461dcb4 --- /dev/null +++ b/ext/src/ext_algebra/tensor_resolution.rs @@ -0,0 +1,2445 @@ +//! Nassau's untwisted tensor resolution: $\Ext_A(M, k)$ from a resolution of $k$. +//! +//! A minimal resolution engine (Nassau's algorithm) resolves a *finite* module directly, and its +//! generators already *are* $\Ext$ — nothing to do. It cannot resolve an *infinite* `M`. Nassau's +//! workaround: resolve the base field `k` (finite, fast) as $P_\bullet \to k$, then tensor with +//! `M`. Since the Steenrod algebra `A` is a Hopf algebra, $P_s \otimes_k M$ is free, so +//! $P_\bullet \otimes M \to M$ is a free — but **non-minimal** — resolution of `M`. Its coboundary +//! is non-zero, so $\Ext_A(M, k)$ is the genuine cohomology of $\Hom_A(P_\bullet \otimes M, k)$, +//! which [`ExtAlgebra`] takes via [`ExtDifferential`]. +//! +//! # The coboundary, in closed form +//! We never build the tensor module. Writing $Q(D_s) = (P_s \otimes M)/(\bar A \cdot (P_s\otimes +//! M))$ for the indecomposables (the cochain generators of $\Hom_A(P_s\otimes M, k)$), the Hopf +//! "tensor-with-free is free" isomorphism identifies $Q(D_s) \cong \bigoplus_i \Sigma^{d_i} M$, where the +//! $x_i$ are the generators of the free module $P_s$ in internal degrees $d_i$. So a cochain +//! generator at internal degree `t` is a pair $(i, \alpha)$ — a $P_s$-generator $x_i$ and an +//! `M`-basis element $m_\alpha$ with $d_i + |m_\alpha| = t$ — and $$ \dim \Hom_A(P_s\otimes M, k)_t +//! = \sum_i \dim M_{t - d_i}. $$ The coboundary $\delta\colon C^s \to C^{s+1}$, dual to +//! $Q(\partial)$, has the closed form $$ \delta_{(i,\alpha),(l,\gamma)} = +//! [m_\alpha]\bigl(\chi(a_{li}) \cdot m_\gamma\bigr), $$ where $a_{li} \in A$ is the component of +//! the $k$-resolution differential $d_P(z_l)$ on the generator $x_i$ (an algebra element of degree +//! $|z_l| - d_i$), $\chi$ is the [antipode](Antipode), and the action is on `M`. For a **minimal** +//! $P_\bullet$ every $a_{li} \in \bar A$, so $\chi(a_{li})$ is a genuine positive-degree operator +//! and $\delta \neq 0$ — exactly what makes this a non-minimal resolution whose cohomology must be +//! taken. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use algebra::{ + Algebra, Bialgebra, + module::{ + FreeModule, Module, ZeroModule, + homomorphism::{FreeModuleHomomorphism, ModuleHomomorphism}, + }, + pair_algebra::PairAlgebra, +}; +use dashmap::DashMap; +use fp::{ + matrix::Matrix, + prime::{Prime, ValidPrime}, + vector::FpVector, +}; +use once::{OnceBiVec, OnceVec}; +use sseq::coordinates::{Bidegree, BidegreeElement}; + +use super::{Cochain, ExtAlgebra, ExtDifferential}; +use crate::{ + chain_complex::{ChainComplex, FreeChainComplex}, + resolution::secondary::SecondaryResolution, + resolution_homomorphism::ResolutionHomomorphism, + save::{SaveDirectory, SaveFile, SaveKind}, + secondary::SecondaryLift, +}; + +/// The Hopf antipode $\chi\colon A \to A$ of a connected graded bialgebra, computed generically and +/// memoised. +/// +/// For a connected graded Hopf algebra the antipode is determined by +/// $\sum_{(x)} \chi(x_{(1)}) x_{(2)} = \varepsilon(x)$, which at $p = 2$ unrolls to the recursion +/// $$ \chi(x) = x + \sum_{\text{mid}} \chi(x_{(1)})\, x_{(2)}, $$ +/// the sum over coproduct terms with $0 < |x_{(1)}| < |x|$. We only ever need $\chi$ on the +/// [`Bialgebra::decompose`] atoms (via the anti-homomorphism property $\chi(ab) = \chi(b)\chi(a)$), +/// whose coproducts are directly available. +/// +/// Currently specialised to $p = 2$ (all Hopf signs are $+$); the differential-entry degrees stay +/// within the resolved range. +pub struct Antipode { + algebra: Arc, + /// `(degree, basis index)` → $\chi$ of that basis element, as a vector over the algebra basis. + cache: DashMap<(i32, usize), FpVector>, +} + +impl Antipode { + /// # Panics + /// If `algebra` is not at `p = 2`. Every entry point into the untwisted tensor resolution + /// funnels through here, so they all inherit this restriction. + pub fn new(algebra: Arc) -> Self { + assert_eq!( + algebra.prime(), + ValidPrime::new(2), + "Antipode is currently implemented only at p = 2" + ); + Self { + algebra, + cache: DashMap::new(), + } + } + + fn prime(&self) -> ValidPrime { + self.algebra.prime() + } + + /// $\chi$ of the basis element `(degree, idx)`, as a vector over the degree-`degree` algebra + /// basis. Requires the algebra basis computed through `degree`. + pub fn apply(&self, degree: i32, idx: usize) -> FpVector { + if degree == 0 { + let mut v = FpVector::new(self.prime(), self.algebra.dimension(0)); + v.add_basis_element(idx, 1); + return v; + } + if let Some(v) = self.cache.get(&(degree, idx)) { + return v.clone(); + } + + // χ(x) = χ(atom_1) · χ(atom_2) · … in acting order, since χ is an anti-homomorphism and + // `decompose` lists the factors in the order they act (rightmost algebra factor first). + let atoms = self.algebra.decompose(degree, idx); + let result = if atoms.len() == 1 { + // `x` is itself an atom (a single generator whose product is `x`); use the recursion. + self.apply_atom(degree, idx) + } else { + let mut acc = FpVector::new(self.prime(), self.algebra.dimension(0)); + acc.add_basis_element(0, 1); + let mut acc_deg = 0; + for &(a_deg, a_idx) in &atoms { + let chi_atom = self.apply(a_deg, a_idx); + let out_deg = acc_deg + a_deg; + let mut next = FpVector::new(self.prime(), self.algebra.dimension(out_deg)); + for (l_idx, l_coeff) in acc.iter_nonzero() { + for (r_idx, r_coeff) in chi_atom.iter_nonzero() { + self.algebra.multiply_basis_elements( + next.as_slice_mut(), + l_coeff * r_coeff, + acc_deg, + l_idx, + a_deg, + r_idx, + ); + } + } + acc = next; + acc_deg = out_deg; + } + acc + }; + + self.cache + .entry((degree, idx)) + .or_insert(result) + .value() + .clone() + } + + /// $\chi$ of a single [`decompose`](Bialgebra::decompose) atom, via the recursion + /// $\chi(g) = g + \sum_{\text{mid}} \chi(g_{(1)}) g_{(2)}$ over its coproduct. + fn apply_atom(&self, degree: i32, idx: usize) -> FpVector { + let mut result = FpVector::new(self.prime(), self.algebra.dimension(degree)); + result.add_basis_element(idx, 1); + for (l_deg, l_idx, r_deg, r_idx) in self.algebra.coproduct(degree, idx) { + // Skip the two boundary terms g ⊗ 1 (l_deg == degree) and 1 ⊗ g (l_deg == 0). + if l_deg == 0 || l_deg == degree { + continue; + } + let chi_left = self.apply(l_deg, l_idx); + for (j, coeff) in chi_left.iter_nonzero() { + self.algebra.multiply_basis_elements( + result.as_slice_mut(), + coeff, + l_deg, + j, + r_deg, + r_idx, + ); + } + } + result + } + + /// $\chi$ of a general algebra element `elt` of degree `degree` (linear extension). + fn apply_element(&self, degree: i32, elt: FpVector) -> FpVector { + let mut out = FpVector::new(self.prime(), self.algebra.dimension(degree)); + for (idx, coeff) in elt.iter_nonzero() { + out.as_slice_mut() + .add(self.apply(degree, idx).as_slice(), coeff); + } + out + } +} + +/// The full coproduct $\Delta(x) = \sum_{(x)} x_{(1)} \otimes x_{(2)}$ of a basis element, +/// memoised. +/// +/// At $p = 2$ every term has coefficient $1$, so a coproduct is a *set* of basis-element pairs +/// $(|x_{(1)}|, x_{(1)}, |x_{(2)}|, x_{(2)})$. It is obtained by folding the coproducts of the +/// [`decompose`](Bialgebra::decompose) atoms in $A \otimes A$ — the same route the [`Antipode`] +/// uses, but keeping *all* terms (including the two boundary terms $x \otimes 1$ and $1 \otimes +/// x$). +pub(crate) struct FullCoproduct { + algebra: Arc, + cache: DashMap<(i32, usize), Arc>>, +} + +impl FullCoproduct { + pub(crate) fn new(algebra: Arc) -> Self { + Self { + algebra, + cache: DashMap::new(), + } + } + + /// $\Delta$ of the basis element `(degree, idx)`, as the list of surviving pairs + /// `(|a'|, a', |a''|, a'')`. Requires the algebra basis computed through `degree`. + pub(crate) fn terms(&self, degree: i32, idx: usize) -> Arc> { + if degree == 0 { + // Δ(1) = 1 ⊗ 1 (the unit is grouplike). + return Arc::new(vec![(0, 0, 0, 0)]); + } + if let Some(v) = self.cache.get(&(degree, idx)) { + return Arc::clone(&v); + } + + let p = self.algebra.prime(); + // Δ as a map (l_deg, l_idx, r_deg, r_idx) → coeff, seeded with the unit 1 ⊗ 1. + let mut terms: HashMap<(i32, usize, i32, usize), u32> = HashMap::new(); + terms.insert((0, 0, 0, 0), 1); + for (a_deg, a_idx) in self.algebra.decompose(degree, idx) { + let mut next: HashMap<(i32, usize, i32, usize), u32> = HashMap::new(); + for (&(ll, li, rl, ri), &c) in &terms { + for (cl, cli, cr, cri) in self.algebra.coproduct(a_deg, a_idx) { + // (ll ⊗ rl) · (cl ⊗ cr) = (ll · cl) ⊗ (rl · cr). + let mut left = FpVector::new(p, self.algebra.dimension(ll + cl)); + self.algebra + .multiply_basis_elements(left.as_slice_mut(), 1, ll, li, cl, cli); + let mut right = FpVector::new(p, self.algebra.dimension(rl + cr)); + self.algebra + .multiply_basis_elements(right.as_slice_mut(), 1, rl, ri, cr, cri); + for (lj, lc) in left.iter_nonzero() { + for (rj, rc) in right.iter_nonzero() { + *next.entry((ll + cl, lj, rl + cr, rj)).or_insert(0) += c * lc * rc; + } + } + } + } + terms = next; + } + let result: Vec<(i32, usize, i32, usize)> = terms + .into_iter() + .filter(|&(_, c)| c % p.as_u32() != 0) + .map(|(k, _)| k) + .collect(); + Arc::clone( + self.cache + .entry((degree, idx)) + .or_insert_with(|| Arc::new(result)) + .value(), + ) + } +} + +/// The dualised differential $\Hom(d, k)$ of the untwisted tensor resolution $P_\bullet \otimes M$, +/// as an [`ExtDifferential`] over the backing $k$-resolution `CC`. +/// +/// Attach it to an [`ExtAlgebra`] over the $k$-resolution to compute $\Ext_A(M, k)$ — see +/// [`tensor_resolution_ext`] and the [module docs](self). +pub struct TensorResolutionDifferential +where + CC: FreeChainComplex, + CC::Algebra: Bialgebra, + N: Module, +{ + /// $P_\bullet$: a (minimal) free resolution of `k`. + resolution: Arc, + /// The module `M` being resolved. + module: Arc, + antipode: Antipode, + /// `(s, t)` → the ordered cochain basis of $\Hom_A(P_s \otimes M, k)_t$: pairs + /// `(generator degree d_i, generator index i, M-basis index α)`. Cached so that `matrix(b)`'s + /// rows and `matrix(source)`'s columns share one coordinate system. + cochain_bases: DashMap<(i32, i32), Arc>>, + /// The coboundary matrix `δ_Q` out of each bidegree, memoised — it is intrinsic (the + /// closed-form untwisting), so a Massey sweep does not recompute it per bracket. + matrix_cache: DashMap>, + /// Where the `δ_Q` matrices are persisted, if disk caching is enabled. Because the on-disk + /// header only distinguishes by algebra and bidegree (not by `M`), this directory must be + /// dedicated to a single module — see [`with_save_dir`](Self::with_save_dir). + save_dir: SaveDirectory, +} + +impl TensorResolutionDifferential +where + CC: FreeChainComplex, + CC::Algebra: Bialgebra, + N: Module, +{ + pub fn new(resolution: Arc, module: Arc) -> Self { + let antipode = Antipode::new(resolution.algebra()); + Self { + resolution, + module, + antipode, + cochain_bases: DashMap::new(), + matrix_cache: DashMap::new(), + save_dir: SaveDirectory::None, + } + } + + /// Persist (and reload) the closed-form `δ_Q` matrices under `save_dir`, using the + /// [`save`](crate::save) machinery. Because [`SaveFile`]'s header does not encode `M`, **the + /// directory must be dedicated to a single module**; reusing one for two modules over the same + /// algebra is caught by the module fingerprint stored in each file, + /// which panics on load rather than silently mixing differentials. The + /// [`SaveKind::TensorDifferential`] subdirectory is created eagerly so writes succeed lazily. + #[must_use] + pub fn with_save_dir(mut self, save_dir: impl Into) -> Self { + let save_dir = save_dir.into(); + if let Some(p) = save_dir.write() { + SaveKind::TensorDifferential + .create_dir(p) + .expect("Failed to create tensor-differential save directory"); + } + self.save_dir = save_dir; + self + } + + fn prime(&self) -> ValidPrime { + self.resolution.prime() + } + + /// The [`SaveFile`] for the `δ_Q` matrix out of bidegree `b`. + fn delta_save_file(&self, b: Bidegree) -> SaveFile { + SaveFile { + kind: SaveKind::TensorDifferential, + algebra: self.resolution.algebra(), + b, + idx: None, + } + } + + /// A fingerprint of `M` through internal degree `t`, stored in every `δ_Q` save file. + /// + /// [`SaveFile`]'s header records only the kind, algebra and bidegree, so two different modules + /// over the same algebra produce the same filename. The shape check below catches most + /// mismatches, but two modules whose graded dimensions happen to agree at the bidegrees + /// actually loaded would slip through and yield a silently wrong `Ext` — the worst possible + /// failure for a research computation. Mixing in the module's identity closes that gap. + fn module_fingerprint(&self, t: i32) -> u32 { + let mut adler = adler::Adler32::new(); + adler.write_slice(self.module.to_string().as_bytes()); + adler.write_slice(&self.module.min_degree().to_le_bytes()); + for d in self.module.min_degree()..=t { + adler.write_slice(&(self.module.dimension(d) as u64).to_le_bytes()); + } + adler.checksum() + } + + /// Read the `δ_Q` matrix out of `b` from disk, if present. The file is self-describing: it + /// stores a module fingerprint and its own row/column counts, both + /// of which are checked, so a directory holding another module's data fails loudly rather than + /// returning nonsense. + fn read_delta(&self, b: Bidegree, rows: usize, cols: usize) -> Option { + use byteorder::{LittleEndian, ReadBytesExt}; + + let dir = self.save_dir.read()?; + let mut f = self.delta_save_file(b).open_file(dir.clone())?; + let saved_fingerprint = f.read_u32::().unwrap(); + let fingerprint = self.module_fingerprint(b.t()); + assert_eq!( + saved_fingerprint, fingerprint, + "tensor-differential save file at {b} was written for a different module (fingerprint \ + {saved_fingerprint:#010x}, expected {fingerprint:#010x}); each save directory must \ + be dedicated to a single module" + ); + let saved_rows = f.read_u64::().unwrap() as usize; + let saved_cols = f.read_u64::().unwrap() as usize; + assert_eq!( + (saved_rows, saved_cols), + (rows, cols), + "tensor-differential save file at {b} has shape {saved_rows}×{saved_cols}, expected \ + {rows}×{cols}" + ); + Some(Matrix::from_bytes(self.prime(), rows, cols, &mut f).unwrap()) + } + + /// Persist the `δ_Q` matrix out of `b` to disk, if writing is enabled and it is not already + /// present. A module fingerprint and the shape (`rows`, `cols`) are + /// written ahead of the matrix body so [`read_delta`](Self::read_delta) is self-describing. + fn write_delta(&self, b: Bidegree, matrix: &Matrix) { + use byteorder::{LittleEndian, WriteBytesExt}; + + let Some(dir) = self.save_dir.write() else { + return; + }; + let save_file = self.delta_save_file(b); + if save_file.exists(dir.clone()) { + return; + } + let mut f = save_file.create_file(dir.clone(), false); + f.write_u32::(self.module_fingerprint(b.t())) + .unwrap(); + f.write_u64::(matrix.rows() as u64).unwrap(); + f.write_u64::(matrix.columns() as u64) + .unwrap(); + matrix.to_bytes(&mut f).unwrap(); + } + + /// True once $P_s$ is resolved through internal degree `t` (so its generators up to `t` and the + /// differential out of it are available). + fn computed(&self, s: i32, t: i32) -> bool { + s >= 0 && self.resolution.has_computed_bidegree(Bidegree::s_t(s, t)) + } + + /// The ordered cochain basis of $\Hom_A(P_s \otimes M, k)_t$ (see [`Self::cochain_bases`]). + fn cochain_basis(&self, s: i32, t: i32) -> Arc> { + if let Some(b) = self.cochain_bases.get(&(s, t)) { + return Arc::clone(&b); + } + let module_s = self.resolution.module(s); + let mut basis = Vec::new(); + // Generators of P_s in increasing degree; then M-basis in the complementary degree. + for (d_i, i) in module_s.iter_gens(t) { + let m_dim = self.module.dimension(t - d_i); + for alpha in 0..m_dim { + basis.push((d_i, i, alpha)); + } + } + Arc::clone( + self.cochain_bases + .entry((s, t)) + .or_insert_with(|| Arc::new(basis)) + .value(), + ) + } + + /// The closed-form matrix of an $A$-linear "untwisted" cochain operator + /// $\Hom_A(Q_{\mathrm{tgt}}, k) \to \Hom_A(Q_{\mathrm{src}}, k)$ read off a map on the backing + /// free resolution `P`. + /// + /// Rows index the source cochain basis $(d_i, i, \alpha)$ at $(\text{src\_s}, \text{src\_t})$, + /// columns the target cochain basis $(e_l, l, \gamma)$ at $(\text{tgt\_s}, \text{tgt\_t})$. + /// `get_image(e_l, l)` gives the image of the target generator $z_l$ as an element of + /// $P_{\text{src\_s}}$ at internal degree $e_l - (\text{tgt\_t} - \text{src\_t})$ (for the + /// differential this is $d_P(z_l)$; for the cup product by a class it is $f_x(z_l)$). The entry + /// is $[m_\alpha]\bigl(\chi(a_{li}) \cdot m_\gamma\bigr)$, where $a_{li}$ is the component of + /// that image on the generator $(d_i, i)$ — the same untwisting the free differential uses, so + /// only the augmentation ($a' = 1$) part survives in $\Hom_A(-, k)$. + fn closed_form_matrix( + &self, + src_s: i32, + src_t: i32, + tgt_s: i32, + tgt_t: i32, + get_image: impl Fn(i32, usize) -> FpVector, + ) -> Matrix { + let p = self.prime(); + let algebra = self.resolution.algebra(); + let rows = self.cochain_basis(src_s, src_t); // (d_i, i, α) — Q(D_src) + let cols = self.cochain_basis(tgt_s, tgt_t); // (e_l, l, γ) — Q(D_tgt) + let mut matrix = Matrix::new(p, rows.len(), cols.len()); + + let module_src = self.resolution.module(src_s); + let internal_shift = tgt_t - src_t; + + // Row lookup: (generator degree, generator index) → its first row (α = 0). The α rows for a + // generator are contiguous by construction of `cochain_basis`. + let mut row_base: HashMap<(i32, usize), usize> = HashMap::new(); + for (r, &(d_i, i, alpha)) in rows.iter().enumerate() { + if alpha == 0 { + row_base.insert((d_i, i), r); + } + } + + for (c, &(e_l, l, gamma)) in cols.iter().enumerate() { + let img_deg = e_l - internal_shift; // degree of the image in P_{src_s} + if img_deg < 0 { + continue; + } + let img = get_image(e_l, l); // image of z_l ∈ (P_{src_s})_{img_deg} + if img.is_zero() { + continue; + } + let gamma_deg = tgt_t - e_l; // |m_γ| + + // For each P_{src_s}-generator (d_i, i): extract a_{li}, act χ(a_{li}) on m_γ, scatter. + for (&(d_i, i), &base) in &row_base { + let op_deg = img_deg - d_i; + if op_deg < 0 { + continue; + } + let width = algebra.dimension(op_deg); + if width == 0 { + continue; + } + // a_{li} = block of the image at generator (d_i, i), as an algebra element. + let offset = module_src.generator_offset(img_deg, d_i, i); + let mut a_li = FpVector::new(p, width); + for q in 0..width { + let v = img.entry(offset + q); + if v != 0 { + a_li.add_basis_element(q, v); + } + } + if a_li.is_zero() { + continue; + } + // χ(a_{li}) · m_γ ∈ M_{src_t - d_i}. (`op_deg == 0` forces `src_t - d_i == + // gamma_deg`, so both branches write into the same graded piece.) + let mut acted = FpVector::new(p, self.module.dimension(src_t - d_i)); + if op_deg == 0 { + // a_{li} = c·1, so χ(a_{li}) m_γ = c·m_γ. Do this directly: some module actions + // (e.g. `RealProjectiveSpace`) short-circuit the identity operation + // `op_degree == 0` and would drop this term. The differential never reaches + // here — a minimal `P` has no degree-0 component — but the cup product by a + // class does, and that component is exactly what `hom_k` reads. + acted.add_basis_element(gamma, a_li.entry(0)); + } else { + let chi = self.antipode.apply_element(op_deg, a_li); + for (op_idx, coeff) in chi.iter_nonzero() { + self.module.act_on_basis( + acted.as_slice_mut(), + coeff, + op_deg, + op_idx, + gamma_deg, + gamma, + ); + } + } + if acted.is_zero() { + continue; + } + // Entry for each (i, α): coefficient of m_α. + for alpha in 0..acted.len() { + let v = acted.entry(alpha); + if v != 0 { + matrix.row_mut(base + alpha).add_basis_element(c, v); + } + } + } + } + matrix + } +} + +impl ExtDifferential for TensorResolutionDifferential +where + CC: FreeChainComplex, + CC::Algebra: Bialgebra, + N: Module, +{ + fn shift(&self) -> Bidegree { + // δ: Ext^{s,t} → Ext^{s+1,t}, i.e. (n, s) → (n-1, s+1) with t fixed. + Bidegree::n_s(-1, 1) + } + + fn dimension(&self, b: Bidegree) -> Option { + let (s, t) = (b.s(), b.t()); + if !self.computed(s, t) { + return None; + } + Some(self.cochain_basis(s, t).len()) + } + + fn matrix(&self, b: Bidegree) -> Option { + if let Some(m) = self.matrix_cache.get(&b) { + return Some((**m).clone()); + } + let (s, t) = (b.s(), b.t()); + // Need P_s (rows / a_{li}) and P_{s+1} (columns / d_P out of s+1) through degree t. + if !self.computed(s, t) || !self.computed(s + 1, t) { + return None; + } + + // The `δ_Q` matrix maps the cochain basis at `(s, t)` to the one at `(s + 1, t)`; those + // shapes let us both reload a self-describing save file and size a fresh computation. + let rows = self.cochain_basis(s, t).len(); + let cols = self.cochain_basis(s + 1, t).len(); + let (matrix, from_disk) = if let Some(m) = self.read_delta(b, rows, cols) { + (m, true) + } else { + // δ: C^s → C^{s+1} at the same internal degree, read off the free differential d_P. + let p = self.prime(); + let module_s = self.resolution.module(s); + let d_p = self.resolution.differential(s + 1); // P_{s+1} → P_s + let matrix = self.closed_form_matrix(s, t, s + 1, t, |e_l, l| { + let mut dp = FpVector::new(p, module_s.dimension(e_l)); + d_p.apply_to_generator(&mut dp, 1, e_l, l); + dp + }); + (matrix, false) + }; + + // Claim the cache slot before writing: whoever inserts owns the (single) disk write, so a + // freshly computed matrix is persisted exactly once even if two threads raced to compute it + // (a fresh `create_new` would otherwise panic on the loser). + use dashmap::mapref::entry::Entry; + Some(match self.matrix_cache.entry(b) { + Entry::Occupied(e) => (**e.get()).clone(), + Entry::Vacant(e) => { + if !from_disk { + self.write_delta(b, &matrix); + } + (**e.insert(Arc::new(matrix))).clone() + } + }) + } +} + +impl super::CochainCup for TensorResolutionDifferential +where + CC: FreeChainComplex, + CC::Algebra: Bialgebra, + N: Module, +{ + /// The cup product reads `f_x` through the same untwisting as + /// `TensorResolutionDifferential`'s closed form: the module action + /// `x ∪ v = v ∘ (f_x ⊗ id)` keeps only the augmentation part, giving entry `[m_β](χ(b_li)·m_γ)` + /// with `b_li` the component of `f_x(z_l)` on the generator `x_i`. + fn cup_matrix( + &self, + f_x: &ResolutionHomomorphism, + src_s: i32, + src_t: i32, + shift: Bidegree, + ) -> Matrix { + let tgt_s = src_s + shift.s(); + let tgt_t = src_t + shift.t(); + let map = f_x.get_map(tgt_s); + self.closed_form_matrix(src_s, src_t, tgt_s, tgt_t, move |e_l, l| { + map.output(e_l, l).to_owned() + }) + } +} + +/// Build an [`ExtAlgebra`] over the $k$-resolution whose cohomology is $\Ext_A(M, k)$, computed by +/// Nassau's untwisted tensor resolution (see the [module docs](self)). +/// +/// `resolution` must be a free resolution of the base field `k` over the same algebra as `module`. +/// Take cohomology with [`ExtAlgebra::cohomology_dimension`] / +/// [`ExtAlgebra::cohomology_subquotient`]. +pub fn tensor_resolution_ext(resolution: Arc, module: Arc) -> ExtAlgebra +where + CC: FreeChainComplex + 'static, + CC::Algebra: Bialgebra, + N: Module + 'static, +{ + tensor_resolution_ext_with_save_dir(resolution, module, SaveDirectory::None) +} + +/// [`tensor_resolution_ext`] with the closed-form `δ_Q` matrices persisted under `save_dir` via the +/// [`save`](crate::save) machinery. The directory must be dedicated to this single module `M` — see +/// [`TensorResolutionDifferential::with_save_dir`]. +pub fn tensor_resolution_ext_with_save_dir( + resolution: Arc, + module: Arc, + save_dir: impl Into, +) -> ExtAlgebra +where + CC: FreeChainComplex + 'static, + CC::Algebra: Bialgebra, + N: Module + 'static, +{ + let diff = Arc::new( + TensorResolutionDifferential::new(Arc::clone(&resolution), module).with_save_dir(save_dir), + ); + ExtAlgebra::without_unit(resolution).with_differential(diff) +} + +/// Build an [`ExtAlgebra`] whose $\Ext(M, k)$ supports **products and Massey products** by the +/// untwisted tensor resolution (see the [module docs](self)). +/// +/// This is entirely **closed form**: the resolution is the (small, minimal) $k$-resolution +/// $P_\bullet$ itself, and everything about `M` lives in the attached +/// [`TensorResolutionDifferential`] — used *both* as the coboundary $\delta_Q$ (giving the additive +/// $\Ext(M, k)$, exactly as [`tensor_resolution_ext`]) *and* as the cochain cup product +/// ([`CochainCup`](super::CochainCup)). The genuine tensor complex $Q_\bullet = P_\bullet\otimes M$ +/// is **never materialised** — its free modules would have dimension `#gens ×` (Steenrod algebra +/// dimension in the complementary degree), which blows up with the stem. Instead: +/// +/// - products read `x·y = project(y ∪ x)` off the closed-form cup +/// ([`multiply_into`](ExtAlgebra::multiply_into)), touching only cochain generators; +/// - Massey products dispatch to the cochain-DGA bracket ($\langle a,b,c\rangle = [a\cup v]$, +/// $\delta_Q v = b\cup c$), likewise cochain-sized. +/// +/// The only chain maps built are self-maps of the *minimal* $P_\bullet$ (the ordinary $\Ext(k,k)$ +/// product cost). `resolution` must be a (minimal) free resolution of the base field `k` over the +/// same algebra as `module`. +pub fn tensor_resolution_products(resolution: Arc, module: Arc) -> ExtAlgebra +where + CC: FreeChainComplex + crate::chain_complex::AugmentedChainComplex + 'static, + CC::Algebra: Bialgebra, + N: Module + 'static, +{ + // One engine serves as both the coboundary δ_Q (additive Ext) and the cup product — same + // untwisting closed form, same cochain basis. + let engine = Arc::new(TensorResolutionDifferential::new( + resolution.clone(), + module, + )); + ExtAlgebra::without_unit(resolution) + .with_differential(engine.clone() as Arc) + .with_cup(engine as Arc>) +} + +/// The dualised differential $\Hom_A(\partial, k)$ of *any* free chain complex `Q`, read straight +/// off `Q`'s own differential, as an [`ExtDifferential`]. +/// +/// Its cohomology is $\Ext$ computed from `Q`: zero coboundary (so $\Ext = $ generators) for a +/// minimal `Q`, and the genuine non-minimal coboundary for a tensored `Q` such as +/// [`TensorResolution`]. Because the cochain basis at each bidegree is exactly `Q`'s generators, an +/// [`ExtAlgebra`] carrying this differential shares one coordinate system with `Q`'s secondary +/// machinery — which is what lets [`TensorResolutionSecondary`] transport the Adams $d_2$ between +/// them by [`lift`](ExtAlgebra::lift)/[`project`](ExtAlgebra::project). +pub struct DualizedDifferential { + complex: Arc, +} + +impl DualizedDifferential { + pub fn new(complex: Arc) -> Self { + Self { complex } + } +} + +impl ExtDifferential for DualizedDifferential { + fn shift(&self) -> Bidegree { + // δ: Ext^{s,t} → Ext^{s+1,t}, i.e. (n, s) → (n-1, s+1) with t fixed. + Bidegree::n_s(-1, 1) + } + + fn dimension(&self, b: Bidegree) -> Option { + if b.s() < 0 || !self.complex.has_computed_bidegree(b) { + return None; + } + Some(self.complex.number_of_gens_in_bidegree(b)) + } + + fn matrix(&self, b: Bidegree) -> Option { + let (s, t) = (b.s(), b.t()); + let target = b + self.shift(); // (s + 1, t) + if s < 0 + || !self.complex.has_computed_bidegree(b) + || !self.complex.has_computed_bidegree(target) + { + return None; + } + let p = self.complex.prime(); + let rows = self.complex.number_of_gens_in_bidegree(b); // C^s_t + let cols = self.complex.number_of_gens_in_bidegree(target); // C^{s+1}_t + let mut matrix = Matrix::new(p, rows, cols); + // ∂_{s+1}: Q_{s+1} → Q_s; `hom_k(t)` is indexed [Q_s gen][Q_{s+1} gen] = [row][col], with + // entry = coefficient of Q_s-generator `row` in ∂ of Q_{s+1}-generator `col` — exactly the + // coboundary δ_{row,col} (and the augmentation part of `TensorResolution`'s free ∂). + let hk = self.complex.differential(s + 1).hom_k(t); + for (row, cols_of_row) in hk.iter().enumerate() { + for (col, &v) in cols_of_row.iter().enumerate() { + if v != 0 { + matrix.row_mut(row).set_entry(col, v); + } + } + } + Some(matrix) + } +} + +/// A materialised, **genuine** free resolution $Q_\bullet = P_\bullet \otimes M \to M$ of `M`, +/// built by Nassau's untwisted tensor resolution (see the [module docs](self)). +/// +/// Unlike [`TensorResolutionDifferential`] — which only produces the *dualised* $\Hom_A(Q, k)$ and +/// so knows just the additive $\Ext$ — this is the whole chain complex, with free modules and free +/// differentials, and therefore is a bona fide [`FreeChainComplex`]. That unlocks everything the +/// secondary/product machinery needs from a resolution: in particular +/// [`SecondaryResolution`] accepts it directly, +/// giving the Adams $d_2$ on $\Ext_A(M, k)$ for infinite/tensored `M`. +/// +/// # The free differential, in closed form +/// $Q_s = P_s \otimes M$ carries the diagonal $A$-action; the untwisting isomorphism +/// $\Phi\colon P_s \otimes M^{\mathrm{triv}} \xrightarrow{\ \sim\ } P_s \otimes M$, +/// $b\,x_i \otimes m \mapsto \sum b_{(1)} x_i \otimes b_{(2)} m$, presents it as *free* on the +/// pairs $x_i \otimes m_\alpha$. Conjugating $\partial_P \otimes \mathrm{id}$ by $\Phi$ (its +/// inverse is $\Psi(b\,y \otimes m) = \sum b_{(1)} y \otimes \chi(b_{(2)}) m$) gives the free +/// differential $$ \partial_Q(x_i \otimes m_\alpha) = \sum_j \sum_{(a_{ij})} a'_{ij} \cdot +/// \bigl(y_j \otimes \chi(a''_{ij})\, m_\alpha\bigr), $$ where $\partial_P(x_i) = \sum_j a_{ij} +/// y_j$ and $\Delta(a_{ij}) = \sum a'_{ij} \otimes a''_{ij}$ is the [full +/// coproduct. Its $\Hom_A(-, k)$ keeps only the augmentation part $a' = 1$, +/// recovering the closed-form [`TensorResolutionDifferential`]; and it squares to zero by +/// construction, being conjugate to $\partial_P \otimes \mathrm{id}$. +/// +/// Generators of $Q_s$ at internal degree `t` are added in the same $(x_i, m_\alpha)$ order as +/// `TensorResolutionDifferential`'s cochain basis, so the two share one coordinate system. +pub struct TensorResolution +where + CC: FreeChainComplex, + CC::Algebra: Bialgebra, + N: Module + ZeroModule, +{ + /// $P_\bullet$: a (minimal) free resolution of `k`. + resolution: Arc, + /// The module `M` being resolved. + module: Arc, + antipode: Antipode, + coproduct: FullCoproduct, + zero_module: Arc>, + /// `s` → $Q_s = P_s \otimes M$. + modules: OnceBiVec>>, + /// `s` → $\partial_s\colon Q_s \to Q_{s-1}$ (with $\partial_0\colon Q_0 \to 0$). + differentials: OnceVec>>>, + lock: Mutex<()>, +} + +impl TensorResolution +where + CC: FreeChainComplex, + CC::Algebra: Bialgebra, + N: Module + ZeroModule, +{ + pub fn new(resolution: Arc, module: Arc) -> Self { + let algebra = resolution.algebra(); + let antipode = Antipode::new(Arc::clone(&algebra)); + let coproduct = FullCoproduct::new(Arc::clone(&algebra)); + let zero_module = Arc::new(FreeModule::new(Arc::clone(&algebra), "0".to_string(), 0)); + Self { + resolution, + module, + antipode, + coproduct, + zero_module, + modules: OnceBiVec::new(0), + differentials: OnceVec::new(), + lock: Mutex::new(()), + } + } + + /// Build $Q_s$ (free modules and differentials) for all `s ≤ s_max` through internal degree + /// `t_max`. Idempotent and monotone: re-calling with a larger box extends in place. + fn extend(&self, s_max: i32, t_max: i32) { + if s_max < 0 || t_max < 0 { + return; + } + let _lock = self.lock.lock().unwrap(); + let algebra = self.resolution.algebra(); + + // Grow the substrate first: P_• through (s_max, t_max); M and the algebra through t_max. + self.resolution + .compute_through_bidegree(Bidegree::s_t(s_max, t_max)); + self.module.compute_basis(t_max); + algebra.compute_basis(t_max); + self.zero_module.compute_basis(t_max); + + // Ensure a free module exists for each 0..=s_max, then grow its generators + basis to + // t_max. + for s in self.modules.len()..=s_max { + self.modules.push(Arc::new(FreeModule::new( + Arc::clone(&algebra), + format!("(P⊗M)_{s}"), + 0, + ))); + } + for s in 0..=s_max { + let fm = &self.modules[s]; + fm.compute_basis(t_max); + let p_s = self.resolution.module(s); + for d in (fm.max_computed_degree() + 1)..=t_max { + // Generators of Q_s at degree d: the (i, α) with |x_i| + |m_α| = d. + let count: usize = p_s + .iter_gens(d) + .map(|(d_i, _)| self.module.dimension(d - d_i)) + .sum(); + fm.add_generators(d, count, None); + } + } + + // Ensure the differentials exist, then grow their outputs to t_max. + for s in self.differentials.len() as i32..=s_max { + let d = if s == 0 { + FreeModuleHomomorphism::new( + Arc::clone(&self.modules[0]), + Arc::clone(&self.zero_module), + 0, + ) + } else { + FreeModuleHomomorphism::new( + Arc::clone(&self.modules[s]), + Arc::clone(&self.modules[s - 1]), + 0, + ) + }; + self.differentials.push(Arc::new(d)); + } + for s in 0..=s_max { + let d = &self.differentials[s as usize]; + if s == 0 { + d.extend_by_zero(t_max); + } else { + for degree in d.next_degree()..=t_max { + let rows = self.differential_rows(s, degree); + d.add_generators_from_rows(degree, rows); + } + } + } + + // The secondary machinery lifts intermediates through the differentials, so each needs its + // quasi-inverse (image/kernel/QI). Minimal resolvers build these while resolving; here we + // compute them explicitly for the materialised free differentials. + for s in 0..=s_max { + self.differentials[s as usize].compute_auxiliary_data_through_degree(t_max); + } + } + + /// The outputs of $\partial_s\colon Q_s \to Q_{s-1}$ on the generators of $Q_s$ at degree + /// `degree`, one [`FpVector`] (in $(Q_{s-1})_{\text{degree}}$) per generator, in generator + /// order. + fn differential_rows(&self, s: i32, degree: i32) -> Vec { + let p = self.resolution.prime(); + let algebra = self.resolution.algebra(); + let fm = &self.modules[s]; + let target = &self.modules[s - 1]; + let p_s = self.resolution.module(s); + let p_prev = self.resolution.module(s - 1); + let d_p = self.resolution.differential(s); + let tgt_dim = target.dimension(degree); + + let mut rows: Vec = Vec::with_capacity(fm.number_of_gens_in_degree(degree)); + for (d_i, i) in p_s.iter_gens(degree) { + let e_alpha = degree - d_i; + let m_dim = self.module.dimension(e_alpha); + if m_dim == 0 { + continue; + } + // ∂_P(x_i) ∈ (P_{s-1})_{d_i}. + let mut dp = FpVector::new(p, p_prev.dimension(d_i)); + d_p.apply_to_generator(&mut dp, 1, d_i, i); + + for alpha in 0..m_dim { + let mut out = FpVector::new(p, tgt_dim); + if !dp.is_zero() { + // For each P_{s-1}-generator (d'_j, j): extract a_{ij}, apply the Hopf formula. + for (dpj, j) in p_prev.iter_gens(d_i) { + let op_deg = d_i - dpj; // |a_{ij}| + let width = algebra.dimension(op_deg); + if width == 0 { + continue; + } + let off = p_prev.generator_offset(d_i, dpj, j); + for a_idx in 0..width { + let coeff = dp.entry(off + a_idx); + if coeff == 0 { + continue; + } + // ∂_Q term: Σ_(a) a' · (y_j ⊗ χ(a'') m_α). + for &(l_deg, l_idx, r_deg, r_idx) in + self.coproduct.terms(op_deg, a_idx).iter() + { + // χ(a'') m_α ∈ M_{r_deg + e_alpha}. + let mbeta_deg = r_deg + e_alpha; + let mut acted = FpVector::new(p, self.module.dimension(mbeta_deg)); + if r_deg == 0 { + // a'' = 1, so χ(a'') m_α = m_α. Do this directly: some module + // actions (e.g. `RealProjectiveSpace`) short-circuit the + // identity operation `op_degree == 0` and would drop this term. + acted.add_basis_element(alpha, coeff); + } else { + let chi = self.antipode.apply(r_deg, r_idx); + for (op_idx, op_c) in chi.iter_nonzero() { + self.module.act_on_basis( + acted.as_slice_mut(), + (op_c * coeff) % p.as_u32(), + r_deg, + op_idx, + e_alpha, + alpha, + ); + } + } + if acted.is_zero() { + continue; + } + // Place a' · gen(j, β) for each β: block = generator (j, β) of + // degree d'_j + |m_β|; within-block offset = l_idx (a' at deg + // l_deg). + let gen_deg = dpj + mbeta_deg; + for (beta, b_c) in acted.iter_nonzero() { + let gen_idx = + self.local_gen_index(s - 1, gen_deg, dpj, j, beta); + let block = target.generator_offset(degree, gen_deg, gen_idx); + debug_assert_eq!(degree - gen_deg, l_deg); + out.add_basis_element(block + l_idx, b_c); + } + } + } + } + } + rows.push(out); + } + } + rows + } + + /// Index of the generator $(y_j, m_\beta)$ among the generators of $Q_s$ at degree `gen_deg`, + /// where `y_j` is the P_s-generator `(target_pdeg, target_pidx)`. Matches the add-order in + /// [`Self::extend`] (iterate P_s generators, then M-basis β). + fn local_gen_index( + &self, + s: i32, + gen_deg: i32, + target_pdeg: i32, + target_pidx: usize, + beta: usize, + ) -> usize { + let p_s = self.resolution.module(s); + let mut idx = 0; + for (dk, k) in p_s.iter_gens(gen_deg) { + if dk == target_pdeg && k == target_pidx { + return idx + beta; + } + idx += self.module.dimension(gen_deg - dk); + } + panic!("generator ({target_pdeg}, {target_pidx}) not found in Q_{s} at degree {gen_deg}"); + } +} + +impl ChainComplex for TensorResolution +where + CC: FreeChainComplex, + CC::Algebra: Bialgebra, + N: Module + ZeroModule, +{ + type Algebra = CC::Algebra; + type Homomorphism = FreeModuleHomomorphism>; + type Module = FreeModule; + + fn algebra(&self) -> Arc { + self.resolution.algebra() + } + + fn min_degree(&self) -> i32 { + 0 + } + + 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 as usize]) + } + + fn has_computed_bidegree(&self, b: Bidegree) -> bool { + b.s() >= 0 + && self.differentials.len() > b.s() as usize + && self.differential(b.s()).next_degree() > b.t() + } + + fn compute_through_bidegree(&self, b: Bidegree) { + self.extend(b.s(), b.t()); + } + + fn next_homological_degree(&self) -> i32 { + self.modules.len() + } +} + +/// The Adams $d_2$ on $\Ext_A(M, k)$ for a module resolved by the untwisted tensor resolution. +/// +/// It wraps the genuine [`TensorResolution`] $Q_\bullet = P_\bullet \otimes M$ in the standard +/// [`SecondaryResolution`] machinery (which computes $d_2$ from any free resolution — $d_2$ is a +/// chain-homotopy invariant), and an [`ExtAlgebra`] whose $E_2$ page is the cohomology of +/// $\Hom_A(Q, k)$ (via [`DualizedDifferential`]). Because $Q$ is *non-minimal*, $d_2$ is only +/// defined on cohomology classes, not on cochain generators; the transport +/// $$ d_2 = \text{project} \circ (\text{cochain } d_2) \circ \text{lift} $$ +/// restricts to cocycles and quotients the spurious coboundary part — exactly the +/// [`lift`](ExtAlgebra::lift)/[`project`](ExtAlgebra::project) of the cohomology-first foundation. +/// +/// Works for finite *and* infinite `M`. Cross-checked against the direct +/// [`SecondaryExtAlgebra`](super::SecondaryExtAlgebra) on the minimal resolution of `M`. +pub struct TensorResolutionSecondary +where + CC: FreeChainComplex + 'static, + CC::Algebra: Bialgebra + PairAlgebra, + N: Module + ZeroModule + 'static, +{ + resolution: Arc>, + secondary: Arc>>, + /// The $E_2$ page of $\Ext_A(M, k)$, in $Q_\bullet$-generator (cochain) coordinates. + e2: ExtAlgebra>, +} + +impl TensorResolutionSecondary +where + CC: FreeChainComplex + 'static, + CC::Algebra: Bialgebra + PairAlgebra, + N: Module + ZeroModule + 'static, +{ + /// Build the secondary layer over the untwisted tensor resolution. `resolution` resolves the + /// base field `k`; `module` is `M`. Construction is cheap — call + /// [`compute_through_stem`](Self::compute_through_stem) to do the work. + pub fn new(resolution: Arc, module: Arc) -> Self { + let q = Arc::new(TensorResolution::new(resolution, module)); + // Q• is non-minimal, so its composite ∂∂ genuinely hits same-degree generators. + let secondary = Arc::new(SecondaryResolution::new_with_hit_generator( + Arc::clone(&q), + true, + )); + let e2 = ExtAlgebra::without_unit(Arc::clone(&q)) + .with_differential(Arc::new(DualizedDifferential::new(Arc::clone(&q)))); + Self { + resolution: q, + secondary, + e2, + } + } + + fn prime(&self) -> ValidPrime { + self.resolution.prime() + } + + /// The materialised free resolution $Q_\bullet = P_\bullet \otimes M$. + pub fn resolution(&self) -> &Arc> { + &self.resolution + } + + /// The $E_2$ page $\Ext_A(M, k)$, with cohomology exposed via + /// [`lift`](ExtAlgebra::lift)/[`project`](ExtAlgebra::project). + pub fn ext(&self) -> &ExtAlgebra> { + &self.e2 + } + + /// Compute $Q_\bullet$ and its secondary homotopies far enough to read $d_2$ on the box up to + /// `max`. Resolves $Q_\bullet$ with the margin the Adams $d_2$ needs (one extra stem and two + /// extra filtrations), then extends the secondary resolution. + pub fn compute_through_stem(&self, max: Bidegree) { + // d2 out of (n, s) reads the target (n-1, s+2), and the secondary homotopies at s need the + // resolution two filtrations higher; also grow one extra stem for the incoming coboundary. + let margin = Bidegree::n_s(max.n() + 1, max.s() + 3); + self.resolution.compute_through_bidegree(margin); + self.compute_secondary(); + } + + /// Compute the secondary homotopies on the non-minimal $Q_\bullet$. + /// + /// This mirrors [`SecondaryLift::extend_all`] but drives the final homotopy pass with the + /// wavefront a *non-minimal* resolution needs. Computing $h_s(t)$ applies $h_{s-1}$ to + /// $d(x)$ for each generator $x$ at $(s, t)$. On a minimal resolution $d(x) \in \bar A\cdot + /// P_{s-1}$, so its components sit on generators of degree *strictly* below `t` and + /// [`iter_s_t`](sseq::coordinates::iter_s_t)'s $t' < t$ guarantee is exactly enough. Our + /// $Q_\bullet$ has an identity component in its differential (that non-minimality is the whole + /// point), so $d(x)$ can land on the generator of degree `t` itself and $h_s(t)$ needs + /// $h_{s-1}(t)$ — one column further, which is what + /// [`iter_s_t_inclusive`](sseq::coordinates::iter_s_t_inclusive) provides while keeping the + /// filtrations overlapping in time. + fn compute_secondary(&self) { + let sec = &self.secondary; + sec.initialize_homotopies(); + sec.compute_composites(); + sec.compute_intermediates(); + + // Base case: the homotopies at s = shift.s() (= 2) are zero. + let shift = sec.shift(); + { + let h = &sec.homotopies()[shift.s()]; + h.homotopies.extend_by_zero(h.composites.max_degree()); + } + + let min_t = sec.homotopies()[shift.s()].homotopies.min_degree(); + let s_range = sec.homotopies().range(); + let max = sec.max().restrict(s_range.end); + // `s` starts one above the base case, whose homotopies are the zero map set above — so the + // bottom row of the wavefront already has everything it reads below it. + sseq::coordinates::iter_s_t_inclusive( + &|b| sec.compute_homotopy_step(b), + Bidegree::s_t(s_range.start + 1, min_t), + max, + ); + } + + /// The dimension of $\Ext_A(M, k)$ (the $E_2$ page) at bidegree `b`. + pub fn cohomology_dimension(&self, b: Bidegree) -> Option { + self.e2.cohomology_dimension(b) + } + + /// The Adams differential $d_2(x)$, a class in bidegree `(n - 1, s + 2)`. + /// + /// Returns `None` if the target bidegree is out of the computed range. A computed-but-zero + /// differential is `Some` of a zero class. + pub fn d2(&self, x: &BidegreeElement) -> Option { + let b = x.degree(); + let target = b + Bidegree::n_s(-1, 2); + if !(b.t() > 0 && self.resolution.has_computed_bidegree(target)) { + return None; + } + + // Lift the class to a cocycle representative in Q_•-generator coordinates. + let cocycle = self.e2.lift(x); + + // Cochain-level d2: `m[i]` is the d2 of the i-th Q_•-generator at `b`, as a vector over the + // Q_•-generators at `target`. This is exactly what `SecondaryResolution::e3_page` reads. + let m = self.secondary.homotopy(b.s() + 2).homotopies.hom_k(b.t()); + + let target_dim = self.resolution.number_of_gens_in_bidegree(target); + let mut out = FpVector::new(self.prime(), target_dim); + if !m.is_empty() && !m[0].is_empty() { + let p = self.prime().as_u32(); + for (i, ci) in cocycle.vec().iter_nonzero() { + for (k, &v) in m[i].iter().enumerate() { + out.add_basis_element(k, (ci * v) % p); + } + } + } + + // Project the resulting cochain back to an Ext class (quotient by coboundaries). + Some(self.e2.project(&Cochain::new(target, out))) + } + + /// Whether `x` is a $d_2$-cycle (survives to $E_3$). `None` if $d_2$ is out of range. + pub fn survives(&self, x: &BidegreeElement) -> Option { + self.d2(x).map(|d| d.vec().is_zero()) + } +} + +#[cfg(test)] +mod tests { + use algebra::{ + SteenrodAlgebra, + module::{FDModule, Module}, + }; + use fp::prime::TWO; + use sseq::coordinates::Bidegree; + + use super::*; + use crate::{ + chain_complex::{ChainComplex, FreeChainComplex}, + utils::construct_standard, + }; + + /// A helper: the antipode of `S_2`'s algebra, with the algebra basis computed through `max`. + fn sphere_antipode(max: i32) -> (Arc, Antipode) { + let res = construct_standard::("S_2", None).unwrap(); + let algebra = res.algebra(); + algebra.compute_basis(max + 1); + let antipode = Antipode::new(Arc::clone(&algebra)); + (algebra, antipode) + } + + #[test] + fn antipode_low_degree_and_involution() { + // Basis-agnostic checks (the default basis is Milnor). The unique degree-1 generator is + // primitive, so χ fixes it; and the Steenrod algebra is cocommutative, so χ² = id. + let (algebra, chi) = sphere_antipode(10); + + assert_eq!(algebra.dimension(1), 1); + let mut deg1 = FpVector::new(TWO, 1); + deg1.add_basis_element(0, 1); + assert_eq!( + chi.apply(1, 0), + deg1, + "χ fixes the primitive degree-1 generator" + ); + + for degree in 0..=10 { + for idx in 0..algebra.dimension(degree) { + let chi_x = chi.apply(degree, idx); + let chi_chi_x = chi.apply_element(degree, chi_x); + let mut x = FpVector::new(TWO, algebra.dimension(degree)); + x.add_basis_element(idx, 1); + assert_eq!(chi_chi_x, x, "χ² = id at degree {degree}, idx {idx}"); + } + } + } + + #[test] + fn antipode_hopf_identity() { + // Σ χ(x_(1)) x_(2) = ε(x)·1 for every basis element x through some degree. + let (algebra, chi) = sphere_antipode(10); + let coproduct = FullCoproduct::new(Arc::clone(&algebra)); + for degree in 1..=10 { + for idx in 0..algebra.dimension(degree) { + let mut acc = FpVector::new(TWO, algebra.dimension(degree)); + // Use the same decompose/coproduct route as the antipode for the full coproduct. + for &(l_deg, l_idx, r_deg, r_idx) in coproduct.terms(degree, idx).iter() { + let chi_left = chi.apply(l_deg, l_idx); + for (j, coeff) in chi_left.iter_nonzero() { + algebra.multiply_basis_elements( + acc.as_slice_mut(), + coeff, + l_deg, + j, + r_deg, + r_idx, + ); + } + } + assert!( + acc.is_zero(), + "Hopf identity failed at degree {degree}, idx {idx}: {acc:?}" + ); + } + } + } + + /// The untwisted tensor resolution reproduces the direct minimal resolution of a finite module. + fn assert_matches_direct(module_name: &str, sphere: &str, nn: i32, ss: i32) { + let t_max = nn + ss; + + // k-resolution and M over the same algebra. + let k_res = Arc::new(construct_standard::(sphere, None).unwrap()); + k_res.compute_through_bidegree(Bidegree::s_t(ss + 1, t_max)); + k_res.algebra().compute_basis(t_max + 1); + + let m_json = crate::utils::parse_module_name(module_name).unwrap(); + let module = Arc::new( + FDModule::from_json(k_res.algebra(), &m_json).expect("finite module for cross-check"), + ); + module.compute_basis(t_max); + + let alg = tensor_resolution_ext(Arc::clone(&k_res), module); + + // Direct minimal resolution of M (its generator counts are Ext(M,k)). + let direct = Arc::new(construct_standard::(module_name, None).unwrap()); + direct.compute_through_stem(Bidegree::n_s(nn, ss)); + + for n in 0..=nn { + for s in 0..=ss { + let b = Bidegree::n_s(n, s); + assert_eq!( + alg.cohomology_dimension(b), + Some(direct.number_of_gens_in_bidegree(b)), + "Ext({module_name}, k) mismatch at {b:?}", + ); + } + } + } + + #[test] + fn tensor_resolution_matches_direct_c2() { + assert_matches_direct("C2", "S_2", 10, 6); + } + + /// The closed-form `δ_Q` matrices persist to disk via [`crate::save`] and reload + /// byte-faithfully: a fresh engine pointed at the populated directory (empty in-memory cache) + /// reproduces the same $\Ext(C2, k)$ as the direct minimal resolution, exercising the disk read + /// path. + #[test] + fn tensor_delta_matrices_persist_to_disk() { + use crate::save::SaveDirectory; + + let (nn, ss) = (10, 6); + let t_max = nn + ss; + let k_res = Arc::new(construct_standard::("S_2", None).unwrap()); + k_res.compute_through_bidegree(Bidegree::s_t(ss + 1, t_max)); + k_res.algebra().compute_basis(t_max + 1); + + let m_json = crate::utils::parse_module_name("C2").unwrap(); + let make_module = || { + let m = Arc::new(FDModule::from_json(k_res.algebra(), &m_json).unwrap()); + m.compute_basis(t_max); + m + }; + + // Ground truth: the direct minimal resolution of C2. + let direct = Arc::new(construct_standard::("C2", None).unwrap()); + direct.compute_through_stem(Bidegree::n_s(nn, ss)); + + let dir = tempfile::tempdir().unwrap(); + let save_dir = SaveDirectory::from(Some(dir.path().to_owned())); + + let check_against_direct = |alg: &ExtAlgebra<_>| { + for n in 0..=nn { + for s in 0..=ss { + let b = Bidegree::n_s(n, s); + assert_eq!( + alg.cohomology_dimension(b), + Some(direct.number_of_gens_in_bidegree(b)), + "Ext(C2, k) mismatch at {b:?}", + ); + } + } + }; + + // Pass 1: writing enabled — computing the cohomology populates the δ_Q save directory. + let alg = tensor_resolution_ext_with_save_dir( + Arc::clone(&k_res), + make_module(), + save_dir.clone(), + ); + check_against_direct(&alg); + + // δ_Q matrices were actually written to disk. + let subdir = dir.path().join("tensor_differentials"); + let written = std::fs::read_dir(&subdir).map_or(0, Iterator::count); + assert!(written > 0, "expected persisted δ_Q matrices in {subdir:?}"); + + // Pass 2: a fresh engine with an empty in-memory cache reads the matrices back from disk + // and must reproduce the same Ext (the read path asserts each file's shape as an integrity + // guard). + let reloaded = + tensor_resolution_ext_with_save_dir(Arc::clone(&k_res), make_module(), save_dir); + check_against_direct(&reloaded); + } + + /// A save directory populated for one module must not be silently reused for another. The + /// [`SaveFile`] header records only the algebra and bidegree, so the module fingerprint stored + /// in each file is the only thing standing between a reused directory and a wrong `Ext`. + #[test] + #[should_panic(expected = "written for a different module")] + fn tensor_delta_save_dir_rejects_a_different_module() { + use crate::save::SaveDirectory; + + let (nn, ss) = (8, 4); + let t_max = nn + ss; + let k_res = Arc::new(construct_standard::("S_2", None).unwrap()); + k_res.compute_through_bidegree(Bidegree::s_t(ss + 1, t_max)); + k_res.algebra().compute_basis(t_max + 1); + + let dir = tempfile::tempdir().unwrap(); + let save_dir = SaveDirectory::from(Some(dir.path().to_owned())); + + // Populate the directory with C2's δ_Q matrices. + let c2 = tensor_resolution_ext_with_save_dir( + Arc::clone(&k_res), + finite_module(&k_res.algebra(), "C2", t_max), + save_dir.clone(), + ); + for n in 0..=nn { + for s in 0..=ss { + let _ = c2.cohomology_dimension(Bidegree::n_s(n, s)); + } + } + + // Point a different module at the same directory: the fingerprint must reject it. + let ceta = tensor_resolution_ext_with_save_dir( + Arc::clone(&k_res), + finite_module(&k_res.algebra(), "Ceta", t_max), + save_dir, + ); + for n in 0..=nn { + for s in 0..=ss { + let _ = ceta.cohomology_dimension(Bidegree::n_s(n, s)); + } + } + } + + #[test] + fn cohomology_basis_and_lift_project() { + use sseq::coordinates::BidegreeGenerator; + + // On the untwisted tensor resolution (non-minimal) `Ext*` exposes the cohomology: + // `dimension` is the cohomology dim (≤ the cochain-generator count, strictly less where the + // coboundary bites), and `project ∘ lift` is the identity on cohomology classes. + let (nn, ss) = (12, 7); + let t_max = nn + ss; + let k_res = Arc::new(construct_standard::("S_2", None).unwrap()); + k_res.compute_through_bidegree(Bidegree::s_t(ss + 1, t_max)); + k_res.algebra().compute_basis(t_max + 1); + let m = Arc::new( + FDModule::from_json( + k_res.algebra(), + &crate::utils::parse_module_name("C2").unwrap(), + ) + .unwrap(), + ); + m.compute_basis(t_max); + let alg = tensor_resolution_ext(Arc::clone(&k_res), m); + + let mut saw_nonminimal = false; + for n in 0..=nn { + for s in 0..=ss { + let b = Bidegree::n_s(n, s); + let dim = alg.dimension(b); + assert_eq!(Some(dim), alg.cohomology_dimension(b)); + let cochain = alg.cochain_dimension(b); + assert!( + cochain >= dim, + "cochain {cochain} < cohomology {dim} at {b:?}" + ); + saw_nonminimal |= cochain > dim; + + for i in 0..dim { + let class = alg.generator(BidegreeGenerator::new(b, i)); + assert_eq!( + class, + alg.project(&alg.lift(&class)), + "project∘lift {b:?}#{i}" + ); + } + } + } + assert!( + saw_nonminimal, + "expected a non-minimal bidegree where cochain dim > cohomology dim" + ); + } + + #[test] + fn tensor_resolution_matches_direct_rp_inf() { + // The real payoff: `RP^∞` is *infinite*, so Nassau's engine cannot resolve it — but the + // non-nassau standard resolver works degreewise and *can*. Cross-check the whole Ext chart + // of the untwisted tensor resolution against that direct resolution of RP^∞. + use algebra::module::RealProjectiveSpace; + + let (nn, ss) = (18, 8); + let t_max = nn + ss; + + let k_res = Arc::new(construct_standard::("S_2", None).unwrap()); + k_res.compute_through_bidegree(Bidegree::s_t(ss + 1, t_max)); + k_res.algebra().compute_basis(t_max + 1); + let rp = Arc::new(RealProjectiveSpace::new(k_res.algebra(), 1, None, false)); + rp.compute_basis(t_max); + let alg = tensor_resolution_ext(Arc::clone(&k_res), rp); + + // Direct resolution of the infinite module RP^∞ (non-nassau; resolves degreewise). + let direct = Arc::new(construct_standard::("RP_inf", None).unwrap()); + direct.compute_through_stem(Bidegree::n_s(nn, ss)); + + for n in 0..=nn { + for s in 0..=ss { + let b = Bidegree::n_s(n, s); + assert_eq!( + alg.cohomology_dimension(b), + Some(direct.number_of_gens_in_bidegree(b)), + "Ext(RP^∞, k) mismatch at {b:?}", + ); + } + } + + // Sanity: Ext^0 = A-module indecomposables of RP^∞ = F_2[x], which sit in degrees 2^k − 1 + // (since Sq^1 x = x^2 makes even powers decomposable). + for n in 1..=nn { + let expected = usize::from(((n + 1) as u32).is_power_of_two()); + assert_eq!( + alg.cohomology_dimension(Bidegree::n_s(n, 0)), + Some(expected), + "Ext^0(RP^∞, k) at n = {n}", + ); + } + } + + #[test] + fn closed_form_dimension() { + // dim C^s_t = Σ_i dim M_{t - d_i}, straight from generator degrees and M's dimensions. + let (nn, ss) = (10, 6); + let t_max = nn + ss; + let k_res = Arc::new(construct_standard::("S_2", None).unwrap()); + k_res.compute_through_bidegree(Bidegree::s_t(ss + 1, t_max)); + k_res.algebra().compute_basis(t_max + 1); + let m_json = crate::utils::parse_module_name("C2").unwrap(); + let module = Arc::new(FDModule::from_json(k_res.algebra(), &m_json).unwrap()); + module.compute_basis(t_max); + + let diff = TensorResolutionDifferential::new(Arc::clone(&k_res), Arc::clone(&module)); + + for n in 0..=nn { + for s in 0..=ss { + let b = Bidegree::n_s(n, s); + let t = b.t(); + let expected: usize = k_res + .module(s) + .iter_gens(t) + .map(|(d_i, _)| module.dimension(t - d_i)) + .sum(); + assert_eq!(diff.dimension(b), Some(expected), "dim C^{s}_{t} at {b:?}"); + } + } + } + + pub(crate) fn finite_module_pub( + algebra: &Arc, + name: &str, + t_max: i32, + ) -> Arc> { + finite_module(algebra, name, t_max) + } + + /// Build the trivial module for `name` over the sphere algebra, basis computed through `t_max`. + fn finite_module( + algebra: &Arc, + name: &str, + t_max: i32, + ) -> Arc> { + let m = Arc::new( + FDModule::from_json( + Arc::clone(algebra), + &crate::utils::parse_module_name(name).unwrap(), + ) + .unwrap(), + ); + m.compute_basis(t_max); + m + } + + #[test] + fn tensor_resolution_is_a_complex() { + // ∂ ∘ ∂ = 0 on every generator of Q_• = P_• ⊗ C2 over a box — the free differential is + // conjugate to ∂_P ⊗ id, so it must square to zero. + use algebra::module::homomorphism::ModuleHomomorphism; + + let (ss, t_max) = (6, 16); + let k_res = Arc::new(construct_standard::("S_2", None).unwrap()); + let m = finite_module(&k_res.algebra(), "C2", t_max); + let q = TensorResolution::new(Arc::clone(&k_res), m); + q.compute_through_bidegree(Bidegree::s_t(ss, t_max)); + + for s in 2..=ss { + let d_s = q.differential(s); + let d_prev = q.differential(s - 1); + let q_s = q.module(s); + let q_prev2 = q.module(s - 2); + for t in 0..=t_max { + for i in 0..q_s.number_of_gens_in_degree(t) { + let dx = d_s.output(t, i); // ∂(gen) ∈ Q_{s-1} at t + let mut ddx = FpVector::new(TWO, q_prev2.dimension(t)); + d_prev.apply(ddx.as_slice_mut(), 1, t, dx.as_slice()); + assert!(ddx.is_zero(), "∂² ≠ 0 at s = {s}, t = {t}, gen {i}"); + } + } + } + } + + #[test] + fn q_complex_computes_ext_c2() { + // Gate A: the genuine Q_• complex (via its dualised differential) computes the same + // additive Ext(C2, k) as the closed form and the direct minimal resolution — and is + // genuinely non-minimal (some bidegree has more cochains than cohomology). + let (nn, ss) = (10, 6); + let t_max = nn + ss; + let k_res = Arc::new(construct_standard::("S_2", None).unwrap()); + k_res.compute_through_bidegree(Bidegree::s_t(ss + 1, t_max)); + k_res.algebra().compute_basis(t_max + 1); + let m = finite_module(&k_res.algebra(), "C2", t_max); + + let q = Arc::new(TensorResolution::new(Arc::clone(&k_res), Arc::clone(&m))); + q.compute_through_bidegree(Bidegree::s_t(ss + 1, t_max)); + let e2 = ExtAlgebra::without_unit(Arc::clone(&q)) + .with_differential(Arc::new(DualizedDifferential::new(Arc::clone(&q)))); + + let closed = tensor_resolution_ext(Arc::clone(&k_res), Arc::clone(&m)); + let direct = Arc::new(construct_standard::("C2", None).unwrap()); + direct.compute_through_stem(Bidegree::n_s(nn, ss)); + + let mut saw_nonminimal = false; + for n in 0..=nn { + for s in 0..=ss { + let b = Bidegree::n_s(n, s); + let d = direct.number_of_gens_in_bidegree(b); + assert_eq!( + e2.cohomology_dimension(b), + Some(d), + "Q• Ext vs direct at {b:?}" + ); + assert_eq!( + e2.cohomology_dimension(b), + closed.cohomology_dimension(b), + "Q• Ext vs closed form at {b:?}" + ); + saw_nonminimal |= e2.cochain_dimension(b) > d; + } + } + assert!( + saw_nonminimal, + "expected a non-minimal bidegree (cochains > cohomology)" + ); + } + + #[test] + fn tensor_d2_reproduces_sphere() { + // Gate B sanity: with M = k the tensor complex Q_• collapses to the minimal P_• (the + // trivial module makes χ(a) act as ε(a)), so the untwisted tensor resolution d2 must + // reproduce the standard Adams d2: d2(h4) = h0 h3² at (14, 3), with h0, h1, h2 permanent. + use sseq::coordinates::BidegreeGenerator; + + let k_res = Arc::new(construct_standard::("S_2", None).unwrap()); + let m = finite_module(&k_res.algebra(), "S_2", 24); + let sec = TensorResolutionSecondary::new(k_res, m); + sec.compute_through_stem(Bidegree::n_s(16, 4)); + + for (n, s) in [(0, 1), (1, 1), (3, 1)] { + let b = Bidegree::n_s(n, s); + let h = sec.ext().generator(BidegreeGenerator::new(b, 0)); + assert_eq!( + sec.survives(&h), + Some(true), + "h at (n = {n}, s = {s}) should survive d2" + ); + } + + let h4 = sec + .ext() + .generator(BidegreeGenerator::new(Bidegree::n_s(15, 1), 0)); + let d = sec.d2(&h4).expect("d2(h4) target should be computed"); + assert_eq!(d.degree(), Bidegree::n_s(14, 3)); + assert_eq!(sec.cohomology_dimension(Bidegree::n_s(14, 3)), Some(1)); + assert!(!d.vec().is_zero(), "d2(h4) = h0 h3² should be nonzero"); + assert_eq!(sec.survives(&h4), Some(false), "h4 should not survive d2"); + } + + #[test] + fn tensor_d2_matches_direct_c2() { + // Gate B: on the genuinely non-minimal Q_• = P_• ⊗ C2, the untwisted tensor resolution d2 + // agrees with the direct minimal resolution's secondary d2. Ranks of the outgoing d2 (a + // basis-independent invariant of the Adams differential) match at every bidegree, and the + // E2 dimensions agree. + use fp::matrix::Matrix; + use sseq::coordinates::BidegreeGenerator; + + use crate::ext_algebra::SecondaryExtAlgebra; + + let (nn, ss) = (12, 6); + + let k_res = Arc::new(construct_standard::("S_2", None).unwrap()); + let m = finite_module(&k_res.algebra(), "C2", nn + ss + 8); + let tensor = TensorResolutionSecondary::new(k_res, m); + tensor.compute_through_stem(Bidegree::n_s(nn, ss)); + + let direct_res = Arc::new(construct_standard::("C2", None).unwrap()); + direct_res.compute_through_stem(Bidegree::n_s(nn + 1, ss + 3)); + let direct_e2 = Arc::new(ExtAlgebra::new( + Arc::clone(&direct_res), + Arc::clone(&direct_res), + )); + let direct_sec = SecondaryExtAlgebra::new(Arc::clone(&direct_e2)); + direct_sec.extend_all(); + + let rank_of = + |dim: usize, target_dim: usize, d2_of: &mut dyn FnMut(usize) -> FpVector| -> usize { + if dim == 0 || target_dim == 0 { + return 0; + } + let rows: Vec = (0..dim).map(&mut *d2_of).collect(); + Matrix::from_rows(TWO, rows, target_dim).row_reduce() + }; + + let mut compared = 0; + for n in 1..=nn { + for s in 1..=ss { + let b = Bidegree::n_s(n, s); + let target = b + Bidegree::n_s(-1, 2); + let (Some(fd), Some(ftd)) = ( + tensor.cohomology_dimension(b), + tensor.cohomology_dimension(target), + ) else { + continue; + }; + assert_eq!(fd, direct_e2.dimension(b), "E2 dim mismatch at {b:?}"); + assert_eq!( + ftd, + direct_e2.dimension(target), + "E2 dim mismatch at target {target:?}" + ); + + let tensor_rank = rank_of(fd, ftd, &mut |i| { + tensor + .d2(&tensor.ext().generator(BidegreeGenerator::new(b, i))) + .map(BidegreeElement::into_vec) + .unwrap_or_else(|| FpVector::new(TWO, ftd)) + }); + let direct_rank = rank_of(fd, ftd, &mut |i| { + direct_sec + .d2(&direct_e2.generator(BidegreeGenerator::new(b, i))) + .map(BidegreeElement::into_vec) + .unwrap_or_else(|| FpVector::new(TWO, ftd)) + }); + assert_eq!(tensor_rank, direct_rank, "d2 rank mismatch at {b:?}"); + compared += 1; + } + } + assert!(compared > 0, "no bidegrees compared"); + } + + #[test] + fn tensor_products_match_direct_c2() { + // The untwisted tensor resolution $\Ext(k, k)$-module products on $\Ext(C2, k)$ agree with + // the direct minimal resolution's. The two Ext bases differ by a change of basis, so we + // compare the basis-independent **rank** of multiplication by `h0` at every bidegree (plus + // the Ext dimensions). Sharing the *same* minimal `S_2` unit removes any ambiguity on the + // operand side. Products here go through the closed-form cup product on the cochain DGA + // plus the lift/project transport — no chain map over `Q•` is ever built. + use sseq::coordinates::BidegreeGenerator; + + use super::tensor_resolution_products; + + let (nn, ss) = (8, 5); + let t_max = nn + ss + 6; + + let s2 = Arc::new(construct_standard::("S_2", None).unwrap()); + s2.compute_through_bidegree(Bidegree::s_t(ss + 3, t_max)); + s2.algebra().compute_basis(t_max + 1); + let m = finite_module(&s2.algebra(), "C2", t_max); + + // Tensor side: products via the untwisted tensor resolution; `unit` is the shared minimal + // `S_2`. + let tensor = tensor_resolution_products(Arc::clone(&s2), Arc::clone(&m)); + tensor.compute_through_bidegree(Bidegree::s_t(ss + 3, t_max)); + + // Direct side: minimal resolution of C2, sharing the same minimal `S_2` unit. + let c2 = Arc::new(construct_standard::("C2", None).unwrap()); + c2.compute_through_stem(Bidegree::n_s(nn + 1, ss + 3)); + let direct = ExtAlgebra::new(Arc::clone(&c2), Arc::clone(&s2)); + + let h0 = BidegreeGenerator::new(Bidegree::n_s(0, 1), 0); + let tensor_h0 = tensor.unit_generator(h0); + let direct_h0 = direct.unit_generator(h0); + + let rank_of = + |dim: usize, tdim: usize, f: &mut dyn FnMut(usize) -> Option| -> usize { + if dim == 0 || tdim == 0 { + return 0; + } + let rows: Vec = (0..dim) + .map(|i| f(i).unwrap_or_else(|| FpVector::new(TWO, tdim))) + .collect(); + Matrix::from_rows(TWO, rows, tdim).row_reduce() + }; + + let mut compared = 0; + let mut saw_nonzero = false; + for n in 0..=nn { + for s in 0..=ss { + let b = Bidegree::n_s(n, s); + let target = b + h0.degree(); + let (Some(fd), Some(ftd)) = ( + tensor.cohomology_dimension(b), + tensor.cohomology_dimension(target), + ) else { + continue; + }; + assert_eq!(fd, direct.dimension(b), "Ext dim mismatch at {b:?}"); + assert_eq!( + ftd, + direct.dimension(target), + "Ext dim mismatch at target {target:?}" + ); + + let tensor_rank = rank_of(fd, ftd, &mut |i| { + tensor + .try_multiply(&tensor.generator(BidegreeGenerator::new(b, i)), &tensor_h0) + .map(BidegreeElement::into_vec) + }); + let direct_rank = rank_of(fd, ftd, &mut |i| { + direct + .try_multiply(&direct.generator(BidegreeGenerator::new(b, i)), &direct_h0) + .map(BidegreeElement::into_vec) + }); + assert_eq!(tensor_rank, direct_rank, "(·h0) rank mismatch at {b:?}"); + saw_nonzero |= tensor_rank > 0; + compared += 1; + } + } + assert!(compared > 0, "no bidegrees compared"); + assert!(saw_nonzero, "expected nonzero h0-multiplication somewhere"); + } + + #[test] + fn tensor_products_match_direct_rp_inf() { + // Products on an **infinite** module — the case the construction exists for, and the one + // the `C2` product tests cannot reach. `FDModule::act_on_basis` handles the identity + // operation, but `RealProjectiveSpace::act_on_basis` returns early on `op_degree == 0`, so + // any closed form that reaches the identity component through `act_on_basis` silently drops + // it here while staying correct on `C2`. Ground truth is the direct (non-nassau) minimal + // resolution of `RP^∞`, sharing the same minimal `S_2` unit. + use algebra::module::RealProjectiveSpace; + use sseq::coordinates::BidegreeGenerator; + + use super::tensor_resolution_products; + + let (nn, ss) = (8, 4); + let t_max = nn + ss + 6; + + let s2 = Arc::new(construct_standard::("S_2", None).unwrap()); + s2.compute_through_bidegree(Bidegree::s_t(ss + 3, t_max)); + s2.algebra().compute_basis(t_max + 1); + let rp = Arc::new(RealProjectiveSpace::new(s2.algebra(), 1, None, false)); + rp.compute_basis(t_max); + + let tensor = tensor_resolution_products(Arc::clone(&s2), Arc::clone(&rp)); + tensor.compute_through_bidegree(Bidegree::s_t(ss + 3, t_max)); + + let rp_direct = Arc::new(construct_standard::("RP_inf", None).unwrap()); + rp_direct.compute_through_stem(Bidegree::n_s(nn + 1, ss + 3)); + let direct = ExtAlgebra::new(Arc::clone(&rp_direct), Arc::clone(&s2)); + + let h0 = BidegreeGenerator::new(Bidegree::n_s(0, 1), 0); + let tensor_h0 = tensor.unit_generator(h0); + let direct_h0 = direct.unit_generator(h0); + + let rank_of = + |dim: usize, tdim: usize, f: &mut dyn FnMut(usize) -> Option| -> usize { + if dim == 0 || tdim == 0 { + return 0; + } + let rows: Vec = (0..dim) + .map(|i| f(i).unwrap_or_else(|| FpVector::new(TWO, tdim))) + .collect(); + Matrix::from_rows(TWO, rows, tdim).row_reduce() + }; + + let mut saw_nonzero = false; + for n in 0..=nn { + for s in 0..=ss { + let b = Bidegree::n_s(n, s); + let target = b + h0.degree(); + let (Some(fd), Some(ftd)) = ( + tensor.cohomology_dimension(b), + tensor.cohomology_dimension(target), + ) else { + continue; + }; + assert_eq!(fd, direct.dimension(b), "Ext dim mismatch at {b:?}"); + assert_eq!( + ftd, + direct.dimension(target), + "Ext dim mismatch at target {target:?}" + ); + + let tensor_rank = rank_of(fd, ftd, &mut |i| { + tensor + .try_multiply(&tensor.generator(BidegreeGenerator::new(b, i)), &tensor_h0) + .map(BidegreeElement::into_vec) + }); + let direct_rank = rank_of(fd, ftd, &mut |i| { + direct + .try_multiply(&direct.generator(BidegreeGenerator::new(b, i)), &direct_h0) + .map(BidegreeElement::into_vec) + }); + assert_eq!(tensor_rank, direct_rank, "(·h0) rank mismatch at {b:?}"); + saw_nonzero |= tensor_rank > 0; + } + } + // `Ext(RP^∞, k)` has an infinite h0-tower off every `Ext^0` class, so a build that drops + // the identity component of the cup product would leave every rank at zero. + assert!(saw_nonzero, "expected nonzero h0-multiplication somewhere"); + } + + #[test] + fn tensor_products_match_direct_c2_nassau() { + // The untwisted tensor resolution over a **Nassau / Milnor** sphere `P•` (never exercised + // before — every other untwisted tensor resolution test uses the standard `SteenrodAlgebra` + // resolver). This is the exact `P•` a large-scale run would load from disk, so it validates + // the antipode/coproduct and cup path over Milnor's basis. Ext dimensions and the rank of + // `(·h0)` are presentation-independent, so we cross-check them against the direct minimal + // resolution of C2 (over `SteenrodAlgebra`). + use sseq::coordinates::BidegreeGenerator; + + use super::tensor_resolution_products; + + let (nn, ss) = (8, 5); + let t_max = nn + ss + 6; + + // Tensor side: Milnor sphere via Nassau's algorithm, tensored with C2 over the same + // algebra. Nassau keeps its quasi-inverse on disk, not in memory, so the product/cup maps + // (which lift through the unit via `apply_quasi_inverse`) require a populated save + // directory — precisely the "save the sphere, then compute from the saved res" workflow. + let s2_dir = tempfile::tempdir().unwrap(); + let s2 = Arc::new( + crate::utils::construct_nassau("S_2", Some(s2_dir.path().to_owned())).unwrap(), + ); + s2.compute_through_bidegree(Bidegree::s_t(ss + 3, t_max)); + s2.algebra().compute_basis(t_max + 1); + let m = Arc::new( + FDModule::from_json( + s2.algebra(), + &crate::utils::parse_module_name("C2").unwrap(), + ) + .unwrap(), + ); + m.compute_basis(t_max); + let tensor = tensor_resolution_products(Arc::clone(&s2), Arc::clone(&m)); + tensor.compute_through_bidegree(Bidegree::s_t(ss + 3, t_max)); + + // Direct side: minimal resolution of C2 (over `SteenrodAlgebra`) as ground truth. + let c2 = Arc::new(construct_standard::("C2", None).unwrap()); + c2.compute_through_stem(Bidegree::n_s(nn + 1, ss + 3)); + let s2_std = Arc::new(construct_standard::("S_2", None).unwrap()); + s2_std.compute_through_bidegree(Bidegree::s_t(ss + 3, t_max)); + let direct = ExtAlgebra::new(Arc::clone(&c2), s2_std); + + let h0 = BidegreeGenerator::new(Bidegree::n_s(0, 1), 0); + let tensor_h0 = tensor.unit_generator(h0); + let direct_h0 = direct.unit_generator(h0); + + let rank_of = + |dim: usize, tdim: usize, f: &mut dyn FnMut(usize) -> Option| -> usize { + if dim == 0 || tdim == 0 { + return 0; + } + let rows: Vec = (0..dim) + .map(|i| f(i).unwrap_or_else(|| FpVector::new(TWO, tdim))) + .collect(); + Matrix::from_rows(TWO, rows, tdim).row_reduce() + }; + + let mut compared = 0; + let mut saw_nonzero = false; + for n in 0..=nn { + for s in 0..=ss { + let b = Bidegree::n_s(n, s); + let target = b + h0.degree(); + let (Some(fd), Some(ftd)) = ( + tensor.cohomology_dimension(b), + tensor.cohomology_dimension(target), + ) else { + continue; + }; + assert_eq!(fd, direct.dimension(b), "Ext dim mismatch at {b:?}"); + assert_eq!( + ftd, + direct.dimension(target), + "Ext dim mismatch at {target:?}" + ); + + let tensor_rank = rank_of(fd, ftd, &mut |i| { + tensor + .try_multiply(&tensor.generator(BidegreeGenerator::new(b, i)), &tensor_h0) + .map(BidegreeElement::into_vec) + }); + let direct_rank = rank_of(fd, ftd, &mut |i| { + direct + .try_multiply(&direct.generator(BidegreeGenerator::new(b, i)), &direct_h0) + .map(BidegreeElement::into_vec) + }); + assert_eq!(tensor_rank, direct_rank, "(·h0) rank mismatch at {b:?}"); + saw_nonzero |= tensor_rank > 0; + compared += 1; + } + } + assert!(compared > 0, "no bidegrees compared"); + assert!(saw_nonzero, "expected nonzero h0-multiplication somewhere"); + } + + #[test] + fn tensor_massey_dga_family_matches_direct_c2() { + // The cochain-DGA untwisted tensor resolution Massey family ⟨h0, h1, -⟩ on Ext(C2, k) + // agrees with the direct minimal resolution across the whole computed range: same set of + // nonzero brackets, keyed by bracket bidegree with matching indeterminacy dimensions. This + // is the family the chain-map / null-homotopy path could not compute on a non-minimal + // resolution. + use sseq::coordinates::BidegreeGenerator; + + use crate::ext_algebra::massey::MasseyResult; + + let (nn, ss) = (8, 5); + let s2 = Arc::new(construct_standard::("S_2", None).unwrap()); + let m = finite_module(&s2.algebra(), "C2", nn + ss + 8); + let tensor = tensor_resolution_products(Arc::clone(&s2), m); + tensor.compute_through_bidegree(Bidegree::n_s(nn + 2, ss + 3)); + + let c2 = Arc::new(construct_standard::("C2", None).unwrap()); + c2.compute_through_stem(Bidegree::n_s(nn + 1, ss + 3)); + let direct = ExtAlgebra::new(Arc::clone(&c2), Arc::clone(&s2)); + + let h0 = tensor.unit_generator(BidegreeGenerator::new(Bidegree::n_s(0, 1), 0)); + let h1 = tensor.unit_generator(BidegreeGenerator::new(Bidegree::n_s(1, 1), 0)); + let d_h0 = direct.unit_generator(BidegreeGenerator::new(Bidegree::n_s(0, 1), 0)); + let d_h1 = direct.unit_generator(BidegreeGenerator::new(Bidegree::n_s(1, 1), 0)); + + // Key a family by bracket bidegree → sorted (bracket degree, indeterminacy dim). Restrict + // to the range both resolutions cover. + let summarize = |family: Vec<(BidegreeElement, MasseyResult)>| -> Vec<(String, usize)> { + let mut keyed: Vec<(String, usize)> = family + .into_iter() + .filter(|(_, r)| r.degree.n() <= nn && r.degree.s() <= ss) + .map(|(_, r)| (format!("{:?}", r.degree), r.coset.linear_part().dimension())) + .collect(); + keyed.sort(); + keyed + }; + + // The SAME `massey_iter_c` as on a minimal resolution — the attached cup makes it dispatch + // to the cochain-DGA bracket. + let tensor_fam = summarize(tensor.massey_iter_c(&h0, &h1)); + let direct_fam = summarize(direct.massey_iter_c(&d_h0, &d_h1)); + assert!(!tensor_fam.is_empty(), "expected some nonzero brackets"); + assert_eq!( + tensor_fam, direct_fam, + "⟨h0, h1, -⟩ over C2 disagrees tensor (DGA) vs direct" + ); + } + + #[test] + fn tensor_massey_dga_cup_matches_products() { + // The closed-form cup product `h0 ∪ v` (the DGA's module action) reproduces the chain-map + // product on Ext(C2, k): for every class y, project(h0 ∪ lift(y)) == y · h0. This isolates + // the cup engine from the δ-preimage half of the bracket. + use sseq::coordinates::BidegreeGenerator; + + use crate::resolution_homomorphism::ResolutionHomomorphism; + + let (nn, ss) = (8, 5); + let s2 = Arc::new(construct_standard::("S_2", None).unwrap()); + let m = finite_module(&s2.algebra(), "C2", nn + ss + 8); + let tensor = tensor_resolution_products(Arc::clone(&s2), m); + tensor.compute_through_bidegree(Bidegree::n_s(nn + 2, ss + 3)); + let h0 = tensor.unit_generator(BidegreeGenerator::new(Bidegree::n_s(0, 1), 0)); + + let cup = tensor + .cup() + .expect("the untwisted tensor resolution attaches a cup product"); + let f_h0 = ResolutionHomomorphism::from_class( + String::new(), + Arc::clone(tensor.unit()), + Arc::clone(tensor.unit()), + h0.degree(), + &[1], + ); + + let mut checked = 0; + for n in 0..=nn { + for s in 0..=ss { + let b = Bidegree::n_s(n, s); + let target = b + h0.degree(); + if tensor.cohomology(b).is_none() || tensor.cohomology(target).is_none() { + continue; + } + f_h0.extend_through_stem(target); + let m_cup = cup.cup_matrix(&f_h0, b.s(), b.t(), h0.degree()); + for i in 0..tensor.dimension(b) { + let y = tensor.generator(BidegreeGenerator::new(b, i)); + let mut out = FpVector::new(TWO, m_cup.columns()); + m_cup.apply(out.as_slice_mut(), 1, tensor.lift(&y).vec()); + let via_cup = tensor.project(&super::Cochain::new(target, out)); + let via_mult = tensor.multiply(&y, &h0); + assert_eq!(via_cup, via_mult, "h0 ∪ y != y · h0 at {b:?}#{i}"); + checked += 1; + } + } + } + assert!(checked > 0, "no cup products checked"); + } + + #[test] + fn tensor_massey_dga_matches_direct_c2() { + // The unified `massey` on the untwisted tensor resolution agrees with the direct minimal + // resolution on the classic ⟨h0, h1, c⟩ — the bracket the chain-map/null-homotopy path + // loses to non-minimality. + use sseq::coordinates::BidegreeGenerator; + + let (nn, ss) = (8, 5); + let s2 = Arc::new(construct_standard::("S_2", None).unwrap()); + let m = finite_module(&s2.algebra(), "C2", nn + ss + 8); + let tensor = tensor_resolution_products(Arc::clone(&s2), m); + tensor.compute_through_bidegree(Bidegree::n_s(nn + 2, ss + 3)); + + let c2 = Arc::new(construct_standard::("C2", None).unwrap()); + c2.compute_through_stem(Bidegree::n_s(nn + 1, ss + 3)); + let direct = ExtAlgebra::new(Arc::clone(&c2), Arc::clone(&s2)); + + let h0 = tensor.unit_generator(BidegreeGenerator::new(Bidegree::n_s(0, 1), 0)); + let h1 = tensor.unit_generator(BidegreeGenerator::new(Bidegree::n_s(1, 1), 0)); + let d_h0 = direct.unit_generator(BidegreeGenerator::new(Bidegree::n_s(0, 1), 0)); + let d_h1 = direct.unit_generator(BidegreeGenerator::new(Bidegree::n_s(1, 1), 0)); + + // The classic bracket ⟨h0, h1, c⟩ at c = (2, 2): the null-homotopy path read 0 here. + let cd = Bidegree::n_s(2, 2); + let c_tensor = tensor.generator(BidegreeGenerator::new(cd, 0)); + let c_direct = direct.generator(BidegreeGenerator::new(cd, 0)); + let tb = tensor + .massey(&h0, &h1, &c_tensor) + .expect("tensor ⟨h0,h1,c⟩ defined"); + let db = direct + .massey(&d_h0, &d_h1, &c_direct) + .expect("direct ⟨h0,h1,c⟩ defined"); + assert_eq!(tb.degree, db.degree, "bracket bidegree"); + assert_eq!( + tb.coset.linear_part().dimension(), + db.coset.linear_part().dimension(), + "indeterminacy dimension" + ); + // (4,3) is 1-dimensional with indet 0, so the representative's vanishing is basis-free. + assert_eq!( + tb.coset.offset().is_zero(), + db.coset.offset().is_zero(), + "⟨h0,h1,c⟩ vanishing disagrees: tensor={:?} direct={:?}", + tb.coset.offset(), + db.coset.offset() + ); + assert!( + !tb.coset.offset().is_zero(), + "tensor ⟨h0,h1,c⟩ should be nonzero (the bracket the null-homotopy path lost)" + ); + } + + #[test] + fn tensor_resolution_is_a_complex_rp_inf() { + // ∂ ∘ ∂ = 0 for the *infinite* module RP^∞, whose nontrivial higher action exercises the + // full antipode-Hopf differential (including the identity-operation term dropped by + // `RealProjectiveSpace`'s `act_on_basis` unless special-cased). + use algebra::module::{RealProjectiveSpace, homomorphism::ModuleHomomorphism}; + + let (ss, t_max) = (7, 20); + let k_res = Arc::new(construct_standard::("S_2", None).unwrap()); + let rp = Arc::new(RealProjectiveSpace::new(k_res.algebra(), 1, None, false)); + rp.compute_basis(t_max); + let q = TensorResolution::new(Arc::clone(&k_res), rp); + q.compute_through_bidegree(Bidegree::s_t(ss, t_max)); + + for s in 2..=ss { + let d_s = q.differential(s); + let d_prev = q.differential(s - 1); + let q_s = q.module(s); + let q_prev2 = q.module(s - 2); + for t in 0..=t_max { + for i in 0..q_s.number_of_gens_in_degree(t) { + let dx = d_s.output(t, i); + let mut ddx = FpVector::new(TWO, q_prev2.dimension(t)); + d_prev.apply(ddx.as_slice_mut(), 1, t, dx.as_slice()); + assert!(ddx.is_zero(), "∂² ≠ 0 at s = {s}, t = {t}, gen {i}"); + } + } + } + } + + #[test] + #[ignore = "blocked by a MATHEMATICAL obstruction, not minimality: H*(RP^∞) admits several \ + secondary (B-module) structures, and the secondary machinery assumes the `zero` \ + one — which does not lift for RP^∞ (see secondary_zero_structure_fails_for_rp_inf; \ + the DIRECT minimal resolution fails identically at (10,3)). The minimality \ + hypothesis itself IS removed (hit_generator flag + the act empty-block fix let the \ + secondary machinery run on the non-minimal Q•, verified for C2). Determining the \ + geometrically correct secondary structure of RP^∞ is separate theoretical work."] + fn tensor_d2_matches_direct_rp_inf() { + // The payoff (blocked, see #[ignore]): the Adams d2 on `Ext(RP^∞, k)` for the *infinite* + // module RP^∞, via the untwisted tensor resolution, should agree with the direct minimal + // resolution's secondary d2. Ranks of the outgoing d2 match at every bidegree, E2 dims + // agree. + use algebra::module::RealProjectiveSpace; + use fp::matrix::Matrix; + use sseq::coordinates::BidegreeGenerator; + + use crate::ext_algebra::SecondaryExtAlgebra; + + let (nn, ss) = (10, 5); + + let k_res = Arc::new(construct_standard::("S_2", None).unwrap()); + let rp = Arc::new(RealProjectiveSpace::new(k_res.algebra(), 1, None, false)); + rp.compute_basis(nn + ss + 8); + let tensor = TensorResolutionSecondary::new(k_res, rp); + tensor.compute_through_stem(Bidegree::n_s(nn, ss)); + + let direct_res = Arc::new(construct_standard::("RP_inf", None).unwrap()); + direct_res.compute_through_stem(Bidegree::n_s(nn + 1, ss + 3)); + let direct_e2 = Arc::new(ExtAlgebra::new( + Arc::clone(&direct_res), + Arc::clone(&direct_res), + )); + let direct_sec = SecondaryExtAlgebra::new(Arc::clone(&direct_e2)); + direct_sec.extend_all(); + + let rank_of = + |dim: usize, target_dim: usize, d2_of: &mut dyn FnMut(usize) -> FpVector| -> usize { + if dim == 0 || target_dim == 0 { + return 0; + } + let rows: Vec = (0..dim).map(&mut *d2_of).collect(); + Matrix::from_rows(TWO, rows, target_dim).row_reduce() + }; + + let mut compared = 0; + for n in 1..=nn { + for s in 1..=ss { + let b = Bidegree::n_s(n, s); + let target = b + Bidegree::n_s(-1, 2); + let (Some(fd), Some(ftd)) = ( + tensor.cohomology_dimension(b), + tensor.cohomology_dimension(target), + ) else { + continue; + }; + assert_eq!(fd, direct_e2.dimension(b), "E2 dim mismatch at {b:?}"); + assert_eq!( + ftd, + direct_e2.dimension(target), + "E2 dim mismatch at target {target:?}" + ); + + let tensor_rank = rank_of(fd, ftd, &mut |i| { + tensor + .d2(&tensor.ext().generator(BidegreeGenerator::new(b, i))) + .map(BidegreeElement::into_vec) + .unwrap_or_else(|| FpVector::new(TWO, ftd)) + }); + let direct_rank = rank_of(fd, ftd, &mut |i| { + direct_sec + .d2(&direct_e2.generator(BidegreeGenerator::new(b, i))) + .map(BidegreeElement::into_vec) + .unwrap_or_else(|| FpVector::new(TWO, ftd)) + }); + assert_eq!(tensor_rank, direct_rank, "d2 rank mismatch at {b:?}"); + compared += 1; + } + } + assert!(compared > 0, "no bidegrees compared"); + } + + #[test] + fn secondary_zero_structure_fails_for_rp_inf() { + // The obstruction blocking infinite-module d2 is MATHEMATICAL, not the minimality + // hypothesis: H*(RP^∞) admits several secondary (B-module) structures, and the machinery + // assumes the `zero` one — which does not lift for RP^∞. This is a property of RP^∞ itself, + // independent of the untwisted tensor resolution: even the DIRECT *minimal* resolution's + // secondary lift fails, at (n, s) = (10, 3). (Contrast the sphere and C2, whose zero + // structure lifts.) + // + // We drive the secondary homotopies exactly as `compute_homotopies` does — but stop before + // the failing step — then compute it via the fallible path and check it reports the lift + // failure rather than silently producing a wrong d2. (Mirrors `secondary::cofib_h4`.) + use crate::secondary::SecondaryLift; + + let res = Arc::new(construct_standard::("RP_inf", None).unwrap()); + res.compute_through_stem(Bidegree::n_s(12, 6)); + let lift = crate::secondary::SecondaryResolution::new(Arc::clone(&res)); + + let failing = Bidegree::n_s(10, 3); + + lift.initialize_homotopies(); + lift.compute_composites(); + lift.compute_intermediates(); + let shift = lift.shift(); + { + let h = &lift.homotopies()[shift.s()]; + h.homotopies.extend_by_zero(h.composites.max_degree()); + } + let min_t = lift.homotopies()[shift.s()].homotopies.min_degree(); + let s_range = lift.homotopies().range(); + let min = Bidegree::s_t(s_range.start + 1, min_t); + let max = lift.max().restrict(s_range.end); + sseq::coordinates::iter_s_t( + &|b| { + if b.s() > failing.s() || (b.s() == failing.s() && b.t() >= failing.t()) { + return b.t()..b.t() + 1; + } + lift.compute_homotopy_step(b) + }, + min, + max, + ); + + let result = lift.try_compute_homotopy_step(failing); + assert!( + result.is_err(), + "expected RP^∞'s zero secondary structure to fail to lift" + ); + assert!( + result.unwrap_err().to_string().contains("Failed to lift"), + "expected a lift failure at {failing}" + ); + } +} + +#[cfg(test)] +mod heavy_tests { + use std::{sync::Arc, time::Instant}; + + use fp::{matrix::Matrix, prime::TWO, vector::FpVector}; + use sseq::coordinates::{Bidegree, BidegreeElement, BidegreeGenerator}; + + use super::{TensorResolutionSecondary, tests::finite_module_pub}; + use crate::{ + chain_complex::ChainComplex, + ext_algebra::{ExtAlgebra, SecondaryExtAlgebra}, + utils::construct_standard, + }; + + /// The untwisted tensor resolution Adams d2 for C2 matches the direct minimal resolution's d2 + /// at *every* bidegree up to **stem 60** (verified: 1080 bidegrees, 21 with nonzero d2, 0 + /// mismatches). `#[ignore]`d because the non-minimal Q• secondary is expensive (~4.5 min); run + /// with `cargo test --release -- --ignored tensor_d2_matches_direct_c2_stem60 --nocapture`. + #[test] + #[ignore = "heavy (~4.5 min): untwisted tensor resolution C2 d2 vs direct up to stem 60"] + fn tensor_d2_matches_direct_c2_stem60() { + let (nn, ss): (i32, i32) = (60, 18); + let t0 = Instant::now(); + + let k_res = Arc::new(construct_standard::("S_2", None).unwrap()); + let m = finite_module_pub(&k_res.algebra(), "C2", nn + ss + 8); + let tensor = TensorResolutionSecondary::new(k_res, m); + tensor.compute_through_stem(Bidegree::n_s(nn, ss)); + eprintln!("tensor secondary computed in {:.1?}", t0.elapsed()); + + let t1 = Instant::now(); + let direct_res = Arc::new(construct_standard::("C2", None).unwrap()); + direct_res.compute_through_stem(Bidegree::n_s(nn + 1, ss + 3)); + let direct_e2 = Arc::new(ExtAlgebra::new( + Arc::clone(&direct_res), + Arc::clone(&direct_res), + )); + let direct_sec = SecondaryExtAlgebra::new(Arc::clone(&direct_e2)); + direct_sec.extend_all(); + eprintln!("direct secondary computed in {:.1?}", t1.elapsed()); + + let rank_of = |dim: usize, tdim: usize, f: &mut dyn FnMut(usize) -> FpVector| -> usize { + if dim == 0 || tdim == 0 { + return 0; + } + Matrix::from_rows(TWO, (0..dim).map(&mut *f).collect(), tdim).row_reduce() + }; + + let mut compared = 0; + let mut nonzero_d2 = 0; + let mut mismatches = 0; + for n in 1..=nn { + for s in 1..=ss { + let b = Bidegree::n_s(n, s); + let target = b + Bidegree::n_s(-1, 2); + let (Some(fd), Some(ftd)) = ( + tensor.cohomology_dimension(b), + tensor.cohomology_dimension(target), + ) else { + continue; + }; + if fd != direct_e2.dimension(b) { + eprintln!( + "E2 dim mismatch at {b:?}: tensor {fd} direct {}", + direct_e2.dimension(b) + ); + mismatches += 1; + continue; + } + let fr = rank_of(fd, ftd, &mut |i| { + tensor + .d2(&tensor.ext().generator(BidegreeGenerator::new(b, i))) + .map(BidegreeElement::into_vec) + .unwrap_or_else(|| FpVector::new(TWO, ftd)) + }); + let dr = rank_of( + direct_e2.dimension(b), + direct_e2.dimension(target), + &mut |i| { + direct_sec + .d2(&direct_e2.generator(BidegreeGenerator::new(b, i))) + .map(BidegreeElement::into_vec) + .unwrap_or_else(|| FpVector::new(TWO, direct_e2.dimension(target))) + }, + ); + if fr != dr { + eprintln!("d2 RANK mismatch at {b:?}: tensor {fr} direct {dr}"); + mismatches += 1; + } + if fr > 0 { + nonzero_d2 += 1; + } + compared += 1; + } + } + eprintln!( + "compared {compared} bidegrees, {nonzero_d2} with nonzero d2, {mismatches} mismatches" + ); + assert_eq!( + mismatches, 0, + "untwisted tensor resolution d2 disagrees with direct somewhere" + ); + } +} diff --git a/ext/src/save.rs b/ext/src/save.rs index 536f0b3d23..1186acb384 100644 --- a/ext/src/save.rs +++ b/ext/src/save.rs @@ -127,6 +127,11 @@ pub enum SaveKind { /// The quasi-inverse data in Nassau's algorithm NassauQi, + + /// The closed-form coboundary `δ_Q` of an untwisted tensor resolution `Q• = P• ⊗ M`. This is + /// module-specific: the header only distinguishes by algebra and bidegree, so the save + /// directory must be dedicated to a single module `M`. + TensorDifferential, } impl SaveKind { @@ -143,6 +148,7 @@ impl SaveKind { Self::ChainHomotopy => 0x11110000, Self::NassauDifferential => 0xD1FF0001, Self::NassauQi => 0x0100D1FE, + Self::TensorDifferential => 0xD1FF0002, } } @@ -159,6 +165,7 @@ impl SaveKind { Self::ChainHomotopy => "chain_homotopy", Self::NassauDifferential => "nassau_differential", Self::NassauQi => "nassau_qi", + Self::TensorDifferential => "tensor_differential", } } From e2bf71e8ebfd7d3ef47ac1a0307538bebdffb005 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 06:04:41 +0000 Subject: [PATCH 11/12] =?UTF-8?q?ext:=20chart=20Ext=20of=20the=20infinite?= =?UTF-8?q?=20RP^=E2=88=9E=20via=20the=20untwisted=20tensor=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example computes `Ext_A(RP^∞, F_2)`, a module the minimal-resolution engine cannot reach. Rows above the highest non-zero one carry no information and are dropped, so the header reports the range actually printed rather than the range requested — otherwise every row is mis-indexed by the reader. The benchmark fixture gives CI an end-to-end check of the construction. It covers n = 0..20, s = 0..10 in well under a second, and the output is identical with and without `--features concurrent`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LdymHggmtPKyEsPEbF7DWo --- .../benchmarks/tensor_resolution-RPinf | 13 ++++ ext/examples/tensor_resolution.rs | 78 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 ext/examples/benchmarks/tensor_resolution-RPinf create mode 100644 ext/examples/tensor_resolution.rs diff --git a/ext/examples/benchmarks/tensor_resolution-RPinf b/ext/examples/benchmarks/tensor_resolution-RPinf new file mode 100644 index 0000000000..b848bf77a2 --- /dev/null +++ b/ext/examples/benchmarks/tensor_resolution-RPinf @@ -0,0 +1,13 @@ +tensor_resolution -- 20 10 +Ext_A(RP^∞, F_2), rows s = 10..0 (top to bottom), columns n = 0..20: + · + · · + · · + · · + · · · · + · · · · · · · + · · · : · · · · + · · · · · · · ∴ : + · : · : · · : ∴ : : + · · · · · : · · · : : + · · · · diff --git a/ext/examples/tensor_resolution.rs b/ext/examples/tensor_resolution.rs new file mode 100644 index 0000000000..04dcd28064 --- /dev/null +++ b/ext/examples/tensor_resolution.rs @@ -0,0 +1,78 @@ +//! Compute `Ext_A(M, k)` for an **infinite** module `M` via Nassau's untwisted tensor resolution: +//! resolve the base field `k`, tensor with `M` to get a non-minimal free resolution, and take the +//! cohomology of `Hom_A(P_• ⊗ M, k)`. +//! +//! The default `M` is `RP^∞` (real projective space), which is infinite-dimensional and therefore +//! *cannot* be resolved by the usual minimal-resolution engine — the whole point of the +//! construction. +//! +//! Run with e.g. `cargo run --example tensor_resolution` (p = 2). + +use std::sync::Arc; + +use algebra::{ + Algebra, + module::{Module, RealProjectiveSpace}, +}; +use ext::{ + chain_complex::ChainComplex, + ext_algebra::tensor_resolution_ext, + utils::{construct_standard, unicode_num}, +}; +use sseq::coordinates::Bidegree; + +fn main() -> anyhow::Result<()> { + ext::utils::init_logging()?; + + eprintln!("Computes Ext_A(RP^∞, F_2) by resolving k and tensoring with RP^∞ (p = 2)."); + + let max_n: i32 = query::with_default("Max n", "25", str::parse); + let max_s: i32 = query::with_default("Max s", "15", str::parse); + let max = Bidegree::n_s(max_n, max_s); + let t_max = max.t(); + + // Minimally resolve the base field k = S_2; this is `P_•`, the complex we tensor with `M`. + let resolution = Arc::new(construct_standard::("S_2", None)?); + resolution.compute_through_bidegree(Bidegree::s_t(max_s + 1, t_max)); + resolution.algebra().compute_basis(t_max + 1); + + // M = RP^∞, an infinite module. `None` max degree ⇒ genuinely unbounded. + let rp_inf = Arc::new(RealProjectiveSpace::new( + resolution.algebra(), + 1, + None, + false, + )); + rp_inf.compute_basis(t_max); + + let ext = tensor_resolution_ext(Arc::clone(&resolution), rp_inf); + + // Print the Ext chart: rows are s (high to low), columns are n. Rows above the highest + // non-zero one carry no information, so drop them — and report the range we actually print, + // otherwise the reader cannot tell which `s` the top row is. + let rows: Vec = (0..=max_s) + .rev() + .map(|s| { + (0..=max_n) + .map(|n| { + let dim = ext + .cohomology_dimension(Bidegree::n_s(n, s)) + .expect("computed range"); + format!("{} ", unicode_num(dim)) + }) + .collect() + }) + .collect(); + let top_s = max_s - rows.iter().take_while(|row| row.trim().is_empty()).count() as i32; + + if top_s < 0 { + println!("Ext_A(RP^∞, F_2) vanishes on n = 0..{max_n}, s = 0..{max_s}."); + } else { + println!("Ext_A(RP^∞, F_2), rows s = {top_s}..0 (top to bottom), columns n = 0..{max_n}:"); + for row in rows.iter().skip((max_s - top_s) as usize) { + println!("{}", row.trim_end()); + } + } + + Ok(()) +} From 85ad5caa98374e655c3e68f6ed05064f87de9fa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 06:36:25 +0000 Subject: [PATCH 12/12] Correct the stem-60 test's runtime estimate The `#[ignore]` attribute claimed ~4.5 min. Two measured runs with `--features concurrent` took 1824s uncontended and 2071s while competing with other cargo invocations, so ~30 min is the honest figure. Both passed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LdymHggmtPKyEsPEbF7DWo --- ext/src/ext_algebra/tensor_resolution.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ext/src/ext_algebra/tensor_resolution.rs b/ext/src/ext_algebra/tensor_resolution.rs index c50461dcb4..26883428d8 100644 --- a/ext/src/ext_algebra/tensor_resolution.rs +++ b/ext/src/ext_algebra/tensor_resolution.rs @@ -2358,7 +2358,8 @@ mod heavy_tests { /// mismatches). `#[ignore]`d because the non-minimal Q• secondary is expensive (~4.5 min); run /// with `cargo test --release -- --ignored tensor_d2_matches_direct_c2_stem60 --nocapture`. #[test] - #[ignore = "heavy (~4.5 min): untwisted tensor resolution C2 d2 vs direct up to stem 60"] + #[ignore = "heavy (~30 min with `--features concurrent`): untwisted tensor resolution C2 d2 vs \ + direct up to stem 60"] fn tensor_d2_matches_direct_c2_stem60() { let (nn, ss): (i32, i32) = (60, 18); let t0 = Instant::now();