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 f229886636..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; @@ -25,7 +34,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}; @@ -35,6 +48,62 @@ use crate::{ utils::{QueryModuleResolution, get_unit}, }; +/// 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. 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}}$. + 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 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 + } + + /// 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) + } +} + /// $\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 +114,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 +147,7 @@ impl ExtAlgebra { resolution, unit, products: DashMap::new(), + differential: None, } } @@ -90,6 +163,140 @@ 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 (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 + /// 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()); + // 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, + "ExtDifferential violates d∘d=0 at {b:?}: rank_out={rank_out}, rank_in={rank_in}, \ + gens={gens}" + ); + 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)?; + 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(); + 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) => { + 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), + }; + + Some(Subquotient::from_parts(numerator, denominator)) + } + pub fn resolution(&self) -> &Arc { &self.resolution } @@ -264,8 +471,100 @@ where #[cfg(test)] 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() { + // 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. + // 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 { + 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) + } + } + + let res = Arc::new(construct_standard::("S_2", None).unwrap()); + res.compute_through_stem(Bidegree::n_s(8, 8)); + 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 + 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 + + // 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] fn test_sphere_products() { diff --git a/ext/src/ext_algebra/secondary.rs b/ext/src/ext_algebra/secondary.rs index 983a7c60ac..f36463bf38 100644 --- a/ext/src/ext_algebra/secondary.rs +++ b/ext/src/ext_algebra/secondary.rs @@ -12,14 +12,18 @@ //! 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::Subquotient, prime::Prime, vector::FpVector}; +use fp::{ + matrix::{Matrix, Subquotient}, + prime::Prime, + 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,87 @@ 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(crate) struct SecondaryCoboundary +where + CC::Algebra: PairAlgebra, +{ + res_lift: Arc>, +} + +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 p = res.prime(); + 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). + // + // 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) + } else if res.has_computed_bidegree(x) { + Some(res.number_of_gens_in_bidegree(x)) + } else { + 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).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 + // 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 { @@ -50,15 +135,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, { @@ -71,32 +159,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`, @@ -152,25 +244,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, { @@ -221,13 +313,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 @@ -247,7 +337,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), @@ -305,4 +395,84 @@ 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), + }); + 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))" + ); + } + + #[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