Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions ext/SAVE-FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,35 @@ u64, which is to be interpreted as follows:
The pivot column and the image are expressed in terms of the original basis,
while the lift is expressed in terms of the masked basis under the current
signature. The latter measure is done in order to save space.

### Tensor-resolution differentials

This has magic `0xD1FF0002`.

This is the closed-form coboundary `δ_Q` of an untwisted tensor resolution
`Q• = P• ⊗ M`, where `P•` is a minimal resolution of the base field and `M` is
an arbitrary (possibly infinite) module. `Q•` itself is never materialised;
only the cochain-level matrices of `δ_Q` are, so this kind stores one matrix
per bidegree and no chain map data.

The header records only the kind, algebra and bidegree, so files for two
different modules over the same algebra collide by name. Each file therefore
opens with a fingerprint of `M` — an Adler-32 checksum of the module's
`Display` name, its minimum degree, and its dimensions in every degree up to
`t` — which the reader checks before anything else. A save directory must
still be dedicated to a single module; the fingerprint turns a violation into a
panic instead of a silently wrong `Ext`.

```text
struct {
module_fingerprint: u32,
rows: u64,
cols: u64,
matrix: [[u64; num_limbs(cols)]; rows],
}
```

Unlike the other kinds, the shape is stored in the file rather than being
recovered from the resolution, since the cochain dimensions depend on `M`. The
reader checks it against the cochain basis it expects, so a truncated or
foreign file fails loudly.
165 changes: 150 additions & 15 deletions ext/crates/sseq/src/coordinates/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,56 @@ pub fn iter_s_t<T: Sync>(
f: &(impl Fn(Bidegree) -> std::ops::Range<i32> + Sync),
min: Bidegree,
max: BidegreeRange<T>,
) {
iter_s_t_with_lag(f, min, max, 1);
}

/// [`iter_s_t`] for a complex whose dependency at `(s, t)` reaches `(s - 1, t)` at the **same**
/// `t`, rather than only `t' < t`.
///
/// [`iter_s_t`] runs `f(s, t)` once `f(s - 1, t')` is done for every `t' < t`. That is exactly
/// right for a **minimal** resolution: `d` lands in $\bar A \cdot P_{s-1}$, so the coefficient of
/// every component has positive degree and `f(s, t)` only ever reads generators of degree *below*
/// `t`. A **non-minimal** differential has an identity (degree-zero) component, so `f(s, t)` can
/// read the generator of degree exactly `t` in filtration `s - 1`, and needs `f(s - 1, t)` itself.
///
/// That is a shift of the wavefront by one column, *not* a reason to serialise filtrations. This
/// driver is [`iter_s_t`] with the recursion advanced one step less: having finished `f(s, t)`, it
/// releases `(s + 1, t')` for `t' < T` rather than `t' < T + 1`, where `T` is the extent of the
/// contiguous computed prefix of row `s` reported by `f`. Filtrations still overlap in time — row
/// `s + 1` works on low `t` while row `s` is still climbing — so the available parallelism is
/// [`iter_s_t`]'s, minus one diagonal.
///
/// The bottom row is the only seed. [`iter_s_t`] additionally seeds `(s, min_t)` for every `s` at
/// once, which is sound only because `f(s, min_t)`'s dependency is vacuous there; here it is not —
/// it is `f(s - 1, min_t)` — so those must be reached through the recursion.
///
/// # Arguments (matching [`iter_s_t`]):
/// - `max.s()`: exclusive
/// - `max.t(s)`: exclusive
pub fn iter_s_t_inclusive<T: Sync>(
f: &(impl Fn(Bidegree) -> std::ops::Range<i32> + Sync),
min: Bidegree,
max: BidegreeRange<T>,
) {
iter_s_t_with_lag(f, min, max, 0);
}

