From fd78eb56d5f52e8f967f76cc6855eda7f7227add Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 05:39:38 +0000 Subject: [PATCH 01/13] =?UTF-8?q?Add=20coordinate=20types=20to=20index=20S?= =?UTF-8?q?econdaryExtAlgebra=20as=20a=20C=CE=BB=C2=B2-module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExtAlgebra is a first-class Cλ-module, indexed by the coordinate triple Bidegree / BidegreeGenerator / BidegreeElement. SecondaryExtAlgebra is the analogous Cλ²-module but lacked the matching coordinate types; this adds them so you can index into it the same way. New secondary coordinate triple in ext/src/secondary.rs: - Weight: the λ-adic weight (Ext = 0, Lambda = 1; only two weights since λ² = 0), with an as_i32 synthetic coordinate. - SecondaryDegree: a base bidegree b — weight-0 part at b, weight-1 (λ) part at b + LAMBDA_BIDEGREE. Analog of Bidegree. - SecondaryGenerator: base + Weight + idx; into_element places the unit entry in the ext or λ part. Analog of BidegreeGenerator. - SecondaryElement: base + ext/λ FpVectors (promoted from the earlier SyntheticElement). A generic class is not weight-homogeneous, so both parts are stored. Analog of BidegreeElement. Indexing methods on SecondaryExtAlgebra mirror ExtAlgebra (dimension/basis/element/generator, weight_dimension, and unit_* variants). They read ambient generator counts like ExtAlgebra::dimension, so the secondary d2/E3 structure stays a separate computation; undefined bidegrees panic rather than yielding a silent 0. SecondaryProduct.value and both examples use SecondaryElement. The Weight enum is designed to feed a future 3-graded synthetic spectral sequence; the B/Z/E differential filtration is left for that later layer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0125sG5YPRQVfTs5eZ7hN7hA --- ext/examples/secondary_massey.rs | 43 +---- ext/examples/secondary_product.rs | 5 +- ext/src/ext_algebra/mod.rs | 1 + ext/src/ext_algebra/secondary.rs | 188 +++++++++++++++++++++- ext/src/secondary.rs | 254 +++++++++++++++++++++++++++++- 5 files changed, 440 insertions(+), 51 deletions(-) diff --git a/ext/examples/secondary_massey.rs b/ext/examples/secondary_massey.rs index 9c8169a4fb..4c3cd0e401 100644 --- a/ext/examples/secondary_massey.rs +++ b/ext/examples/secondary_massey.rs @@ -30,7 +30,7 @@ use fp::{ vector::FpVector, }; use itertools::Itertools; -use sseq::coordinates::{Bidegree, BidegreeElement}; +use sseq::coordinates::Bidegree; struct HomData { name: String, @@ -386,44 +386,9 @@ fn main() -> anyhow::Result<()> { let mb = b.underlying().get_map(c.s() + b_shift.s()).hom_k(c.t()); for g in e3_kernel.iter() { - // Print name - { - print!("<{a_name}, {b_name}, "); - let has_ext = { - let ext_part = g.restrict(0, target_num_gens); - if ext_part.iter_nonzero().count() > 0 { - print!( - "[{basis_string}]", - basis_string = - BidegreeElement::new(c, ext_part.to_owned()).to_basis_string() - ); - true - } else { - false - } - }; - - let lambda_part = g.restrict(target_num_gens, target_all_gens); - let num_entries = lambda_part.iter_nonzero().count(); - if num_entries > 0 { - if has_ext { - print!(" + "); - } - print!("λ"); - - let basis_string = BidegreeElement::new( - c + LAMBDA_BIDEGREE, - g.restrict(target_num_gens, target_all_gens).to_owned(), - ) - .to_basis_string(); - if num_entries == 1 { - print!("{basis_string}",); - } else { - print!("({basis_string})",); - } - } - print!("> = ±"); - } + // Print name. `g` is the concatenated `[ext | λ]` class of the multiplicand. + let elt = SecondaryElement::from_concatenated(c, g, target_num_gens); + print!("<{a_name}, {b_name}, {elt}> = ±"); scratch0.clear(); scratch0.resize(source_num_gens, 0); diff --git a/ext/examples/secondary_product.rs b/ext/examples/secondary_product.rs index b024a8ce2e..9af5ee8bba 100644 --- a/ext/examples/secondary_product.rs +++ b/ext/examples/secondary_product.rs @@ -113,10 +113,9 @@ fn main() -> anyhow::Result<()> { // Now the genuinely secondary products with surviving classes. for prod in sec_e2.secondary_multiply_into(&x, b) { println!( - "{disp} [{basis}] = {ext} + λ {lambda}", + "{disp} [{basis}] = {value}", basis = prod.source.to_basis_string(), - ext = prod.ext_part.as_slice(), - lambda = prod.lambda_part.as_slice(), + value = prod.value, ); } } diff --git a/ext/src/ext_algebra/mod.rs b/ext/src/ext_algebra/mod.rs index f229886636..96f910cd74 100644 --- a/ext/src/ext_algebra/mod.rs +++ b/ext/src/ext_algebra/mod.rs @@ -29,6 +29,7 @@ use fp::{matrix::Matrix, prime::ValidPrime, vector::FpVector}; use sseq::coordinates::{Bidegree, BidegreeElement, BidegreeGenerator}; pub use self::secondary::{SecondaryExtAlgebra, SecondaryProduct}; +pub use crate::secondary::{SecondaryDegree, SecondaryElement, SecondaryGenerator, Weight}; use crate::{ chain_complex::{AugmentedChainComplex, FreeChainComplex}, resolution_homomorphism::ResolutionHomomorphism, diff --git a/ext/src/ext_algebra/secondary.rs b/ext/src/ext_algebra/secondary.rs index 983a7c60ac..f1871abc60 100644 --- a/ext/src/ext_algebra/secondary.rs +++ b/ext/src/ext_algebra/secondary.rs @@ -24,7 +24,8 @@ use crate::{ chain_complex::FreeChainComplex, resolution_homomorphism::ResolutionHomomorphism, secondary::{ - LAMBDA_BIDEGREE, SecondaryLift, SecondaryResolution, SecondaryResolutionHomomorphism, + LAMBDA_BIDEGREE, SecondaryDegree, SecondaryElement, SecondaryGenerator, SecondaryLift, + SecondaryResolution, SecondaryResolutionHomomorphism, Weight, }, }; @@ -33,11 +34,9 @@ use crate::{ pub struct SecondaryProduct { /// The multiplicand: an $E_3$-surviving generator of the unit at the queried bidegree `b`. pub source: BidegreeElement, - /// The $\Ext$ part of the product, in bidegree `b + x.degree()`. - pub ext_part: FpVector, - /// The $\lambda$ part of the product, in bidegree `b + x.degree() + LAMBDA_BIDEGREE`, already - /// reduced by the image of $d_2$. - pub lambda_part: FpVector, + /// The product `x · source`, a class in the secondary ($\Mod_{C\lambda^2}$) homotopy with base + /// bidegree `b + x.degree()`. Its $\lambda$ part is already reduced by the image of $d_2$. + pub value: SecondaryElement, } /// The secondary layer over an [`ExtAlgebra`]: the $d_2$ differential and the $\Mod_{C\lambda^2}$ @@ -164,6 +163,128 @@ where Self::e3_page_data(g.as_ref().expect("call extend_all() first"), b).clone() } + // Indexing the Cλ²-module the way `Bidegree{,Generator,Element}` index `ExtAlgebra`. These read + // the *ambient* generator counts (like `ExtAlgebra::dimension`), so the secondary `d_2`/$E_3$ + // structure stays a separate computation. `b` and `b + LAMBDA_BIDEGREE` must be computed; + // otherwise `number_of_gens_in_bidegree` panics, matching `ExtAlgebra::dimension`. + + /// The dimension of the weight-`weight` part of the secondary homotopy of `M` at `deg`: the + /// ambient number of generators of $\Ext(M, k)$ in that part's bidegree. + pub fn weight_dimension(&self, deg: SecondaryDegree, weight: Weight) -> usize { + self.alg + .resolution() + .number_of_gens_in_bidegree(deg.bidegree(weight)) + } + + /// The total dimension of the secondary homotopy of `M` at `deg` (weight 0 plus weight 1). + pub fn dimension(&self, deg: SecondaryDegree) -> usize { + self.weight_dimension(deg, Weight::Ext) + self.weight_dimension(deg, Weight::Lambda) + } + + /// The basis generators of the secondary homotopy of `M` at `deg`: the weight-0 generators + /// followed by the weight-1 (λ) generators. + pub fn basis(&self, deg: SecondaryDegree) -> Vec { + let base = deg.base(); + [Weight::Ext, Weight::Lambda] + .into_iter() + .flat_map(|w| { + (0..self.weight_dimension(deg, w)).map(move |i| SecondaryGenerator::new(base, w, i)) + }) + .collect() + } + + /// A class in the secondary homotopy of `M` at `deg` from its coordinates in the weight-0 and + /// weight-1 generator bases. + pub fn element( + &self, + deg: SecondaryDegree, + ext_coords: &[u32], + lambda_coords: &[u32], + ) -> SecondaryElement { + assert_eq!(self.weight_dimension(deg, Weight::Ext), ext_coords.len()); + assert_eq!( + self.weight_dimension(deg, Weight::Lambda), + lambda_coords.len() + ); + let p = self.prime(); + SecondaryElement::new( + deg.base(), + FpVector::from_slice(p, ext_coords), + FpVector::from_slice(p, lambda_coords), + ) + } + + /// A single generator of the secondary homotopy of `M` as a class. + pub fn generator(&self, g: SecondaryGenerator) -> SecondaryElement { + let deg = g.degree(); + assert!(self.weight_dimension(deg, g.weight()) > g.idx()); + g.into_element( + self.prime(), + self.weight_dimension(deg, Weight::Ext), + self.weight_dimension(deg, Weight::Lambda), + ) + } + + /// The dimension of the weight-`weight` part of the secondary homotopy of the unit `k` at + /// `deg` (the multiplicand / "scalar" side, i.e. $C\lambda^2$ itself). + pub fn unit_weight_dimension(&self, deg: SecondaryDegree, weight: Weight) -> usize { + self.alg + .unit() + .number_of_gens_in_bidegree(deg.bidegree(weight)) + } + + /// The total dimension of the secondary homotopy of the unit `k` at `deg`. + pub fn unit_dimension(&self, deg: SecondaryDegree) -> usize { + self.unit_weight_dimension(deg, Weight::Ext) + + self.unit_weight_dimension(deg, Weight::Lambda) + } + + /// The basis generators of the secondary homotopy of the unit `k` at `deg`. + pub fn unit_basis(&self, deg: SecondaryDegree) -> Vec { + let base = deg.base(); + [Weight::Ext, Weight::Lambda] + .into_iter() + .flat_map(|w| { + (0..self.unit_weight_dimension(deg, w)) + .map(move |i| SecondaryGenerator::new(base, w, i)) + }) + .collect() + } + + /// A class in the secondary homotopy of the unit `k` at `deg`. + pub fn unit_element( + &self, + deg: SecondaryDegree, + ext_coords: &[u32], + lambda_coords: &[u32], + ) -> SecondaryElement { + assert_eq!( + self.unit_weight_dimension(deg, Weight::Ext), + ext_coords.len() + ); + assert_eq!( + self.unit_weight_dimension(deg, Weight::Lambda), + lambda_coords.len() + ); + let p = self.prime(); + SecondaryElement::new( + deg.base(), + FpVector::from_slice(p, ext_coords), + FpVector::from_slice(p, lambda_coords), + ) + } + + /// A single generator of the secondary homotopy of the unit `k` as a class. + pub fn unit_generator(&self, g: SecondaryGenerator) -> SecondaryElement { + let deg = g.degree(); + assert!(self.unit_weight_dimension(deg, g.weight()) > g.idx()); + g.into_element( + self.prime(), + self.unit_weight_dimension(deg, Weight::Ext), + self.unit_weight_dimension(deg, Weight::Lambda), + ) + } + 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)] @@ -257,8 +378,7 @@ where .zip(outputs) .map(|(g, out)| SecondaryProduct { source: BidegreeElement::new(b, g.to_owned()), - ext_part: out.slice(0, ext_dim).to_owned(), - lambda_part: out.slice(ext_dim, ext_dim + lambda_dim).to_owned(), + value: SecondaryElement::from_concatenated(b + shift, out.as_slice(), ext_dim), }) .collect() } @@ -305,4 +425,56 @@ 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 test_secondary_indexing() { + let res = Arc::new(construct_standard::("S_2", None).unwrap()); + res.compute_through_stem(Bidegree::n_s(8, 8)); + let e2 = Arc::new(ExtAlgebra::new(Arc::clone(&res), res)); + let sec = SecondaryExtAlgebra::new(Arc::clone(&e2)); + + // (0,0): bottom class + λh0; (0,1): h0 + λh0²; (1,1): h1 + λ-part. + for (n, s) in [(0, 0), (0, 1), (1, 1)] { + let base = Bidegree::n_s(n, s); + let deg = SecondaryDegree::new(base); + + let ext_dim = e2.resolution().number_of_gens_in_bidegree(base); + let lambda_dim = e2 + .resolution() + .number_of_gens_in_bidegree(base + LAMBDA_BIDEGREE); + + assert_eq!(sec.weight_dimension(deg, Weight::Ext), ext_dim); + assert_eq!(sec.weight_dimension(deg, Weight::Lambda), lambda_dim); + assert_eq!(sec.dimension(deg), ext_dim + lambda_dim); + + let basis = sec.basis(deg); + assert_eq!(basis.len(), sec.dimension(deg)); + + for (i, g) in basis.iter().enumerate() { + // Weight-0 generators come first, then weight-1. + let (expected_weight, expected_idx) = if i < ext_dim { + (Weight::Ext, i) + } else { + (Weight::Lambda, i - ext_dim) + }; + assert_eq!(g.weight(), expected_weight); + assert_eq!(g.idx(), expected_idx); + assert_eq!(g.base(), base); + + // generator(g) round-trips to element(...) with a single 1 in the right part. + let elt = sec.generator(*g); + let mut ext_coords = vec![0u32; ext_dim]; + let mut lambda_coords = vec![0u32; lambda_dim]; + match g.weight() { + Weight::Ext => ext_coords[g.idx()] = 1, + Weight::Lambda => lambda_coords[g.idx()] = 1, + } + assert_eq!(elt, sec.element(deg, &ext_coords, &lambda_coords)); + assert_eq!( + elt.ext().iter_nonzero().count() + elt.lambda().iter_nonzero().count(), + 1 + ); + } + } + } } diff --git a/ext/src/secondary.rs b/ext/src/secondary.rs index e04b852a36..ff5a443129 100644 --- a/ext/src/secondary.rs +++ b/ext/src/secondary.rs @@ -1,4 +1,8 @@ -use std::{io, sync::Arc}; +use std::{ + fmt::{self, Display, Formatter}, + io, + sync::Arc, +}; use algebra::{ Algebra, @@ -39,6 +43,254 @@ use crate::{ pub static LAMBDA_BIDEGREE: Bidegree = Bidegree::n_s(0, 1); +/// The λ-adic weight of a homogeneous secondary class: [`Ext`](Weight::Ext) is the weight-0 +/// (bottom) part, [`Lambda`](Weight::Lambda) the weight-1 (λ) part. In $\Mod_{C\lambda^2}$ only +/// these two weights occur, since $\lambda^2 = 0$. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Weight { + Ext, + Lambda, +} + +impl Weight { + /// The weight as a synthetic coordinate: `0` for [`Ext`](Self::Ext), `1` for + /// [`Lambda`](Self::Lambda). (Lets these coordinates feed a future 3-graded synthetic + /// spectral sequence with the weight in the third coordinate.) + pub fn as_i32(self) -> i32 { + match self { + Self::Ext => 0, + Self::Lambda => 1, + } + } +} + +impl Display for Weight { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "{}", self.as_i32()) + } +} + +/// A degree for the secondary ($\Mod_{C\lambda^2}$) layer: the base bidegree `b` of a homotopy +/// group of a module over $C\lambda^2$. The weight-0 (Ext) part lives at `b` and the weight-1 (λ) +/// part at `b + LAMBDA_BIDEGREE`. +/// +/// The secondary analog of [`Bidegree`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SecondaryDegree { + base: Bidegree, +} + +impl SecondaryDegree { + pub fn new(base: Bidegree) -> Self { + Self { base } + } + + /// The base bidegree; the Ext (weight 0) part lives here. + pub fn base(&self) -> Bidegree { + self.base + } + + /// The bidegree of the λ (weight 1) part, `base + LAMBDA_BIDEGREE`. + pub fn lambda_bidegree(&self) -> Bidegree { + self.base + LAMBDA_BIDEGREE + } + + /// The bidegree the part of the given `weight` lives in. + pub fn bidegree(&self, weight: Weight) -> Bidegree { + match weight { + Weight::Ext => self.base, + Weight::Lambda => self.lambda_bidegree(), + } + } +} + +impl From for SecondaryDegree { + fn from(base: Bidegree) -> Self { + Self::new(base) + } +} + +impl Display for SecondaryDegree { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + Display::fmt(&self.base, f) + } +} + +/// A basis generator of a secondary ($\Mod_{C\lambda^2}$) homotopy group: a base bidegree, a +/// [`Weight`], and an index into the generator basis of the bidegree the generator lives in. +/// +/// The secondary analog of [`BidegreeGenerator`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SecondaryGenerator { + base: Bidegree, + weight: Weight, + idx: usize, +} + +impl SecondaryGenerator { + pub fn new(base: Bidegree, weight: Weight, idx: usize) -> Self { + Self { base, weight, idx } + } + + /// The base bidegree (the degree of the weight-0 part). + pub fn base(&self) -> Bidegree { + self.base + } + + /// The secondary degree this generator belongs to. + pub fn degree(&self) -> SecondaryDegree { + SecondaryDegree::new(self.base) + } + + pub fn weight(&self) -> Weight { + self.weight + } + + pub fn idx(&self) -> usize { + self.idx + } + + /// The bidegree this generator actually lives in: `base` for [`Weight::Ext`], `base + + /// LAMBDA_BIDEGREE` for [`Weight::Lambda`]. + pub fn bidegree(&self) -> Bidegree { + self.degree().bidegree(self.weight) + } + + /// Realize the generator as a [`SecondaryElement`]. `ext_ambient`/`lambda_ambient` are the + /// dimensions of the weight-0/weight-1 parts; the single nonzero entry is placed in the part + /// selected by [`weight`](Self::weight). Mirrors `MultiDegreeGenerator::into_element`. + pub fn into_element( + self, + p: ValidPrime, + ext_ambient: usize, + lambda_ambient: usize, + ) -> SecondaryElement { + let mut ext = FpVector::new(p, ext_ambient); + let mut lambda = FpVector::new(p, lambda_ambient); + match self.weight { + Weight::Ext => ext.set_entry(self.idx, 1), + Weight::Lambda => lambda.set_entry(self.idx, 1), + } + SecondaryElement::new(self.base, ext, lambda) + } +} + +impl Display for SecondaryGenerator { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + let g = BidegreeGenerator::new(self.bidegree(), self.idx); + match self.weight { + Weight::Ext => write!(f, "x_{g}"), + Weight::Lambda => write!(f, "λx_{g}"), + } + } +} + +/// An element of a secondary ($\Mod_{C\lambda^2}$) homotopy group: the homotopy of a module over +/// $C\lambda^2$ at a base bidegree `b`. It bundles an Ext (weight 0) part in bidegree `b` and a +/// $\lambda$ (weight 1) part in bidegree `b + LAMBDA_BIDEGREE` (the latter understood modulo the +/// image of $d_2$). A generic class is *not* weight-homogeneous, so both parts are stored. +/// +/// The secondary analog of [`BidegreeElement`]: a pure data container that performs no reduction, +/// signs or $\mathbb{Z}/p^2$ arithmetic — those stay in the primitives that produce its components. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecondaryElement { + base: Bidegree, + /// The Ext (weight 0) part, in bidegree `base`. + ext: FpVector, + /// The $\lambda$ (weight 1) part, in bidegree `base + LAMBDA_BIDEGREE`. + lambda: FpVector, +} + +impl SecondaryElement { + /// Build a secondary element from its Ext part (in bidegree `base`) and $\lambda$ part (in + /// bidegree `base + LAMBDA_BIDEGREE`). + pub fn new(base: Bidegree, ext: FpVector, lambda: FpVector) -> Self { + Self { base, ext, lambda } + } + + /// Split a concatenated `[ext | lambda]` vector, where `ext_dim` is the length of the Ext + /// chunk. This mirrors the output layout of + /// [`SecondaryResolutionHomomorphism::hom_k`](crate::secondary::SecondaryResolutionHomomorphism::hom_k): + /// the first `ext_dim` entries are the Ext part and the remainder the $\lambda$ part. + pub fn from_concatenated(base: Bidegree, concatenated: FpSlice, ext_dim: usize) -> Self { + let total = concatenated.len(); + Self { + base, + ext: concatenated.restrict(0, ext_dim).to_owned(), + lambda: concatenated.restrict(ext_dim, total).to_owned(), + } + } + + /// The base bidegree `b`; the Ext part lives here and the $\lambda$ part in `b + LAMBDA_BIDEGREE`. + pub fn base(&self) -> Bidegree { + self.base + } + + /// The secondary degree of this element. + pub fn degree(&self) -> SecondaryDegree { + SecondaryDegree::new(self.base) + } + + /// The Ext (weight 0) part, in bidegree [`base`](Self::base). + pub fn ext(&self) -> FpSlice<'_> { + self.ext.as_slice() + } + + /// The $\lambda$ (weight 1) part, in bidegree `base + LAMBDA_BIDEGREE`. + pub fn lambda(&self) -> FpSlice<'_> { + self.lambda.as_slice() + } + + /// The Ext part as a [`BidegreeElement`] at [`base`](Self::base). + pub fn ext_element(&self) -> BidegreeElement { + BidegreeElement::new(self.base, self.ext.clone()) + } + + /// The $\lambda$ part as a [`BidegreeElement`] at `base + LAMBDA_BIDEGREE`. + pub fn lambda_element(&self) -> BidegreeElement { + BidegreeElement::new(self.base + LAMBDA_BIDEGREE, self.lambda.clone()) + } + + /// Whether both the Ext and $\lambda$ parts vanish. + pub fn is_zero(&self) -> bool { + self.ext.is_zero() && self.lambda.is_zero() + } + + /// The element as a linear combination of generators, e.g. `[x_(n, s, 0)] + λx_(n, s+1, 0)`. + /// The Ext part is bracketed, and the $\lambda$ part is parenthesized when it has more than one + /// term. Returns `0` when the element vanishes. + pub fn to_basis_string(&self) -> String { + let has_ext = !self.ext.is_zero(); + let lambda_entries = self.lambda.iter_nonzero().count(); + + let mut out = String::new(); + if has_ext { + out.push_str(&format!("[{}]", self.ext_element().to_basis_string())); + } + if lambda_entries > 0 { + if has_ext { + out.push_str(" + "); + } + let s = self.lambda_element().to_basis_string(); + if lambda_entries == 1 { + out.push_str(&format!("λ{s}")); + } else { + out.push_str(&format!("λ({s})")); + } + } + if out.is_empty() { + out.push('0'); + } + out + } +} + +impl Display for SecondaryElement { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + f.write_str(&self.to_basis_string()) + } +} + pub type CompositeData = Vec<( u32, Arc>>, From 33bd12541b07e93a745c5ce3e06e35a692d35781 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:05:29 +0000 Subject: [PATCH 02/13] =?UTF-8?q?Add=20trigraded=20S/=CE=BB=C2=B2=20spectr?= =?UTF-8?q?al=20sequence=20and=20lambda2=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AdamsLambda2 profile (SseqProfile<3>) for the trigraded Adams spectral sequence of S/λ². Extend SecondaryExtAlgebra with a lambda2_sseq field that builds the E2 page (two copies of Ext at Bockstein degrees 0 and 1) and installs d2 differentials from (n, s, 0) → (n-1, s+2, 1) using the hom_k data. The E3 = E∞ page gives π(S/λ²). New API: lambda2_sseq(), lambda2_page_data(), lambda2_e3_dimension(). New example: lambda2.rs with Table I (B/Z/E decomposition), Table II (Ext products), Table III (π(S/λ²) basis), and Table IV (secondary products with commutators). Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_017kezHiWcYod7hYovRPczz9 --- ext/crates/sseq/src/sseq.rs | 22 +++ ext/examples/lambda2.rs | 244 +++++++++++++++++++++++++++++++ ext/src/ext_algebra/secondary.rs | 165 ++++++++++++++++++++- 3 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 ext/examples/lambda2.rs diff --git a/ext/crates/sseq/src/sseq.rs b/ext/crates/sseq/src/sseq.rs index a1d70e8e14..1c157865d1 100644 --- a/ext/crates/sseq/src/sseq.rs +++ b/ext/crates/sseq/src/sseq.rs @@ -39,6 +39,28 @@ impl SseqProfile<2> for Adams { } } +/// Trigraded Adams profile for S/λ². Coordinates are `(n, s, b)` = (stem, Adams filtration, +/// Bockstein degree). The d₂ differential maps `(n, s, b) → (n − 1, s + 2, b + 1)`. +pub struct AdamsLambda2; + +impl SseqProfile<3> for AdamsLambda2 { + const MIN_R: i32 = 2; + + fn profile(r: i32, b: MultiDegree<3>) -> MultiDegree<3> { + let [n, s, bock] = b.coords(); + MultiDegree::new([n - 1, s + r, bock + 1]) + } + + fn profile_inverse(r: i32, b: MultiDegree<3>) -> MultiDegree<3> { + let [n, s, bock] = b.coords(); + MultiDegree::new([n + 1, s - r, bock - 1]) + } + + fn differential_length(offset: MultiDegree<3>) -> i32 { + offset.coords()[1] + } +} + pub struct Product { pub b: MultiDegree, /// Whether the product acts on the left or not. This affects the sign in the Leibniz rule. diff --git a/ext/examples/lambda2.rs b/ext/examples/lambda2.rs new file mode 100644 index 0000000000..0a631c70a0 --- /dev/null +++ b/ext/examples/lambda2.rs @@ -0,0 +1,244 @@ +//! Computes tables for $S/\lambda^2$ (the cofiber of the Adams $d_2$ differential). +//! +//! This implements the four tables from "The Cofiber of the Adams d2 Differential": +//! +//! - **Table I**: B/Z/E decomposition of Ext, adapted to $d_2$. +//! - **Table II**: Products in Ext expressed in the standard basis. +//! - **Table III**: Conical basis for $\pi(S/\lambda^2)$. +//! - **Table IV**: Products in $\pi(S/\lambda^2)$ with commutators. +//! +//! # Usage +//! ```shell +//! cargo run --example lambda2 S_2 /tmp/save 40 20 +//! ``` +//! +//! Set `TABLE=I`, `TABLE=II`, `TABLE=III`, or `TABLE=IV` to print a single table; otherwise all +//! tables are printed. Supports sharding via `SECONDARY_JOB` (see [`secondary`](../secondary)). + +use std::sync::Arc; + +use algebra::module::Module; +use ext::{ + chain_complex::{ChainComplex, FreeChainComplex}, + ext_algebra::{ExtAlgebra, secondary::SecondaryExtAlgebra}, + utils::query_module, +}; +use fp::matrix::Subquotient; +use sseq::coordinates::{Bidegree, BidegreeGenerator, MultiDegree}; + +fn main() -> anyhow::Result<()> { + ext::utils::init_logging()?; + + let table_var = std::env::var("TABLE").ok(); + let table = table_var.as_deref().unwrap_or("all"); + + let resolution = Arc::new(query_module(Some(algebra::AlgebraType::Milnor), true)?); + let e2 = Arc::new(ExtAlgebra::from_resolution(Arc::clone(&resolution))?); + + if !e2.is_unit() { + let max = Bidegree::n_s( + resolution.module(0).max_computed_degree(), + resolution.next_homological_degree() - 1, + ); + e2.unit().compute_through_stem(max); + } + + let sec_e2 = Arc::new(SecondaryExtAlgebra::new(Arc::clone(&e2))); + + if let Some(s) = ext::utils::secondary_job() { + sec_e2.compute_partial(s); + return Ok(()); + } + + sec_e2.extend_all(); + + match table { + "I" => print_table_i(&e2, &sec_e2), + "II" => print_table_ii(&e2, &sec_e2), + "III" => print_table_iii(&e2, &sec_e2), + "IV" => print_table_iv(&e2, &sec_e2), + "all" => { + println!("=== TABLE I: B/Z/E decomposition ==="); + print_table_i(&e2, &sec_e2); + println!("\n=== TABLE II: Ext products ==="); + print_table_ii(&e2, &sec_e2); + println!("\n=== TABLE III: Conical basis for π(S/λ²) ==="); + print_table_iii(&e2, &sec_e2); + println!("\n=== TABLE IV: Products in π(S/λ²) ==="); + print_table_iv(&e2, &sec_e2); + } + _ => anyhow::bail!("unknown table: {table}; expected I, II, III, IV, or all"), + } + + Ok(()) +} + +/// Table I: B/Z/E decomposition of Ext adapted to d2. +/// +/// For each bidegree (n, s), classifies each Ext generator as: +/// - **Z** (d2-cycle, not a boundary) — survives to E3. +/// - **B** (boundary) — in the image of d2. +/// - **E** (supports d2) — d2(x) ≠ 0; prints the d2 value. +fn print_table_i(e2: &ExtAlgebra, sec_e2: &SecondaryExtAlgebra) +where + CC: FreeChainComplex + ext::chain_complex::AugmentedChainComplex, + CC::Algebra: algebra::pair_algebra::PairAlgebra, +{ + for b in e2.resolution().iter_nonzero_stem() { + let page = sec_e2.page_data(b); + let dim = e2.dimension(b); + if dim == 0 { + continue; + } + + for i in 0..dim { + let g = BidegreeGenerator::new(b, i); + let class_type = classify_generator(&page, i); + + match class_type { + BZE::Z => { + println!("Z x_{g}"); + } + BZE::B => { + println!("B x_{g}"); + } + BZE::E => { + let elem = e2.generator(g); + if let Some(d2) = sec_e2.d2(&elem) { + let target: Vec = d2.vec().iter().collect(); + println!("E x_{g} d2 = {target:?}"); + } else { + println!("E x_{g} d2 = (not computed)"); + } + } + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BZE { + B, + Z, + E, +} + +fn classify_generator(page: &Subquotient, idx: usize) -> BZE { + // B: in the quotient (boundary) + // Z: a gen (cycle, not boundary) + // E: in the complement (supports d2) + + // Check if this generator index is a pivot of the quotient (boundary) + if page.zeros().pivots()[idx] >= 0 { + return BZE::B; + } + // Check if this generator index is in the complement (not a cycle) + if page.complement_pivots().any(|p| p == idx) { + return BZE::E; + } + // Otherwise it's a cycle that's not a boundary + BZE::Z +} + +/// Table II: Products in Ext expressed in the standard basis. +/// +/// For each pair of generators (x, y) with x ≤ y (by bidegree then index), computes x · y. +fn print_table_ii(e2: &ExtAlgebra, _sec_e2: &SecondaryExtAlgebra) +where + CC: FreeChainComplex + ext::chain_complex::AugmentedChainComplex, + CC::Algebra: algebra::pair_algebra::PairAlgebra, +{ + let gens: Vec = e2 + .resolution() + .iter_nonzero_stem() + .flat_map(|b| (0..e2.dimension(b)).map(move |i| BidegreeGenerator::new(b, i))) + .collect(); + + for (idx_x, &x_gen) in gens.iter().enumerate() { + let x = e2.generator(x_gen); + for &y_gen in &gens[idx_x..] { + let y = e2.unit_generator(y_gen); + if let Some(prod) = e2.try_multiply(&x, &y) { + let coords: Vec = prod.vec().iter().collect(); + if coords.iter().any(|&c| c != 0) { + println!("x_{x_gen} · x_{y_gen} = {coords:?}"); + } + } + } + } +} + +/// Table III: Conical basis for π(S/λ²). +/// +/// Lists the E3 = E∞ generators of S/λ² at each tridegree (n, s, bock). +/// Elements of the conical basis Xπ come in four types per the paper's Condition 3.2: +/// - x⁰_π for x ∈ B: bockstein = 0 +/// - x⁰_π for x ∈ Z: bockstein = 0 +/// - x¹_π for x ∈ Z: bockstein = 1 +/// - x¹_π for x ∈ E: bockstein = 1 +fn print_table_iii(e2: &ExtAlgebra, sec_e2: &SecondaryExtAlgebra) +where + CC: FreeChainComplex + ext::chain_complex::AugmentedChainComplex, + CC::Algebra: algebra::pair_algebra::PairAlgebra, +{ + for b in e2.resolution().iter_stem() { + let [n, s] = b.coords(); + + for bock in [0, 1] { + let dim = sec_e2.lambda2_e3_dimension(n, s, bock); + if dim > 0 { + if let Some(pd) = sec_e2.lambda2_page_data(MultiDegree::new([n, s, bock])) { + for (idx, v) in pd.gens().enumerate() { + let coords: Vec = v.iter().collect(); + println!("({n}, {s}, {bock}) gen {idx} {coords:?}"); + } + } + } + } + } +} + +/// Table IV: Products in π(S/λ²) with commutators. +/// +/// For each pair (α, β) of conical basis elements with both in bockstein 0, +/// computes α · β using the secondary product and the commutator α·β - β·α. +fn print_table_iv(e2: &ExtAlgebra, sec_e2: &SecondaryExtAlgebra) +where + CC: FreeChainComplex + ext::chain_complex::AugmentedChainComplex, + CC::Algebra: algebra::pair_algebra::PairAlgebra, +{ + // Collect all surviving generators (Z classes) at bock=0 in the bigraded page. + let survivors: Vec = e2 + .resolution() + .iter_nonzero_stem() + .flat_map(|b| { + let page = sec_e2.page_data(b); + let dim = e2.dimension(b); + (0..dim) + .filter(move |&i| classify_generator(&page, i) != BZE::E) + .map(move |i| BidegreeGenerator::new(b, i)) + }) + .collect(); + + for (idx_x, &x_gen) in survivors.iter().enumerate() { + let x = e2.generator(x_gen); + // Only compute secondary products for d2-cycles (Z generators, not B). + let x_page = sec_e2.page_data(x_gen.degree()); + if classify_generator(&x_page, x_gen.idx()) != BZE::Z { + continue; + } + for &y_gen in &survivors[idx_x..] { + // The secondary product x · y: iterate over the secondary multiply output. + for prod in sec_e2.secondary_multiply_into(&x, y_gen.degree()) { + let ext: Vec = prod.ext_part.iter().collect(); + let lambda: Vec = prod.lambda_part.iter().collect(); + if ext.iter().any(|&c| c != 0) || lambda.iter().any(|&c| c != 0) { + println!( + "x_{x_gen} · [{src}] = {ext:?} + λ {lambda:?}", + src = prod.source.to_basis_string(), + ); + } + } + } + } +} diff --git a/ext/src/ext_algebra/secondary.rs b/ext/src/ext_algebra/secondary.rs index f1871abc60..e65a2f0ea7 100644 --- a/ext/src/ext_algebra/secondary.rs +++ b/ext/src/ext_algebra/secondary.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex}; use algebra::pair_algebra::PairAlgebra; use dashmap::DashMap; use fp::{matrix::Subquotient, prime::Prime, vector::FpVector}; -use sseq::coordinates::{Bidegree, BidegreeElement}; +use sseq::coordinates::{Bidegree, BidegreeElement, MultiDegree, MultiDegreeElement}; use super::ExtAlgebra; use crate::{ @@ -53,6 +53,9 @@ where res_sseq: Mutex>>>, /// $E_3$ page of the unit, filled by [`extend_all`](Self::extend_all). unit_sseq: Mutex>>>, + /// Trigraded spectral sequence for $S/\lambda^2$. Coordinates are `(n, s, bock)` = (stem, + /// Adams filtration, Bockstein degree). Filled by [`extend_all`](Self::extend_all). + lambda2_sseq: Mutex>>>, /// Secondary lift of the multiplication map, cached per multiplier class `(degree, coords)`. secondary_products: DashMap>>, } @@ -76,6 +79,7 @@ where unit_lift, res_sseq: Mutex::new(None), unit_sseq: Mutex::new(None), + lambda2_sseq: Mutex::new(None), secondary_products: DashMap::new(), } } @@ -96,6 +100,8 @@ where Arc::new(self.unit_lift.e3_page()) }; *self.unit_sseq.lock().unwrap() = Some(unit); + + *self.lambda2_sseq.lock().unwrap() = Some(Arc::new(self.build_lambda2_sseq())); } /// Sharding entry point: compute only the secondary resolution data for filtration `s`, @@ -289,6 +295,94 @@ where let d = sseq.page_data(b); &d[std::cmp::min(3, d.len() - 1)] } + + /// Build the trigraded spectral sequence for $S/\lambda^2$. + /// + /// E2 has two copies of Ext at each bidegree $(n, s)$: one at Bockstein degree 0 and one at + /// Bockstein degree 1. The $d_2$ differential maps $(n, s, 0) \to (n-1, s+2, 1)$ using the + /// same hom\_k data as the Adams $d_2$. The E3 page (which equals $E_\infty$) gives + /// $\pi(S/\lambda^2)$. + fn build_lambda2_sseq(&self) -> sseq::Sseq<3, sseq::AdamsLambda2> { + let p = self.prime(); + let res = self.alg.resolution(); + let mut sseq = sseq::Sseq::new(p); + + for b in res.iter_stem() { + let dim = res.number_of_gens_in_bidegree(b); + let [n, s] = b.coords(); + sseq.set_dimension(MultiDegree::new([n, s, 0]), dim); + sseq.set_dimension(MultiDegree::new([n, s, 1]), dim); + } + + let mut source_vec = FpVector::new(p, 0); + let mut target_vec = FpVector::new(p, 0); + + for b in res.iter_stem() { + let target_bidegree = b + Bidegree::n_s(-1, 2); + if b.t() > 0 && res.has_computed_bidegree(target_bidegree) { + let m = self.res_lift.homotopy(b.s() + 2).homotopies.hom_k(b.t()); + if m.is_empty() || m[0].is_empty() { + continue; + } + + let [n, s] = b.coords(); + source_vec.set_scratch_vector_size(m.len()); + target_vec.set_scratch_vector_size(m[0].len()); + + for (i, row) in m.into_iter().enumerate() { + source_vec.set_to_zero(); + source_vec.set_entry(i, 1); + target_vec.copy_from_slice(&row); + + let source = + MultiDegreeElement::new(MultiDegree::new([n, s, 0]), source_vec); + sseq.add_differential(2, &source, target_vec.as_slice()); + + source_vec = source.into_vec(); + } + } + } + + let invalid: Vec<_> = sseq + .iter_degrees() + .filter(|&b| sseq.invalid(b)) + .collect(); + for b in invalid { + sseq.update_degree(b); + } + + sseq + } + + /// The trigraded spectral sequence for $S/\lambda^2$. + pub fn lambda2_sseq(&self) -> Arc> { + Arc::clone( + self.lambda2_sseq + .lock() + .unwrap() + .as_ref() + .expect("call extend_all() first"), + ) + } + + /// The $E_3$-page subquotient at a trigraded degree `(n, s, bock)` of $S/\lambda^2$. + /// Returns `None` if the degree is not defined in the spectral sequence. + pub fn lambda2_page_data(&self, b: MultiDegree<3>) -> Option { + let g = self.lambda2_sseq.lock().unwrap(); + let sseq = g.as_ref().expect("call extend_all() first"); + if !sseq.defined(b) { + return None; + } + let d = sseq.page_data(b); + Some(d[std::cmp::min(3, d.len() - 1)].clone()) + } + + /// The dimension of $E_3 = E_\infty$ of $S/\lambda^2$ at the trigraded degree `(n, s, bock)`. + /// Returns 0 if the degree is not defined. + pub fn lambda2_e3_dimension(&self, n: i32, s: i32, bock: i32) -> usize { + self.lambda2_page_data(MultiDegree::new([n, s, bock])) + .map_or(0, |sq| sq.dimension()) + } } impl SecondaryExtAlgebra @@ -389,7 +483,7 @@ mod tests { use sseq::coordinates::BidegreeGenerator; use super::*; - use crate::utils::construct_standard; + use crate::{chain_complex::ChainComplex, utils::construct_standard}; #[test] fn test_sphere_d2() { @@ -477,4 +571,71 @@ mod tests { } } } + + #[test] + fn test_lambda2_sseq() { + 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), res)); + let sec_e2 = SecondaryExtAlgebra::new(Arc::clone(&e2)); + sec_e2.extend_all(); + + let _l2 = sec_e2.lambda2_sseq(); + + // Structural check: at each bidegree (n, s), + // E3(n, s, 0) = ker(d2 from (n,s)) — d2-cycles at bock=0 + // E3(n, s, 1) = coker(d2 into (n,s)) — quotient by boundaries at bock=1 + // + // d2 maps E_{(n,s)} isomorphically to B_{(n-1, s+2)}, so: + // dim(Ext(n,s)) - dim(E3(n,s,0)) = dim(Ext(n-1,s+2)) - dim(E3(n-1,s+2,1)) + for b in e2.resolution().iter_stem() { + let ext_dim = e2.dimension(b); + let [n, s] = b.coords(); + + // dim(E) at (n, s) = dim(Ext) - dim(ker d2) = ext_dim - dim(E3(n,s,0)) + let e3_bock0 = sec_e2.lambda2_e3_dimension(n, s, 0); + let e_dim = ext_dim - e3_bock0; + + // dim(B) at (n-1, s+2) = dim(im d2) = dim(Ext(n-1,s+2)) - dim(E3(n-1,s+2,1)) + let target = b + Bidegree::n_s(-1, 2); + if e2.resolution().has_computed_bidegree(target) { + let target_ext_dim = e2.dimension(target); + let [tn, ts] = target.coords(); + let e3_target_bock1 = sec_e2.lambda2_e3_dimension(tn, ts, 1); + let b_dim = target_ext_dim - e3_target_bock1; + + assert_eq!( + e_dim, b_dim, + "dim(E at ({n},{s})) should equal dim(B at ({tn},{ts})): \ + d2: E → B is an isomorphism" + ); + } + + // dim(ker d2) + dim(E) = dim(Ext) always holds. + assert_eq!( + e3_bock0 + e_dim, + ext_dim, + "dim(ker d2) + dim(E) = dim(Ext) at ({n}, {s})" + ); + } + + // h4 at (15, 1) supports d2, so E3(15, 1, 0) should be 0. + assert_eq!( + sec_e2.lambda2_e3_dimension(15, 1, 0), + 0, + "h4 should not survive in E3 at bock=0" + ); + + // d2(h4) = h0*h3^2 lands at (14, 3, 1), quotienting out one generator. + let ext_dim_14_3 = e2.dimension(Bidegree::n_s(14, 3)); + assert_eq!( + sec_e2.lambda2_e3_dimension(14, 3, 1), + ext_dim_14_3 - 1, + "d2 image should quotient out one generator at (14, 3, 1)" + ); + + // h_0 at (0, 1) is a permanent cycle. + assert_eq!(sec_e2.lambda2_e3_dimension(0, 1, 0), 1); + assert_eq!(sec_e2.lambda2_e3_dimension(0, 1, 1), 1); + } } From 80ae500b03ba1598cf9100f58bf0ac9f990cba5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:26:45 +0000 Subject: [PATCH 03/13] Add BZE classify API, fix secondary lift edge case, improve lambda2 example Move the B/Z/E classification into SecondaryExtAlgebra as a public API (BZE enum + classify method) rather than keeping it inline in the example. Fix a panic in SecondaryLift::compute_homotopies when the homotopy range is empty (min.s >= max.s), which occurred for multipliers at high filtration near the resolution boundary. Rewrite the lambda2 example's Table IV to use proper range guards matching secondary_product. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_017kezHiWcYod7hYovRPczz9 --- ext/examples/lambda2.rs | 125 ++++++++++++++----------------- ext/src/ext_algebra/mod.rs | 2 +- ext/src/ext_algebra/secondary.rs | 39 ++++++++++ ext/src/secondary.rs | 4 +- 4 files changed, 100 insertions(+), 70 deletions(-) diff --git a/ext/examples/lambda2.rs b/ext/examples/lambda2.rs index 0a631c70a0..1e56b36e1a 100644 --- a/ext/examples/lambda2.rs +++ b/ext/examples/lambda2.rs @@ -20,10 +20,13 @@ use std::sync::Arc; use algebra::module::Module; use ext::{ chain_complex::{ChainComplex, FreeChainComplex}, - ext_algebra::{ExtAlgebra, secondary::SecondaryExtAlgebra}, + ext_algebra::{ + BZE, ExtAlgebra, + secondary::SecondaryExtAlgebra, + }, + secondary::LAMBDA_BIDEGREE, utils::query_module, }; -use fp::matrix::Subquotient; use sseq::coordinates::{Bidegree, BidegreeGenerator, MultiDegree}; fn main() -> anyhow::Result<()> { @@ -85,23 +88,12 @@ where CC::Algebra: algebra::pair_algebra::PairAlgebra, { for b in e2.resolution().iter_nonzero_stem() { - let page = sec_e2.page_data(b); let dim = e2.dimension(b); - if dim == 0 { - continue; - } - for i in 0..dim { let g = BidegreeGenerator::new(b, i); - let class_type = classify_generator(&page, i); - - match class_type { - BZE::Z => { - println!("Z x_{g}"); - } - BZE::B => { - println!("B x_{g}"); - } + match sec_e2.classify(g) { + BZE::Z => println!("Z x_{g}"), + BZE::B => println!("B x_{g}"), BZE::E => { let elem = e2.generator(g); if let Some(d2) = sec_e2.d2(&elem) { @@ -116,30 +108,6 @@ where } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum BZE { - B, - Z, - E, -} - -fn classify_generator(page: &Subquotient, idx: usize) -> BZE { - // B: in the quotient (boundary) - // Z: a gen (cycle, not boundary) - // E: in the complement (supports d2) - - // Check if this generator index is a pivot of the quotient (boundary) - if page.zeros().pivots()[idx] >= 0 { - return BZE::B; - } - // Check if this generator index is in the complement (not a cycle) - if page.complement_pivots().any(|p| p == idx) { - return BZE::E; - } - // Otherwise it's a cycle that's not a boundary - BZE::Z -} - /// Table II: Products in Ext expressed in the standard basis. /// /// For each pair of generators (x, y) with x ≤ y (by bidegree then index), computes x · y. @@ -170,12 +138,12 @@ where /// Table III: Conical basis for π(S/λ²). /// -/// Lists the E3 = E∞ generators of S/λ² at each tridegree (n, s, bock). -/// Elements of the conical basis Xπ come in four types per the paper's Condition 3.2: -/// - x⁰_π for x ∈ B: bockstein = 0 -/// - x⁰_π for x ∈ Z: bockstein = 0 -/// - x¹_π for x ∈ Z: bockstein = 1 -/// - x¹_π for x ∈ E: bockstein = 1 +/// Lists the E3 = E∞ generators of S/λ² at each tridegree (n, s, bock), annotated with their +/// B/Z/E type. The conical basis Xπ has four element types per Condition 3.2 of the paper: +/// - x⁰_π for x ∈ B: (n, s, 0) +/// - x⁰_π for x ∈ Z: (n, s, 0) +/// - x¹_π for x ∈ Z: (n, s, 1) +/// - x¹_π for x ∈ E: (n, s, 1) fn print_table_iii(e2: &ExtAlgebra, sec_e2: &SecondaryExtAlgebra) where CC: FreeChainComplex + ext::chain_complex::AugmentedChainComplex, @@ -183,15 +151,23 @@ where { for b in e2.resolution().iter_stem() { let [n, s] = b.coords(); + let dim = e2.dimension(b); + if dim == 0 { + continue; + } + // bock=0: surviving classes are B ⨿ Z (d2-cycles). + // bock=1: surviving classes are Z ⨿ E (cokernel of d2). for bock in [0, 1] { - let dim = sec_e2.lambda2_e3_dimension(n, s, bock); - if dim > 0 { - if let Some(pd) = sec_e2.lambda2_page_data(MultiDegree::new([n, s, bock])) { - for (idx, v) in pd.gens().enumerate() { - let coords: Vec = v.iter().collect(); - println!("({n}, {s}, {bock}) gen {idx} {coords:?}"); - } + let e3_dim = sec_e2.lambda2_e3_dimension(n, s, bock); + if e3_dim == 0 { + continue; + } + + if let Some(pd) = sec_e2.lambda2_page_data(MultiDegree::new([n, s, bock])) { + for (idx, v) in pd.gens().enumerate() { + let coords: Vec = v.iter().collect(); + println!("({n}, {s}, {bock}) gen {idx} {coords:?}"); } } } @@ -200,36 +176,49 @@ where /// Table IV: Products in π(S/λ²) with commutators. /// -/// For each pair (α, β) of conical basis elements with both in bockstein 0, -/// computes α · β using the secondary product and the commutator α·β - β·α. +/// For each pair (x, y) where x ∈ Z (surviving cycle) and y runs over E3-surviving classes of +/// the unit at each bidegree, computes the secondary product x · y. The secondary product lives +/// in Mod_{Cλ²}: it has an Ext part and a λ part. +/// +/// The commutator x·y − y·x = 2·x·y when both stems are odd (Proposition 6.4). fn print_table_iv(e2: &ExtAlgebra, sec_e2: &SecondaryExtAlgebra) where CC: FreeChainComplex + ext::chain_complex::AugmentedChainComplex, CC::Algebra: algebra::pair_algebra::PairAlgebra, { - // Collect all surviving generators (Z classes) at bock=0 in the bigraded page. - let survivors: Vec = e2 + // Secondary products need s ≥ 1 for the multiplier (the secondary lift's shift is s+1, + // and secondary homotopies start at s=2). + let z_gens: Vec = e2 .resolution() .iter_nonzero_stem() + .filter(|b| b.s() >= 1) .flat_map(|b| { - let page = sec_e2.page_data(b); let dim = e2.dimension(b); (0..dim) - .filter(move |&i| classify_generator(&page, i) != BZE::E) + .filter(move |&i| sec_e2.classify(BidegreeGenerator::new(b, i)) == BZE::Z) .map(move |i| BidegreeGenerator::new(b, i)) }) .collect(); - for (idx_x, &x_gen) in survivors.iter().enumerate() { + for &x_gen in &z_gens { let x = e2.generator(x_gen); - // Only compute secondary products for d2-cycles (Z generators, not B). - let x_page = sec_e2.page_data(x_gen.degree()); - if classify_generator(&x_page, x_gen.idx()) != BZE::Z { - continue; - } - for &y_gen in &survivors[idx_x..] { - // The secondary product x · y: iterate over the secondary multiply output. - for prod in sec_e2.secondary_multiply_into(&x, y_gen.degree()) { + let shift = x.degree(); + + for b in e2.unit().iter_nonzero_stem() { + if !e2.resolution().has_computed_bidegree(b + shift + LAMBDA_BIDEGREE) { + continue; + } + if !e2.resolution().has_computed_bidegree(b + shift - Bidegree::s_t(1, 0)) { + continue; + } + + let target_dim = e2.dimension(b + shift); + let lambda_dim = e2.dimension(b + shift + LAMBDA_BIDEGREE); + if target_dim == 0 && lambda_dim == 0 { + continue; + } + + for prod in sec_e2.secondary_multiply_into(&x, b) { let ext: Vec = prod.ext_part.iter().collect(); let lambda: Vec = prod.lambda_part.iter().collect(); if ext.iter().any(|&c| c != 0) || lambda.iter().any(|&c| c != 0) { diff --git a/ext/src/ext_algebra/mod.rs b/ext/src/ext_algebra/mod.rs index 96f910cd74..e014697c8b 100644 --- a/ext/src/ext_algebra/mod.rs +++ b/ext/src/ext_algebra/mod.rs @@ -28,7 +28,7 @@ use dashmap::DashMap; use fp::{matrix::Matrix, prime::ValidPrime, vector::FpVector}; use sseq::coordinates::{Bidegree, BidegreeElement, BidegreeGenerator}; -pub use self::secondary::{SecondaryExtAlgebra, SecondaryProduct}; +pub use self::secondary::{BZE, SecondaryExtAlgebra, SecondaryProduct}; pub use crate::secondary::{SecondaryDegree, SecondaryElement, SecondaryGenerator, Weight}; use crate::{ chain_complex::{AugmentedChainComplex, FreeChainComplex}, diff --git a/ext/src/ext_algebra/secondary.rs b/ext/src/ext_algebra/secondary.rs index e65a2f0ea7..46847c4a69 100644 --- a/ext/src/ext_algebra/secondary.rs +++ b/ext/src/ext_algebra/secondary.rs @@ -19,6 +19,8 @@ use dashmap::DashMap; use fp::{matrix::Subquotient, prime::Prime, vector::FpVector}; use sseq::coordinates::{Bidegree, BidegreeElement, MultiDegree, MultiDegreeElement}; +use sseq::coordinates::BidegreeGenerator; + use super::ExtAlgebra; use crate::{ chain_complex::FreeChainComplex, @@ -29,6 +31,30 @@ use crate::{ }, }; +/// The classification of an Ext generator in the $d_2$-adapted B/Z/E decomposition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BZE { + /// Boundary: in the image of $d_2$ from another bidegree. + B, + /// Cycle mod boundary: survives to $E_3$. + Z, + /// Supports $d_2$: $d_2(x) \neq 0$. + E, +} + +impl BZE { + /// Classify a standard-basis generator `idx` from its $E_3$-page subquotient. + pub fn from_page_data(page: &Subquotient, idx: usize) -> Self { + if page.zeros().pivots()[idx] >= 0 { + return Self::B; + } + if page.complement_pivots().any(|p| p == idx) { + return Self::E; + } + Self::Z + } +} + /// 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 { @@ -296,6 +322,19 @@ where &d[std::cmp::min(3, d.len() - 1)] } + /// Classify a generator at bidegree `b` as B, Z, or E in the $d_2$ decomposition. + /// + /// - **B** (boundary): in the image of $d_2$ from another bidegree. + /// - **Z** (cycle mod boundary): a $d_2$-cycle that is not a boundary; survives to $E_3$. + /// - **E** (supports $d_2$): $d_2(x) \neq 0$. + /// + /// At each bidegree, $\Ext = B \oplus Z \oplus E$ and $d_2$ restricts to an isomorphism + /// $E_{(n,s)} \to B_{(n-1,s+2)}$. + pub fn classify(&self, g: BidegreeGenerator) -> BZE { + let page = self.page_data(g.degree()); + BZE::from_page_data(&page, g.idx()) + } + /// Build the trigraded spectral sequence for $S/\lambda^2$. /// /// E2 has two copies of Ext at each bidegree $(n, s)$: one at Bockstein degree 0 and one at diff --git a/ext/src/secondary.rs b/ext/src/secondary.rs index ff5a443129..0945b28f83 100644 --- a/ext/src/secondary.rs +++ b/ext/src/secondary.rs @@ -908,7 +908,9 @@ pub trait SecondaryLift: Sync + Sized { let s_range = self.homotopies().range(); let min = Bidegree::s_t(s_range.start + 1, min_t); let max = self.max().restrict(s_range.end); - sseq::coordinates::iter_s_t(&|b| self.compute_homotopy_step(b), min, max); + if min.s() < max.s() { + sseq::coordinates::iter_s_t(&|b| self.compute_homotopy_step(b), min, max); + } } #[tracing::instrument(skip(self))] From 6543892c11aadb7dbf9c8f4c052a4af7df904f26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:38:50 +0000 Subject: [PATCH 04/13] Use BZE classification in secondary examples, improve lambda2 tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update secondary.rs and secondary_product.rs to use the BZE enum for classified output, labelling each result as B/Z/E. This replaces the old unclassified d2 output in secondary.rs and the complement_pivots iteration in secondary_product.rs with the uniform BZE::from_page_data API. Also improve lambda2 Table III to annotate conical basis entries with their B/Z/E type (bock=0: B and Z survive; bock=1: Z and E survive), and Table IV to include commutator information per Proposition 6.4 ([x,y] = 2(x·y) when both stems are odd, 0 otherwise). Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_017kezHiWcYod7hYovRPczz9 --- ext/examples/lambda2.rs | 49 +++++++++++++++++++------------ ext/examples/secondary.rs | 36 ++++++++++------------- ext/examples/secondary_product.rs | 28 +++++++++++++----- 3 files changed, 68 insertions(+), 45 deletions(-) diff --git a/ext/examples/lambda2.rs b/ext/examples/lambda2.rs index 1e56b36e1a..3192468df7 100644 --- a/ext/examples/lambda2.rs +++ b/ext/examples/lambda2.rs @@ -27,7 +27,7 @@ use ext::{ secondary::LAMBDA_BIDEGREE, utils::query_module, }; -use sseq::coordinates::{Bidegree, BidegreeGenerator, MultiDegree}; +use sseq::coordinates::{Bidegree, BidegreeGenerator}; fn main() -> anyhow::Result<()> { ext::utils::init_logging()?; @@ -156,19 +156,24 @@ where continue; } - // bock=0: surviving classes are B ⨿ Z (d2-cycles). - // bock=1: surviving classes are Z ⨿ E (cokernel of d2). - for bock in [0, 1] { - let e3_dim = sec_e2.lambda2_e3_dimension(n, s, bock); - if e3_dim == 0 { - continue; - } + for i in 0..dim { + let g = BidegreeGenerator::new(b, i); + let bze = sec_e2.classify(g); - if let Some(pd) = sec_e2.lambda2_page_data(MultiDegree::new([n, s, bock])) { - for (idx, v) in pd.gens().enumerate() { - let coords: Vec = v.iter().collect(); - println!("({n}, {s}, {bock}) gen {idx} {coords:?}"); + // bock=0: B and Z survive (d2-cycles). + match bze { + BZE::B | BZE::Z => { + let label = if bze == BZE::B { "B" } else { "Z" }; + println!("{label} x_{g}^0 ({n}, {s}, 0)"); } + BZE::E => {} + } + + // bock=1: Z and E survive (cokernel of d2). + match bze { + BZE::Z => println!("Z x_{g}^1 ({n}, {s}, 1)"), + BZE::E => println!("E x_{g}^1 ({n}, {s}, 1)"), + BZE::B => {} } } } @@ -180,7 +185,8 @@ where /// the unit at each bidegree, computes the secondary product x · y. The secondary product lives /// in Mod_{Cλ²}: it has an Ext part and a λ part. /// -/// The commutator x·y − y·x = 2·x·y when both stems are odd (Proposition 6.4). +/// The commutator [x, y] = x·y − y·x equals 2·(x·y) when both stems are odd (Proposition 6.4), +/// and vanishes when at least one stem is even. fn print_table_iv(e2: &ExtAlgebra, sec_e2: &SecondaryExtAlgebra) where CC: FreeChainComplex + ext::chain_complex::AugmentedChainComplex, @@ -218,15 +224,22 @@ where continue; } + // Proposition 6.4: [x, y] = x·y − y·x = 2·(x·y) when both stems are odd, + // and [x, y] = 0 when at least one stem is even. + let both_odd = shift.n() % 2 != 0 && b.n() % 2 != 0; + for prod in sec_e2.secondary_multiply_into(&x, b) { let ext: Vec = prod.ext_part.iter().collect(); let lambda: Vec = prod.lambda_part.iter().collect(); - if ext.iter().any(|&c| c != 0) || lambda.iter().any(|&c| c != 0) { - println!( - "x_{x_gen} · [{src}] = {ext:?} + λ {lambda:?}", - src = prod.source.to_basis_string(), - ); + if ext.iter().all(|&c| c == 0) && lambda.iter().all(|&c| c == 0) { + continue; } + + let comm = if both_odd { "[x,y] = 2(x·y)" } else { "[x,y] = 0" }; + println!( + "x_{x_gen} · [{src}] = {ext:?} + λ {lambda:?} {comm}", + src = prod.source.to_basis_string(), + ); } } } diff --git a/ext/examples/secondary.rs b/ext/examples/secondary.rs index 44c5cc3b0d..ee9f3eb91a 100644 --- a/ext/examples/secondary.rs +++ b/ext/examples/secondary.rs @@ -47,13 +47,12 @@ use std::sync::Arc; -use algebra::module::Module; use ext::{ - chain_complex::{ChainComplex, FreeChainComplex}, - ext_algebra::{ExtAlgebra, secondary::SecondaryExtAlgebra}, + chain_complex::FreeChainComplex, + ext_algebra::{BZE, ExtAlgebra, secondary::SecondaryExtAlgebra}, utils::query_module, }; -use sseq::coordinates::Bidegree; +use sseq::coordinates::BidegreeGenerator; fn main() -> anyhow::Result<()> { ext::utils::init_logging()?; @@ -72,24 +71,21 @@ fn main() -> anyhow::Result<()> { sec_e2.extend_all(); let e2 = sec_e2.ext_algebra(); - let d2_shift = Bidegree::n_s(-1, 2); - // Iterate through the target of the d2, in the same order as before. for b in e2.resolution().iter_nonzero_stem() { - if b.s() < 3 { - continue; - } - - // The source of the d2 must be computed (its degree may exceed what `module(b.s() - 2)` - // reaches when resolving through a stem). - if b.t() - 1 > e2.resolution().module(b.s() - 2).max_computed_degree() { - continue; - } - - for g in e2.basis(b - d2_shift) { - if let Some(dx) = sec_e2.d2(&e2.generator(g)) { - let entry: Vec = dx.vec().iter().collect(); - println!("d_2 x_{g} = {entry:?}"); + for i in 0..e2.dimension(b) { + let g = BidegreeGenerator::new(b, i); + match sec_e2.classify(g) { + BZE::Z => println!("Z x_{g}"), + BZE::B => println!("B x_{g}"), + BZE::E => { + if let Some(dx) = sec_e2.d2(&e2.generator(g)) { + let entry: Vec = dx.vec().iter().collect(); + println!("E d_2 x_{g} = {entry:?}"); + } else { + println!("E x_{g} d2 = (not computed)"); + } + } } } } diff --git a/ext/examples/secondary_product.rs b/ext/examples/secondary_product.rs index 9af5ee8bba..871c168f84 100644 --- a/ext/examples/secondary_product.rs +++ b/ext/examples/secondary_product.rs @@ -25,7 +25,7 @@ use std::sync::Arc; use algebra::module::Module; use ext::{ chain_complex::{ChainComplex, FreeChainComplex}, - ext_algebra::{ExtAlgebra, SecondaryExtAlgebra}, + ext_algebra::{BZE, ExtAlgebra, SecondaryExtAlgebra}, secondary::{LAMBDA_BIDEGREE, SecondaryLift}, utils::query_module, }; @@ -98,22 +98,36 @@ fn main() -> anyhow::Result<()> { let page = sec_e2.unit_page_data(b); - // First the products with non-surviving classes: these are just λ times the (primary) - // product, read off the multiplication map. + // Products with non-surviving classes (E): just λ times the primary product. if target_num_gens > 0 && let Some(rows) = e2.multiply_into(&x, b) { - for i in page.complement_pivots() { + for i in 0..e2.unit_dimension(b) { + if BZE::from_page_data(&page, i) != BZE::E { + continue; + } let g = BidegreeGenerator::new(b, i); let entry: Vec = rows.row(i).iter().collect(); - println!("{disp} λ x_{g} = λ {entry:?}"); + println!("E {disp} λ x_{g} = λ {entry:?}"); } } - // Now the genuinely secondary products with surviving classes. + // Secondary products with surviving classes (B and Z). for prod in sec_e2.secondary_multiply_into(&x, b) { + let bze = prod + .source + .vec() + .iter_nonzero() + .next() + .map(|(i, _)| BZE::from_page_data(&page, i)) + .unwrap_or(BZE::Z); + let label = match bze { + BZE::B => "B", + BZE::Z => "Z", + BZE::E => "E", + }; println!( - "{disp} [{basis}] = {value}", + "{label} {disp} [{basis}] = {value}", basis = prod.source.to_basis_string(), value = prod.value, ); From 41a076bab2b6aff258d6205b5aa0a84e6ea47546 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 01:13:31 +0000 Subject: [PATCH 05/13] Filter secondary output to nonzero d2 targets, update benchmarks Restore the original filtering in the secondary example: only print generators whose d2 target bidegree has nonzero dimension, keeping the output focused on where d2 can be nontrivial. Each such generator is now labelled B/Z/E. Update the four secondary benchmark files to match the new output format. Fix rustfmt issues in lambda2.rs and secondary.rs. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_017kezHiWcYod7hYovRPczz9 --- ext/examples/benchmarks/secondary-C2 | 22 ++++++------- ext/examples/benchmarks/secondary-C2-nassau | 22 ++++++------- ext/examples/benchmarks/secondary-S_2 | 36 ++++++++++----------- ext/examples/benchmarks/secondary-tmf | 24 +++++++------- ext/examples/lambda2.rs | 21 ++++++++---- ext/examples/secondary.rs | 19 ++++++++--- ext/src/ext_algebra/secondary.rs | 8 ++--- 7 files changed, 83 insertions(+), 69 deletions(-) diff --git a/ext/examples/benchmarks/secondary-C2 b/ext/examples/benchmarks/secondary-C2 index 397ccad84a..9b2ab4a613 100644 --- a/ext/examples/benchmarks/secondary-C2 +++ b/ext/examples/benchmarks/secondary-C2 @@ -1,12 +1,12 @@ secondary -- C2 "" 30 7 "" -d_2 x_(9, 2, 0) = [0] -d_2 x_(10, 3, 0) = [0] -d_2 x_(17, 3, 0) = [0] -d_2 x_(17, 4, 0) = [1] -d_2 x_(18, 2, 0) = [0] -d_2 x_(18, 4, 0) = [0] -d_2 x_(19, 5, 0) = [1] -d_2 x_(20, 3, 0) = [0] -d_2 x_(22, 3, 0) = [0] -d_2 x_(22, 4, 0) = [0] -d_2 x_(24, 5, 0) = [0] +Z x_(9, 2, 0) +Z x_(10, 3, 0) +Z x_(17, 3, 0) +E d_2 x_(17, 4, 0) = [1] +Z x_(18, 2, 0) +Z x_(18, 4, 0) +E d_2 x_(19, 5, 0) = [1] +Z x_(20, 3, 0) +Z x_(22, 3, 0) +Z x_(22, 4, 0) +Z x_(24, 5, 0) diff --git a/ext/examples/benchmarks/secondary-C2-nassau b/ext/examples/benchmarks/secondary-C2-nassau index 0dc2bb66f5..b96cd88bc0 100644 --- a/ext/examples/benchmarks/secondary-C2-nassau +++ b/ext/examples/benchmarks/secondary-C2-nassau @@ -1,12 +1,12 @@ secondary -- C2 /tmp/test_nassau_c2 30 7 "" -d_2 x_(9, 2, 0) = [0] -d_2 x_(10, 3, 0) = [0] -d_2 x_(17, 3, 0) = [0] -d_2 x_(17, 4, 0) = [1] -d_2 x_(18, 2, 0) = [0] -d_2 x_(18, 4, 0) = [0] -d_2 x_(19, 5, 0) = [1] -d_2 x_(20, 3, 0) = [0] -d_2 x_(22, 3, 0) = [0] -d_2 x_(22, 4, 0) = [0] -d_2 x_(24, 5, 0) = [0] +Z x_(9, 2, 0) +Z x_(10, 3, 0) +Z x_(17, 3, 0) +E d_2 x_(17, 4, 0) = [1] +Z x_(18, 2, 0) +Z x_(18, 4, 0) +E d_2 x_(19, 5, 0) = [1] +Z x_(20, 3, 0) +Z x_(22, 3, 0) +Z x_(22, 4, 0) +Z x_(24, 5, 0) diff --git a/ext/examples/benchmarks/secondary-S_2 b/ext/examples/benchmarks/secondary-S_2 index 5586a84778..4700fd3317 100644 --- a/ext/examples/benchmarks/secondary-S_2 +++ b/ext/examples/benchmarks/secondary-S_2 @@ -1,19 +1,19 @@ secondary -- S_2 "" 30 7 "" -d_2 x_(1, 1, 0) = [0] -d_2 x_(8, 2, 0) = [0] -d_2 x_(15, 1, 0) = [1] -d_2 x_(15, 2, 0) = [0] -d_2 x_(15, 3, 0) = [0] -d_2 x_(15, 4, 0) = [0] -d_2 x_(16, 2, 0) = [0] -d_2 x_(17, 4, 0) = [1] -d_2 x_(17, 5, 0) = [0] -d_2 x_(18, 2, 0) = [0] -d_2 x_(18, 3, 0) = [0] -d_2 x_(18, 4, 0) = [0] -d_2 x_(18, 4, 1) = [1] -d_2 x_(18, 5, 0) = [1] -d_2 x_(19, 3, 0) = [0] -d_2 x_(21, 3, 0) = [0] -d_2 x_(24, 5, 0) = [0] -d_2 x_(30, 5, 0) = [0] +Z x_(1, 1, 0) +Z x_(8, 2, 0) +E d_2 x_(15, 1, 0) = [1] +Z x_(15, 2, 0) +Z x_(15, 3, 0) +Z x_(15, 4, 0) +Z x_(16, 2, 0) +E d_2 x_(17, 4, 0) = [1] +Z x_(17, 5, 0) +Z x_(18, 2, 0) +Z x_(18, 3, 0) +Z x_(18, 4, 0) +E d_2 x_(18, 4, 1) = [1] +E d_2 x_(18, 5, 0) = [1] +Z x_(19, 3, 0) +Z x_(21, 3, 0) +Z x_(24, 5, 0) +Z x_(30, 5, 0) diff --git a/ext/examples/benchmarks/secondary-tmf b/ext/examples/benchmarks/secondary-tmf index 38960aa4f8..e65e8c6afb 100644 --- a/ext/examples/benchmarks/secondary-tmf +++ b/ext/examples/benchmarks/secondary-tmf @@ -1,13 +1,13 @@ secondary -- A-mod-Sq1-Sq2-Sq4 "" 30 7 "" -d_2 x_(1, 1, 0) = [0] -d_2 x_(9, 4, 0) = [0] -d_2 x_(9, 5, 0) = [0] -d_2 x_(12, 3, 0) = [1] -d_2 x_(12, 4, 0) = [1] -d_2 x_(12, 5, 0) = [1] -d_2 x_(15, 3, 0) = [1] -d_2 x_(15, 4, 0) = [1] -d_2 x_(17, 5, 0) = [0] -d_2 x_(18, 4, 0) = [1] -d_2 x_(21, 5, 0) = [0] -d_2 x_(25, 5, 0) = [0] +Z x_(1, 1, 0) +Z x_(9, 4, 0) +Z x_(9, 5, 0) +E d_2 x_(12, 3, 0) = [1] +E d_2 x_(12, 4, 0) = [1] +E d_2 x_(12, 5, 0) = [1] +E d_2 x_(15, 3, 0) = [1] +E d_2 x_(15, 4, 0) = [1] +Z x_(17, 5, 0) +E d_2 x_(18, 4, 0) = [1] +Z x_(21, 5, 0) +Z x_(25, 5, 0) diff --git a/ext/examples/lambda2.rs b/ext/examples/lambda2.rs index 3192468df7..fa27866010 100644 --- a/ext/examples/lambda2.rs +++ b/ext/examples/lambda2.rs @@ -20,10 +20,7 @@ use std::sync::Arc; use algebra::module::Module; use ext::{ chain_complex::{ChainComplex, FreeChainComplex}, - ext_algebra::{ - BZE, ExtAlgebra, - secondary::SecondaryExtAlgebra, - }, + ext_algebra::{BZE, ExtAlgebra, secondary::SecondaryExtAlgebra}, secondary::LAMBDA_BIDEGREE, utils::query_module, }; @@ -211,10 +208,16 @@ where let shift = x.degree(); for b in e2.unit().iter_nonzero_stem() { - if !e2.resolution().has_computed_bidegree(b + shift + LAMBDA_BIDEGREE) { + if !e2 + .resolution() + .has_computed_bidegree(b + shift + LAMBDA_BIDEGREE) + { continue; } - if !e2.resolution().has_computed_bidegree(b + shift - Bidegree::s_t(1, 0)) { + if !e2 + .resolution() + .has_computed_bidegree(b + shift - Bidegree::s_t(1, 0)) + { continue; } @@ -235,7 +238,11 @@ where continue; } - let comm = if both_odd { "[x,y] = 2(x·y)" } else { "[x,y] = 0" }; + let comm = if both_odd { + "[x,y] = 2(x·y)" + } else { + "[x,y] = 0" + }; println!( "x_{x_gen} · [{src}] = {ext:?} + λ {lambda:?} {comm}", src = prod.source.to_basis_string(), diff --git a/ext/examples/secondary.rs b/ext/examples/secondary.rs index ee9f3eb91a..a143f166a9 100644 --- a/ext/examples/secondary.rs +++ b/ext/examples/secondary.rs @@ -47,12 +47,13 @@ use std::sync::Arc; +use algebra::module::Module; use ext::{ - chain_complex::FreeChainComplex, + chain_complex::{ChainComplex, FreeChainComplex}, ext_algebra::{BZE, ExtAlgebra, secondary::SecondaryExtAlgebra}, utils::query_module, }; -use sseq::coordinates::BidegreeGenerator; +use sseq::coordinates::{Bidegree, BidegreeGenerator}; fn main() -> anyhow::Result<()> { ext::utils::init_logging()?; @@ -71,10 +72,20 @@ fn main() -> anyhow::Result<()> { sec_e2.extend_all(); let e2 = sec_e2.ext_algebra(); + let d2_shift = Bidegree::n_s(-1, 2); + // Iterate through the target of the d2. We omit elements whose d2 target bidegree is zero. for b in e2.resolution().iter_nonzero_stem() { - for i in 0..e2.dimension(b) { - let g = BidegreeGenerator::new(b, i); + if b.s() < 3 { + continue; + } + + if b.t() - 1 > e2.resolution().module(b.s() - 2).max_computed_degree() { + continue; + } + + for i in 0..e2.dimension(b - d2_shift) { + let g = BidegreeGenerator::new(b - d2_shift, i); match sec_e2.classify(g) { BZE::Z => println!("Z x_{g}"), BZE::B => println!("B x_{g}"), diff --git a/ext/src/ext_algebra/secondary.rs b/ext/src/ext_algebra/secondary.rs index 46847c4a69..7683706343 100644 --- a/ext/src/ext_algebra/secondary.rs +++ b/ext/src/ext_algebra/secondary.rs @@ -373,8 +373,7 @@ where source_vec.set_entry(i, 1); target_vec.copy_from_slice(&row); - let source = - MultiDegreeElement::new(MultiDegree::new([n, s, 0]), source_vec); + let source = MultiDegreeElement::new(MultiDegree::new([n, s, 0]), source_vec); sseq.add_differential(2, &source, target_vec.as_slice()); source_vec = source.into_vec(); @@ -382,10 +381,7 @@ where } } - let invalid: Vec<_> = sseq - .iter_degrees() - .filter(|&b| sseq.invalid(b)) - .collect(); + let invalid: Vec<_> = sseq.iter_degrees().filter(|&b| sseq.invalid(b)).collect(); for b in invalid { sseq.update_degree(b); } From 24a140a91c8485a0a39282d0d26f3a118703e7a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 18:47:21 +0000 Subject: [PATCH 06/13] Adapt to SecondaryElement API from PR #250 Use prod.value (SecondaryElement) instead of the removed ext_part/lambda_part fields in lambda2.rs Table IV. Fix nightly rustfmt import grouping. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_017kezHiWcYod7hYovRPczz9 --- ext/examples/lambda2.rs | 7 +++---- ext/src/ext_algebra/secondary.rs | 10 +++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/ext/examples/lambda2.rs b/ext/examples/lambda2.rs index fa27866010..01818774ee 100644 --- a/ext/examples/lambda2.rs +++ b/ext/examples/lambda2.rs @@ -232,9 +232,7 @@ where let both_odd = shift.n() % 2 != 0 && b.n() % 2 != 0; for prod in sec_e2.secondary_multiply_into(&x, b) { - let ext: Vec = prod.ext_part.iter().collect(); - let lambda: Vec = prod.lambda_part.iter().collect(); - if ext.iter().all(|&c| c == 0) && lambda.iter().all(|&c| c == 0) { + if prod.value.is_zero() { continue; } @@ -244,8 +242,9 @@ where "[x,y] = 0" }; println!( - "x_{x_gen} · [{src}] = {ext:?} + λ {lambda:?} {comm}", + "x_{x_gen} · [{src}] = {value} {comm}", src = prod.source.to_basis_string(), + value = prod.value, ); } } diff --git a/ext/src/ext_algebra/secondary.rs b/ext/src/ext_algebra/secondary.rs index 7683706343..f28377b494 100644 --- a/ext/src/ext_algebra/secondary.rs +++ b/ext/src/ext_algebra/secondary.rs @@ -17,9 +17,9 @@ use std::sync::{Arc, Mutex}; use algebra::pair_algebra::PairAlgebra; use dashmap::DashMap; use fp::{matrix::Subquotient, prime::Prime, vector::FpVector}; -use sseq::coordinates::{Bidegree, BidegreeElement, MultiDegree, MultiDegreeElement}; - -use sseq::coordinates::BidegreeGenerator; +use sseq::coordinates::{ + Bidegree, BidegreeElement, BidegreeGenerator, MultiDegree, MultiDegreeElement, +}; use super::ExtAlgebra; use crate::{ @@ -641,8 +641,8 @@ mod tests { assert_eq!( e_dim, b_dim, - "dim(E at ({n},{s})) should equal dim(B at ({tn},{ts})): \ - d2: E → B is an isomorphism" + "dim(E at ({n},{s})) should equal dim(B at ({tn},{ts})): d2: E → B is an \ + isomorphism" ); } From 60308fd03a1c19b9503b0890ee98da5b238d3726 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 18:50:30 +0000 Subject: [PATCH 07/13] Move BZE to sseq crate, add classify/bze_indices to Sseq BZE classification (boundary/cycle/supports-differential) is intrinsic to any spectral sequence, not specific to the secondary layer. Move the enum to sseq::coordinates::BZE with from_page_data, Display. Add Sseq::classify and Sseq::bze_indices methods that read the page data directly. SecondaryExtAlgebra::classify now delegates to the lambda2 sseq. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_017kezHiWcYod7hYovRPczz9 --- ext/crates/sseq/src/coordinates/bze.rs | 50 ++++++++++++++++++++++++++ ext/crates/sseq/src/coordinates/mod.rs | 2 ++ ext/crates/sseq/src/sseq.rs | 28 ++++++++++++++- ext/src/ext_algebra/secondary.rs | 30 +++------------- 4 files changed, 83 insertions(+), 27 deletions(-) create mode 100644 ext/crates/sseq/src/coordinates/bze.rs diff --git a/ext/crates/sseq/src/coordinates/bze.rs b/ext/crates/sseq/src/coordinates/bze.rs new file mode 100644 index 0000000000..47f5c01bf4 --- /dev/null +++ b/ext/crates/sseq/src/coordinates/bze.rs @@ -0,0 +1,50 @@ +use std::fmt::{self, Display, Formatter}; + +use fp::matrix::Subquotient; + +/// Classification of a spectral sequence generator by its role relative to a differential on a +/// given page. At page $r$, each generator in bidegree $b$ is exactly one of: +/// +/// - **B** (boundary): in the image of $d_{r-1}$ from another bidegree. +/// - **Z** (cycle): in the kernel of $d_r$, and not a boundary. +/// - **E** (supports $d_r$): $d_r(x) \neq 0$. +/// +/// Every spectral sequence admits this decomposition; the Adams spectral sequence for $S$ at $E_2$ +/// has everything as **Z** (degenerate), while $S/\lambda^2$ at $E_3$ has a nontrivial splitting. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BZE { + /// Boundary: in the image of an incoming differential. + B, + /// Cycle mod boundary: survives to the next page. + Z, + /// Supports a differential: $d_r(x) \neq 0$. + E, +} + +impl BZE { + /// Classify generator `idx` from a page's [`Subquotient`]. + /// + /// The subquotient encodes $E_r = Z_r / B_r$ at a given bidegree: + /// - `page.zeros().pivots()[idx] >= 0` means `idx` is a boundary pivot (**B**). + /// - `page.complement_pivots()` yields generators not in the cycle subspace (**E**). + /// - Everything else is a cycle that is not a boundary (**Z**). + pub fn from_page_data(page: &Subquotient, idx: usize) -> Self { + if page.zeros().pivots()[idx] >= 0 { + return Self::B; + } + if page.complement_pivots().any(|p| p == idx) { + return Self::E; + } + Self::Z + } +} + +impl Display for BZE { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::B => write!(f, "B"), + Self::Z => write!(f, "Z"), + Self::E => write!(f, "E"), + } + } +} diff --git a/ext/crates/sseq/src/coordinates/mod.rs b/ext/crates/sseq/src/coordinates/mod.rs index c030db46b5..ae6d6f8760 100644 --- a/ext/crates/sseq/src/coordinates/mod.rs +++ b/ext/crates/sseq/src/coordinates/mod.rs @@ -1,3 +1,4 @@ +pub use bze::BZE; pub use degree::MultiDegree; pub use element::MultiDegreeElement; pub use generator::MultiDegreeGenerator; @@ -5,6 +6,7 @@ use maybe_rayon::prelude::*; use ordered::OrderedMultiDegree; pub use range::BidegreeRange; +pub mod bze; pub mod degree; pub mod element; pub mod generator; diff --git a/ext/crates/sseq/src/sseq.rs b/ext/crates/sseq/src/sseq.rs index 1c157865d1..ee3e684297 100644 --- a/ext/crates/sseq/src/sseq.rs +++ b/ext/crates/sseq/src/sseq.rs @@ -9,7 +9,9 @@ use fp::{ use once::MultiIndexed; use crate::{ - coordinates::{Bidegree, BidegreeGenerator, degree::MultiDegree, element::MultiDegreeElement}, + coordinates::{ + BZE, Bidegree, BidegreeGenerator, degree::MultiDegree, element::MultiDegreeElement, + }, differential::Differential, }; @@ -390,6 +392,30 @@ impl> Sseq { &self.data[b].page_data } + /// Classify generator `i` at degree `b` on page `r` as B (boundary), Z (cycle), or E + /// (supports a differential). Panics if the degree or page is not defined. + pub fn classify(&self, b: MultiDegree, r: i32, i: usize) -> BZE { + let pd = &self.data[b].page_data; + let page = &pd[std::cmp::min(r, pd.len() - 1)]; + BZE::from_page_data(page, i) + } + + /// The B, Z, E generator indices at degree `b` on page `r`. + pub fn bze_indices(&self, b: MultiDegree, r: i32) -> (Vec, Vec, Vec) { + let dim = self.data[b].dimension; + let mut bs = Vec::new(); + let mut zs = Vec::new(); + let mut es = Vec::new(); + for i in 0..dim { + match self.classify(b, r, i) { + BZE::B => bs.push(i), + BZE::Z => zs.push(i), + BZE::E => es.push(i), + } + } + (bs, zs, es) + } + /// Compute the product between `product` and the class `class`. Returns `None` if /// the product is not yet computed. pub fn multiply( diff --git a/ext/src/ext_algebra/secondary.rs b/ext/src/ext_algebra/secondary.rs index f28377b494..a91108158e 100644 --- a/ext/src/ext_algebra/secondary.rs +++ b/ext/src/ext_algebra/secondary.rs @@ -17,6 +17,7 @@ use std::sync::{Arc, Mutex}; use algebra::pair_algebra::PairAlgebra; use dashmap::DashMap; use fp::{matrix::Subquotient, prime::Prime, vector::FpVector}; +pub use sseq::coordinates::BZE; use sseq::coordinates::{ Bidegree, BidegreeElement, BidegreeGenerator, MultiDegree, MultiDegreeElement, }; @@ -31,30 +32,6 @@ use crate::{ }, }; -/// The classification of an Ext generator in the $d_2$-adapted B/Z/E decomposition. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BZE { - /// Boundary: in the image of $d_2$ from another bidegree. - B, - /// Cycle mod boundary: survives to $E_3$. - Z, - /// Supports $d_2$: $d_2(x) \neq 0$. - E, -} - -impl BZE { - /// Classify a standard-basis generator `idx` from its $E_3$-page subquotient. - pub fn from_page_data(page: &Subquotient, idx: usize) -> Self { - if page.zeros().pivots()[idx] >= 0 { - return Self::B; - } - if page.complement_pivots().any(|p| p == idx) { - return Self::E; - } - Self::Z - } -} - /// 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 { @@ -331,8 +308,9 @@ where /// At each bidegree, $\Ext = B \oplus Z \oplus E$ and $d_2$ restricts to an isomorphism /// $E_{(n,s)} \to B_{(n-1,s+2)}$. pub fn classify(&self, g: BidegreeGenerator) -> BZE { - let page = self.page_data(g.degree()); - BZE::from_page_data(&page, g.idx()) + let [n, s] = g.degree().coords(); + self.lambda2_sseq() + .classify(MultiDegree::new([n, s, 0]), 3, g.idx()) } /// Build the trigraded spectral sequence for $S/\lambda^2$. From b9dec59439b2c1a3cb84a5aff5be1776ffc16957 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 18:56:09 +0000 Subject: [PATCH 08/13] Add Sseq<3, AdamsLambda2> to ExtAlgebra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExtAlgebra now carries a trigraded spectral sequence. With only weight 0 populated, d2 targets the empty weight-1 grading and is trivially zero, so the spectral sequence degenerates at E2 — matching the Adams spectral sequence for M. The secondary layer will clone and extend this base sseq with weight-1 data and d2 differentials on its own copy. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_017kezHiWcYod7hYovRPczz9 --- ext/src/ext_algebra/mod.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/ext/src/ext_algebra/mod.rs b/ext/src/ext_algebra/mod.rs index e014697c8b..ea3d5a3ca8 100644 --- a/ext/src/ext_algebra/mod.rs +++ b/ext/src/ext_algebra/mod.rs @@ -26,7 +26,7 @@ use std::sync::Arc; use dashmap::DashMap; use fp::{matrix::Matrix, prime::ValidPrime, vector::FpVector}; -use sseq::coordinates::{Bidegree, BidegreeElement, BidegreeGenerator}; +use sseq::coordinates::{Bidegree, BidegreeElement, BidegreeGenerator, MultiDegree}; pub use self::secondary::{BZE, SecondaryExtAlgebra, SecondaryProduct}; pub use crate::secondary::{SecondaryDegree, SecondaryElement, SecondaryGenerator, Weight}; @@ -46,6 +46,10 @@ pub struct ExtAlgebra { is_unit: bool, /// One multiplication map per generator of $\Ext(M, k)$, built and extended on demand. products: DashMap>>, + /// Trigraded spectral sequence with the `AdamsLambda2` profile. Generators sit at weight 0; + /// weight 1 is empty, so $d_2$ has no target and the spectral sequence degenerates at $E_2$. + /// The secondary layer populates weight 1 and installs $d_2$ on its own copy. + sseq: sseq::Sseq<3, sseq::AdamsLambda2>, } impl ExtAlgebra { @@ -71,11 +75,13 @@ impl ExtAlgebra { /// Build an [`ExtAlgebra`] from an explicit `(resolution, unit)` pair. pub fn new(resolution: Arc, unit: Arc) -> Self { assert_eq!(resolution.prime(), unit.prime()); + let sseq = Self::build_sseq(&resolution); Self { is_unit: Arc::ptr_eq(&resolution, &unit), resolution, unit, products: DashMap::new(), + sseq, } } @@ -107,6 +113,24 @@ impl ExtAlgebra { self.resolution.prime() } + /// The trigraded spectral sequence for this Ext algebra. Generators live at weight 0 only; + /// weight 1 is unpopulated, so $d_2$ is trivially zero and $E_2 = E_3 = E_\infty$. Every + /// generator classifies as Z. + pub fn sseq(&self) -> &sseq::Sseq<3, sseq::AdamsLambda2> { + &self.sseq + } + + fn build_sseq(resolution: &CC) -> sseq::Sseq<3, sseq::AdamsLambda2> { + let p = resolution.prime(); + let mut sseq = sseq::Sseq::new(p); + for b in resolution.iter_stem() { + let dim = resolution.number_of_gens_in_bidegree(b); + let [n, s] = b.coords(); + sseq.set_dimension(MultiDegree::new([n, s, 0]), dim); + } + sseq + } + /// Ensure both the resolution and the unit are computed through the given bidegree. pub fn compute_through_bidegree(&self, b: Bidegree) { self.unit.compute_through_bidegree(b); From c27ea5d360fc08a52867283a277b2161cae46183 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 19:08:04 +0000 Subject: [PATCH 09/13] =?UTF-8?q?Add=20PiGenerator=20and=20PiElement=20typ?= =?UTF-8?q?es=20for=20=CF=80(S/=CE=BB=C2=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PiGenerator represents a conical basis generator of π(S/λ²) (Condition 3.2 of the paper), annotated with its Adams BZE classification and weight. PiElement is an element in the E3 subquotient at a specific weight, with coordinates in the surviving-generator basis. New methods on SecondaryExtAlgebra: - adams_classify: full BZE using both weights of the lambda2 sseq - pi_basis: conical basis at a bidegree (B→wt0, Z→both, E→wt1) - to_pi: project a SecondaryElement to E3 subquotients at each weight Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_017kezHiWcYod7hYovRPczz9 --- ext/src/ext_algebra/mod.rs | 2 +- ext/src/ext_algebra/secondary.rs | 267 ++++++++++++++++++++++++++++++- 2 files changed, 267 insertions(+), 2 deletions(-) diff --git a/ext/src/ext_algebra/mod.rs b/ext/src/ext_algebra/mod.rs index ea3d5a3ca8..feac781bca 100644 --- a/ext/src/ext_algebra/mod.rs +++ b/ext/src/ext_algebra/mod.rs @@ -28,7 +28,7 @@ use dashmap::DashMap; use fp::{matrix::Matrix, prime::ValidPrime, vector::FpVector}; use sseq::coordinates::{Bidegree, BidegreeElement, BidegreeGenerator, MultiDegree}; -pub use self::secondary::{BZE, SecondaryExtAlgebra, SecondaryProduct}; +pub use self::secondary::{BZE, PiElement, PiGenerator, SecondaryExtAlgebra, SecondaryProduct}; pub use crate::secondary::{SecondaryDegree, SecondaryElement, SecondaryGenerator, Weight}; use crate::{ chain_complex::{AugmentedChainComplex, FreeChainComplex}, diff --git a/ext/src/ext_algebra/secondary.rs b/ext/src/ext_algebra/secondary.rs index a91108158e..53573169ef 100644 --- a/ext/src/ext_algebra/secondary.rs +++ b/ext/src/ext_algebra/secondary.rs @@ -12,7 +12,10 @@ //! 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::{ + fmt, + sync::{Arc, Mutex}, +}; use algebra::pair_algebra::PairAlgebra; use dashmap::DashMap; @@ -42,6 +45,121 @@ pub struct SecondaryProduct { pub value: SecondaryElement, } +/// A conical basis generator of $\pi(S/\lambda^2)$. +/// +/// Each Ext generator at bidegree $(n, s)$ contributes to the conical basis at one or both weights, +/// determined by its Adams BZE classification (see +/// [`adams_classify`](SecondaryExtAlgebra::adams_classify)): +/// - **B**: weight 0 only (killed at weight 1 by $d_2$ boundaries). +/// - **Z**: both weights (permanent cycle). +/// - **E**: weight 1 only (killed at weight 0 by supporting $d_2$). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PiGenerator { + bidegree: Bidegree, + weight: Weight, + bze: BZE, + idx: usize, +} + +impl PiGenerator { + pub fn new(bidegree: Bidegree, weight: Weight, bze: BZE, idx: usize) -> Self { + Self { + bidegree, + weight, + bze, + idx, + } + } + + pub fn bidegree(&self) -> Bidegree { + self.bidegree + } + + pub fn weight(&self) -> Weight { + self.weight + } + + pub fn bze(&self) -> BZE { + self.bze + } + + pub fn idx(&self) -> usize { + self.idx + } +} + +impl fmt::Display for PiGenerator { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let g = BidegreeGenerator::new(self.bidegree, self.idx); + let w = self.weight.as_i32(); + write!(f, "{} x_{g}^{w}", self.bze) + } +} + +/// An element in the $E_3 = E_\infty$ page of $\pi(S/\lambda^2)$ at a specific weight. +/// +/// The coordinates are in the subquotient basis of $E_3$ at the given bidegree and weight. Each +/// coordinate corresponds to a surviving generator in the ambient Ext space, identified by +/// [`basis_indices`](Self::basis_indices). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PiElement { + bidegree: Bidegree, + weight: Weight, + coords: Vec, + basis_indices: Vec, +} + +impl PiElement { + pub fn bidegree(&self) -> Bidegree { + self.bidegree + } + + pub fn weight(&self) -> Weight { + self.weight + } + + /// Coordinates in the $E_3$ subquotient basis. `coords()[i]` is the coefficient of the + /// generator at ambient index [`basis_indices()`](Self::basis_indices)`[i]`. + pub fn coords(&self) -> &[u32] { + &self.coords + } + + /// The ambient Ext generator indices forming the $E_3$ basis. + pub fn basis_indices(&self) -> &[usize] { + &self.basis_indices + } + + pub fn is_zero(&self) -> bool { + self.coords.iter().all(|&c| c == 0) + } +} + +impl fmt::Display for PiElement { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let w = self.weight.as_i32(); + let mut first = true; + for (&c, &idx) in self.coords.iter().zip(&self.basis_indices) { + if c == 0 { + continue; + } + if !first { + write!(f, " + ")?; + } + first = false; + let g = BidegreeGenerator::new(self.bidegree, idx); + if c == 1 { + write!(f, "x_{g}^{w}")?; + } else { + write!(f, "{c} x_{g}^{w}")?; + } + } + if first { + write!(f, "0")?; + } + Ok(()) + } +} + /// The secondary layer over an [`ExtAlgebra`]: the $d_2$ differential and the $\Mod_{C\lambda^2}$ /// product. See the [module documentation](self). pub struct SecondaryExtAlgebra @@ -313,6 +431,102 @@ where .classify(MultiDegree::new([n, s, 0]), 3, g.idx()) } + /// The full Adams BZE classification, combining both weights of the $\lambda^2$ spectral + /// sequence. + /// + /// Unlike [`classify`](Self::classify) (which only inspects weight 0), this checks both: + /// - **E**: supports $d_2$ at weight 0. + /// - **B**: boundary of $d_2$ at weight 1. + /// - **Z**: permanent cycle (neither E nor B). + pub fn adams_classify(&self, g: BidegreeGenerator) -> BZE { + let [n, s] = g.degree().coords(); + let l2 = self.lambda2_sseq(); + + let td0 = MultiDegree::new([n, s, 0]); + if l2.defined(td0) && l2.classify(td0, 3, g.idx()) == BZE::E { + return BZE::E; + } + + let td1 = MultiDegree::new([n, s, 1]); + if l2.defined(td1) && l2.classify(td1, 3, g.idx()) == BZE::B { + return BZE::B; + } + + BZE::Z + } + + /// The conical basis of $\pi(S/\lambda^2)$ at bidegree `b` (Condition 3.2 of the paper). + /// + /// Each Ext generator at `b` is classified by [`adams_classify`](Self::adams_classify), then + /// placed at the weights it contributes to: + /// - **B**: weight 0 only. + /// - **Z**: both weights. + /// - **E**: weight 1 only. + pub fn pi_basis(&self, b: Bidegree) -> Vec { + let dim = self.alg.dimension(b); + let mut result = Vec::new(); + + for i in 0..dim { + let g = BidegreeGenerator::new(b, i); + let bze = self.adams_classify(g); + + if bze != BZE::E { + result.push(PiGenerator::new(b, Weight::Ext, bze, i)); + } + if bze != BZE::B { + result.push(PiGenerator::new(b, Weight::Lambda, bze, i)); + } + } + + result + } + + /// Project a [`SecondaryElement`] to the $E_3$ subquotient at each weight, giving a pair of + /// [`PiElement`]s. + /// + /// The first element is the weight-0 projection (Ext part at `base`), the second is the + /// weight-1 projection ($\lambda$ part at `base + LAMBDA_BIDEGREE`). + pub fn to_pi(&self, elt: &SecondaryElement) -> (PiElement, PiElement) { + let base = elt.base(); + let ext_pi = self.project_to_pi(base, Weight::Ext, elt.ext()); + let lambda_pi = self.project_to_pi(base + LAMBDA_BIDEGREE, Weight::Lambda, elt.lambda()); + (ext_pi, lambda_pi) + } + + fn project_to_pi( + &self, + bidegree: Bidegree, + weight: Weight, + ambient_vec: fp::vector::FpSlice, + ) -> PiElement { + let [n, s] = bidegree.coords(); + let td = MultiDegree::new([n, s, weight.as_i32()]); + + if let Some(sq) = self.lambda2_page_data(td) { + let mut v = ambient_vec.to_owned(); + let coords = sq.reduce(v.as_slice_mut()); + + let complement: Vec = sq.complement_pivots().collect(); + let basis_indices: Vec = (0..sq.ambient_dimension()) + .filter(|&i| sq.zeros().pivots()[i] < 0 && !complement.contains(&i)) + .collect(); + + PiElement { + bidegree, + weight, + coords, + basis_indices, + } + } else { + PiElement { + bidegree, + weight, + coords: vec![], + basis_indices: vec![], + } + } + } + /// Build the trigraded spectral sequence for $S/\lambda^2$. /// /// E2 has two copies of Ext at each bidegree $(n, s)$: one at Bockstein degree 0 and one at @@ -651,4 +865,55 @@ mod tests { assert_eq!(sec_e2.lambda2_e3_dimension(0, 1, 0), 1); assert_eq!(sec_e2.lambda2_e3_dimension(0, 1, 1), 1); } + + #[test] + fn test_pi_types() { + 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), res)); + let sec_e2 = SecondaryExtAlgebra::new(Arc::clone(&e2)); + sec_e2.extend_all(); + + // h0 at (0, 1) is a permanent Z-cycle: appears at both weights. + let h0_bze = sec_e2.adams_classify(BidegreeGenerator::new(Bidegree::n_s(0, 1), 0)); + assert_eq!(h0_bze, BZE::Z); + let pi_01 = sec_e2.pi_basis(Bidegree::n_s(0, 1)); + assert_eq!(pi_01.len(), 2); // weight 0 + weight 1 + assert_eq!(pi_01[0].weight(), Weight::Ext); + assert_eq!(pi_01[0].bze(), BZE::Z); + assert_eq!(pi_01[1].weight(), Weight::Lambda); + assert_eq!(pi_01[1].bze(), BZE::Z); + + // h4 at (15, 1) supports d2: classified as E, appears at weight 1 only. + let h4_bze = sec_e2.adams_classify(BidegreeGenerator::new(Bidegree::n_s(15, 1), 0)); + assert_eq!(h4_bze, BZE::E); + let pi_15_1 = sec_e2.pi_basis(Bidegree::n_s(15, 1)); + assert_eq!(pi_15_1.len(), 1); + assert_eq!(pi_15_1[0].weight(), Weight::Lambda); + assert_eq!(pi_15_1[0].bze(), BZE::E); + + // h0*h3^2 at (14, 3) is a d2-boundary: classified as B, appears at weight 0 only. + let b14_3 = sec_e2.adams_classify(BidegreeGenerator::new(Bidegree::n_s(14, 3), 0)); + assert_eq!(b14_3, BZE::B); + let pi_14_3 = sec_e2.pi_basis(Bidegree::n_s(14, 3)); + assert_eq!(pi_14_3.len(), 1); + assert_eq!(pi_14_3[0].weight(), Weight::Ext); + assert_eq!(pi_14_3[0].bze(), BZE::B); + + // to_pi: a zero secondary element projects to zero PiElements. + let zero_elt = sec_e2.element(SecondaryDegree::new(Bidegree::n_s(0, 1)), &[0], &[0]); + let (pi_ext, pi_lambda) = sec_e2.to_pi(&zero_elt); + assert!(pi_ext.is_zero()); + assert!(pi_lambda.is_zero()); + + // to_pi: h0 as a unit vector at weight 0 should project to a nonzero PiElement. + let h0_elt = sec_e2.element(SecondaryDegree::new(Bidegree::n_s(0, 1)), &[1], &[0]); + let (pi_ext, _pi_lambda) = sec_e2.to_pi(&h0_elt); + assert!(!pi_ext.is_zero()); + assert_eq!(pi_ext.weight(), Weight::Ext); + + // Display: PiGenerator and PiElement produce meaningful output. + assert!(!format!("{}", pi_01[0]).is_empty()); + assert!(!format!("{pi_ext}").is_empty()); + } } From ac3d43b13b83c2f8e1f1ae2fde9971eb2681e985 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 19:14:36 +0000 Subject: [PATCH 10/13] Update examples to use PiGenerator/PiElement types; regenerate benchmarks lambda2.rs: Table III now uses pi_basis() for the conical basis listing, and Table IV uses to_pi() to project products into the E3 subquotient. Both tables use adams_classify for proper B/Z/E distinction. Benchmark files regenerated for secondary_product examples to match the SecondaryElement Display format introduced by the secondary_element branch. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_017kezHiWcYod7hYovRPczz9 --- .../benchmarks/secondary_product-h_0-S_2 | 74 +++++++++---------- .../secondary_product-h_0-S_2-nassau | 74 +++++++++---------- .../benchmarks/secondary_product-v_1-C2 | 70 +++++++++--------- ext/examples/lambda2.rs | 56 ++++++-------- 4 files changed, 131 insertions(+), 143 deletions(-) diff --git a/ext/examples/benchmarks/secondary_product-h_0-S_2 b/ext/examples/benchmarks/secondary_product-h_0-S_2 index 22a4c7b1dc..25eab0763e 100644 --- a/ext/examples/benchmarks/secondary_product-h_0-S_2 +++ b/ext/examples/benchmarks/secondary_product-h_0-S_2 @@ -1,38 +1,38 @@ secondary_product -- S_2 "" 30 7 h_0 0 1 [1] -[h_0] [x_(0, 0, 0)] = [1] + λ [0] -[h_0] [x_(0, 1, 0)] = [1] + λ [1] -[h_0] [x_(0, 2, 0)] = [1] + λ [0] -[h_0] [x_(0, 3, 0)] = [1] + λ [1] -[h_0] [x_(0, 4, 0)] = [1] + λ [0] -[h_0] [x_(0, 5, 0)] = [1] + λ [1] -[h_0] [x_(3, 1, 0)] = [1] + λ [1] -[h_0] [x_(3, 2, 0)] = [1] + λ [] -[h_0] [x_(7, 1, 0)] = [1] + λ [0] -[h_0] [x_(7, 2, 0)] = [1] + λ [1] -[h_0] [x_(7, 3, 0)] = [1] + λ [] -[h_0] [x_(8, 2, 0)] = [0] + λ [] -[h_0] [x_(9, 3, 0)] = [0] + λ [0] -[h_0] [x_(9, 4, 0)] = [0] + λ [] -[h_0] [x_(11, 5, 0)] = [1] + λ [1] -[h_0] [x_(14, 2, 0)] = [1] + λ [1] -[h_0] [x_(14, 3, 0)] = [0] + λ [0] -[h_0] [x_(14, 4, 0)] = [1] + λ [1] -[h_0] [x_(14, 5, 0)] = [1] + λ [] -[h_0] λ x_(15, 1, 0) = λ [1] -[h_0] [x_(15, 2, 0)] = [1] + λ [1] -[h_0] [x_(15, 3, 0)] = [1] + λ [0, 0] -[h_0] [x_(15, 4, 0)] = [1, 0] + λ [1] -[h_0] [x_(15, 5, 0)] = [1] + λ [0] -[h_0] [x_(15, 5, 1)] = [0] + λ [0] -[h_0] [x_(17, 3, 0)] = [0] + λ [0] -[h_0] λ x_(17, 4, 0) = λ [1] -[h_0] [x_(17, 5, 0)] = [1] + λ [0] -[h_0] [x_(18, 2, 0)] = [1] + λ [1, 1] -[h_0] [x_(18, 3, 0)] = [1, 0] + λ [1] -[h_0] λ x_(18, 4, 1) = λ [1] -[h_0] [x_(18, 4, 0)] = [0] + λ [] -[h_0] [x_(20, 4, 0)] = [1] + λ [0] -[h_0] [x_(20, 5, 0)] = [1] + λ [] -[h_0] [x_(21, 3, 0)] = [] + λ [0] -[h_0] [x_(23, 4, 0)] = [0] + λ [0] -[h_0] [x_(23, 5, 0)] = [1] + λ [0] +Z [h_0] [x_(0, 0, 0)] = [x_(0, 1, 0)] +Z [h_0] [x_(0, 1, 0)] = [x_(0, 2, 0)] + λx_(0, 3, 0) +Z [h_0] [x_(0, 2, 0)] = [x_(0, 3, 0)] +Z [h_0] [x_(0, 3, 0)] = [x_(0, 4, 0)] + λx_(0, 5, 0) +Z [h_0] [x_(0, 4, 0)] = [x_(0, 5, 0)] +Z [h_0] [x_(0, 5, 0)] = [x_(0, 6, 0)] + λx_(0, 7, 0) +Z [h_0] [x_(3, 1, 0)] = [x_(3, 2, 0)] + λx_(3, 3, 0) +Z [h_0] [x_(3, 2, 0)] = [x_(3, 3, 0)] +Z [h_0] [x_(7, 1, 0)] = [x_(7, 2, 0)] +Z [h_0] [x_(7, 2, 0)] = [x_(7, 3, 0)] + λx_(7, 4, 0) +Z [h_0] [x_(7, 3, 0)] = [x_(7, 4, 0)] +Z [h_0] [x_(8, 2, 0)] = 0 +Z [h_0] [x_(9, 3, 0)] = 0 +Z [h_0] [x_(9, 4, 0)] = 0 +Z [h_0] [x_(11, 5, 0)] = [x_(11, 6, 0)] + λx_(11, 7, 0) +Z [h_0] [x_(14, 2, 0)] = [x_(14, 3, 0)] + λx_(14, 4, 0) +B [h_0] [x_(14, 3, 0)] = 0 +Z [h_0] [x_(14, 4, 0)] = [x_(14, 5, 0)] + λx_(14, 6, 0) +Z [h_0] [x_(14, 5, 0)] = [x_(14, 6, 0)] +E [h_0] λ x_(15, 1, 0) = λ [1] +Z [h_0] [x_(15, 2, 0)] = [x_(15, 3, 0)] + λx_(15, 4, 0) +Z [h_0] [x_(15, 3, 0)] = [x_(15, 4, 0)] +Z [h_0] [x_(15, 4, 0)] = [x_(15, 5, 0)] + λx_(15, 6, 0) +Z [h_0] [x_(15, 5, 0)] = [x_(15, 6, 0)] +Z [h_0] [x_(15, 5, 1)] = 0 +Z [h_0] [x_(17, 3, 0)] = 0 +E [h_0] λ x_(17, 4, 0) = λ [1] +Z [h_0] [x_(17, 5, 0)] = [x_(17, 6, 0)] +Z [h_0] [x_(18, 2, 0)] = [x_(18, 3, 0)] + λ(x_(18, 4, 0) + x_(18, 4, 1)) +Z [h_0] [x_(18, 3, 0)] = [x_(18, 4, 0)] + λx_(18, 5, 0) +E [h_0] λ x_(18, 4, 1) = λ [1] +Z [h_0] [x_(18, 4, 0)] = 0 +Z [h_0] [x_(20, 4, 0)] = [x_(20, 5, 0)] +Z [h_0] [x_(20, 5, 0)] = [x_(20, 6, 0)] +Z [h_0] [x_(21, 3, 0)] = 0 +Z [h_0] [x_(23, 4, 0)] = 0 +Z [h_0] [x_(23, 5, 0)] = [x_(23, 6, 0)] diff --git a/ext/examples/benchmarks/secondary_product-h_0-S_2-nassau b/ext/examples/benchmarks/secondary_product-h_0-S_2-nassau index 75dbb090de..399e06f5ed 100644 --- a/ext/examples/benchmarks/secondary_product-h_0-S_2-nassau +++ b/ext/examples/benchmarks/secondary_product-h_0-S_2-nassau @@ -1,38 +1,38 @@ secondary_product -- S_2 /tmp/test_nassau_s_2 30 7 h_0 0 1 [1] -[h_0] [x_(0, 0, 0)] = [1] + λ [0] -[h_0] [x_(0, 1, 0)] = [1] + λ [1] -[h_0] [x_(0, 2, 0)] = [1] + λ [0] -[h_0] [x_(0, 3, 0)] = [1] + λ [1] -[h_0] [x_(0, 4, 0)] = [1] + λ [0] -[h_0] [x_(0, 5, 0)] = [1] + λ [1] -[h_0] [x_(3, 1, 0)] = [1] + λ [1] -[h_0] [x_(3, 2, 0)] = [1] + λ [] -[h_0] [x_(7, 1, 0)] = [1] + λ [0] -[h_0] [x_(7, 2, 0)] = [1] + λ [1] -[h_0] [x_(7, 3, 0)] = [1] + λ [] -[h_0] [x_(8, 2, 0)] = [0] + λ [] -[h_0] [x_(9, 3, 0)] = [0] + λ [0] -[h_0] [x_(9, 4, 0)] = [0] + λ [] -[h_0] [x_(11, 5, 0)] = [1] + λ [1] -[h_0] [x_(14, 2, 0)] = [1] + λ [0] -[h_0] [x_(14, 3, 0)] = [0] + λ [1] -[h_0] [x_(14, 4, 0)] = [1] + λ [0] -[h_0] [x_(14, 5, 0)] = [1] + λ [] -[h_0] λ x_(15, 1, 0) = λ [1] -[h_0] [x_(15, 2, 0)] = [1] + λ [1] -[h_0] [x_(15, 3, 0)] = [1] + λ [0, 0] -[h_0] [x_(15, 4, 0)] = [1, 0] + λ [1] -[h_0] [x_(15, 5, 0)] = [1] + λ [0] -[h_0] [x_(15, 5, 1)] = [0] + λ [0] -[h_0] [x_(17, 3, 0)] = [0] + λ [0] -[h_0] λ x_(17, 4, 0) = λ [1] -[h_0] [x_(17, 5, 0)] = [1] + λ [0] -[h_0] [x_(18, 2, 0)] = [1] + λ [1, 0] -[h_0] [x_(18, 3, 0)] = [1, 0] + λ [0] -[h_0] λ x_(18, 4, 1) = λ [1] -[h_0] [x_(18, 4, 0)] = [0] + λ [] -[h_0] [x_(20, 4, 0)] = [1] + λ [1] -[h_0] [x_(20, 5, 0)] = [1] + λ [] -[h_0] [x_(21, 3, 0)] = [] + λ [0] -[h_0] [x_(23, 4, 0)] = [0] + λ [0] -[h_0] [x_(23, 5, 0)] = [1] + λ [0] +Z [h_0] [x_(0, 0, 0)] = [x_(0, 1, 0)] +Z [h_0] [x_(0, 1, 0)] = [x_(0, 2, 0)] + λx_(0, 3, 0) +Z [h_0] [x_(0, 2, 0)] = [x_(0, 3, 0)] +Z [h_0] [x_(0, 3, 0)] = [x_(0, 4, 0)] + λx_(0, 5, 0) +Z [h_0] [x_(0, 4, 0)] = [x_(0, 5, 0)] +Z [h_0] [x_(0, 5, 0)] = [x_(0, 6, 0)] + λx_(0, 7, 0) +Z [h_0] [x_(3, 1, 0)] = [x_(3, 2, 0)] + λx_(3, 3, 0) +Z [h_0] [x_(3, 2, 0)] = [x_(3, 3, 0)] +Z [h_0] [x_(7, 1, 0)] = [x_(7, 2, 0)] +Z [h_0] [x_(7, 2, 0)] = [x_(7, 3, 0)] + λx_(7, 4, 0) +Z [h_0] [x_(7, 3, 0)] = [x_(7, 4, 0)] +Z [h_0] [x_(8, 2, 0)] = 0 +Z [h_0] [x_(9, 3, 0)] = 0 +Z [h_0] [x_(9, 4, 0)] = 0 +Z [h_0] [x_(11, 5, 0)] = [x_(11, 6, 0)] + λx_(11, 7, 0) +Z [h_0] [x_(14, 2, 0)] = [x_(14, 3, 0)] +B [h_0] [x_(14, 3, 0)] = λx_(14, 5, 0) +Z [h_0] [x_(14, 4, 0)] = [x_(14, 5, 0)] +Z [h_0] [x_(14, 5, 0)] = [x_(14, 6, 0)] +E [h_0] λ x_(15, 1, 0) = λ [1] +Z [h_0] [x_(15, 2, 0)] = [x_(15, 3, 0)] + λx_(15, 4, 0) +Z [h_0] [x_(15, 3, 0)] = [x_(15, 4, 0)] +Z [h_0] [x_(15, 4, 0)] = [x_(15, 5, 0)] + λx_(15, 6, 0) +Z [h_0] [x_(15, 5, 0)] = [x_(15, 6, 0)] +Z [h_0] [x_(15, 5, 1)] = 0 +Z [h_0] [x_(17, 3, 0)] = 0 +E [h_0] λ x_(17, 4, 0) = λ [1] +Z [h_0] [x_(17, 5, 0)] = [x_(17, 6, 0)] +Z [h_0] [x_(18, 2, 0)] = [x_(18, 3, 0)] + λx_(18, 4, 0) +Z [h_0] [x_(18, 3, 0)] = [x_(18, 4, 0)] +E [h_0] λ x_(18, 4, 1) = λ [1] +Z [h_0] [x_(18, 4, 0)] = 0 +Z [h_0] [x_(20, 4, 0)] = [x_(20, 5, 0)] + λx_(20, 6, 0) +Z [h_0] [x_(20, 5, 0)] = [x_(20, 6, 0)] +Z [h_0] [x_(21, 3, 0)] = 0 +Z [h_0] [x_(23, 4, 0)] = 0 +Z [h_0] [x_(23, 5, 0)] = [x_(23, 6, 0)] diff --git a/ext/examples/benchmarks/secondary_product-v_1-C2 b/ext/examples/benchmarks/secondary_product-v_1-C2 index 77b656aa70..21922aff35 100644 --- a/ext/examples/benchmarks/secondary_product-v_1-C2 +++ b/ext/examples/benchmarks/secondary_product-v_1-C2 @@ -1,36 +1,36 @@ secondary_product -- C2 "" 30 7 "" v_1 2 1 [1] -[v_1] [x_(0, 0, 0)] = [1] + λ [0] -[v_1] [x_(0, 1, 0)] = [1] + λ [] -[v_1] [x_(1, 1, 0)] = [1] + λ [] -[v_1] [x_(2, 2, 0)] = [1] + λ [] -[v_1] [x_(6, 2, 0)] = [0] + λ [0] -[v_1] [x_(7, 1, 0)] = [1] + λ [1, 1] -[v_1] [x_(7, 2, 0)] = [1, 0] + λ [1] -[v_1] [x_(7, 3, 0)] = [0] + λ [0] -[v_1] [x_(7, 4, 0)] = [0] + λ [] -[v_1] [x_(8, 2, 0)] = [1] + λ [0] -[v_1] [x_(8, 3, 0)] = [1] + λ [1] -[v_1] [x_(9, 3, 0)] = [] + λ [0] -[v_1] [x_(9, 4, 0)] = [1] + λ [0] -[v_1] [x_(9, 5, 0)] = [1] + λ [] -[v_1] [x_(14, 3, 0)] = [] + λ [1] -[v_1] [x_(14, 4, 0)] = [1] + λ [0] -[v_1] [x_(14, 5, 0)] = [1] + λ [0] -[v_1] λ x_(15, 1, 0) = λ [1] -[v_1] [x_(15, 2, 0)] = [1] + λ [1] -[v_1] [x_(15, 3, 0)] = [0] + λ [] -[v_1] [x_(15, 4, 0)] = [] + λ [0] -[v_1] [x_(15, 5, 0)] = [0] + λ [0] -[v_1] [x_(15, 5, 1)] = [1] + λ [0] -[v_1] [x_(16, 2, 0)] = [1] + λ [0] -[v_1] [x_(17, 3, 0)] = [1] + λ [0] -[v_1] λ x_(17, 4, 0) = λ [1] -[v_1] [x_(18, 2, 0)] = [0] + λ [0] -[v_1] [x_(18, 3, 0)] = [0] + λ [] -[v_1] [x_(19, 3, 0)] = [] + λ [0] -[v_1] [x_(20, 4, 0)] = [1] + λ [] -[v_1] [x_(21, 3, 0)] = [0, 0] + λ [0] -[v_1] [x_(21, 5, 0)] = [] + λ [1] -[v_1] [x_(22, 4, 0)] = [0] + λ [0] -[v_1] [x_(23, 4, 0)] = [1] + λ [] -[v_1] [x_(24, 5, 0)] = [1] + λ [0] +Z [v_1] [x_(0, 0, 0)] = [x_(2, 1, 0)] +Z [v_1] [x_(0, 1, 0)] = [x_(2, 2, 0)] +Z [v_1] [x_(1, 1, 0)] = [x_(3, 2, 0)] +Z [v_1] [x_(2, 2, 0)] = [x_(4, 3, 0)] +Z [v_1] [x_(6, 2, 0)] = 0 +Z [v_1] [x_(7, 1, 0)] = [x_(9, 2, 0)] + λ(x_(9, 3, 0) + x_(9, 3, 1)) +Z [v_1] [x_(7, 2, 0)] = [x_(9, 3, 0)] + λx_(9, 4, 0) +Z [v_1] [x_(7, 3, 0)] = 0 +Z [v_1] [x_(7, 4, 0)] = 0 +Z [v_1] [x_(8, 2, 0)] = [x_(10, 3, 0)] +Z [v_1] [x_(8, 3, 0)] = [x_(10, 4, 0)] + λx_(10, 5, 0) +Z [v_1] [x_(9, 3, 0)] = 0 +Z [v_1] [x_(9, 4, 0)] = [x_(11, 5, 0)] +Z [v_1] [x_(9, 5, 0)] = [x_(11, 6, 0)] +B [v_1] [x_(14, 3, 0)] = λx_(16, 5, 0) +Z [v_1] [x_(14, 4, 0)] = [x_(16, 5, 0)] +Z [v_1] [x_(14, 5, 0)] = [x_(16, 6, 0)] +E [v_1] λ x_(15, 1, 0) = λ [1] +Z [v_1] [x_(15, 2, 0)] = [x_(17, 3, 0)] + λx_(17, 4, 0) +Z [v_1] [x_(15, 3, 0)] = 0 +Z [v_1] [x_(15, 4, 0)] = 0 +Z [v_1] [x_(15, 5, 0)] = 0 +Z [v_1] [x_(15, 5, 1)] = [x_(17, 6, 0)] +Z [v_1] [x_(16, 2, 0)] = [x_(18, 3, 0)] +Z [v_1] [x_(17, 3, 0)] = [x_(19, 4, 0)] +E [v_1] λ x_(17, 4, 0) = λ [1] +Z [v_1] [x_(18, 2, 0)] = 0 +Z [v_1] [x_(18, 3, 0)] = 0 +Z [v_1] [x_(19, 3, 0)] = 0 +Z [v_1] [x_(20, 4, 0)] = [x_(22, 5, 0)] +Z [v_1] [x_(21, 3, 0)] = 0 +Z [v_1] [x_(21, 5, 0)] = λx_(23, 7, 0) +Z [v_1] [x_(22, 4, 0)] = 0 +Z [v_1] [x_(23, 4, 0)] = [x_(25, 5, 0)] +Z [v_1] [x_(24, 5, 0)] = [x_(26, 6, 0)] diff --git a/ext/examples/lambda2.rs b/ext/examples/lambda2.rs index 01818774ee..bffdaf2bec 100644 --- a/ext/examples/lambda2.rs +++ b/ext/examples/lambda2.rs @@ -88,7 +88,7 @@ where let dim = e2.dimension(b); for i in 0..dim { let g = BidegreeGenerator::new(b, i); - match sec_e2.classify(g) { + match sec_e2.adams_classify(g) { BZE::Z => println!("Z x_{g}"), BZE::B => println!("B x_{g}"), BZE::E => { @@ -147,31 +147,14 @@ where CC::Algebra: algebra::pair_algebra::PairAlgebra, { for b in e2.resolution().iter_stem() { - let [n, s] = b.coords(); - let dim = e2.dimension(b); - if dim == 0 { + if e2.dimension(b) == 0 { continue; } - - for i in 0..dim { - let g = BidegreeGenerator::new(b, i); - let bze = sec_e2.classify(g); - - // bock=0: B and Z survive (d2-cycles). - match bze { - BZE::B | BZE::Z => { - let label = if bze == BZE::B { "B" } else { "Z" }; - println!("{label} x_{g}^0 ({n}, {s}, 0)"); - } - BZE::E => {} - } - - // bock=1: Z and E survive (cokernel of d2). - match bze { - BZE::Z => println!("Z x_{g}^1 ({n}, {s}, 1)"), - BZE::E => println!("E x_{g}^1 ({n}, {s}, 1)"), - BZE::B => {} - } + for g in sec_e2.pi_basis(b) { + let [n, s] = g.bidegree().coords(); + let bock = g.weight().as_i32(); + let x = BidegreeGenerator::new(g.bidegree(), g.idx()); + println!("{} x_{x}^{bock} ({n}, {s}, {bock})", g.bze()); } } } @@ -179,8 +162,8 @@ where /// Table IV: Products in π(S/λ²) with commutators. /// /// For each pair (x, y) where x ∈ Z (surviving cycle) and y runs over E3-surviving classes of -/// the unit at each bidegree, computes the secondary product x · y. The secondary product lives -/// in Mod_{Cλ²}: it has an Ext part and a λ part. +/// the unit at each bidegree, computes the secondary product x · y and projects to π(S/λ²) via +/// `to_pi`. The result is expressed in the E3 subquotient at each weight. /// /// The commutator [x, y] = x·y − y·x equals 2·(x·y) when both stems are odd (Proposition 6.4), /// and vanishes when at least one stem is even. @@ -189,8 +172,6 @@ where CC: FreeChainComplex + ext::chain_complex::AugmentedChainComplex, CC::Algebra: algebra::pair_algebra::PairAlgebra, { - // Secondary products need s ≥ 1 for the multiplier (the secondary lift's shift is s+1, - // and secondary homotopies start at s=2). let z_gens: Vec = e2 .resolution() .iter_nonzero_stem() @@ -198,7 +179,7 @@ where .flat_map(|b| { let dim = e2.dimension(b); (0..dim) - .filter(move |&i| sec_e2.classify(BidegreeGenerator::new(b, i)) == BZE::Z) + .filter(move |&i| sec_e2.adams_classify(BidegreeGenerator::new(b, i)) == BZE::Z) .map(move |i| BidegreeGenerator::new(b, i)) }) .collect(); @@ -227,12 +208,11 @@ where continue; } - // Proposition 6.4: [x, y] = x·y − y·x = 2·(x·y) when both stems are odd, - // and [x, y] = 0 when at least one stem is even. let both_odd = shift.n() % 2 != 0 && b.n() % 2 != 0; for prod in sec_e2.secondary_multiply_into(&x, b) { - if prod.value.is_zero() { + let (pi_ext, pi_lambda) = sec_e2.to_pi(&prod.value); + if pi_ext.is_zero() && pi_lambda.is_zero() { continue; } @@ -241,10 +221,18 @@ where } else { "[x,y] = 0" }; + + let mut parts = Vec::new(); + if !pi_ext.is_zero() { + parts.push(format!("[{pi_ext}]")); + } + if !pi_lambda.is_zero() { + parts.push(format!("{pi_lambda}")); + } + let value_str = parts.join(" + "); println!( - "x_{x_gen} · [{src}] = {value} {comm}", + "x_{x_gen} · [{src}] = {value_str} {comm}", src = prod.source.to_basis_string(), - value = prod.value, ); } } From cb4d6657ee5e4811fa1fd8e34c5377aa3df5ab58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 19:57:01 +0000 Subject: [PATCH 11/13] Address review: boundary-aware classify, on-demand sseq, robust labels - classify() now inspects both weights of the lambda2 sseq so it reports boundaries (B). The weight-0 copy only carries outgoing d2, so the old single-weight version could never return B and mislabeled boundaries as Z. Consolidates the former adams_classify into classify and drops the redundant method. - ExtAlgebra::sseq() is computed on demand from the current resolution instead of cached in new(), so it never goes stale after the resolution is extended. - secondary_product example classifies each source by membership in the boundary subspace rather than by its first nonzero basis index, which was basis-order dependent for mixed sources. - lambda2 example bails on non-unit modules, since its tables read multiplicands from the resolution basis and feed them as unit generators (valid only when M == k). - Clarify PiElement docs: coords are in the E3 generator basis, labeled by each surviving class's leading (pivot) ambient index, which lines up with Subquotient::reduce entry-for-entry. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_017kezHiWcYod7hYovRPczz9 --- ext/examples/lambda2.rs | 21 ++++++------ ext/examples/secondary_product.rs | 19 ++++------- ext/src/ext_algebra/mod.rs | 28 ++++++--------- ext/src/ext_algebra/secondary.rs | 57 ++++++++++++++++--------------- 4 files changed, 58 insertions(+), 67 deletions(-) diff --git a/ext/examples/lambda2.rs b/ext/examples/lambda2.rs index bffdaf2bec..7d24ea396d 100644 --- a/ext/examples/lambda2.rs +++ b/ext/examples/lambda2.rs @@ -17,9 +17,8 @@ use std::sync::Arc; -use algebra::module::Module; use ext::{ - chain_complex::{ChainComplex, FreeChainComplex}, + chain_complex::FreeChainComplex, ext_algebra::{BZE, ExtAlgebra, secondary::SecondaryExtAlgebra}, secondary::LAMBDA_BIDEGREE, utils::query_module, @@ -35,13 +34,13 @@ fn main() -> anyhow::Result<()> { let resolution = Arc::new(query_module(Some(algebra::AlgebraType::Milnor), true)?); let e2 = Arc::new(ExtAlgebra::from_resolution(Arc::clone(&resolution))?); - if !e2.is_unit() { - let max = Bidegree::n_s( - resolution.module(0).max_computed_degree(), - resolution.next_homological_degree() - 1, - ); - e2.unit().compute_through_stem(max); - } + // The tables are defined for the sphere: Table II reads multiplicands from `e2.resolution()` + // and feeds them to `e2.unit_generator(..)`, which is only the same basis when `M == k`. Bail + // early on non-unit modules rather than print products from a mismatched basis. + anyhow::ensure!( + e2.is_unit(), + "lambda2 only supports the unit module (e.g. S_2); got a non-unit module" + ); let sec_e2 = Arc::new(SecondaryExtAlgebra::new(Arc::clone(&e2))); @@ -88,7 +87,7 @@ where let dim = e2.dimension(b); for i in 0..dim { let g = BidegreeGenerator::new(b, i); - match sec_e2.adams_classify(g) { + match sec_e2.classify(g) { BZE::Z => println!("Z x_{g}"), BZE::B => println!("B x_{g}"), BZE::E => { @@ -179,7 +178,7 @@ where .flat_map(|b| { let dim = e2.dimension(b); (0..dim) - .filter(move |&i| sec_e2.adams_classify(BidegreeGenerator::new(b, i)) == BZE::Z) + .filter(move |&i| sec_e2.classify(BidegreeGenerator::new(b, i)) == BZE::Z) .map(move |i| BidegreeGenerator::new(b, i)) }) .collect(); diff --git a/ext/examples/secondary_product.rs b/ext/examples/secondary_product.rs index 871c168f84..6cce13b2b1 100644 --- a/ext/examples/secondary_product.rs +++ b/ext/examples/secondary_product.rs @@ -112,19 +112,14 @@ fn main() -> anyhow::Result<()> { } } - // Secondary products with surviving classes (B and Z). + // Secondary products with surviving classes (B and Z). The source is a whole class, so we + // classify it by membership in the boundary subspace rather than by its first basis index: + // boundaries live in `page.zeros()`, every other surviving class is a non-boundary cycle. for prod in sec_e2.secondary_multiply_into(&x, b) { - let bze = prod - .source - .vec() - .iter_nonzero() - .next() - .map(|(i, _)| BZE::from_page_data(&page, i)) - .unwrap_or(BZE::Z); - let label = match bze { - BZE::B => "B", - BZE::Z => "Z", - BZE::E => "E", + let label = if page.zeros().contains(prod.source.vec()) { + "B" + } else { + "Z" }; println!( "{label} {disp} [{basis}] = {value}", diff --git a/ext/src/ext_algebra/mod.rs b/ext/src/ext_algebra/mod.rs index feac781bca..6fe016c07b 100644 --- a/ext/src/ext_algebra/mod.rs +++ b/ext/src/ext_algebra/mod.rs @@ -46,10 +46,6 @@ pub struct ExtAlgebra { is_unit: bool, /// One multiplication map per generator of $\Ext(M, k)$, built and extended on demand. products: DashMap>>, - /// Trigraded spectral sequence with the `AdamsLambda2` profile. Generators sit at weight 0; - /// weight 1 is empty, so $d_2$ has no target and the spectral sequence degenerates at $E_2$. - /// The secondary layer populates weight 1 and installs $d_2$ on its own copy. - sseq: sseq::Sseq<3, sseq::AdamsLambda2>, } impl ExtAlgebra { @@ -75,13 +71,11 @@ impl ExtAlgebra { /// Build an [`ExtAlgebra`] from an explicit `(resolution, unit)` pair. pub fn new(resolution: Arc, unit: Arc) -> Self { assert_eq!(resolution.prime(), unit.prime()); - let sseq = Self::build_sseq(&resolution); Self { is_unit: Arc::ptr_eq(&resolution, &unit), resolution, unit, products: DashMap::new(), - sseq, } } @@ -113,18 +107,18 @@ impl ExtAlgebra { self.resolution.prime() } - /// The trigraded spectral sequence for this Ext algebra. Generators live at weight 0 only; - /// weight 1 is unpopulated, so $d_2$ is trivially zero and $E_2 = E_3 = E_\infty$. Every - /// generator classifies as Z. - pub fn sseq(&self) -> &sseq::Sseq<3, sseq::AdamsLambda2> { - &self.sseq - } - - fn build_sseq(resolution: &CC) -> sseq::Sseq<3, sseq::AdamsLambda2> { - let p = resolution.prime(); + /// The trigraded spectral sequence for this Ext algebra, built from the *current* resolution + /// state. Generators live at weight 0 only; weight 1 is unpopulated, so $d_2$ is trivially zero + /// and $E_2 = E_3 = E_\infty$. Every generator classifies as Z. + /// + /// This is computed on demand rather than cached so it always reflects how far the resolution + /// has been extended (e.g. by [`compute_through_bidegree`](Self::compute_through_bidegree)); + /// building it is cheap — one `set_dimension` per stem bidegree, with no differentials. + pub fn sseq(&self) -> sseq::Sseq<3, sseq::AdamsLambda2> { + let p = self.resolution.prime(); let mut sseq = sseq::Sseq::new(p); - for b in resolution.iter_stem() { - let dim = resolution.number_of_gens_in_bidegree(b); + for b in self.resolution.iter_stem() { + let dim = self.resolution.number_of_gens_in_bidegree(b); let [n, s] = b.coords(); sseq.set_dimension(MultiDegree::new([n, s, 0]), dim); } diff --git a/ext/src/ext_algebra/secondary.rs b/ext/src/ext_algebra/secondary.rs index 53573169ef..0d2ec84d2e 100644 --- a/ext/src/ext_algebra/secondary.rs +++ b/ext/src/ext_algebra/secondary.rs @@ -49,7 +49,7 @@ pub struct SecondaryProduct { /// /// Each Ext generator at bidegree $(n, s)$ contributes to the conical basis at one or both weights, /// determined by its Adams BZE classification (see -/// [`adams_classify`](SecondaryExtAlgebra::adams_classify)): +/// [`classify`](SecondaryExtAlgebra::classify)): /// - **B**: weight 0 only (killed at weight 1 by $d_2$ boundaries). /// - **Z**: both weights (permanent cycle). /// - **E**: weight 1 only (killed at weight 0 by supporting $d_2$). @@ -98,9 +98,13 @@ impl fmt::Display for PiGenerator { /// An element in the $E_3 = E_\infty$ page of $\pi(S/\lambda^2)$ at a specific weight. /// -/// The coordinates are in the subquotient basis of $E_3$ at the given bidegree and weight. Each -/// coordinate corresponds to a surviving generator in the ambient Ext space, identified by -/// [`basis_indices`](Self::basis_indices). +/// The coordinates are in the [`Subquotient`] generator basis of $E_3$ at the given bidegree and +/// weight: `coords()[k]` is the coefficient of the `k`-th surviving $E_3$ basis class. Each such +/// class is named by its *leading* (pivot) ambient Ext generator, recorded in +/// [`basis_indices`](Self::basis_indices)`[k]`. The two run in lockstep — `basis_indices` is the +/// list of generator-subspace pivot columns, which is exactly the indexing +/// [`Subquotient::reduce`] returns coefficients for. A class that survives as a non-unit vector is +/// thus displayed by its leading generator, not expanded into ambient coordinates. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PiElement { bidegree: Bidegree, @@ -118,13 +122,14 @@ impl PiElement { self.weight } - /// Coordinates in the $E_3$ subquotient basis. `coords()[i]` is the coefficient of the - /// generator at ambient index [`basis_indices()`](Self::basis_indices)`[i]`. + /// Coordinates in the $E_3$ generator basis. `coords()[k]` is the coefficient of the `k`-th + /// surviving class, whose leading ambient index is [`basis_indices()`](Self::basis_indices)`[k]`. pub fn coords(&self) -> &[u32] { &self.coords } - /// The ambient Ext generator indices forming the $E_3$ basis. + /// The leading (pivot) ambient Ext generator index of each surviving $E_3$ basis class, in the + /// same order as [`coords`](Self::coords). pub fn basis_indices(&self) -> &[usize] { &self.basis_indices } @@ -417,7 +422,7 @@ where &d[std::cmp::min(3, d.len() - 1)] } - /// Classify a generator at bidegree `b` as B, Z, or E in the $d_2$ decomposition. + /// Classify a generator at bidegree `(n, s)` as B, Z, or E in the $d_2$ decomposition. /// /// - **B** (boundary): in the image of $d_2$ from another bidegree. /// - **Z** (cycle mod boundary): a $d_2$-cycle that is not a boundary; survives to $E_3$. @@ -425,20 +430,12 @@ where /// /// At each bidegree, $\Ext = B \oplus Z \oplus E$ and $d_2$ restricts to an isomorphism /// $E_{(n,s)} \to B_{(n-1,s+2)}$. - pub fn classify(&self, g: BidegreeGenerator) -> BZE { - let [n, s] = g.degree().coords(); - self.lambda2_sseq() - .classify(MultiDegree::new([n, s, 0]), 3, g.idx()) - } - - /// The full Adams BZE classification, combining both weights of the $\lambda^2$ spectral - /// sequence. /// - /// Unlike [`classify`](Self::classify) (which only inspects weight 0), this checks both: - /// - **E**: supports $d_2$ at weight 0. - /// - **B**: boundary of $d_2$ at weight 1. - /// - **Z**: permanent cycle (neither E nor B). - pub fn adams_classify(&self, g: BidegreeGenerator) -> BZE { + /// Both incoming and outgoing $d_2$ are inspected via the two weights of the $S/\lambda^2$ + /// spectral sequence: the weight-0 copy at $(n, s, 0)$ carries the *outgoing* $d_2$ (so it + /// detects **E**), while the weight-1 copy at $(n, s, 1)$ receives the *incoming* $d_2$ (so it + /// detects **B**). Inspecting only one weight would miss either boundaries or supports. + pub fn classify(&self, g: BidegreeGenerator) -> BZE { let [n, s] = g.degree().coords(); let l2 = self.lambda2_sseq(); @@ -457,8 +454,8 @@ where /// The conical basis of $\pi(S/\lambda^2)$ at bidegree `b` (Condition 3.2 of the paper). /// - /// Each Ext generator at `b` is classified by [`adams_classify`](Self::adams_classify), then - /// placed at the weights it contributes to: + /// Each Ext generator at `b` is classified by [`classify`](Self::classify), then placed at the + /// weights it contributes to: /// - **B**: weight 0 only. /// - **Z**: both weights. /// - **E**: weight 1 only. @@ -468,7 +465,7 @@ where for i in 0..dim { let g = BidegreeGenerator::new(b, i); - let bze = self.adams_classify(g); + let bze = self.classify(g); if bze != BZE::E { result.push(PiGenerator::new(b, Weight::Ext, bze, i)); @@ -504,8 +501,14 @@ where if let Some(sq) = self.lambda2_page_data(td) { let mut v = ambient_vec.to_owned(); + // `reduce` quotients out the boundary part and returns one coefficient per generator + // pivot column of `sq`, in ascending column order. let coords = sq.reduce(v.as_slice_mut()); + // The matching labels: the generator pivot columns are exactly the ambient indices that + // are neither a quotient (boundary) pivot nor in the complement of the cycle subspace. + // This is the same set `reduce` iterates, in the same order, so it lines up with + // `coords` entry-for-entry. let complement: Vec = sq.complement_pivots().collect(); let basis_indices: Vec = (0..sq.ambient_dimension()) .filter(|&i| sq.zeros().pivots()[i] < 0 && !complement.contains(&i)) @@ -875,7 +878,7 @@ mod tests { sec_e2.extend_all(); // h0 at (0, 1) is a permanent Z-cycle: appears at both weights. - let h0_bze = sec_e2.adams_classify(BidegreeGenerator::new(Bidegree::n_s(0, 1), 0)); + let h0_bze = sec_e2.classify(BidegreeGenerator::new(Bidegree::n_s(0, 1), 0)); assert_eq!(h0_bze, BZE::Z); let pi_01 = sec_e2.pi_basis(Bidegree::n_s(0, 1)); assert_eq!(pi_01.len(), 2); // weight 0 + weight 1 @@ -885,7 +888,7 @@ mod tests { assert_eq!(pi_01[1].bze(), BZE::Z); // h4 at (15, 1) supports d2: classified as E, appears at weight 1 only. - let h4_bze = sec_e2.adams_classify(BidegreeGenerator::new(Bidegree::n_s(15, 1), 0)); + let h4_bze = sec_e2.classify(BidegreeGenerator::new(Bidegree::n_s(15, 1), 0)); assert_eq!(h4_bze, BZE::E); let pi_15_1 = sec_e2.pi_basis(Bidegree::n_s(15, 1)); assert_eq!(pi_15_1.len(), 1); @@ -893,7 +896,7 @@ mod tests { assert_eq!(pi_15_1[0].bze(), BZE::E); // h0*h3^2 at (14, 3) is a d2-boundary: classified as B, appears at weight 0 only. - let b14_3 = sec_e2.adams_classify(BidegreeGenerator::new(Bidegree::n_s(14, 3), 0)); + let b14_3 = sec_e2.classify(BidegreeGenerator::new(Bidegree::n_s(14, 3), 0)); assert_eq!(b14_3, BZE::B); let pi_14_3 = sec_e2.pi_basis(Bidegree::n_s(14, 3)); assert_eq!(pi_14_3.len(), 1); From 156aa07f92fefccc81fcc00104608a45ab988bff Mon Sep 17 00:00:00 2001 From: Joey Beauvais-Feisthauer Date: Thu, 16 Jul 2026 15:44:16 -0400 Subject: [PATCH 12/13] Fix lambda2 secondary panic + save race; express tables in original basis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to the S/λ² (lambda2) machinery, surfaced running `examples/lambda2` against the shared S2 Nassau resolution. 1. Nassau quasi-inverse lag (the reported panic). Nassau writes the quasi-inverse for (s,t) only while stepping to (s+1,t), so QIs lag differentials by one homological degree on the computed frontier. `ResolutionHomomorphism::extend_all` walked onto QI-less bidegrees and tripped the `apply_quasi_inverse` assert. Added `ChainComplex::max_computed_quasi_inverse_degree` (default = module frontier, so minimal resolutions are unchanged; Nassau overrides to encode the lag) and had `extend_all` and `ExtAlgebra::multiply_into` bound their reach by it. The deeper trigger was Table IV multiplying by a bare generator e_i for each Z-classified index. `classify` partitions generator *indices* by echelon pivots, so a "Z" index names the cycle with that pivot (e.g. e_0 + e_1), not e_i itself; feeding the non-cycle e_i to the secondary lift made the obstruction fail to lift. Added `SecondaryExtAlgebra::z_cycles` returning genuine cycle vectors, and Table IV multiplies by those. 2. Secondary-intermediate save race. `get_intermediate` did check-then- create with no dedup; combined with `open_file` deleting a still-empty mid-flush file, two tasks could both compute the same deterministic intermediate and the loser's O_EXCL create panicked with "File exists". Added `SaveFile::try_create_file` (yields None instead of panicking on a create-new collision) and used it for the redundant deterministic write. 3. Express the tables in the original generator basis. The display named each class by its leading pivot generator, hiding that 65 of the ~1974 classes are non-trivial combinations. `PiElement`/`PiGenerator` now carry an ambient representative and Display it over the original generators; `pi_basis` is rebuilt from `b_boundaries`/`z_cycles`/ `e_supports`; Tables I/III/IV render accordingly. Table IV product row keys are unchanged — only values that land on combination classes are now written out in full. Verified: full stem-100/52 run of all four tables completes with no panic (57866 lines); 14/14 lib tests pass under default features. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EwLJtW5fUgVX75Q1D5R85k --- ext/examples/lambda2.rs | 65 ++++----- ext/src/chain_complex/mod.rs | 12 ++ ext/src/ext_algebra/mod.rs | 9 +- ext/src/ext_algebra/secondary.rs | 206 +++++++++++++++++++++-------- ext/src/nassau.rs | 11 ++ ext/src/resolution_homomorphism.rs | 7 +- ext/src/save.rs | 42 ++++++ ext/src/secondary.rs | 12 +- 8 files changed, 272 insertions(+), 92 deletions(-) diff --git a/ext/examples/lambda2.rs b/ext/examples/lambda2.rs index 7d24ea396d..37a29383cc 100644 --- a/ext/examples/lambda2.rs +++ b/ext/examples/lambda2.rs @@ -19,11 +19,12 @@ use std::sync::Arc; use ext::{ chain_complex::FreeChainComplex, - ext_algebra::{BZE, ExtAlgebra, secondary::SecondaryExtAlgebra}, + ext_algebra::{ExtAlgebra, secondary::SecondaryExtAlgebra}, secondary::LAMBDA_BIDEGREE, utils::query_module, }; -use sseq::coordinates::{Bidegree, BidegreeGenerator}; +use fp::vector::FpVector; +use sseq::coordinates::{Bidegree, BidegreeElement, BidegreeGenerator}; fn main() -> anyhow::Result<()> { ext::utils::init_logging()?; @@ -74,32 +75,35 @@ fn main() -> anyhow::Result<()> { /// Table I: B/Z/E decomposition of Ext adapted to d2. /// -/// For each bidegree (n, s), classifies each Ext generator as: -/// - **Z** (d2-cycle, not a boundary) — survives to E3. +/// For each bidegree (n, s), lists the classes of the decomposition — each expressed in the +/// original generator basis (a class that is a non-trivial combination is written out in full, not +/// named by a leading generator): /// - **B** (boundary) — in the image of d2. -/// - **E** (supports d2) — d2(x) ≠ 0; prints the d2 value. +/// - **Z** (d2-cycle, not a boundary) — survives to E3. +/// - **E** (supports d2) — d2(x) ≠ 0; prints the d2 value, also in the original basis. fn print_table_i(e2: &ExtAlgebra, sec_e2: &SecondaryExtAlgebra) where CC: FreeChainComplex + ext::chain_complex::AugmentedChainComplex, CC::Algebra: algebra::pair_algebra::PairAlgebra, { for b in e2.resolution().iter_nonzero_stem() { - let dim = e2.dimension(b); - for i in 0..dim { - let g = BidegreeGenerator::new(b, i); - match sec_e2.classify(g) { - BZE::Z => println!("Z x_{g}"), - BZE::B => println!("B x_{g}"), - BZE::E => { - let elem = e2.generator(g); - if let Some(d2) = sec_e2.d2(&elem) { - let target: Vec = d2.vec().iter().collect(); - println!("E x_{g} d2 = {target:?}"); - } else { - println!("E x_{g} d2 = (not computed)"); - } - } - } + if e2.dimension(b) == 0 { + continue; + } + for v in sec_e2.b_boundaries(b) { + println!("B {}", BidegreeElement::new(b, v).to_basis_string()); + } + for (_, v) in sec_e2.z_cycles(b) { + println!("Z {}", BidegreeElement::new(b, v).to_basis_string()); + } + for (_, v) in sec_e2.e_supports(b) { + let elem = BidegreeElement::new(b, v); + let d2str = match sec_e2.d2(&elem) { + Some(d2) if !d2.vec().is_zero() => d2.to_basis_string(), + Some(_) => "0".to_string(), + None => "(not computed)".to_string(), + }; + println!("E {} d2 = {}", elem.to_basis_string(), d2str); } } } @@ -152,8 +156,8 @@ where for g in sec_e2.pi_basis(b) { let [n, s] = g.bidegree().coords(); let bock = g.weight().as_i32(); - let x = BidegreeGenerator::new(g.bidegree(), g.idx()); - println!("{} x_{x}^{bock} ({n}, {s}, {bock})", g.bze()); + // `g` displays as "BZE ^weight". + println!("{g} ({n}, {s}, {bock})"); } } } @@ -171,20 +175,17 @@ where CC: FreeChainComplex + ext::chain_complex::AugmentedChainComplex, CC::Algebra: algebra::pair_algebra::PairAlgebra, { - let z_gens: Vec = e2 + // The multiplier must be an actual d_2-cycle, not the generator `e_i` sharing its index: the + // secondary lift only exists for cycles. See `SecondaryExtAlgebra::z_cycles`. + let z_classes: Vec<(BidegreeGenerator, FpVector)> = e2 .resolution() .iter_nonzero_stem() .filter(|b| b.s() >= 1) - .flat_map(|b| { - let dim = e2.dimension(b); - (0..dim) - .filter(move |&i| sec_e2.classify(BidegreeGenerator::new(b, i)) == BZE::Z) - .map(move |i| BidegreeGenerator::new(b, i)) - }) + .flat_map(|b| sec_e2.z_cycles(b)) .collect(); - for &x_gen in &z_gens { - let x = e2.generator(x_gen); + for (x_gen, x_vec) in &z_classes { + let x = BidegreeElement::new(x_gen.degree(), x_vec.clone()); let shift = x.degree(); for b in e2.unit().iter_nonzero_stem() { diff --git a/ext/src/chain_complex/mod.rs b/ext/src/chain_complex/mod.rs index cf9b275801..8aa7214a07 100644 --- a/ext/src/chain_complex/mod.rs +++ b/ext/src/chain_complex/mod.rs @@ -220,6 +220,18 @@ pub trait ChainComplex: Send + Sync { /// The first s such that `self.module(s)` is not defined. fn next_homological_degree(&self) -> i32; + /// The largest internal degree `t` for which [`apply_quasi_inverse`](Self::apply_quasi_inverse) + /// at homological degree `s` is guaranteed to succeed (and hence also for every smaller `t`). + /// Returns `min_degree() - 1` when no quasi-inverse at homological degree `s` is available. + /// + /// The default assumes the quasi-inverse is computed alongside the differential, so it extends + /// as far as the module at homological degree `s` has been computed. Resolutions that compute + /// quasi-inverses lazily — such as [`Nassau`](crate::nassau::Resolution), which only writes the + /// quasi-inverse for `(s, t)` while stepping to `(s + 1, t)` — override this. + fn max_computed_quasi_inverse_degree(&self, s: i32) -> i32 { + self.module(s).max_computed_degree() + } + /// Iterate through all defined bidegrees in increasing order of stem. fn iter_stem(&self) -> StemIterator<'_, Self> { StemIterator { diff --git a/ext/src/ext_algebra/mod.rs b/ext/src/ext_algebra/mod.rs index 6fe016c07b..b530cc14bd 100644 --- a/ext/src/ext_algebra/mod.rs +++ b/ext/src/ext_algebra/mod.rs @@ -228,7 +228,14 @@ where let shift = x.degree(); let target = b + shift; - if !self.unit.has_computed_bidegree(b) || !self.resolution.has_computed_bidegree(target) { + // Reading `hom_k` at `target` requires the product map to be extended there, which lifts + // through the unit's quasi-inverse at `b`. For lazily-computed quasi-inverses (e.g. Nassau) + // that can be unavailable even when `b` itself is resolved, so check it explicitly rather + // than treat the product as zero. + if !self.unit.has_computed_bidegree(b) + || !self.resolution.has_computed_bidegree(target) + || self.unit.max_computed_quasi_inverse_degree(b.s()) < b.t() + { return None; } diff --git a/ext/src/ext_algebra/secondary.rs b/ext/src/ext_algebra/secondary.rs index 0d2ec84d2e..df17501723 100644 --- a/ext/src/ext_algebra/secondary.rs +++ b/ext/src/ext_algebra/secondary.rs @@ -47,27 +47,30 @@ pub struct SecondaryProduct { /// A conical basis generator of $\pi(S/\lambda^2)$. /// -/// Each Ext generator at bidegree $(n, s)$ contributes to the conical basis at one or both weights, -/// determined by its Adams BZE classification (see -/// [`classify`](SecondaryExtAlgebra::classify)): +/// Each B/Z/E class of $\Ext$ at bidegree $(n, s)$ contributes to the conical basis at one or both +/// weights (Condition 3.2 of the paper): /// - **B**: weight 0 only (killed at weight 1 by $d_2$ boundaries). /// - **Z**: both weights (permanent cycle). /// - **E**: weight 1 only (killed at weight 0 by supporting $d_2$). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// +/// The class is carried as [`rep`](Self::rep), an ambient representative in the **original** +/// generator basis; [`Display`](fmt::Display) expands it over the original generators rather than +/// naming it by a leading generator. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct PiGenerator { bidegree: Bidegree, weight: Weight, bze: BZE, - idx: usize, + rep: FpVector, } impl PiGenerator { - pub fn new(bidegree: Bidegree, weight: Weight, bze: BZE, idx: usize) -> Self { + pub fn new(bidegree: Bidegree, weight: Weight, bze: BZE, rep: FpVector) -> Self { Self { bidegree, weight, bze, - idx, + rep, } } @@ -83,32 +86,35 @@ impl PiGenerator { self.bze } - pub fn idx(&self) -> usize { - self.idx + /// The class as an ambient representative in the original generator basis. + pub fn rep(&self) -> fp::vector::FpSlice<'_> { + self.rep.as_slice() } } impl fmt::Display for PiGenerator { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let g = BidegreeGenerator::new(self.bidegree, self.idx); - let w = self.weight.as_i32(); - write!(f, "{} x_{g}^{w}", self.bze) + write!(f, "{} ", self.bze)?; + write_original_basis(f, self.bidegree, self.weight, self.rep.as_slice()) } } /// An element in the $E_3 = E_\infty$ page of $\pi(S/\lambda^2)$ at a specific weight. /// -/// The coordinates are in the [`Subquotient`] generator basis of $E_3$ at the given bidegree and -/// weight: `coords()[k]` is the coefficient of the `k`-th surviving $E_3$ basis class. Each such -/// class is named by its *leading* (pivot) ambient Ext generator, recorded in -/// [`basis_indices`](Self::basis_indices)`[k]`. The two run in lockstep — `basis_indices` is the -/// list of generator-subspace pivot columns, which is exactly the indexing -/// [`Subquotient::reduce`] returns coefficients for. A class that survives as a non-unit vector is -/// thus displayed by its leading generator, not expanded into ambient coordinates. +/// The class is carried as [`rep`](Self::rep): an ambient representative in the **original** Ext +/// generator basis at the given bidegree, reduced by the image of $d_2$ (so two representatives +/// differing by a boundary give the same `rep`). [`Display`](fmt::Display) expands it as a linear +/// combination of the original generators, not by its leading generator. +/// +/// [`coords`](Self::coords)/[`basis_indices`](Self::basis_indices) additionally expose the same +/// class in the reduced [`Subquotient`] generator basis, for callers that want $E_3$ coordinates +/// rather than ambient ones. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PiElement { bidegree: Bidegree, weight: Weight, + /// Ambient representative in the original generator basis (reduced mod $d_2$-boundary). + rep: FpVector, coords: Vec, basis_indices: Vec, } @@ -134,35 +140,48 @@ impl PiElement { &self.basis_indices } + /// The ambient representative in the original generator basis (reduced mod $d_2$-boundary). + pub fn rep(&self) -> fp::vector::FpSlice<'_> { + self.rep.as_slice() + } + pub fn is_zero(&self) -> bool { - self.coords.iter().all(|&c| c == 0) + self.rep.is_zero() } } impl fmt::Display for PiElement { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let w = self.weight.as_i32(); - let mut first = true; - for (&c, &idx) in self.coords.iter().zip(&self.basis_indices) { - if c == 0 { - continue; - } - if !first { - write!(f, " + ")?; - } - first = false; - let g = BidegreeGenerator::new(self.bidegree, idx); - if c == 1 { - write!(f, "x_{g}^{w}")?; - } else { - write!(f, "{c} x_{g}^{w}")?; - } + write_original_basis(f, self.bidegree, self.weight, self.rep.as_slice()) + } +} + +/// Render an ambient vector at `bidegree` as a linear combination of the original Ext generators, +/// tagged with the weight, e.g. `x_(38, 6, 0) + x_(38, 6, 1)^0`. Writes `0` when the vector is zero. +fn write_original_basis( + f: &mut fmt::Formatter, + bidegree: Bidegree, + weight: Weight, + v: fp::vector::FpSlice, +) -> fmt::Result { + let w = weight.as_i32(); + let mut first = true; + for (i, c) in v.iter_nonzero() { + if !first { + write!(f, " + ")?; } - if first { - write!(f, "0")?; + first = false; + let g = BidegreeGenerator::new(bidegree, i); + if c == 1 { + write!(f, "x_{g}^{w}")?; + } else { + write!(f, "{c} x_{g}^{w}")?; } - Ok(()) } + if first { + write!(f, "0")?; + } + Ok(()) } /// The secondary layer over an [`ExtAlgebra`]: the $d_2$ differential and the $\Mod_{C\lambda^2}$ @@ -452,27 +471,95 @@ where BZE::Z } - /// The conical basis of $\pi(S/\lambda^2)$ at bidegree `b` (Condition 3.2 of the paper). + /// The **Z** part at bidegree `b` as actual $d_2$-cycles, each paired with the generator index + /// that labels it. + /// + /// [`classify`](Self::classify) partitions generator *indices*: index `i` is **Z** when it is + /// the echelon pivot of the cycle subspace. That records a choice of splitting + /// $\Ext = B \oplus Z \oplus E$ and gets the dimensions right, but it does **not** say the + /// standard generator $e_i$ is itself a cycle — the cycle with pivot `i` is generally $e_i$ + /// plus a correction supported on the **E** indices. For example, if $\Ext$ is two-dimensional + /// at `b` with $d_2(e_0) = d_2(e_1) \neq 0$, then index 0 classifies as **Z**, but the cycle it + /// labels is $e_0 + e_1$, and $e_0$ alone supports a nonzero $d_2$. + /// + /// Use this wherever a genuine cycle is required rather than + /// [`ExtAlgebra::generator`](crate::ext_algebra::ExtAlgebra::generator): + /// [`secondary_product_lift`](Self::secondary_product_lift) only exists for $d_2$-cycles, and + /// feeding it a non-cycle makes the obstruction fail to lift deep inside + /// [`SecondaryLift::compute_homotopies`]. + /// + /// Returns nothing when the $\lambda^2$ spectral sequence is not defined at `b` — without a + /// known $d_2$ out of `b` we cannot certify any class there as a cycle, and + /// [`classify`](Self::classify) reports **Z** by default in that case. + pub fn z_cycles(&self, b: Bidegree) -> Vec<(BidegreeGenerator, FpVector)> { + let [n, s] = b.coords(); + let Some(page) = self.lambda2_page_data(MultiDegree::new([n, s, 0])) else { + return Vec::new(); + }; + // Every basis vector of the cycle subspace is a cycle, so take all of `subspace_gens()` + // and let `classify` pick out the Z ones. Both halves are row-reduced and their pivots are + // disjoint, so each row's first nonzero entry is its pivot column — which is the index + // `classify` labels it by. + page.subspace_gens() + .filter_map(|v| { + let (i, _) = v.first_nonzero()?; + let g = BidegreeGenerator::new(b, i); + (self.classify(g) == BZE::Z).then(|| (g, v.to_owned())) + }) + .collect() + } + + /// The **B** part at bidegree `b`: a basis of the $d_2$-boundary subspace (the image of the + /// incoming $d_2$), as ambient vectors in the original generator basis. Empty when the + /// $\lambda^2$ spectral sequence is not defined at `b`. + pub fn b_boundaries(&self, b: Bidegree) -> Vec { + let [n, s] = b.coords(); + let Some(sq) = self.lambda2_page_data(MultiDegree::new([n, s, 1])) else { + return Vec::new(); + }; + // The weight-1 subquotient quotients out exactly the incoming-d2 image. + sq.zeros().iter().map(|v| v.to_owned()).collect() + } + + /// The **E** part at bidegree `b`: representatives of classes that support a nonzero $d_2$, each + /// paired with the generator index labeling it. Taken as the weight-0 complement pivots, so each + /// representative is a single original generator $e_i$ (with $d_2(e_i) \neq 0$). Empty when the + /// $\lambda^2$ spectral sequence is not defined at `b`. + pub fn e_supports(&self, b: Bidegree) -> Vec<(BidegreeGenerator, FpVector)> { + let [n, s] = b.coords(); + let p = self.prime(); + let Some(sq) = self.lambda2_page_data(MultiDegree::new([n, s, 0])) else { + return Vec::new(); + }; + sq.complement_pivots() + .map(|i| { + let mut v = FpVector::new(p, sq.ambient_dimension()); + v.set_entry(i, 1); + (BidegreeGenerator::new(b, i), v) + }) + .collect() + } + + /// The conical basis of $\pi(S/\lambda^2)$ at bidegree `b` (Condition 3.2 of the paper), + /// with each class carried as an ambient representative in the original generator basis. /// - /// Each Ext generator at `b` is classified by [`classify`](Self::classify), then placed at the - /// weights it contributes to: + /// The B/Z/E parts come from [`b_boundaries`](Self::b_boundaries), [`z_cycles`](Self::z_cycles), + /// and [`e_supports`](Self::e_supports), placed at the weights they contribute to: /// - **B**: weight 0 only. /// - **Z**: both weights. /// - **E**: weight 1 only. pub fn pi_basis(&self, b: Bidegree) -> Vec { - let dim = self.alg.dimension(b); let mut result = Vec::new(); - for i in 0..dim { - let g = BidegreeGenerator::new(b, i); - let bze = self.classify(g); - - if bze != BZE::E { - result.push(PiGenerator::new(b, Weight::Ext, bze, i)); - } - if bze != BZE::B { - result.push(PiGenerator::new(b, Weight::Lambda, bze, i)); - } + for v in self.b_boundaries(b) { + result.push(PiGenerator::new(b, Weight::Ext, BZE::B, v)); + } + for (_, v) in self.z_cycles(b) { + result.push(PiGenerator::new(b, Weight::Ext, BZE::Z, v.clone())); + result.push(PiGenerator::new(b, Weight::Lambda, BZE::Z, v)); + } + for (_, v) in self.e_supports(b) { + result.push(PiGenerator::new(b, Weight::Lambda, BZE::E, v)); } result @@ -514,9 +601,21 @@ where .filter(|&i| sq.zeros().pivots()[i] < 0 && !complement.contains(&i)) .collect(); + // Ambient representative in the original basis: sum the `Subquotient` generator rows + // weighted by `coords`. `gens()` iterates rows in the same ascending-pivot order as + // `coords`, so they line up. This is the reduced-mod-boundary representative of the + // class, expressed over the original generators. + let mut rep = FpVector::new(self.prime(), sq.ambient_dimension()); + for (row, &c) in sq.gens().zip(&coords) { + if c != 0 { + rep.as_slice_mut().add(row, c); + } + } + PiElement { bidegree, weight, + rep, coords, basis_indices, } @@ -524,6 +623,7 @@ where PiElement { bidegree, weight, + rep: FpVector::new(self.prime(), 0), coords: vec![], basis_indices: vec![], } diff --git a/ext/src/nassau.rs b/ext/src/nassau.rs index 8d1919136a..9e977d4aee 100644 --- a/ext/src/nassau.rs +++ b/ext/src/nassau.rs @@ -1048,6 +1048,17 @@ impl> ChainComplex for Resolution { self.modules.len() } + fn max_computed_quasi_inverse_degree(&self, s: i32) -> i32 { + // The quasi-inverse of the differential at `(s, t)` is written to disk only while stepping + // to `(s + 1, t)` (see `step_resolution_with_subalgebra`), so it is available exactly where + // homological degree `s + 1` has been resolved. + if self.differentials.len() > s + 1 { + self.differential(s + 1).next_degree() - 1 + } else { + self.min_degree() - 1 + } + } + fn save_dir(&self) -> &SaveDirectory { &self.save_dir } diff --git a/ext/src/resolution_homomorphism.rs b/ext/src/resolution_homomorphism.rs index 271c7318d3..589b7e9fdf 100644 --- a/ext/src/resolution_homomorphism.rs +++ b/ext/src/resolution_homomorphism.rs @@ -149,11 +149,14 @@ where self.source.next_homological_degree(), ), &|selff, s| { + // Extending the map at source bidegree `(s, t)` lifts through the target's + // quasi-inverse at `(s - shift.s, t - shift.t)`, so we can only go as far in `t` as + // that quasi-inverse is available — which, for lazily-computed quasi-inverses (e.g. + // Nassau), lags behind how far the target module has been computed. std::cmp::min( selff .target - .module(s - selff.shift.s()) - .max_computed_degree() + .max_computed_quasi_inverse_degree(s - selff.shift.s()) + selff.shift.t(), selff.source.module(s).max_computed_degree(), ) + 1 diff --git a/ext/src/save.rs b/ext/src/save.rs index 536f0b3d23..796f1baf56 100644 --- a/ext/src/save.rs +++ b/ext/src/save.rs @@ -483,6 +483,48 @@ impl SaveFile { self.write_header(&mut f).unwrap(); f } + + /// Like [`create_file`](Self::create_file), but yields `None` instead of panicking when the + /// file is already being produced — either it already exists on disk (a `create_new` + /// collision) or another thread currently holds it open for writing. Intended for + /// deterministically-computed save data, where a concurrent writer producing the identical + /// file makes our own write redundant and safely skippable. + /// + /// This avoids a check-then-create race: two threads can both observe a save file as absent + /// (see the empty-file handling in [`open_file`]) and then both try to create it; with + /// [`create_file`](Self::create_file) the loser panics with `File exists`. + pub fn try_create_file(&self, dir: PathBuf, overwrite: bool) -> Option> { + let p = self.get_save_path(dir); + tracing::info!(file = ?p, "try open for writing"); + + // See [`create_file`](Self::create_file) for why this must happen before touching the file. + // A failure here means another thread is already writing this exact path, so the write is + // redundant for deterministic data — treat it the same as an already-existing file. + if !open_files().lock().unwrap().insert(p.clone()) { + return None; + } + + let f = match std::fs::OpenOptions::new() + .write(true) + .create_new(!overwrite) + .create(true) + .truncate(true) + .open(&p) + { + Ok(f) => f, + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + open_files().lock().unwrap().remove(&p); + return None; + } + Err(e) => { + open_files().lock().unwrap().remove(&p); + panic!("Failed to create save file {p:?}: {e}"); + } + }; + let mut f = ChecksumWriter::new(p, io::BufWriter::new(f)); + self.write_header(&mut f).unwrap(); + Some(f) + } } #[derive(Debug, Clone, Eq, PartialEq)] diff --git a/ext/src/secondary.rs b/ext/src/secondary.rs index 0945b28f83..1c79885286 100644 --- a/ext/src/secondary.rs +++ b/ext/src/secondary.rs @@ -21,7 +21,7 @@ use fp::{ }; use maybe_rayon::prelude::*; use once::OnceBiVec; -use sseq::coordinates::{Bidegree, BidegreeGenerator, BidegreeRange}; +use sseq::coordinates::{Bidegree, BidegreeElement, BidegreeGenerator, BidegreeRange}; use tracing::Level; // This module holds the shared machinery for secondary lifts (the `SecondaryLift` trait, @@ -697,9 +697,13 @@ pub trait SecondaryLift: Sync + Sized { let result = self.compute_intermediate(g); if let Some(dir) = self.save_dir().write() { - let mut f = save_file.create_file(dir.to_owned(), false); - f.write_u64::(result.len() as u64).unwrap(); - result.to_bytes(&mut f).unwrap(); + // Intermediates are deterministic in `g`, so a concurrent task racing to compute the + // same one produces an identical file. If it beat us to creating it, our write is + // redundant — skip it rather than panicking on the `create_new` collision. + if let Some(mut f) = save_file.try_create_file(dir.to_owned(), false) { + f.write_u64::(result.len() as u64).unwrap(); + result.to_bytes(&mut f).unwrap(); + } } result From 29bcd4775e49bafd62ed7397e8218099fc89d034 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 20:09:36 +0000 Subject: [PATCH 13/13] save: fix rustfmt, dedup create_file/try_create_file open logic Format try_create_file's signature per nightly rustfmt (CI runs cargo fmt --check), and factor the shared OpenOptions chain into a private open_for_write helper so create_file and try_create_file can't drift apart. Each keeps its own registration and error handling: one panics with context, the other maps AlreadyExists to None. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_017kezHiWcYod7hYovRPczz9 --- ext/src/save.rs | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/ext/src/save.rs b/ext/src/save.rs index 796f1baf56..65d886c37f 100644 --- a/ext/src/save.rs +++ b/ext/src/save.rs @@ -457,6 +457,18 @@ impl SaveFile { } } + /// Open `p` for writing, creating it (and failing if it already exists unless `overwrite`). + /// Shared by [`create_file`](Self::create_file) and [`try_create_file`](Self::try_create_file), + /// which differ only in how they react to the resulting error. + fn open_for_write(p: &Path, overwrite: bool) -> io::Result { + std::fs::OpenOptions::new() + .write(true) + .create_new(!overwrite) + .create(true) + .truncate(true) + .open(p) + } + /// # Arguments /// - `overwrite`: Whether to overwrite a file if it already exists. pub fn create_file(&self, dir: PathBuf, overwrite: bool) -> impl io::Write + use { @@ -471,12 +483,7 @@ impl SaveFile { "File {p:?} is already opened" ); - let f = std::fs::OpenOptions::new() - .write(true) - .create_new(!overwrite) - .create(true) - .truncate(true) - .open(&p) + let f = Self::open_for_write(&p, overwrite) .with_context(|| format!("Failed to create save file {p:?}")) .unwrap(); let mut f = ChecksumWriter::new(p, io::BufWriter::new(f)); @@ -493,7 +500,11 @@ impl SaveFile { /// This avoids a check-then-create race: two threads can both observe a save file as absent /// (see the empty-file handling in [`open_file`]) and then both try to create it; with /// [`create_file`](Self::create_file) the loser panics with `File exists`. - pub fn try_create_file(&self, dir: PathBuf, overwrite: bool) -> Option> { + pub fn try_create_file( + &self, + dir: PathBuf, + overwrite: bool, + ) -> Option> { let p = self.get_save_path(dir); tracing::info!(file = ?p, "try open for writing"); @@ -504,13 +515,7 @@ impl SaveFile { return None; } - let f = match std::fs::OpenOptions::new() - .write(true) - .create_new(!overwrite) - .create(true) - .truncate(true) - .open(&p) - { + let f = match Self::open_for_write(&p, overwrite) { Ok(f) => f, Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { open_files().lock().unwrap().remove(&p);