diff --git a/ext/crates/algebra/src/algebra/milnor_algebra.rs b/ext/crates/algebra/src/algebra/milnor_algebra.rs index 38d54960df..ec398b4815 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,13 +552,246 @@ 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; + + /// The polynomial grading is unscaled: $\xi_i$ has degree `XI_DEGREES[i]` exactly. + fn q(_p: ValidPrime) -> i32 { + 1 + } + + /// 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); + } + + /// 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}$, as far as the profile allows. + 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; + + /// 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) + } + + /// 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); + } + + /// 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$. + 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) + } + + /// $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(); + } + 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") + } + } + + /// $a_0$, dual to the Bockstein, and $h_0$, dual to $P(1)$. + 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" } 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 { @@ -546,51 +804,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 +823,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 +1028,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 +1048,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 +1077,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 +1088,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 +1106,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 +1153,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 +1202,12 @@ impl MilnorAlgebra { }); } - fn generate_basis_generic(&self, max_degree: i32) { - let q = 2 * self.prime() - 2; + /// 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`. + 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 +1254,8 @@ impl MilnorAlgebra { }); } - fn generate_basis_2(&self, max_degree: i32) { + /// 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. return; @@ -1170,7 +1292,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 +1395,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 +1865,7 @@ impl Iterator for PPartMultiplier { } } -impl MilnorAlgebra { +impl MilnorAlgebraInner { fn decompose_basis_element_qpart( &self, degree: i32, @@ -1832,7 +1954,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 +2043,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 +2064,10 @@ impl MilnorAlgebra { } } -impl Bialgebra for MilnorAlgebra { +/// 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 { @@ -1994,6 +2119,112 @@ impl Bialgebra for MilnorAlgebra { } } +/// Forward an inherent method to whichever flavour this algebra has. +macro_rules! dispatch_milnor { + () => {}; + ($(#[$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),*), + 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, GeneratedAlgebra, UnstableAlgebra)] +pub enum MilnorAlgebra { + Polynomial(MilnorAlgebraInner), + Exterior(MilnorAlgebraInner), +} + +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; + } + + /// 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! { + /// Whether this algebra stores its basis rather than deriving it. + fn stores_basis_table(&self) -> bool; + } +} + +/// 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 { + 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 +2567,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 +2602,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 +2851,357 @@ mod tests { .is_valid() ); } + + /// 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; + + use super::*; + use crate::algebra::motivic::milnor::{ + 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) + } + + /// 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 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(); + 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. + /// + /// 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 + /// `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). + /// + /// 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 + /// `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" + ); + } + + /// 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); + } + + /// 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() { + 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..b0dd257fc1 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 { @@ -429,6 +453,38 @@ fn a_y_inner(algebra: &MilnorAlgebra, a: MilnorElt, k: usize, l: usize) -> FpVec 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 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; + } + + /// 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) + } + + fn finalize_element(elt: &mut Self::Element) { + MilnorAlgebraInner::::finalize_element(elt); + } +} + #[cfg(test)] mod tests { use expect_test::{Expect, expect}; @@ -452,7 +508,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); 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);