/// The wavefront shared by [`iter_s_t`] and [`iter_s_t_inclusive`].
///
/// `lag` is how far filtration `s + 1` may run ahead of the contiguous computed prefix of
/// filtration `s`, and so encodes the dependency the caller has: `1` when `f(s, t)` reads only
/// `f(s - 1, t')` for `t' < t`, `0` when it also reads `t' = t`.
///
/// It also settles the seeding, which is not a free choice. With `lag == 1`, `f(s, min_t)` has a
/// vacuous dependency, so every filtration can be seeded at `min_t` simultaneously; with
/// `lag == 0` it depends on `f(s - 1, min_t)`, so only the bottom filtration may be seeded and the
/// rest must be reached through the recursion.
fn iter_s_t_with_lag<T: Sync>(
f: &(impl Fn(Bidegree) -> std::ops::Range<i32> + Sync),
min: Bidegree,
max: BidegreeRange<T>,
lag: i32,
) {
// Track `tracing` spans correctly
let tracing_span = tracing::Span::current();
Expand All @@ -78,42 +128,50 @@ pub fn iter_s_t<T: Sync>(
f: &'a (impl Fn(Bidegree) -> std::ops::Range<i32> + Sync + 'a),
max: BidegreeRange<'a, S>,
current: Bidegree,
lag: i32,
) {
let mut ret = f(current);
if current.s() + 1 < max.s() {
ret.start += 1;
ret.end = std::cmp::min(ret.end + 1, max.t(current.s() + 1));
// `ret` is `[t, T)` with `f(current.s(), t')` computed for every `t' < T`, so
// filtration `s + 1` is released up to `T - 1 + lag`.
ret.start += lag;
ret.end = std::cmp::min(ret.end + lag, max.t(current.s() + 1));

if !ret.is_empty() {
// We spawn a new scope to avoid recursion, which may blow the stack
scope.spawn(move |scope| {
ret.into_maybe_par_iter()
.for_each(|t| run(scope, f, max, Bidegree::s_t(current.s() + 1, t)));
ret.into_maybe_par_iter().for_each(|t| {
run(scope, f, max, Bidegree::s_t(current.s() + 1, t), lag)
});
});
}
}
}

maybe_rayon::join(
|| {
(min.t()..max.t(min.s()))
.into_maybe_par_iter()
.for_each(|t| run(scope, f, max, Bidegree::s_t(min.s(), t)))
},
|| {
let seed_bottom_row = || {
(min.t()..max.t(min.s()))
.into_maybe_par_iter()
.for_each(|t| run(scope, f, max, Bidegree::s_t(min.s(), t), lag))
};

if lag > 0 {
maybe_rayon::join(seed_bottom_row, || {
(min.s() + 1..max.s())
.into_maybe_par_iter()
.for_each(|s| run(scope, f, max, Bidegree::s_t(s, min.t())))
},
);
.for_each(|s| run(scope, f, max, Bidegree::s_t(s, min.t()), lag))
});
} else if min.s() < max.s() {
// `max.s()` is exclusive, so an empty filtration range visits nothing at all.
seed_bottom_row();
}
});
}

#[cfg(test)]
mod tests {
use fp::{prime::ValidPrime, vector::FpVector};

use super::{Bidegree, BidegreeElement, BidegreeGenerator};
use super::{Bidegree, BidegreeElement, BidegreeGenerator, BidegreeRange};

#[test]
fn test_bidegree_generator_try_from_element() {
Expand All @@ -129,4 +187,81 @@ mod tests {
h0_squared_i.try_into()
);
}

/// Run [`super::iter_s_t_inclusive`] over a rectangle, checking its contract *as it goes*:
/// before `f(s, t)` runs, `f(s - 1, t')` must already have completed for every `t' ≤ t`. The
/// check has to happen inside `f` rather than on a recorded order — under `concurrent` the
/// bidegrees genuinely overlap in time, so a completion list says nothing about happens-before.
///
/// `f` reports the contiguous computed prefix of its own row, as the real callers do via
/// `OnceBiVec::push_ooo`. Returns every bidegree visited.
fn run_checked(
min: Bidegree,
max_s: i32,
max_t: &(dyn Fn(&(), i32) -> i32 + Sync),
) -> Vec<(i32, i32)> {
use std::collections::{BTreeSet, HashMap};

let done: std::sync::Mutex<HashMap<i32, BTreeSet<i32>>> =
std::sync::Mutex::new(HashMap::new());
super::iter_s_t_inclusive(
&|b| {
let mut done = done.lock().unwrap();
if b.s() > min.s() {
let below = done.get(&(b.s() - 1)).cloned().unwrap_or_default();
for t in min.t()..=b.t() {
assert!(
below.contains(&t),
"({}, {}) ran before ({}, {})",
b.s(),
b.t(),
b.s() - 1,
t
);
}
}
let row = done.entry(b.s()).or_default();
assert!(row.insert(b.t()), "({}, {}) ran twice", b.s(), b.t());
// The contiguous prefix of this row, i.e. what `push_ooo` would report.
let mut end = min.t();
while row.contains(&end) {
end += 1;
}
b.t()..end
},
min,
BidegreeRange::new(&(), max_s, max_t),
);

let done = done.into_inner().unwrap();
let mut visited: Vec<(i32, i32)> = done
.iter()
.flat_map(|(s, ts)| ts.iter().map(move |t| (*s, *t)))
.collect();
visited.sort_unstable();
visited
}

#[test]
fn iter_s_t_inclusive_covers_the_rectangle_and_respects_the_same_t_dependency() {
let visited = run_checked(Bidegree::s_t(0, 0), 4, &|(), _| 3);
let expected: Vec<(i32, i32)> = (0..4).flat_map(|s| (0..3).map(move |t| (s, t))).collect();
assert_eq!(visited, expected);
}

#[test]
fn iter_s_t_inclusive_handles_degenerate_ranges() {
// Empty `s` range, and an empty `t` range for every `s`.
assert!(run_checked(Bidegree::s_t(2, 0), 2, &|(), _| 5).is_empty());
assert!(run_checked(Bidegree::s_t(0, 0), 3, &|(), _| 0).is_empty());

// A shrinking `t` bound is honoured per row, exactly as `iter_s_t` clamps it.
let visited = run_checked(Bidegree::s_t(0, 1), 4, &|(), s| 4 - s);
let mut per_row = [0usize; 4];
for (s, t) in &visited {
assert!(*t >= 1 && *t < 4 - *s, "({s}, {t}) out of range");
per_row[*s as usize] += 1;
}
assert_eq!(per_row, [3, 2, 1, 0]);
}
}
13 changes: 13 additions & 0 deletions ext/examples/benchmarks/tensor_resolution-RPinf
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
tensor_resolution -- 20 10
Ext_A(RP^∞, F_2), rows s = 10..0 (top to bottom), columns n = 0..20:
·
· ·
· ·
· ·
· · · ·
· · · · · · ·
· · · : · · · ·
· · · · · · · ∴ :
· : · : · · : ∴ : :
· · · · · : · · · : :
· · · ·
3 changes: 2 additions & 1 deletion ext/examples/secondary_massey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,10 @@ fn main() -> anyhow::Result<()> {
target_all_gens + prod_all_gens,
);

