From 1044814f6eaf5ccd6a8d92a6925d4b1e1ca75982 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 18:35:19 +0000 Subject: [PATCH 1/6] Split MilnorAlgebra by basis shape rather than by prime The dual Steenrod algebra is polynomial on the xi_i tensored with an exterior algebra on the tau_k. The exterior part is absent in exactly one case, the classical algebra at p = 2, so the code has been using the prime as a proxy for the shape: `generic()` was `p != 2`, and `compute_degree` and `compute_ppart` each derived `q` with `if p == 2 { 1 } else { 2p - 2 }`. That proxy fails for A_C/tau, the mod-tau reduction of the C-motivic Steenrod algebra, which has the exterior shape *at* p = 2 with q = 2. Make the shape a type parameter instead: - `MilnorFlavour`, a sealed trait, with markers `Exterior` and `NoExterior`. It carries `q(p)` and the four operations that genuinely bifurcate: basis generation, generators, generator naming, and the filtration one products. - `MilnorAlgebraInner` holds the state; `q` is derived from the flavour and the prime rather than stored, so the two cannot disagree. `Exterior::q(p) = 2(p - 1)` is already the motivic value at p = 2. - `MilnorAlgebra` becomes a two-variant enum over the instantiations, dispatched with `enum_dispatch` for the traits and `dispatch_milnor!` for the inherent surface, mirroring `SteenrodAlgebra`. Its constructor picks the variant the prime implies, so the classical algebra is all it exposes and nothing downstream changes. Two decodings in `generators` assumed that a `p` in `q` could not be confused with a `p` in the exponent, which holds only when the exterior shape implies an odd prime: - Q_k was found by testing `factor_pk(p, degree + 1) == (k, 2)`. At p = 2 the 2 is absorbed into the power of the prime, leaving cofactor 1, so no Q_k was ever found. Look the degree up in `tau_degrees` instead. - The polynomial generators were decoded by factoring the undivided degree, which counts one power of the prime too many when q is even. Divide by q first; the cofactor is then exactly `XI_DEGREES[j - 1]` at every prime. Both are only reachable under a profile that is not an A(n), since the full algebra is generated by Q_0 and the P(p^k). `compute_degree` moves from `MilnorBasisElement` to the algebra, which is what knows q. `PairAlgebra` is restricted to `NoExterior`: it asserts p == 2, which no longer implies the classical algebra. The new configuration is checked against the Kong-Lin closed form in `motivic::milnor`, which shares no code with this file: the bases agree degreewise, and every structure constant to degree 18 agrees. The factor ordering is opposite between the two, so a test also pins that the transposed reading genuinely disagrees rather than silently coinciding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ePtYD7Bt4iPeCtmqtqvZE --- .../algebra/src/algebra/milnor_algebra.rs | 879 ++++++++++++++---- .../algebra/src/algebra/motivic/milnor.rs | 2 +- .../algebra/src/algebra/pair_algebra.rs | 60 +- ext/crates/algebra/src/steenrod_evaluator.rs | 2 +- 4 files changed, 734 insertions(+), 209 deletions(-) diff --git a/ext/crates/algebra/src/algebra/milnor_algebra.rs b/ext/crates/algebra/src/algebra/milnor_algebra.rs index 38d54960df..37c0726062 100644 --- a/ext/crates/algebra/src/algebra/milnor_algebra.rs +++ b/ext/crates/algebra/src/algebra/milnor_algebra.rs @@ -1,4 +1,4 @@ -use std::cell::Cell; +use std::{cell::Cell, marker::PhantomData}; use fp::{ prime::{Binomial, Prime, ValidPrime, factor_pk, iter::BitflagIterator}, @@ -353,20 +353,6 @@ impl MilnorBasisElement { pub fn clone_into(&self, other: &mut Self) { *other = *self; } - - /// Update the degree component to the correct degree - pub fn compute_degree(&mut self, p: ValidPrime) { - let q = if p == 2 { 1 } else { 2 * (p.as_i32() - 1) }; - let xi_degrees = combinatorics::xi_degrees(p); - let tau_degrees = combinatorics::tau_degrees(p); - - self.degree = q * std::iter::zip(xi_degrees, self.p_part.iter()) - .map(|(&a, b)| a * b as i32) - .sum::() - + BitflagIterator::set_bit_iterator(self.q_part as u64) - .map(|k| tau_degrees[k]) - .sum::(); - } } impl std::cmp::PartialEq for MilnorBasisElement { @@ -411,14 +397,62 @@ impl std::fmt::Display for MilnorBasisElement { } } -pub struct MilnorAlgebra { +mod private { + /// Seals [`MilnorFlavour`](super::MilnorFlavour) so that `q` and the presence of an exterior + /// part cannot be chosen independently. + pub trait Sealed {} +} + +/// The shape of the Milnor basis of a dual Steenrod algebra. +/// +/// The dual Steenrod algebra is a polynomial algebra on the $\xi_i$ tensored with an exterior +/// algebra on the $\tau_k$. The exterior part is absent in exactly one case, the classical algebra +/// at $p = 2$, which is why the two shapes are distinguished here rather than by the prime: the +/// mod-$\tau$ C-motivic algebra $A^{\mathbb{C}}/\tau$ has the exterior shape *at* $p = 2$. +pub trait MilnorFlavour: private::Sealed + Sized + Send + Sync + 'static { + /// Whether basis elements carry an exterior part. + const HAS_EXTERIOR: bool; + + /// The scale of the polynomial grading: $\xi_i$ has degree `q * XI_DEGREES[i]`. + /// + /// Equivalently, `q == 1` exactly when there is no exterior part. + fn q(p: ValidPrime) -> i32; + + /// Fill in the algebra's basis table up to `max_degree`. + fn generate_basis(algebra: &MilnorAlgebraInner, max_degree: i32); + + /// The indices in `degree` of the algebra generators, as required by [`GeneratedAlgebra`]. + fn generators(algebra: &MilnorAlgebraInner, degree: i32) -> Vec; + + /// The name of the generator at `(degree, idx)`. + fn generator_to_string(algebra: &MilnorAlgebraInner, degree: i32, idx: usize) -> String; + + /// The elements that induce the filtration one products, with the degree to compute up to. + fn filtration_one_products( + algebra: &MilnorAlgebraInner, + ) -> (Vec<(String, MilnorBasisElement)>, i32); +} + +/// The polynomial-only Milnor basis: the classical dual Steenrod algebra at $p = 2$. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NoExterior; + +/// The Milnor basis with an exterior part: the classical dual Steenrod algebra at odd primes, and +/// $A^{\mathbb{C}}/\tau$ at $p = 2$. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Exterior; + +impl private::Sealed for NoExterior {} +impl private::Sealed for Exterior {} + +pub struct MilnorAlgebraInner { profile: MilnorProfile, p: ValidPrime, - #[cfg(feature = "odd-primes")] - generic: bool, unstable_enabled: bool, + flavour: PhantomData, + /// This is a list of possible P(R) of each degree, where `ppart_table[i]` contains elements of /// degree `q * i`. ppart_table: OnceVec>, @@ -440,13 +474,13 @@ pub struct MilnorAlgebra { multiplication_table: OnceVec>>>, } -impl std::fmt::Display for MilnorAlgebra { +impl std::fmt::Display for MilnorAlgebraInner { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "MilnorAlgebra(p={})", self.prime()) } } -impl MilnorAlgebra { +impl MilnorAlgebraInner { pub fn new(p: ValidPrime, unstable_enabled: bool) -> Self { Self::new_with_profile(p, MilnorProfile::default(), unstable_enabled) } @@ -455,9 +489,8 @@ impl MilnorAlgebra { assert!(profile.is_valid()); Self { p, - #[cfg(feature = "odd-primes")] - generic: p != 2, unstable_enabled, + flavour: PhantomData, profile, ppart_table: OnceVec::new(), basis_table: OnceVec::new(), @@ -468,25 +501,17 @@ impl MilnorAlgebra { } } + /// Whether basis elements carry an exterior part. + /// + /// This is a constant of the flavour, so the branches it guards fold away. #[inline] - pub fn generic(&self) -> bool { - #[cfg(feature = "odd-primes")] - { - self.generic - } - - #[cfg(not(feature = "odd-primes"))] - { - false - } + pub fn has_exterior(&self) -> bool { + F::HAS_EXTERIOR } + /// The scale of the polynomial grading; see [`MilnorFlavour::q`]. pub fn q(&self) -> i32 { - if self.generic() { - 2 * (self.prime().as_i32() - 1) - } else { - 1 - } + F::q(self.p) } pub fn profile(&self) -> &MilnorProfile { @@ -499,7 +524,7 @@ impl MilnorAlgebra { /// basis is re-sorted by excess; in both cases the basis is not a re-wrapping of /// [`Self::ppart_table`] and must be kept. fn stores_basis_table(&self) -> bool { - self.generic() || self.unstable_enabled + F::HAS_EXTERIOR || self.unstable_enabled } pub fn basis_element_from_index(&self, degree: i32, idx: usize) -> MilnorBasisElement { @@ -527,7 +552,218 @@ impl MilnorAlgebra { } } -impl Algebra for MilnorAlgebra { +/// Operations shared by both flavours, parameterised only by [`MilnorFlavour::q`]. +impl MilnorAlgebraInner { + /// Set `elt`'s degree component to the degree it has in this algebra. + pub fn compute_degree(&self, elt: &mut MilnorBasisElement) { + let p = self.prime(); + let xi_degrees = combinatorics::xi_degrees(p); + let tau_degrees = combinatorics::tau_degrees(p); + + elt.degree = self.q() + * std::iter::zip(xi_degrees, elt.p_part.iter()) + .map(|(&a, b)| a * b as i32) + .sum::() + + BitflagIterator::set_bit_iterator(elt.q_part as u64) + .map(|k| tau_degrees[k]) + .sum::(); + } + + /// The index of the polynomial generator in `degree`, if there is one. + /// + /// The generators are $P(0, \ldots, 0, p^k)$ with the entry in slot `j - 1`, of degree + /// `q * XI_DEGREES[j - 1] * p^k`. Dividing by `q` first makes the decoding uniform in the + /// prime: the cofactor left by [`factor_pk`] is then exactly `XI_DEGREES[j - 1]`, which is + /// coprime to `p` because it is $1 + p + \cdots + p^{j-1}$. Factoring the undivided degree + /// instead would over-count the powers of `p` whenever `q` is itself divisible by `p`, which + /// is what happens for [`Exterior`] at `p = 2`. + fn polynomial_generator(&self, degree: i32) -> Vec { + let p = self.prime(); + let q = self.q() as u32; + let degree = degree as u32; + + if !degree.is_multiple_of(q) { + return vec![]; + } + let (k, cofactor) = factor_pk(p, degree / q); + + // `is_an` profiles only keep the generators with `j == 1`, where `XI_DEGREES[0] == 1`. + if self.profile.is_an(F::HAS_EXTERIOR) { + if cofactor != 1 || k >= self.profile.get_p_part(0) { + return vec![]; + } + return vec![self.basis_element_to_index(&MilnorBasisElement { + degree: degree as i32, + q_part: 0, + p_part: PPart::from_iter([p.pow(k)]), + })]; + } + + let Some(j) = combinatorics::xi_degrees(p) + .iter() + .position(|&d| d as u32 == cofactor) + .map(|i| i + 1) + else { + return vec![]; + }; + if self.profile.get_p_part(j - 1) <= k { + return vec![]; + } + let mut p_part = PPart::zero(); + p_part.set(j - 1, p.pow(k)); + vec![self.basis_element_to_index(&MilnorBasisElement { + degree: degree as i32, + q_part: 0, + p_part, + })] + } + + /// The name of the polynomial generator at `(degree, idx)`, given the name of $P(n)$. + fn polynomial_generator_to_string(&self, degree: i32, idx: usize, single: &str) -> String { + let elt = self.basis_element_from_index(degree, idx); + let len = elt.p_part.len(); + if len == 1 { + format!("{single}{}", degree / self.q()) + } else { + format!( + "P^{}_{len}", + degree / (self.q() * combinatorics::xi_degrees(self.prime())[len - 1]), + ) + } + } +} + +impl MilnorFlavour for NoExterior { + const HAS_EXTERIOR: bool = false; + + fn q(_p: ValidPrime) -> i32 { + 1 + } + + fn generate_basis(algebra: &MilnorAlgebraInner, max_degree: i32) { + algebra.generate_basis_polynomial(max_degree); + } + + fn generators(algebra: &MilnorAlgebraInner, degree: i32) -> Vec { + algebra.polynomial_generator(degree) + } + + fn generator_to_string(algebra: &MilnorAlgebraInner, degree: i32, idx: usize) -> String { + algebra.polynomial_generator_to_string(degree, idx, "Sq") + } + + fn filtration_one_products( + algebra: &MilnorAlgebraInner, + ) -> (Vec<(String, MilnorBasisElement)>, i32) { + let profile = &algebra.profile; + let max = if !profile.p_part.is_empty() { + std::cmp::min(4, profile.p_part[0]) + } else if profile.truncated { + 0 + } else { + 4 + }; + let products = (0..max) + .map(|i| { + let degree = 1 << i; // degree is 2^hi + ( + format!("h_{i}"), + MilnorBasisElement { + degree, + q_part: 0, + p_part: PPart::from_iter([1 << i]), + }, + ) + }) + .collect(); + (products, 1 << 3) + } +} + +impl MilnorFlavour for Exterior { + const HAS_EXTERIOR: bool = true; + + fn q(p: ValidPrime) -> i32 { + 2 * (p.as_i32() - 1) + } + + fn generate_basis(algebra: &MilnorAlgebraInner, max_degree: i32) { + algebra.generate_basis_exterior(max_degree); + } + + fn generators(algebra: &MilnorAlgebraInner, degree: i32) -> Vec { + // The polynomial part sits in degrees divisible by `q`, which is even, so an odd degree + // can only hold a $Q_k$. + if degree % 2 == 1 { + if algebra.profile.is_an(true) { + return vec![]; + } + // $|Q_k| = 2p^k - 1$ is exactly `TAU_DEGREES[k]`. Looking it up keeps this correct at + // `p = 2`, where testing `factor_pk(p, degree + 1) == (k, 2)` fails: `2p^k` is then a + // pure power of the prime and the cofactor is 1, not 2. + let Some(k) = combinatorics::tau_degrees(algebra.prime()) + .iter() + .position(|&d| d == degree) + else { + return vec![]; + }; + let q_part = 1 << k; + if algebra.profile.q_part & q_part == 0 { + return vec![]; + } + return vec![algebra.basis_element_to_index(&MilnorBasisElement { + degree, + q_part, + p_part: PPart::zero(), + })]; + } + algebra.polynomial_generator(degree) + } + + fn generator_to_string(algebra: &MilnorAlgebraInner, degree: i32, idx: usize) -> String { + if degree == 1 { + return "b".to_string(); + } + let elt = algebra.basis_element_from_index(degree, idx); + if elt.q_part != 0 { + elt.to_string() + } else { + algebra.polynomial_generator_to_string(degree, idx, "P") + } + } + + fn filtration_one_products( + algebra: &MilnorAlgebraInner, + ) -> (Vec<(String, MilnorBasisElement)>, i32) { + let profile = &algebra.profile; + let mut products = Vec::with_capacity(2); + if profile.q_part & 1 != 0 { + products.push(( + "a_0".to_string(), + MilnorBasisElement { + degree: 1, + q_part: 1, + p_part: PPart::zero(), + }, + )); + } + if (profile.p_part.is_empty() && !profile.truncated) + || (!profile.p_part.is_empty() && profile.p_part[0] > 0) + { + products.push(( + "h_0".to_string(), + MilnorBasisElement { + degree: Self::q(algebra.prime()), + q_part: 0, + p_part: PPart::from_iter([1]), + }, + )); + } + (products, Self::q(algebra.prime())) + } +} + +impl Algebra for MilnorAlgebraInner { fn prefix(&self) -> &str { "milnor" } @@ -546,51 +782,7 @@ impl Algebra for MilnorAlgebra { } fn default_filtration_one_products(&self) -> Vec<(String, i32, usize)> { - let mut products = Vec::with_capacity(4); - let max_degree = if self.generic() { - if self.profile.q_part & 1 != 0 { - products.push(( - "a_0".to_string(), - MilnorBasisElement { - degree: 1, - q_part: 1, - p_part: PPart::zero(), - }, - )); - } - if (self.profile.p_part.is_empty() && !self.profile.truncated) - || (!self.profile.p_part.is_empty() && self.profile.p_part[0] > 0) - { - products.push(( - "h_0".to_string(), - MilnorBasisElement { - degree: (2 * self.prime() - 2) as i32, - q_part: 0, - p_part: PPart::from_iter([1]), - }, - )); - } - (2 * self.prime() - 2) as i32 - } else { - let mut max = 4; - if !self.profile.p_part.is_empty() { - max = std::cmp::min(4, self.profile.p_part[0]); - } else if self.profile.truncated { - max = 0; - } - for i in 0..max { - let degree = 1 << i; // degree is 2^hi - products.push(( - format!("h_{i}"), - MilnorBasisElement { - degree, - q_part: 0, - p_part: PPart::from_iter([1 << i]), - }, - )); - } - 1 << 3 - }; + let (products, max_degree) = F::filtration_one_products(self); self.compute_basis(max_degree + 1); products @@ -609,11 +801,7 @@ impl Algebra for MilnorAlgebra { ); self.compute_ppart(max_degree); - if self.generic() { - self.generate_basis_generic(max_degree); - } else { - self.generate_basis_2(max_degree); - } + F::generate_basis(self, max_degree); // Populate hash map self.basis_element_to_index_map @@ -818,7 +1006,7 @@ impl Algebra for MilnorAlgebra { q_part, p_part, }; - elt.compute_degree(p); + self.compute_degree(&mut elt); if elt.degree > PPart::MAX_DEGREE { return None; } @@ -838,7 +1026,7 @@ impl Algebra for MilnorAlgebra { } } -impl UnstableAlgebra for MilnorAlgebra { +impl UnstableAlgebra for MilnorAlgebraInner { fn dimension_unstable(&self, degree: i32, excess: i32) -> usize { if degree < 0 || excess < 0 { 0 @@ -867,38 +1055,9 @@ impl UnstableAlgebra for MilnorAlgebra { } } -impl GeneratedAlgebra for MilnorAlgebra { +impl GeneratedAlgebra for MilnorAlgebraInner { fn generator_to_string(&self, degree: i32, idx: usize) -> String { - if self.generic() { - if degree == 1 { - return "b".to_string(); - } - let elt = self.basis_element_from_index(degree, idx); - let len = elt.p_part.len(); - if elt.q_part != 0 { - elt.to_string() - } else if len == 1 { - format!("P{}", degree / self.q()) - } else { - format!( - "P^{}_{}", - degree / (self.q() * combinatorics::xi_degrees(self.prime())[len - 1]), - len - ) - } - } else { - let elt = self.basis_element_from_index(degree, idx); - let len = elt.p_part.len(); - if len == 1 { - format!("Sq{degree}") - } else { - format!( - "P^{}_{}", - degree / (combinatorics::xi_degrees(self.prime())[len - 1]), - len - ) - } - } + F::generator_to_string(self, degree, idx) } fn generators(&self, degree: i32) -> Vec { @@ -907,70 +1066,7 @@ impl GeneratedAlgebra for MilnorAlgebra { } else if degree == 1 { return vec![0]; // Q_0 } - - let p = self.prime(); - - // Check for the Q_k - if self.generic() && degree % 2 == 1 { - if self.profile.is_an(true) { - return vec![]; - } - - // If this is 2p^k - 1, then return Q_k - if let (k, 2) = factor_pk(p, degree as u32 + 1) { - let q_part = 1 << k; - if self.profile.q_part & q_part != 0 { - return vec![self.basis_element_to_index(&MilnorBasisElement { - degree, - q_part, - p_part: PPart::zero(), - })]; - } - } - return vec![]; - } - - if self.profile.is_an(self.generic()) { - // Look for P(p^k), which has degree p^k q. - let q = self.q() as u32; - if !(degree as u32).is_multiple_of(q) { - return vec![]; - } - if let (k, 1) = factor_pk(p, degree as u32 / q) - && (k) < self.profile.get_p_part(0) - { - return vec![self.basis_element_to_index(&MilnorBasisElement { - degree, - q_part: 0, - p_part: PPart::from_iter([degree as u32 / q]), - })]; - } - vec![] - } else { - // Look for P(0, ..., 0, p^k), which has degree (2p^j - 2) p^k. - let (k, rem) = factor_pk(p, degree as u32); - - let reduced = if self.generic() { - // rem must be even because degree is even - (rem + 2) / 2 - } else { - rem + 1 - }; - - if let (j, 1) = factor_pk(p, reduced) { - if self.profile.get_p_part(j as usize - 1) <= k { - return vec![]; - } - let mut p_part = PPart::zero(); - p_part.set(j as usize - 1, p.pow(k)); - return vec![self.basis_element_to_index(&MilnorBasisElement { - degree, - q_part: 0, - p_part, - })]; - } - vec![] - } + F::generators(self, degree) } fn decompose_basis_element( @@ -988,12 +1084,12 @@ impl GeneratedAlgebra for MilnorAlgebra { } fn generating_relations(&self, degree: i32) -> Vec> { - if self.generic() && degree == 2 { + if F::HAS_EXTERIOR && degree == 2 { // beta^2 = 0 is an edge case return vec![vec![(1, (1, 0), (1, 0))]]; } let p = self.prime(); - let inadmissible_pairs = combinatorics::inadmissible_pairs(p, self.generic(), degree); + let inadmissible_pairs = combinatorics::inadmissible_pairs(p, F::HAS_EXTERIOR, degree); let mut result = Vec::new(); for (x, b, y) in inadmissible_pairs { let mut relation = Vec::new(); @@ -1035,12 +1131,11 @@ impl GeneratedAlgebra for MilnorAlgebra { } // Compute basis functions -impl MilnorAlgebra { +impl MilnorAlgebraInner { fn compute_ppart(&self, max_degree: i32) { self.ppart_table.extend(0, |_| vec![PPart::zero()]); - let p = self.prime().as_i32(); - let q = if p == 2 { 1 } else { 2 * p - 2 }; + let q = self.q(); let new_deg = max_degree / q; let xi_degrees = combinatorics::xi_degrees(self.prime()); @@ -1085,8 +1180,8 @@ impl MilnorAlgebra { }); } - fn generate_basis_generic(&self, max_degree: i32) { - let q = 2 * self.prime() - 2; + fn generate_basis_exterior(&self, max_degree: i32) { + let q = self.q() as u32; let tau_degrees = combinatorics::tau_degrees(self.prime()); self.basis_table.extend(max_degree as usize, |d| { @@ -1133,7 +1228,7 @@ impl MilnorAlgebra { }); } - fn generate_basis_2(&self, max_degree: i32) { + fn generate_basis_polynomial(&self, max_degree: i32) { if !self.stores_basis_table() { // Derived on demand from `ppart_table`; see the field docs. return; @@ -1170,7 +1265,7 @@ impl MilnorAlgebra { } // Multiplication logic -impl MilnorAlgebra { +impl MilnorAlgebraInner { /// Return the degree and index of $Q_1^e P(x)$, or `None` if the element is not present /// (e.g. out of range or excluded by the profile). pub fn try_beps_pn(&self, e: u32, x: PPartEntry) -> Option<(i32, usize)> { @@ -1273,7 +1368,7 @@ impl MilnorAlgebra { mut allocation: PPartAllocation, ) -> PPartAllocation { let target_deg = m1.degree + m2.degree; - if self.generic() { + if F::HAS_EXTERIOR { let m1f = self.multiply_qpart(m1, m2.q_part); for (cc, basis) in m1f { let mut multiplier = PPartMultiplier::::new_from_allocation( @@ -1743,7 +1838,7 @@ impl Iterator for PPartMultiplier { } } -impl MilnorAlgebra { +impl MilnorAlgebraInner { fn decompose_basis_element_qpart( &self, degree: i32, @@ -1832,7 +1927,7 @@ impl MilnorAlgebra { // This is a power of p if m == 1 { - if len == 1 || !self.profile.is_an(self.generic()) { + if len == 1 || !self.profile.is_an(F::HAS_EXTERIOR) { buffer.extend([(p - c, (degree, idx), (0, 0))]); } else { // Write this as [P(p^(len + k - 1)), P(0, .., 0, P^k)] plus higher order @@ -1921,7 +2016,7 @@ impl MilnorAlgebra { } } -impl MilnorAlgebra { +impl MilnorAlgebraInner { /// Advance `element` to the next p-part bounded entrywise by `max`, in odometer order. /// /// Returns `true` once the odometer wraps, i.e. when `element` was already `max`. @@ -1942,7 +2037,7 @@ impl MilnorAlgebra { } } -impl Bialgebra for MilnorAlgebra { +impl Bialgebra for MilnorAlgebraInner { fn coproduct(&self, op_deg: i32, op_idx: usize) -> Vec<(i32, usize, i32, usize)> { assert_eq!(self.prime(), 2, "Coproduct at odd primes not supported"); if op_deg == 0 { @@ -1994,6 +2089,81 @@ impl Bialgebra for MilnorAlgebra { } } +/// Forward an inherent method to whichever flavour this algebra has. +macro_rules! dispatch_milnor { + () => {}; + ($vis:vis fn $method:ident(&self$(, $arg:ident: $ty:ty )*$(,)?) $(-> $ret:ty)?; $($tail:tt)*) => { + $vis fn $method(&self, $($arg: $ty),* ) $(-> $ret)* { + match self { + MilnorAlgebra::Polynomial(a) => a.$method($($arg),*), + MilnorAlgebra::Exterior(a) => a.$method($($arg),*), + } + } + dispatch_milnor!{$($tail)*} + }; +} + +/// A dual Steenrod algebra in the Milnor basis, of either [flavour](MilnorFlavour). +/// +/// [`Self::new`] picks the flavour that the prime implies, so the classical algebra is all this +/// exposes. `MilnorAlgebraInner` at `p = 2` is the mod-$\tau$ C-motivic algebra, which +/// is a different algebra rather than a different presentation of this one; it is reached through +/// its own wrapper, not from here. +#[allow(clippy::large_enum_variant)] +#[enum_dispatch::enum_dispatch(Algebra, Bialgebra, GeneratedAlgebra, UnstableAlgebra)] +pub enum MilnorAlgebra { + Polynomial(MilnorAlgebraInner), + Exterior(MilnorAlgebraInner), +} + +impl MilnorAlgebra { + dispatch_milnor! { + pub fn has_exterior(&self) -> bool; + pub fn q(&self) -> i32; + pub fn profile(&self) -> &MilnorProfile; + pub fn compute_degree(&self, elt: &mut MilnorBasisElement); + pub fn basis_element_from_index(&self, degree: i32, idx: usize) -> MilnorBasisElement; + pub fn try_basis_element_to_index(&self, elt: &MilnorBasisElement) -> Option; + pub fn basis_element_to_index(&self, elt: &MilnorBasisElement) -> usize; + pub fn ppart_table(&self, t: i32) -> &[PPart]; + pub fn try_beps_pn(&self, e: u32, x: PPartEntry) -> Option<(i32, usize)>; + pub fn beps_pn(&self, e: u32, x: PPartEntry) -> (i32, usize); + pub fn multiply(&self, res: FpSliceMut, coef: u32, m1: MilnorBasisElement, m2: MilnorBasisElement); + pub fn multiply_with_allocation(&self, res: FpSliceMut, coef: u32, m1: MilnorBasisElement, m2: MilnorBasisElement, excess: i32, allocation: PPartAllocation) -> PPartAllocation; + } + + /// The classical dual Steenrod algebra at `p`. + pub fn new(p: ValidPrime, unstable_enabled: bool) -> Self { + Self::new_with_profile(p, MilnorProfile::default(), unstable_enabled) + } + + /// The classical dual Steenrod algebra at `p`, restricted to `profile`. + pub fn new_with_profile(p: ValidPrime, profile: MilnorProfile, unstable_enabled: bool) -> Self { + // The classical algebra has an exterior part exactly at odd primes. + if p == 2 { + MilnorAlgebraInner::::new_with_profile(p, profile, unstable_enabled).into() + } else { + MilnorAlgebraInner::::new_with_profile(p, profile, unstable_enabled).into() + } + } +} + +#[cfg(test)] +impl MilnorAlgebra { + dispatch_milnor! { + fn stores_basis_table(&self) -> bool; + } +} + +impl std::fmt::Display for MilnorAlgebra { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Polynomial(a) => a.fmt(f), + Self::Exterior(a) => a.fmt(f), + } + } +} + #[cfg(test)] mod tests { use std::fmt::Write as _; // Needed for write! macro for String @@ -2336,7 +2506,7 @@ mod tests { let mut cur = PPart::zero(); loop { count += 1; - if MilnorAlgebra::increment_p_part(&mut cur, max) { + if MilnorAlgebraInner::::increment_p_part(&mut cur, max) { break; } } @@ -2371,7 +2541,7 @@ mod tests { assert_eq!(algebra.basis_element_to_index(&elt), i); // The degree really is recoverable from the entries. let mut recomputed = elt; - recomputed.compute_degree(ValidPrime::new(p)); + algebra.compute_degree(&mut recomputed); assert_eq!(recomputed.degree, t); } } @@ -2620,4 +2790,309 @@ mod tests { .is_valid() ); } + + /// The exterior flavour at `p = 2` is $A^{\mathbb{C}}/\tau$, the mod-$\tau$ reduction of the + /// C-motivic Steenrod algebra. That configuration is unreachable through [`MilnorAlgebra`], + /// so these check it against the independent Kong–Lin closed form in + /// [`crate::algebra::motivic::milnor`], which shares no code with this file. + mod exterior_at_two { + use fp::prime::TWO; + + use super::*; + use crate::algebra::motivic::milnor::{ + Bigraded, Dual, Monomial, enum_basis, multiply_closed_mod_tau, + }; + + fn ctau() -> MilnorAlgebraInner { + MilnorAlgebraInner::::new(TWO, false) + } + + /// The two presentations must at least agree on which elements exist in each degree. + #[test] + fn basis_matches_the_engine() { + let algebra = ctau(); + const MAX: i32 = 12; + algebra.compute_basis(MAX); + + for t in 0..=MAX { + let engine: Vec> = enum_basis(t); + assert_eq!( + algebra.dimension(t), + engine.len(), + "dimension disagrees in degree {t}" + ); + for Dual(m) in engine { + let elt = MilnorBasisElement { + q_part: m.q_part, + p_part: m.p_part, + degree: t, + }; + assert_eq!( + m.bidegree().0, + t, + "the engine's own grading disagrees with its enumeration" + ); + // Degree computed from the entries, not taken on trust from the engine. + let mut recomputed = elt; + algebra.compute_degree(&mut recomputed); + assert_eq!(recomputed.degree, t, "degree of {elt} in degree {t}"); + assert!( + algebra.try_basis_element_to_index(&elt).is_some(), + "{elt} is in the engine's degree {t} but not the algebra's" + ); + } + } + } + + /// Every structure constant, against the closed form. + /// + /// The two order their factors oppositely: this algebra commutes the *right* factor's + /// exterior part leftwards, and the engine the left factor's. The orientation is asserted + /// rather than assumed — [`product_orientation_is_not_symmetric`] shows the transposed + /// reading is a genuinely different answer, so a wrong choice here would fail loudly + /// rather than quietly agree. + #[test] + fn products_match_the_engine() { + let algebra = ctau(); + const MAX: i32 = 18; + algebra.compute_basis(MAX); + + let mut checked = 0; + for t1 in 0..=MAX { + for t2 in 0..=(MAX - t1) { + let t = t1 + t2; + for i1 in 0..algebra.dimension(t1) { + for i2 in 0..algebra.dimension(t2) { + let m1 = algebra.basis_element_from_index(t1, i1); + let m2 = algebra.basis_element_from_index(t2, i2); + + let mut ours = FpVector::new(TWO, algebra.dimension(t)); + algebra.multiply(ours.as_slice_mut(), 1, m1, m2); + + let theirs = multiply_closed_mod_tau( + Dual(Monomial::new(m2.q_part, m2.p_part)), + Dual(Monomial::new(m1.q_part, m1.p_part)), + ); + let mut expected = FpVector::new(TWO, algebra.dimension(t)); + for &Dual(m) in &theirs { + let elt = MilnorBasisElement { + q_part: m.q_part, + p_part: m.p_part, + degree: t, + }; + expected.add_basis_element(algebra.basis_element_to_index(&elt), 1); + } + + assert_eq!(ours, expected, "({m1}) * ({m2}) in degree {t}"); + checked += 1; + } + } + } + } + assert!(checked > 1000, "only {checked} products checked"); + } + + /// The orientation in [`products_match_the_engine`] is load-bearing. + /// + /// Transposing the factors is not a no-op that happens to agree: it disagrees with the + /// engine on some product. Without this, passing the check above would be equally + /// consistent with the two conventions coinciding, and a transposed reading of a + /// non-commutative product is a well-formed wrong answer rather than an error. + #[test] + fn the_transposed_orientation_disagrees() { + let algebra = ctau(); + const MAX: i32 = 10; + algebra.compute_basis(MAX); + + let mut disagreements = 0; + for t1 in 0..=MAX { + for t2 in 0..=(MAX - t1) { + let t = t1 + t2; + for i1 in 0..algebra.dimension(t1) { + for i2 in 0..algebra.dimension(t2) { + let m1 = algebra.basis_element_from_index(t1, i1); + let m2 = algebra.basis_element_from_index(t2, i2); + + let mut ours = FpVector::new(TWO, algebra.dimension(t)); + algebra.multiply(ours.as_slice_mut(), 1, m1, m2); + + // The factors the wrong way round. + let transposed = multiply_closed_mod_tau( + Dual(Monomial::new(m1.q_part, m1.p_part)), + Dual(Monomial::new(m2.q_part, m2.p_part)), + ); + let mut expected = FpVector::new(TWO, algebra.dimension(t)); + for &Dual(m) in &transposed { + let elt = MilnorBasisElement { + q_part: m.q_part, + p_part: m.p_part, + degree: t, + }; + expected.add_basis_element(algebra.basis_element_to_index(&elt), 1); + } + if ours != expected { + disagreements += 1; + } + } + } + } + } + assert!( + disagreements > 0, + "the two orientations agree everywhere, so the orientation is untested" + ); + } + + /// The $Q_k$ with `k >= 1` are decomposable in the full algebra, as they are at odd + /// primes: $Q_{k+1} = P(2^k) Q_k - Q_k P(2^k)$. So the full algebra reports none of them. + #[test] + fn the_full_algebra_has_no_odd_degree_generators_above_one() { + let algebra = ctau(); + const MAX: i32 = 33; + algebra.compute_basis(MAX); + + for degree in (3..=MAX).step_by(2) { + assert!( + algebra.generators(degree).is_empty(), + "degree {degree} should have no generators" + ); + } + } + + /// Under a profile that is not an A(n), $Q_k$ *is* a generator, in degree + /// `|Q_k| = 2^{k+1} - 1`. + /// + /// This is the branch where the prime alone no longer identifies the degrees: the + /// classical test for it, `factor_pk(p, degree + 1) == (k, 2)`, matches nothing at + /// `p = 2`, because `2 p^k` is then a pure power of the prime and leaves cofactor 1. + #[test] + fn q_k_is_a_generator_under_a_profile() { + let profile = MilnorProfile { + truncated: false, + q_part: !0, + p_part: vec![2], + }; + assert!(!profile.is_an(true), "the test needs a non-A(n) profile"); + let algebra = MilnorAlgebraInner::::new_with_profile(TWO, profile, false); + const MAX: i32 = 33; + algebra.compute_basis(MAX); + + for k in 1..5 { + let degree = (1 << (k + 1)) - 1; + if degree > MAX { + break; + } + let q_k = MilnorBasisElement { + q_part: 1 << k, + p_part: PPart::zero(), + degree, + }; + let idx = algebra.basis_element_to_index(&q_k); + assert_eq!( + algebra.generators(degree), + vec![idx], + "Q_{k} (degree {degree}) should be the generator in its degree" + ); + } + } + + /// The polynomial generators are the $P(2^k)$, in degree `q * 2^k = 2^{k+1}`. + /// + /// The classical decoding factors the undivided degree, which at `q = 2` counts one power + /// of the prime too many. + #[test] + fn polynomial_generators_are_found() { + let algebra = ctau(); + const MAX: i32 = 60; + algebra.compute_basis(MAX); + + for k in 0..4 { + let degree = 1 << (k + 1); + let gens = algebra.generators(degree); + let generator = MilnorBasisElement { + q_part: 0, + p_part: PPart::from_iter([1 << k]), + degree, + }; + let idx = algebra.basis_element_to_index(&generator); + assert!( + gens.contains(&idx), + "P({}) (degree {degree}) is missing from the generators {gens:?}", + 1 << k + ); + } + } + + /// The polynomial generators under a profile that is not an A(n), where the generators + /// are the $P(0, \ldots, 0, 2^k)$ rather than only the $P(2^k)$. + /// + /// This is the branch that has to divide by `q` before factoring out the prime. Factoring + /// the undivided degree — as the classical code does — absorbs `q`'s own factor of 2 at + /// `p = 2` and decodes degree 6 as $P(2)$, which lives in degree 4. + #[test] + fn polynomial_generators_under_a_profile() { + let profile = MilnorProfile { + truncated: false, + q_part: !0, + p_part: vec![2], + }; + assert!(!profile.is_an(true), "the test needs a non-A(n) profile"); + let algebra = MilnorAlgebraInner::::new_with_profile(TWO, profile, false); + const MAX: i32 = 30; + algebra.compute_basis(MAX); + + // `P(0, ..., 0, 2^k)` with the entry in slot `j - 1` has degree + // `q * XI_DEGREES[j - 1] * 2^k = 2 (2^j - 1) 2^k`. The profile caps slot 0 at `k < 2`. + let expected: &[(i32, &[u32])] = &[ + (2, &[1]), + (4, &[2]), + (6, &[0, 1]), + (12, &[0, 2]), + (14, &[0, 0, 1]), + (24, &[0, 4]), + ]; + for &(degree, entries) in expected { + let generator = MilnorBasisElement { + q_part: 0, + p_part: PPart::try_from_slice(entries).unwrap(), + degree, + }; + let idx = algebra.basis_element_to_index(&generator); + assert_eq!( + algebra.generators(degree), + vec![idx], + "degree {degree} should be generated by {generator}" + ); + } + + // `8 = 2 * 1 * 2^2` only as `j = 1, k = 2`, which the profile excludes. + assert!( + algebra.generators(8).is_empty(), + "the profile should exclude P(4) in degree 8" + ); + } + + /// Generators generate: every basis element in low degrees is a product of them. + #[test] + fn generators_span_the_algebra() { + let algebra = ctau(); + const MAX: i32 = 16; + algebra.compute_basis(MAX); + + for t in 1..=MAX { + let generators = algebra.generators(t); + for i in 0..algebra.dimension(t) { + if generators.contains(&i) { + continue; + } + let decomposition = algebra.decompose_basis_element(t, i); + assert!( + !decomposition.is_empty(), + "{} (degree {t}) does not decompose", + algebra.basis_element_from_index(t, i) + ); + } + } + } + } } diff --git a/ext/crates/algebra/src/algebra/motivic/milnor.rs b/ext/crates/algebra/src/algebra/motivic/milnor.rs index b8be3e4102..9d7cfdc267 100644 --- a/ext/crates/algebra/src/algebra/motivic/milnor.rs +++ b/ext/crates/algebra/src/algebra/motivic/milnor.rs @@ -1468,7 +1468,7 @@ mod tests { p_part: PPart::try_from_slice(p).unwrap(), degree: 0, }; - m.compute_degree(TWO); + alg.compute_degree(&mut m); m }; let (m1, m2) = (mk(a), mk(b)); diff --git a/ext/crates/algebra/src/algebra/pair_algebra.rs b/ext/crates/algebra/src/algebra/pair_algebra.rs index 34815baaa0..8b047581c4 100644 --- a/ext/crates/algebra/src/algebra/pair_algebra.rs +++ b/ext/crates/algebra/src/algebra/pair_algebra.rs @@ -95,7 +95,10 @@ use std::cell::RefCell; use crate::{ MilnorAlgebra, - milnor_algebra::{MilnorBasisElement as MilnorElt, PPart, PPartAllocation, PPartMultiplier}, + milnor_algebra::{ + MilnorAlgebraInner, MilnorBasisElement as MilnorElt, NoExterior, PPart, PPartAllocation, + PPartMultiplier, + }, }; macro_rules! sub { @@ -127,7 +130,23 @@ pub struct MilnorPairElement { ys: Vec>, } -impl PairAlgebra for MilnorAlgebra { +/// Forward a [`PairAlgebra`] method to the classical flavour, which is the only one that has one. +macro_rules! dispatch_pair_milnor { + () => {}; + ($vis:vis fn $method:ident(&self$(, $arg:ident: $ty:ty )*$(,)?) $(-> $ret:ty)?; $($tail:tt)*) => { + $vis fn $method(&self, $($arg: $ty),* ) $(-> $ret)* { + match self { + MilnorAlgebra::Polynomial(a) => a.$method($($arg),*), + MilnorAlgebra::Exterior(_) => unimplemented!( + "the secondary Steenrod algebra is only defined for the classical algebra at p = 2" + ), + } + } + dispatch_pair_milnor!{$($tail)*} + }; +} + +impl PairAlgebra for MilnorAlgebraInner { type Element = MilnorPairElement; fn new_pair_element(&self, degree: i32) -> Self::Element { @@ -365,7 +384,7 @@ thread_local! { /// Compute $A(Sq(R), Y_{k, l})$ where $a = Sq(R)$. This queries the cache and computes it using /// [`a_y_inner`] if not available. fn a_y_cached( - algebra: &MilnorAlgebra, + algebra: &MilnorAlgebraInner, a: MilnorElt, k: usize, l: usize, @@ -394,7 +413,12 @@ fn a_y_cached( } /// Actually computes $A(a, Y_{k, l})$ and returns the result. -fn a_y_inner(algebra: &MilnorAlgebra, a: MilnorElt, k: usize, l: usize) -> FpVector { +fn a_y_inner( + algebra: &MilnorAlgebraInner, + a: MilnorElt, + k: usize, + l: usize, +) -> FpVector { let mut a = a; let mut result = FpVector::new(TWO, algebra.dimension(a.degree + (1 << k) + (1 << l) - 2)); let mut t = MilnorElt { @@ -452,7 +476,7 @@ mod tests { #[test] fn test_a_y() { - let algebra = MilnorAlgebra::new(TWO, false); + let algebra = MilnorAlgebraInner::::new(TWO, false); let mut result = FpVector::new(TWO, 0); @@ -473,3 +497,29 @@ mod tests { check(&[1, 2], 0, 1, expect![["P(2, 2)"]]); } } + +/// Forwards to the classical flavour; see [`PairAlgebra`] for [`MilnorAlgebraInner`]. +/// +/// A [`MilnorAlgebra`] only ever holds the exterior flavour at an odd prime, where this machinery +/// does not apply. +impl PairAlgebra for MilnorAlgebra { + type Element = MilnorPairElement; + + dispatch_pair_milnor! { + fn p_tilde(&self) -> usize; + fn new_pair_element(&self, degree: i32) -> Self::Element; + fn sigma_multiply_basis(&self, result: &mut Self::Element, coeff: u32, r_degree: i32, r_idx: usize, s_degree: i32, s_idx: usize); + fn sigma_multiply(&self, result: &mut Self::Element, coeff: u32, r_degree: i32, r: FpSlice, s_degree: i32, s: FpSlice); + fn a_multiply(&self, result: FpSliceMut, coeff: u32, r_degree: i32, r: FpSlice, s_degree: i32, s: &Self::Element); + fn element_to_bytes(&self, elt: &Self::Element, buffer: &mut impl std::io::Write) -> std::io::Result<()>; + fn element_from_bytes(&self, degree: i32, buffer: &mut impl std::io::Read) -> std::io::Result; + } + + fn element_is_zero(elt: &Self::Element) -> bool { + MilnorAlgebraInner::::element_is_zero(elt) + } + + fn finalize_element(elt: &mut Self::Element) { + MilnorAlgebraInner::::finalize_element(elt); + } +} diff --git a/ext/crates/algebra/src/steenrod_evaluator.rs b/ext/crates/algebra/src/steenrod_evaluator.rs index 27239cb626..427fd595d2 100644 --- a/ext/crates/algebra/src/steenrod_evaluator.rs +++ b/ext/crates/algebra/src/steenrod_evaluator.rs @@ -248,7 +248,7 @@ impl SteenrodEvaluator { // This is currently pretty inefficient... We should memoize results so that we don't repeatedly // recompute the same inverse. fn milnor_to_adem_on_basis(&self, result: &mut FpVector, coeff: u32, degree: i32, idx: usize) { - if self.milnor.generic() { + if self.milnor.has_exterior() { self.milnor_to_adem_on_basis_generic(result, coeff, degree, idx); } else { self.milnor_to_adem_on_basis_2(result, coeff, degree, idx); From 79242325906acf0c7b65664f096c9caa06ce6f5f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 19:32:29 +0000 Subject: [PATCH 2/6] Move the enum's PairAlgebra impl before the test module `clippy::items_after_test_module`, which CI promotes to an error with `-D warnings`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ePtYD7Bt4iPeCtmqtqvZE --- .../algebra/src/algebra/pair_algebra.rs | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/ext/crates/algebra/src/algebra/pair_algebra.rs b/ext/crates/algebra/src/algebra/pair_algebra.rs index 8b047581c4..c0b9059693 100644 --- a/ext/crates/algebra/src/algebra/pair_algebra.rs +++ b/ext/crates/algebra/src/algebra/pair_algebra.rs @@ -453,6 +453,32 @@ fn a_y_inner( result } +/// Forwards to the classical flavour; see [`PairAlgebra`] for [`MilnorAlgebraInner`]. +/// +/// A [`MilnorAlgebra`] only ever holds the exterior flavour at an odd prime, where this machinery +/// does not apply. +impl PairAlgebra for MilnorAlgebra { + type Element = MilnorPairElement; + + dispatch_pair_milnor! { + fn p_tilde(&self) -> usize; + fn new_pair_element(&self, degree: i32) -> Self::Element; + fn sigma_multiply_basis(&self, result: &mut Self::Element, coeff: u32, r_degree: i32, r_idx: usize, s_degree: i32, s_idx: usize); + fn sigma_multiply(&self, result: &mut Self::Element, coeff: u32, r_degree: i32, r: FpSlice, s_degree: i32, s: FpSlice); + fn a_multiply(&self, result: FpSliceMut, coeff: u32, r_degree: i32, r: FpSlice, s_degree: i32, s: &Self::Element); + fn element_to_bytes(&self, elt: &Self::Element, buffer: &mut impl std::io::Write) -> std::io::Result<()>; + fn element_from_bytes(&self, degree: i32, buffer: &mut impl std::io::Read) -> std::io::Result; + } + + fn element_is_zero(elt: &Self::Element) -> bool { + MilnorAlgebraInner::::element_is_zero(elt) + } + + fn finalize_element(elt: &mut Self::Element) { + MilnorAlgebraInner::::finalize_element(elt); + } +} + #[cfg(test)] mod tests { use expect_test::{Expect, expect}; @@ -497,29 +523,3 @@ mod tests { check(&[1, 2], 0, 1, expect![["P(2, 2)"]]); } } - -/// Forwards to the classical flavour; see [`PairAlgebra`] for [`MilnorAlgebraInner`]. -/// -/// A [`MilnorAlgebra`] only ever holds the exterior flavour at an odd prime, where this machinery -/// does not apply. -impl PairAlgebra for MilnorAlgebra { - type Element = MilnorPairElement; - - dispatch_pair_milnor! { - fn p_tilde(&self) -> usize; - fn new_pair_element(&self, degree: i32) -> Self::Element; - fn sigma_multiply_basis(&self, result: &mut Self::Element, coeff: u32, r_degree: i32, r_idx: usize, s_degree: i32, s_idx: usize); - fn sigma_multiply(&self, result: &mut Self::Element, coeff: u32, r_degree: i32, r: FpSlice, s_degree: i32, s: FpSlice); - fn a_multiply(&self, result: FpSliceMut, coeff: u32, r_degree: i32, r: FpSlice, s_degree: i32, s: &Self::Element); - fn element_to_bytes(&self, elt: &Self::Element, buffer: &mut impl std::io::Write) -> std::io::Result<()>; - fn element_from_bytes(&self, degree: i32, buffer: &mut impl std::io::Read) -> std::io::Result; - } - - fn element_is_zero(elt: &Self::Element) -> bool { - MilnorAlgebraInner::::element_is_zero(elt) - } - - fn finalize_element(elt: &mut Self::Element) { - MilnorAlgebraInner::::finalize_element(elt); - } -} From 8d2e2ec15d842beba713a36ab58aa10cb60ed480 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 19:38:34 +0000 Subject: [PATCH 3/6] Restrict the coproduct to the classical flavour, keep p_tilde total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two places where the prime no longer identifies the case, from review on #293. `Bialgebra::coproduct` guarded only on `p == 2`. Its formula ignores `q_part` and grades the polynomial part with `q = 1`, so at `MilnorAlgebraInner` — p = 2, q = 2, exterior part present — the guard passes and the wrong formula runs, giving a mis-graded answer rather than an error. Implement `Bialgebra` for `MilnorAlgebraInner` only, as `PairAlgebra` already is, so the unsupported case cannot be reached; the enum forwards to the classical variant. `PairAlgebra::p_tilde` returned 0 at every prime before the split. Routing it through the classical-only dispatch turned an odd-prime call into a panic, reachable from `SecondaryResolutionHomomorphism::hom_k_with`, which calls it without a prime check. It does not depend on the flavour, so implement it directly on the enum and restore the previous value. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ePtYD7Bt4iPeCtmqtqvZE --- .../algebra/src/algebra/milnor_algebra.rs | 43 ++++++++++++++++++- .../algebra/src/algebra/pair_algebra.rs | 8 +++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/ext/crates/algebra/src/algebra/milnor_algebra.rs b/ext/crates/algebra/src/algebra/milnor_algebra.rs index 37c0726062..fade45b909 100644 --- a/ext/crates/algebra/src/algebra/milnor_algebra.rs +++ b/ext/crates/algebra/src/algebra/milnor_algebra.rs @@ -2037,7 +2037,10 @@ impl MilnorAlgebraInner { } } -impl Bialgebra for MilnorAlgebraInner { +/// The coproduct drops the exterior part and grades the polynomial part with `q = 1`, so this is +/// written for [`NoExterior`] alone. `MilnorAlgebraInner` at `p = 2` would satisfy a +/// guard on the prime and then silently take the wrong formula. +impl Bialgebra for MilnorAlgebraInner { fn coproduct(&self, op_deg: i32, op_idx: usize) -> Vec<(i32, usize, i32, usize)> { assert_eq!(self.prime(), 2, "Coproduct at odd primes not supported"); if op_deg == 0 { @@ -2110,7 +2113,7 @@ macro_rules! dispatch_milnor { /// is a different algebra rather than a different presentation of this one; it is reached through /// its own wrapper, not from here. #[allow(clippy::large_enum_variant)] -#[enum_dispatch::enum_dispatch(Algebra, Bialgebra, GeneratedAlgebra, UnstableAlgebra)] +#[enum_dispatch::enum_dispatch(Algebra, GeneratedAlgebra, UnstableAlgebra)] pub enum MilnorAlgebra { Polynomial(MilnorAlgebraInner), Exterior(MilnorAlgebraInner), @@ -2155,6 +2158,23 @@ impl MilnorAlgebra { } } +/// Forwards to the classical flavour, which is the only one with a coproduct. +/// +/// A [`MilnorAlgebra`] only ever holds the exterior flavour at an odd prime, where the coproduct +/// was already unsupported. +impl Bialgebra for MilnorAlgebra { + fn coproduct(&self, op_deg: i32, op_idx: usize) -> Vec<(i32, usize, i32, usize)> { + match self { + Self::Polynomial(a) => a.coproduct(op_deg, op_idx), + Self::Exterior(_) => unimplemented!("Coproduct at odd primes not supported"), + } + } + + fn decompose(&self, op_deg: i32, op_idx: usize) -> Vec<(i32, usize)> { + vec![(op_deg, op_idx)] + } +} + impl std::fmt::Display for MilnorAlgebra { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { @@ -3072,6 +3092,25 @@ mod tests { ); } + /// The coproduct is not available on this flavour at all. + /// + /// It is a compile-time restriction rather than an assertion, so this only records that + /// the classical one still works and that `MilnorAlgebraInner` does not offer + /// the method. The formula ignores the exterior part and grades $\xi_i$ with `q = 1`, so + /// reaching it here would give a wrong answer, not an error. + #[test] + fn the_classical_coproduct_is_unaffected() { + let classical = MilnorAlgebraInner::::new(TWO, false); + classical.compute_basis(8); + let idx = classical.basis_element_to_index(&MilnorBasisElement { + q_part: 0, + p_part: PPart::from_iter([2]), + degree: 2, + }); + // Sq^2 |-> Sq^2 (x) 1 + Sq^1 (x) Sq^1 + 1 (x) Sq^2. + assert_eq!(classical.coproduct(2, idx).len(), 3); + } + /// Generators generate: every basis element in low degrees is a product of them. #[test] fn generators_span_the_algebra() { diff --git a/ext/crates/algebra/src/algebra/pair_algebra.rs b/ext/crates/algebra/src/algebra/pair_algebra.rs index c0b9059693..b0dd257fc1 100644 --- a/ext/crates/algebra/src/algebra/pair_algebra.rs +++ b/ext/crates/algebra/src/algebra/pair_algebra.rs @@ -461,7 +461,6 @@ impl PairAlgebra for MilnorAlgebra { type Element = MilnorPairElement; dispatch_pair_milnor! { - fn p_tilde(&self) -> usize; fn new_pair_element(&self, degree: i32) -> Self::Element; fn sigma_multiply_basis(&self, result: &mut Self::Element, coeff: u32, r_degree: i32, r_idx: usize, s_degree: i32, s_idx: usize); fn sigma_multiply(&self, result: &mut Self::Element, coeff: u32, r_degree: i32, r: FpSlice, s_degree: i32, s: FpSlice); @@ -470,6 +469,13 @@ impl PairAlgebra for MilnorAlgebra { fn element_from_bytes(&self, degree: i32, buffer: &mut impl std::io::Read) -> std::io::Result; } + /// Unlike the rest of this impl, this does not depend on the flavour: it was `0` at every + /// prime before the algebra was split, and dispatching it would turn an odd-prime call from a + /// value into a panic. + fn p_tilde(&self) -> usize { + 0 + } + fn element_is_zero(elt: &Self::Element) -> bool { MilnorAlgebraInner::::element_is_zero(elt) } From 1e704168544adf22dea00e7163b61e058940208e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 19:43:35 +0000 Subject: [PATCH 4/6] Document the flavour implementations Say what each flavour does differently, rather than restating the trait. The one worth reading is `Exterior::q`: at p = 2 it gives 2, the scale A_C/tau needs, and the classical algebra takes q = 1 there because it is the other flavour, not because the formula breaks. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ePtYD7Bt4iPeCtmqtqvZE --- .../algebra/src/algebra/milnor_algebra.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/ext/crates/algebra/src/algebra/milnor_algebra.rs b/ext/crates/algebra/src/algebra/milnor_algebra.rs index fade45b909..89a860ff6d 100644 --- a/ext/crates/algebra/src/algebra/milnor_algebra.rs +++ b/ext/crates/algebra/src/algebra/milnor_algebra.rs @@ -636,22 +636,27 @@ impl MilnorAlgebraInner { impl MilnorFlavour for NoExterior { const HAS_EXTERIOR: bool = false; + /// The polynomial grading is unscaled: $\xi_i$ has degree `XI_DEGREES[i]` exactly. fn q(_p: ValidPrime) -> i32 { 1 } + /// The basis is the $\xi$ monomials, so it is a re-wrapping of the p-part table. fn generate_basis(algebra: &MilnorAlgebraInner, max_degree: i32) { algebra.generate_basis_polynomial(max_degree); } + /// Every generator is polynomial; there is no exterior part to contribute any. fn generators(algebra: &MilnorAlgebraInner, degree: i32) -> Vec { algebra.polynomial_generator(degree) } + /// $P(n)$ is written $Sq^n$ at the prime 2. fn generator_to_string(algebra: &MilnorAlgebraInner, degree: i32, idx: usize) -> String { algebra.polynomial_generator_to_string(degree, idx, "Sq") } + /// The $h_i$, dual to $Sq^{2^i}$, capped at four and cut down by the profile. fn filtration_one_products( algebra: &MilnorAlgebraInner, ) -> (Vec<(String, MilnorBasisElement)>, i32) { @@ -683,14 +688,18 @@ impl MilnorFlavour for NoExterior { impl MilnorFlavour for Exterior { const HAS_EXTERIOR: bool = true; + /// At `p = 2` this is 2, the scale $A^{\mathbb{C}}/\tau$ needs — the classical algebra takes + /// `q = 1` there because it is the *other* flavour, not because the formula fails. fn q(p: ValidPrime) -> i32 { 2 * (p.as_i32() - 1) } + /// The basis runs over exterior parts, pairing each with the p-parts of the residual degree. fn generate_basis(algebra: &MilnorAlgebraInner, max_degree: i32) { algebra.generate_basis_exterior(max_degree); } + /// The $Q_k$ in odd degrees, the polynomial generators in even ones. fn generators(algebra: &MilnorAlgebraInner, degree: i32) -> Vec { // The polynomial part sits in degrees divisible by `q`, which is even, so an odd degree // can only hold a $Q_k$. @@ -720,6 +729,8 @@ impl MilnorFlavour for Exterior { algebra.polynomial_generator(degree) } + /// $Q_0$ is written `b`, the other exterior generators by their own `Display`, and the + /// polynomial ones $P^n$. fn generator_to_string(algebra: &MilnorAlgebraInner, degree: i32, idx: usize) -> String { if degree == 1 { return "b".to_string(); @@ -732,6 +743,7 @@ impl MilnorFlavour for Exterior { } } + /// $a_0$, dual to the Bockstein, and $h_0$, dual to $P(1)$. fn filtration_one_products( algebra: &MilnorAlgebraInner, ) -> (Vec<(String, MilnorBasisElement)>, i32) { @@ -1180,6 +1192,11 @@ impl MilnorAlgebraInner { }); } + /// Enumerate exterior parts whose degree fits, pairing each with the p-parts that make up + /// the rest of the degree. + /// + /// Only the exterior parts congruent to the degree mod `q` can occur, since every + /// `TAU_DEGREES[k]` is `1` mod `q`. fn generate_basis_exterior(&self, max_degree: i32) { let q = self.q() as u32; let tau_degrees = combinatorics::tau_degrees(self.prime()); @@ -1228,6 +1245,7 @@ impl MilnorAlgebraInner { }); } + /// Re-wrap the p-part table, which already is the basis when there is no exterior part. fn generate_basis_polynomial(&self, max_degree: i32) { if !self.stores_basis_table() { // Derived on demand from `ppart_table`; see the field docs. @@ -2823,6 +2841,7 @@ mod tests { Bigraded, Dual, Monomial, enum_basis, multiply_closed_mod_tau, }; + /// The mod-$\tau$ C-motivic Steenrod algebra: the exterior flavour at the prime 2. fn ctau() -> MilnorAlgebraInner { MilnorAlgebraInner::::new(TWO, false) } From e80e0f62dead9d847d404009f998a736c9c63b14 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 22:18:23 +0000 Subject: [PATCH 5/6] Bring the new comments in line with the repo's style Audited against ext/CLAUDE.md rather than by eye: - `dispatch_milnor!` now accepts doc attributes, so the twelve public methods it forwards onto the enum are documented. Each points at the method it forwards to instead of restating it. The macro `SteenrodAlgebra` uses has the same gap, but only over trait methods, which inherit their declarations' docs. - "capped at four" in the filtration one products named a literal rather than a constant, which is the staleness the convention warns about. - Four summaries ran onto a second line; split into a summary and a body. - The basis generation forwarders repeated what their helpers already said. The forwarder now says what the basis *is* for that flavour and the helper how it is built, so each fact has one home. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ePtYD7Bt4iPeCtmqtqvZE --- .../algebra/src/algebra/milnor_algebra.rs | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/ext/crates/algebra/src/algebra/milnor_algebra.rs b/ext/crates/algebra/src/algebra/milnor_algebra.rs index 89a860ff6d..2ae246f5b3 100644 --- a/ext/crates/algebra/src/algebra/milnor_algebra.rs +++ b/ext/crates/algebra/src/algebra/milnor_algebra.rs @@ -641,7 +641,7 @@ impl MilnorFlavour for NoExterior { 1 } - /// The basis is the $\xi$ monomials, so it is a re-wrapping of the p-part table. + /// Without an exterior part the p-part table is already the basis. fn generate_basis(algebra: &MilnorAlgebraInner, max_degree: i32) { algebra.generate_basis_polynomial(max_degree); } @@ -656,7 +656,7 @@ impl MilnorFlavour for NoExterior { algebra.polynomial_generator_to_string(degree, idx, "Sq") } - /// The $h_i$, dual to $Sq^{2^i}$, capped at four and cut down by the profile. + /// The $h_i$, dual to $Sq^{2^i}$, as far as the profile allows. fn filtration_one_products( algebra: &MilnorAlgebraInner, ) -> (Vec<(String, MilnorBasisElement)>, i32) { @@ -694,7 +694,7 @@ impl MilnorFlavour for Exterior { 2 * (p.as_i32() - 1) } - /// The basis runs over exterior parts, pairing each with the p-parts of the residual degree. + /// A basis element is an exterior part together with a p-part. fn generate_basis(algebra: &MilnorAlgebraInner, max_degree: i32) { algebra.generate_basis_exterior(max_degree); } @@ -1192,8 +1192,7 @@ impl MilnorAlgebraInner { }); } - /// Enumerate exterior parts whose degree fits, pairing each with the p-parts that make up - /// the rest of the degree. + /// Pair each exterior part with the p-parts making up the rest of the degree. /// /// Only the exterior parts congruent to the degree mod `q` can occur, since every /// `TAU_DEGREES[k]` is `1` mod `q`. @@ -1245,7 +1244,7 @@ impl MilnorAlgebraInner { }); } - /// Re-wrap the p-part table, which already is the basis when there is no exterior part. + /// Re-wrap the p-part table as basis elements. fn generate_basis_polynomial(&self, max_degree: i32) { if !self.stores_basis_table() { // Derived on demand from `ppart_table`; see the field docs. @@ -2113,7 +2112,8 @@ impl Bialgebra for MilnorAlgebraInner { /// Forward an inherent method to whichever flavour this algebra has. macro_rules! dispatch_milnor { () => {}; - ($vis:vis fn $method:ident(&self$(, $arg:ident: $ty:ty )*$(,)?) $(-> $ret:ty)?; $($tail:tt)*) => { + ($(#[$meta:meta])* $vis:vis fn $method:ident(&self$(, $arg:ident: $ty:ty )*$(,)?) $(-> $ret:ty)?; $($tail:tt)*) => { + $(#[$meta])* $vis fn $method(&self, $($arg: $ty),* ) $(-> $ret)* { match self { MilnorAlgebra::Polynomial(a) => a.$method($($arg),*), @@ -2139,17 +2139,29 @@ pub enum MilnorAlgebra { impl MilnorAlgebra { dispatch_milnor! { + /// See [`MilnorAlgebraInner::has_exterior`]. pub fn has_exterior(&self) -> bool; + /// See [`MilnorAlgebraInner::q`]. pub fn q(&self) -> i32; + /// See [`MilnorAlgebraInner::profile`]. pub fn profile(&self) -> &MilnorProfile; + /// See [`MilnorAlgebraInner::compute_degree`]. pub fn compute_degree(&self, elt: &mut MilnorBasisElement); + /// See [`MilnorAlgebraInner::basis_element_from_index`]. pub fn basis_element_from_index(&self, degree: i32, idx: usize) -> MilnorBasisElement; + /// See [`MilnorAlgebraInner::try_basis_element_to_index`]. pub fn try_basis_element_to_index(&self, elt: &MilnorBasisElement) -> Option; + /// See [`MilnorAlgebraInner::basis_element_to_index`]. pub fn basis_element_to_index(&self, elt: &MilnorBasisElement) -> usize; + /// See [`MilnorAlgebraInner::ppart_table`]. pub fn ppart_table(&self, t: i32) -> &[PPart]; + /// See [`MilnorAlgebraInner::try_beps_pn`]. pub fn try_beps_pn(&self, e: u32, x: PPartEntry) -> Option<(i32, usize)>; + /// See [`MilnorAlgebraInner::beps_pn`]. pub fn beps_pn(&self, e: u32, x: PPartEntry) -> (i32, usize); + /// See [`MilnorAlgebraInner::multiply`]. pub fn multiply(&self, res: FpSliceMut, coef: u32, m1: MilnorBasisElement, m2: MilnorBasisElement); + /// See [`MilnorAlgebraInner::multiply_with_allocation`]. pub fn multiply_with_allocation(&self, res: FpSliceMut, coef: u32, m1: MilnorBasisElement, m2: MilnorBasisElement, excess: i32, allocation: PPartAllocation) -> PPartAllocation; } @@ -2172,6 +2184,7 @@ impl MilnorAlgebra { #[cfg(test)] impl MilnorAlgebra { dispatch_milnor! { + /// Whether this algebra stores its basis rather than deriving it. fn stores_basis_table(&self) -> bool; } } @@ -2829,10 +2842,11 @@ mod tests { ); } - /// The exterior flavour at `p = 2` is $A^{\mathbb{C}}/\tau$, the mod-$\tau$ reduction of the - /// C-motivic Steenrod algebra. That configuration is unreachable through [`MilnorAlgebra`], - /// so these check it against the independent Kong–Lin closed form in - /// [`crate::algebra::motivic::milnor`], which shares no code with this file. + /// The exterior flavour at `p = 2`, which is $A^{\mathbb{C}}/\tau$. + /// + /// That configuration is unreachable through [`MilnorAlgebra`], so these check it against the + /// independent Kong–Lin closed form in [`crate::algebra::motivic::milnor`], which shares no + /// code with this file. mod exterior_at_two { use fp::prime::TWO; @@ -2982,8 +2996,9 @@ mod tests { ); } - /// The $Q_k$ with `k >= 1` are decomposable in the full algebra, as they are at odd - /// primes: $Q_{k+1} = P(2^k) Q_k - Q_k P(2^k)$. So the full algebra reports none of them. + /// The full algebra reports no $Q_k$ with `k >= 1`, because they are decomposable. + /// + /// As at odd primes: $Q_{k+1} = P(2^k) Q_k - Q_k P(2^k)$. #[test] fn the_full_algebra_has_no_odd_degree_generators_above_one() { let algebra = ctau(); @@ -2998,8 +3013,9 @@ mod tests { } } - /// Under a profile that is not an A(n), $Q_k$ *is* a generator, in degree - /// `|Q_k| = 2^{k+1} - 1`. + /// Under a profile that is not an A(n), $Q_k$ *is* a generator. + /// + /// It sits in degree `|Q_k| = 2^{k+1} - 1`. /// /// This is the branch where the prime alone no longer identifies the degrees: the /// classical test for it, `factor_pk(p, degree + 1) == (k, 2)`, matches nothing at @@ -3062,8 +3078,9 @@ mod tests { } } - /// The polynomial generators under a profile that is not an A(n), where the generators - /// are the $P(0, \ldots, 0, 2^k)$ rather than only the $P(2^k)$. + /// The polynomial generators under a profile that is not an A(n). + /// + /// They are the $P(0, \ldots, 0, 2^k)$ there, rather than only the $P(2^k)$. /// /// This is the branch that has to divide by `q` before factoring out the prime. Factoring /// the undivided degree — as the classical code does — absorbs `q`'s own factor of 2 at From 2f6728621b2a21e7d2547daa04a2977615ccbbd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 22:31:53 +0000 Subject: [PATCH 6/6] Distinguish the flavours in magic() Saved resolutions store coefficients by basis index, and `SaveFile` validates the header against `Algebra::magic()`. That value was `(p << 16)` plus a profile bit, so the two flavours at p = 2 shared one: a file written over `Exterior` would load as the classical algebra and pass validation with every coefficient reindexed against a different basis. The same hazard the basis-order comment already warns about, reached a different way. Only `Exterior` at p = 2 takes the new bit. The prime settles the flavour in every other case, so each configuration that could already have written a file keeps the value it had, and existing saves stay valid. The test pins those values as well as the separation, since a silent change to them invalidates saved resolutions with no error at load time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ePtYD7Bt4iPeCtmqtqvZE --- .../algebra/src/algebra/milnor_algebra.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/ext/crates/algebra/src/algebra/milnor_algebra.rs b/ext/crates/algebra/src/algebra/milnor_algebra.rs index 2ae246f5b3..ec398b4815 100644 --- a/ext/crates/algebra/src/algebra/milnor_algebra.rs +++ b/ext/crates/algebra/src/algebra/milnor_algebra.rs @@ -781,7 +781,17 @@ impl Algebra for MilnorAlgebraInner { } fn magic(&self) -> u32 { + // Saved resolutions store coefficients by basis index, so two algebras sharing a magic + // decode each other's files as their own basis. The prime settles the flavour everywhere + // except `Exterior` at `p = 2`, so only that case takes a bit; every configuration that + // could already have written a file keeps the value it had. + let flavour = if F::HAS_EXTERIOR && self.p == 2 { + 0x4000 + } else { + 0 + }; (self.p << 16) + + flavour + if self.profile.is_trivial() { 0x8000 } else { @@ -3147,6 +3157,30 @@ mod tests { assert_eq!(classical.coproduct(2, idx).len(), 3); } + /// The two flavours at `p = 2` must not share a [`Algebra::magic`]. + /// + /// `SaveFile` validates the header against it, so a collision would let a file written + /// over one basis load as the other with every coefficient reindexed. The literals pin + /// the classical values, which are a wire format: changing one invalidates saved + /// resolutions without any error at load time. + #[test] + fn magic_distinguishes_the_flavours() { + let exterior = MilnorAlgebraInner::::new(TWO, false); + let classical = MilnorAlgebraInner::::new(TWO, false); + assert_ne!(exterior.magic(), classical.magic()); + + assert_eq!(classical.magic(), 0x0002_8000); + assert_eq!(MilnorAlgebra::new(TWO, false).magic(), 0x0002_8000); + assert_eq!( + MilnorAlgebra::new(ValidPrime::new(3), false).magic(), + 0x0003_8000 + ); + assert_eq!( + MilnorAlgebra::new(ValidPrime::new(5), false).magic(), + 0x0005_8000 + ); + } + /// Generators generate: every basis element in low degrees is a product of them. #[test] fn generators_span_the_algebra() {