From 2bcedc8011dce32047e5091901d82b897f1875f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 00:48:41 +0000 Subject: [PATCH 1/5] Fix Massey product panic on proper multiplication kernels (#116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ExtAlgebra::massey_iter_c` and the single-shot `massey` built the null-homotopy of `b ∘ c` per *generator* of the third-factor bidegree, then computed the kernel of `·b` only afterwards. The null-homotopy of `b ∘ gen_i` exists only when `b · gen_i = 0`, so whenever the kernel was a proper subspace (some generator not individually killed by `b`) the lift could not complete and `ChainHomotopy::extend` panicked ("Failed to lift"). This surfaced for first factors of filtration `s >= 2`, e.g. ``. Restructure both to realise the *actual* kernel class via `ResolutionHomomorphism::from_class` and build a single valid null-homotopy, mirroring the already-correct `massey_iter_a`: - `massey_kernel` computes the valid third factors (kernel of `·b`) from the product maps alone, no homotopy. - `massey_bracket_of` realises one kernel class and reads its bracket. - `massey` now verifies `b · c = 0` up front and never lifts an invalid third factor. This is no more expensive: `iter_c` now builds one homotopy per kernel basis element (`dim(ker) <= num_gens`) instead of one per generator. `massey_iter_a` is unchanged. Adds regression tests: `` returns `None` without panicking, and `massey_iter_c(h1^2, h0)` completes and agrees with `massey_iter_a`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CAgVE4H22yxYTjSrH3r1iJ --- ext/src/ext_algebra/massey.rs | 208 +++++++++++++++++++++------------- 1 file changed, 128 insertions(+), 80 deletions(-) diff --git a/ext/src/ext_algebra/massey.rs b/ext/src/ext_algebra/massey.rs index bb2c8988cc..e5246bf701 100644 --- a/ext/src/ext_algebra/massey.rs +++ b/ext/src/ext_algebra/massey.rs @@ -50,12 +50,6 @@ impl MasseyResult { } } -struct MasseyComputeDatum { - answers: Matrix, - kernel: Subspace, - tot: Bidegree, -} - impl ExtAlgebra where CC: FreeChainComplex + AugmentedChainComplex, @@ -85,85 +79,100 @@ where hom } - /// Compute, for a single multiplicand bidegree `c_deg`, the per-generator bracket values and - /// the kernel of multiplication by `b` (the valid third factors). The bracket values form a - /// `num_gens × target_num_gens` matrix whose row `gen` is the bracket of the `gen`th generator - /// of `c_deg`. Returns `None` if the bracket bidegree is empty or uncomputed. - fn massey_at( + /// The kernel of multiplication by `b` at bidegree `c_deg`: the valid third factors of + /// $\langle a, b, -\rangle$, since the bracket is defined only when `b · c = 0`. + /// + /// Computed from the product maps alone (no null-homotopy), as `c · b` (equal to `b · c` up to + /// sign, so the same kernel). Returns `None` when the product bidegree `c_deg + b.degree()` is + /// uncomputed, so callers never mistake an uncomputed product for a zero one; a computed but + /// empty product bidegree correctly yields the full space. + fn massey_kernel(&self, b: &BidegreeElement, c_deg: Bidegree) -> Option { + let p = self.prime(); + let resolution = self.resolution(); + + let prod_deg = c_deg + b.degree(); + if !resolution.has_computed_bidegree(prod_deg) { + return None; + } + let num_gens = resolution.number_of_gens_in_bidegree(c_deg); + let product_num_gens = resolution.number_of_gens_in_bidegree(prod_deg); + + let mut product = AugmentedMatrix::<2>::new(p, num_gens, [product_num_gens, num_gens]); + product.segment(1, 1).add_identity(); + for i in 0..num_gens { + let c_gen = self.generator(BidegreeGenerator::new(c_deg, i)); + let prod = self.try_multiply(&c_gen, b)?; + product + .row_mut(i) + .slice_mut(0, product_num_gens) + .add(prod.vec(), 1); + } + product.row_reduce(); + Some(product.compute_kernel()) + } + + /// The bracket $\langle a, b, c\rangle$ for a single third factor `c`, which the caller must + /// have checked lies in the kernel of multiplication by `b` (so that `b · c = 0` and the + /// null-homotopy exists); otherwise the lift in [`ChainHomotopy::extend`] cannot complete. + /// + /// Unlike the removed per-generator scheme, this realises the *actual* class `c` (a linear + /// combination) via [`ResolutionHomomorphism::from_class`] and builds a single valid + /// null-homotopy, matching the approach of [`massey_iter_a`](Self::massey_iter_a). Returns + /// `None` when the bracket bidegree `c.degree() + shift` is uncomputed or empty. + fn massey_bracket_of( &self, a: &BidegreeElement, - b: &BidegreeElement, b_hom: &Arc>, shift: Bidegree, offset_a: usize, - c_deg: Bidegree, - ) -> Option { + c: &BidegreeElement, + ) -> Option { let p = self.prime(); let resolution = self.resolution(); let unit = self.unit(); - if !resolution.has_computed_bidegree(c_deg + shift) { + let c_deg = c.degree(); + let tot = c_deg + shift; + if !resolution.has_computed_bidegree(tot) { return None; } - let tot = c_deg + shift; - - let num_gens = resolution.number_of_gens_in_bidegree(c_deg); - let product_num_gens = resolution.number_of_gens_in_bidegree(b.degree() + c_deg); let target_num_gens = resolution.number_of_gens_in_bidegree(tot); if target_num_gens == 0 { return None; } let a_coords: Vec = a.vec().iter().collect(); - let b_coords: Vec = b.vec().iter().collect(); - - let mut answers = Matrix::new(p, num_gens, target_num_gens); - let mut product = AugmentedMatrix::<2>::new(p, num_gens, [product_num_gens, num_gens]); - product.segment(1, 1).add_identity(); - - let mut matrix = Matrix::new(p, num_gens, 1); - for idx in 0..num_gens { - let hom = Arc::new(ResolutionHomomorphism::new( - String::new(), - Arc::clone(resolution), - Arc::clone(unit), - c_deg, - )); - - matrix.row_mut(idx).set_entry(0, 1); - hom.extend_step(c_deg, Some(&matrix)); - matrix.row_mut(idx).set_entry(0, 0); - - hom.extend_through_stem(tot); - - let homotopy = ChainHomotopy::new(Arc::clone(&hom), Arc::clone(b_hom)); - homotopy.extend(tot); - - let last = homotopy.homotopy(tot.s()); - let mut answer_row = answers.row_mut(idx); - for i in 0..target_num_gens { - let output = last.output(tot.t(), i); - for (k, &val) in a_coords.iter().enumerate() { - if val != 0 { - answer_row.add_basis_element(i, val * output.entry(offset_a + k)); - } - } - } + let c_coords: Vec = c.vec().iter().collect(); - for (k, &val) in b_coords.iter().enumerate() { + let f_c = Arc::new(ResolutionHomomorphism::from_class( + String::new(), + Arc::clone(resolution), + Arc::clone(unit), + c_deg, + &c_coords, + )); + f_c.extend_through_stem(tot); + + let homotopy = ChainHomotopy::new(f_c, Arc::clone(b_hom)); + homotopy.extend(tot); + + // Read the bracket by pairing the top homotopy against `a`, exactly as the old + // per-generator scheme did, but for the single realised class `c`. + let last = homotopy.homotopy(tot.s()); + let mut representative = FpVector::new(p, target_num_gens); + for i in 0..target_num_gens { + let output = last.output(tot.t(), i); + for (k, &val) in a_coords.iter().enumerate() { if val != 0 { - let g = BidegreeGenerator::new(b.degree(), k); - hom.act(product.row_mut(idx).slice_mut(0, product_num_gens), val, g); + representative.add_basis_element(i, val * output.entry(offset_a + k)); } } } - product.row_reduce(); - let kernel = product.compute_kernel(); - Some(MasseyComputeDatum { - answers, - kernel, - tot, + let indeterminacy = self.massey_indeterminacy(a, c, tot); + Some(MasseyResult { + degree: tot, + coset: AffineSubspace::new(representative, indeterminacy), }) } @@ -244,17 +253,14 @@ where let mut results = Vec::new(); for c_deg in self.resolution().iter_nonzero_stem() { - let Some(MasseyComputeDatum { - answers, - kernel, - tot, - }) = self.massey_at(a, b, &b_hom, shift, offset_a, c_deg) - else { + let Some(kernel) = self.massey_kernel(b, c_deg) else { continue; }; for row in kernel.iter() { let c = BidegreeElement::new(c_deg, row.to_owned()); - let result = self.massey_result(a, &c, &answers, row, tot); + let Some(result) = self.massey_bracket_of(a, &b_hom, shift, offset_a, &c) else { + continue; + }; if result.contains_zero() { continue; } @@ -397,19 +403,15 @@ where return None; } - let MasseyComputeDatum { - answers, - kernel, - tot, - } = self.massey_at(a, b, &b_hom, shift, offset_a, c.degree())?; - - let mut reduced = c.vec().to_owned(); - kernel.reduce(reduced.as_slice_mut()); - if !reduced.is_zero() { - return None; + // The bracket is also defined only when `b · c = 0`. Check this via `c · b` (equal up to + // sign) *before* building any null-homotopy: an invalid `c` has no null-homotopy and would + // otherwise fail to lift. + match self.try_multiply(c, b) { + Some(prod) if prod.vec().is_zero() => {} + _ => return None, } - Some(self.massey_result(a, c, &answers, c.vec(), tot)) + self.massey_bracket_of(a, &b_hom, shift, offset_a, c) } } @@ -460,6 +462,16 @@ mod tests { alg.massey(&h0, &h0, &h1).is_none(), " should be undefined since h0^2 != 0" ); + + // Regression (issue #116): a first factor with `s >= 2` engages the homotopy-lift + // obstruction. is undefined (b · c = h0 · h0 = h0^2 != 0), and `a · b = + // h1^2 · h0 = 0` passes, so the third-factor path is exercised. The old per-generator + // scheme built the null-homotopy for the non-kernel generator h0 and panicked ("Failed to + // lift"); the fix rejects `c` up front and returns `None` without lifting. + assert!( + alg.massey(&h1_sq, &h0, &h0).is_none(), + " should be undefined since h0^2 != 0, and must not panic" + ); } /// For `M == k`, iterating the first factor must agree with iterating the third, via the @@ -489,4 +501,40 @@ mod tests { }; assert_eq!(normalize(by_c), normalize(by_a)); } + + /// Regression (issue #116) with a first factor of filtration `s = 2`. The old `massey_iter_c` + /// built a null-homotopy per generator of each third-factor bidegree and panicked ("Failed to + /// lift") whenever some generator was not killed by `b` — e.g. `h0` at `c_deg = (0, 1)`, since + /// `h0 · h0 = h0^2 != 0`. This only surfaced for `a.s() >= 2` (see the homotopy top step). The + /// fix realises the actual kernel class, so `massey_iter_c(h1^2, h0)` no longer panics and must + /// agree with the reference `massey_iter_a(h0, h1^2)` via ` = ±` + /// (sign trivial at `p = 2`). The mere fact that `massey_iter_c` runs to completion here is the + /// regression guarantee; the equality additionally pins that the fixed `iter_c` agrees with the + /// independent `iter_a` path. + #[test] + fn test_iter_c_proper_kernel() { + let res = Arc::new(construct_standard::("S_2", None).unwrap()); + res.compute_through_stem(Bidegree::n_s(6, 5)); + let alg = ExtAlgebra::new(Arc::clone(&res), res); + + let h0 = alg.generator(BidegreeGenerator::new(Bidegree::n_s(0, 1), 0)); + let h1 = alg.generator(BidegreeGenerator::new(Bidegree::n_s(1, 1), 0)); + let h1_sq = alg.multiply(&h1, &h1); // (n = 2, s = 2), so the fixed first factor has s = 2. + + // Old code panicked ("Failed to lift") building the per-generator homotopy for a non-kernel + // generator (e.g. h0 at c_deg = (0, 1), since h0^2 != 0); the fix realises the actual + // kernel class and completes. + let by_c = alg.massey_iter_c(&h1_sq, &h0); + let by_a = alg.massey_iter_a(&h0, &h1_sq); + + let normalize = |family: Vec<(BidegreeElement, MasseyResult)>| { + let mut keyed: Vec<(String, AffineSubspace)> = family + .into_iter() + .map(|(x, result)| (format!("{x}"), result.coset)) + .collect(); + keyed.sort_by(|l, r| l.0.cmp(&r.0)); + keyed + }; + assert_eq!(normalize(by_c), normalize(by_a)); + } } From 53a42bb8ace8523008c617f789e4e6ae08104be9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 00:56:39 +0000 Subject: [PATCH 2/5] Fix broken intra-doc link to removed massey_at `massey_representative`'s doc comment still linked `Self::massey_at`, which was removed in this change. CI builds docs with broken intra-doc links denied, so rustdoc failed. Point the doc at `massey_iter_a`, the remaining user of the answers-matrix path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CAgVE4H22yxYTjSrH3r1iJ --- ext/src/ext_algebra/massey.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ext/src/ext_algebra/massey.rs b/ext/src/ext_algebra/massey.rs index e5246bf701..0739a64e10 100644 --- a/ext/src/ext_algebra/massey.rs +++ b/ext/src/ext_algebra/massey.rs @@ -176,8 +176,9 @@ where }) } - /// Compute a representative of a Massey product evaluated at `row` using the data returned by - /// [`massey_at`](Self::massey_at). + /// Compute a representative of a Massey product evaluated at `row` from the per-generator + /// bracket matrix `answers`. Used by [`massey_iter_a`](Self::massey_iter_a), which builds one + /// null-homotopy for fixed `b, c` and reads a whole family of first factors off `answers`. fn massey_representative(&self, answers: &Matrix, row: FpSlice) -> FpVector { let mut v = FpVector::new(self.prime(), answers.columns()); answers.apply(v.as_slice_mut(), 1, row); From db57593a56283e26eaef4e1c85b00028b4234681 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 01:11:41 +0000 Subject: [PATCH 3/5] Tidy massey_bracket_of signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Take `b_hom` by value (`Arc<...>`) instead of `&Arc<...>`: the function hands it straight to `ChainHomotopy::new`, which consumes an owned `Arc`, so ownership is what it actually needs — callers now clone at the call site (a no-op move in the single-shot `massey`). Also fold the redundant `offset_a` argument, which was fully derived from `a`, into the function so `a` is self-sufficient. No behavior change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CAgVE4H22yxYTjSrH3r1iJ --- ext/src/ext_algebra/massey.rs | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/ext/src/ext_algebra/massey.rs b/ext/src/ext_algebra/massey.rs index 0739a64e10..00445b9da7 100644 --- a/ext/src/ext_algebra/massey.rs +++ b/ext/src/ext_algebra/massey.rs @@ -122,9 +122,8 @@ where fn massey_bracket_of( &self, a: &BidegreeElement, - b_hom: &Arc>, + b_hom: Arc>, shift: Bidegree, - offset_a: usize, c: &BidegreeElement, ) -> Option { let p = self.prime(); @@ -141,6 +140,10 @@ where return None; } + // Where `a`'s generators sit in the homotopy output, so we can pair against them below. + let offset_a = unit + .module(a.degree().s()) + .generator_offset(a.degree().t(), a.degree().t(), 0); let a_coords: Vec = a.vec().iter().collect(); let c_coords: Vec = c.vec().iter().collect(); @@ -153,7 +156,7 @@ where )); f_c.extend_through_stem(tot); - let homotopy = ChainHomotopy::new(f_c, Arc::clone(b_hom)); + let homotopy = ChainHomotopy::new(f_c, b_hom); homotopy.extend(tot); // Read the bracket by pairing the top homotopy against `a`, exactly as the old @@ -246,10 +249,6 @@ where b: &BidegreeElement, ) -> Vec<(BidegreeElement, MasseyResult)> { let shift = Self::massey_shift(a, b); - let offset_a = - self.unit() - .module(a.degree().s()) - .generator_offset(a.degree().t(), a.degree().t(), 0); let b_hom = self.massey_b_hom(b, shift); let mut results = Vec::new(); @@ -259,7 +258,7 @@ where }; for row in kernel.iter() { let c = BidegreeElement::new(c_deg, row.to_owned()); - let Some(result) = self.massey_bracket_of(a, &b_hom, shift, offset_a, &c) else { + let Some(result) = self.massey_bracket_of(a, Arc::clone(&b_hom), shift, &c) else { continue; }; if result.contains_zero() { @@ -380,10 +379,6 @@ where c: &BidegreeElement, ) -> Option { let shift = Self::massey_shift(a, b); - let offset_a = - self.unit() - .module(a.degree().s()) - .generator_offset(a.degree().t(), a.degree().t(), 0); let b_hom = self.massey_b_hom(b, shift); // The bracket is defined only when `a · b = 0`. Compute `b · a` (equal to `a · b` up to @@ -412,7 +407,7 @@ where _ => return None, } - self.massey_bracket_of(a, &b_hom, shift, offset_a, c) + self.massey_bracket_of(a, b_hom, shift, c) } } From 333260ca3a57b2135bb62e6adc38b9d6b8deb14c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 01:14:07 +0000 Subject: [PATCH 4/5] Apply nightly rustfmt to massey_bracket_of The `offset_a` binding introduced in the previous commit was formatted by stable rustfmt; CI's lint job runs nightly rustfmt, which lays the method chain out differently. Reformat so `just lint` passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CAgVE4H22yxYTjSrH3r1iJ --- ext/src/ext_algebra/massey.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ext/src/ext_algebra/massey.rs b/ext/src/ext_algebra/massey.rs index 00445b9da7..17ca9c262d 100644 --- a/ext/src/ext_algebra/massey.rs +++ b/ext/src/ext_algebra/massey.rs @@ -141,9 +141,9 @@ where } // Where `a`'s generators sit in the homotopy output, so we can pair against them below. - let offset_a = unit - .module(a.degree().s()) - .generator_offset(a.degree().t(), a.degree().t(), 0); + let offset_a = + unit.module(a.degree().s()) + .generator_offset(a.degree().t(), a.degree().t(), 0); let a_coords: Vec = a.vec().iter().collect(); let c_coords: Vec = c.vec().iter().collect(); From b9a7c51f08f7152f80a69a25e06f833a1fe38df2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 05:31:53 +0000 Subject: [PATCH 5/5] massey: treat an empty target bidegree as a defined zero bracket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: when the bracket bidegree `tot` is computed but has no generators, the Massey product lands in the zero group, so it is the (defined) zero element — not an undefined/uncomputed case. Return `Some` with a zero representative instead of `None`, skipping the null-homotopy. `None` now signals only an uncomputed `tot`. This only affects the single-shot `massey`; `massey_iter_c` already filters brackets containing zero, so its output is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CAgVE4H22yxYTjSrH3r1iJ --- ext/src/ext_algebra/massey.rs | 69 +++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/ext/src/ext_algebra/massey.rs b/ext/src/ext_algebra/massey.rs index 17ca9c262d..d29acdc403 100644 --- a/ext/src/ext_algebra/massey.rs +++ b/ext/src/ext_algebra/massey.rs @@ -118,7 +118,8 @@ where /// Unlike the removed per-generator scheme, this realises the *actual* class `c` (a linear /// combination) via [`ResolutionHomomorphism::from_class`] and builds a single valid /// null-homotopy, matching the approach of [`massey_iter_a`](Self::massey_iter_a). Returns - /// `None` when the bracket bidegree `c.degree() + shift` is uncomputed or empty. + /// `None` only when the bracket bidegree `c.degree() + shift` is uncomputed; a computed but + /// empty bidegree yields the (defined) zero bracket, not `None`. fn massey_bracket_of( &self, a: &BidegreeElement, @@ -136,41 +137,45 @@ where return None; } let target_num_gens = resolution.number_of_gens_in_bidegree(tot); - if target_num_gens == 0 { - return None; - } - // Where `a`'s generators sit in the homotopy output, so we can pair against them below. - let offset_a = - unit.module(a.degree().s()) - .generator_offset(a.degree().t(), a.degree().t(), 0); - let a_coords: Vec = a.vec().iter().collect(); - let c_coords: Vec = c.vec().iter().collect(); + // When `tot` is computed but empty the bracket lands in the zero group, so it is the + // (defined) zero element: skip the null-homotopy and use a zero representative. Otherwise + // read the bracket by pairing the top homotopy against `a`, as the old per-generator scheme + // did, but for the single realised class `c`. + let representative = if target_num_gens == 0 { + FpVector::new(p, 0) + } else { + // Where `a`'s generators sit in the homotopy output, so we can pair against them. + let offset_a = + unit.module(a.degree().s()) + .generator_offset(a.degree().t(), a.degree().t(), 0); + let a_coords: Vec = a.vec().iter().collect(); + let c_coords: Vec = c.vec().iter().collect(); + + let f_c = Arc::new(ResolutionHomomorphism::from_class( + String::new(), + Arc::clone(resolution), + Arc::clone(unit), + c_deg, + &c_coords, + )); + f_c.extend_through_stem(tot); - let f_c = Arc::new(ResolutionHomomorphism::from_class( - String::new(), - Arc::clone(resolution), - Arc::clone(unit), - c_deg, - &c_coords, - )); - f_c.extend_through_stem(tot); - - let homotopy = ChainHomotopy::new(f_c, b_hom); - homotopy.extend(tot); - - // Read the bracket by pairing the top homotopy against `a`, exactly as the old - // per-generator scheme did, but for the single realised class `c`. - let last = homotopy.homotopy(tot.s()); - let mut representative = FpVector::new(p, target_num_gens); - for i in 0..target_num_gens { - let output = last.output(tot.t(), i); - for (k, &val) in a_coords.iter().enumerate() { - if val != 0 { - representative.add_basis_element(i, val * output.entry(offset_a + k)); + let homotopy = ChainHomotopy::new(f_c, b_hom); + homotopy.extend(tot); + + let last = homotopy.homotopy(tot.s()); + let mut representative = FpVector::new(p, target_num_gens); + for i in 0..target_num_gens { + let output = last.output(tot.t(), i); + for (k, &val) in a_coords.iter().enumerate() { + if val != 0 { + representative.add_basis_element(i, val * output.entry(offset_a + k)); + } } } - } + representative + }; let indeterminacy = self.massey_indeterminacy(a, c, tot); Some(MasseyResult {