let e3_page = |bd: Bidegree| Some(get_page_data(&unit_sseq, bd).clone());
b.hom_k_with(
b_lambda.as_deref(),
Some(&unit_sseq),
Some(&e3_page as &dyn Fn(Bidegree) -> Option<fp::matrix::Subquotient>),
c,
e2_kernel.basis(),
product_matrix
Expand Down
78 changes: 78 additions & 0 deletions ext/examples/tensor_resolution.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//! Compute `Ext_A(M, k)` for an **infinite** module `M` via Nassau's untwisted tensor resolution:
//! resolve the base field `k`, tensor with `M` to get a non-minimal free resolution, and take the
//! cohomology of `Hom_A(P_• ⊗ M, k)`.
//!
//! The default `M` is `RP^∞` (real projective space), which is infinite-dimensional and therefore
//! *cannot* be resolved by the usual minimal-resolution engine — the whole point of the
//! construction.
//!
//! Run with e.g. `cargo run --example tensor_resolution` (p = 2).

use std::sync::Arc;

use algebra::{
Algebra,
module::{Module, RealProjectiveSpace},
};
use ext::{
chain_complex::ChainComplex,
ext_algebra::tensor_resolution_ext,
utils::{construct_standard, unicode_num},
};
use sseq::coordinates::Bidegree;

fn main() -> anyhow::Result<()> {
ext::utils::init_logging()?;

eprintln!("Computes Ext_A(RP^∞, F_2) by resolving k and tensoring with RP^∞ (p = 2).");

let max_n: i32 = query::with_default("Max n", "25", str::parse);
let max_s: i32 = query::with_default("Max s", "15", str::parse);
let max = Bidegree::n_s(max_n, max_s);
let t_max = max.t();

// Minimally resolve the base field k = S_2; this is `P_•`, the complex we tensor with `M`.
let resolution = Arc::new(construct_standard::<false, _, _>("S_2", None)?);
resolution.compute_through_bidegree(Bidegree::s_t(max_s + 1, t_max));
resolution.algebra().compute_basis(t_max + 1);

// M = RP^∞, an infinite module. `None` max degree ⇒ genuinely unbounded.
let rp_inf = Arc::new(RealProjectiveSpace::new(
resolution.algebra(),
1,
None,
false,
));
rp_inf.compute_basis(t_max);

let ext = tensor_resolution_ext(Arc::clone(&resolution), rp_inf);

// Print the Ext chart: rows are s (high to low), columns are n. Rows above the highest
// non-zero one carry no information, so drop them — and report the range we actually print,
// otherwise the reader cannot tell which `s` the top row is.
let rows: Vec<String> = (0..=max_s)
.rev()
.map(|s| {
(0..=max_n)
.map(|n| {
let dim = ext
.cohomology_dimension(Bidegree::n_s(n, s))
.expect("computed range");
format!("{} ", unicode_num(dim))
})
.collect()
})
.collect();
let top_s = max_s - rows.iter().take_while(|row| row.trim().is_empty()).count() as i32;

if top_s < 0 {
println!("Ext_A(RP^∞, F_2) vanishes on n = 0..{max_n}, s = 0..{max_s}.");
} else {
println!("Ext_A(RP^∞, F_2), rows s = {top_s}..0 (top to bottom), columns n = 0..{max_n}:");
for row in rows.iter().skip((max_s - top_s) as usize) {
println!("{}", row.trim_end());
}
}

Ok(())
}
Loading