holos_tda/scalar/model.rs
1use std::fmt;
2
3use crate::collapse;
4
5/// One persistence interval. `death` is `f64::INFINITY` for essential classes.
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct Bar {
8 /// Homology dimension.
9 pub dim: usize,
10 /// Filtration value at which the class appears.
11 pub birth: f64,
12 /// Filtration value at which the class dies.
13 pub death: f64,
14}
15
16impl Bar {
17 /// True when the class never dies.
18 pub fn is_essential(&self) -> bool {
19 self.death == f64::INFINITY
20 }
21}
22
23/// A persistence diagram: the multiset of bars across dimensions.
24#[derive(Debug, Clone, Default)]
25pub struct Diagram {
26 /// All bars, in canonical order after [`Diagram::canonicalize`].
27 pub bars: Vec<Bar>,
28}
29
30impl Diagram {
31 /// Bars of one homology dimension.
32 pub fn in_dim(&self, dim: usize) -> impl Iterator<Item = &Bar> {
33 self.bars.iter().filter(move |b| b.dim == dim)
34 }
35
36 /// Sort bars into the canonical output order: by dimension, then birth,
37 /// then death. The order is deterministic across runs and point
38 /// permutations.
39 pub fn canonicalize(&mut self) {
40 self.bars.sort_by(|a, b| {
41 a.dim
42 .cmp(&b.dim)
43 .then(a.birth.total_cmp(&b.birth))
44 .then(a.death.total_cmp(&b.death))
45 });
46 }
47}
48
49/// Parameters for [`crate::rips_persistence`].
50///
51/// The engine is dimension-generic. The differential gates cover
52/// `max_dim <= 2`. Stress tests extend through dimension 4.
53#[derive(Debug, Clone)]
54#[non_exhaustive]
55pub struct RipsParams {
56 /// Highest homology dimension to compute.
57 pub max_dim: usize,
58 /// Filtration threshold. `None` means the input's default: the
59 /// enclosing radius for dense matrices, no threshold for sparse ones.
60 pub threshold: Option<f64>,
61 /// Coefficient field Z/p; must be a prime below 32768. Default 2.
62 pub modulus: u32,
63 /// Worker threads for the run. 0 and 1 (the default) both run the
64 /// serial engine. Higher values reduce each dimension concurrently.
65 /// With [`RipsParams::collapse_edges`] set and the ordered or rounds
66 /// [`RipsParams::collapse_schedule`], `threads` is the budget for the
67 /// whole pipeline. The diagram is identical at any thread count.
68 pub threads: usize,
69 /// Optimization toggle. The diagram is identical with any combination
70 /// disabled. For differential testing only.
71 pub use_emergent_pairs: bool,
72 /// See [`RipsParams::use_emergent_pairs`].
73 pub use_apparent_pairs: bool,
74 /// See [`RipsParams::use_emergent_pairs`].
75 pub use_clearing: bool,
76 /// See [`RipsParams::use_emergent_pairs`]. With this set, and the graph
77 /// dense enough for the rows to fit their memory budget, the dim-0
78 /// apparent test runs on adjacency bitsets instead of the neighbor
79 /// lists.
80 pub use_adjacency_rows: bool,
81 /// Collapse dominated edges before the engine runs. Off by default.
82 /// The diagram is identical either way. See [`collapse`].
83 pub collapse_edges: bool,
84 /// The schedule the collapse uses with `collapse_edges` set. Default
85 /// [`CollapseSchedule::Serial`]. See [`CollapseSchedule`].
86 pub collapse_schedule: CollapseSchedule,
87 /// Objective and deterministic work limit for
88 /// [`CollapseSchedule::Adaptive`]. Other schedules ignore this field.
89 pub adaptive_collapse: collapse::AdaptiveCollapseParams,
90 /// Which engine reduces a dense input. Default [`Engine::Auto`]. See
91 /// [`Engine`].
92 pub engine: Engine,
93 /// Which storage form the dense engine reduces from. Default
94 /// [`DenseStorage::Auto`]. See [`DenseStorage`].
95 pub dense_storage: DenseStorage,
96 /// Structural decomposition of a sparse terminal graph. Default
97 /// [`GraphFactorization::Off`]. Dense runs use it only after routing to
98 /// the sparse engine. See [`GraphFactorization`].
99 pub factorization: GraphFactorization,
100}
101
102/// Which engine reduces a dense input.
103///
104/// A dense matrix can be reduced as it stands, or converted to the graph
105/// of its edges at the threshold and reduced by the sparse engine. The
106/// second is faster when few pairs are edges, because the sparse
107/// enumerator walks a neighbor list where the dense one scans every
108/// vertex. `Auto` picks between them from the edge density at the resolved
109/// threshold and from the memory the conversion would take; `Dense` and
110/// `Sparse` force one.
111///
112/// The diagram is identical under all three, bit for bit. Every simplex of
113/// the complex has diameter at most the threshold, and a diameter is the
114/// largest of the edge lengths, so every edge of every simplex survives
115/// the conversion. The conversion keeps only edges at or below the
116/// threshold. The vertex set carries over because the conversion passes
117/// the point count explicitly.
118///
119/// An infinite threshold reads as `f64::MAX` in both engines. An absent
120/// pair has distance `+inf`, so it enters neither complex, and the
121/// conversion keeps exactly the pairs the dense engine admits.
122///
123/// The rule and its constants were frozen on 2026-08-18 from disclosed
124/// engineering data. Performance assessment is WIP.
125///
126/// A sparse input is never routed, so this setting does not reach
127/// [`crate::rips_persistence_sparse`]. With [`RipsParams::collapse_edges`] set,
128/// the collapse already produces a graph and the sparse engine reduces it.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130#[non_exhaustive]
131pub enum Engine {
132 /// Reduce a low-density dense input with the sparse engine, and every
133 /// other dense input with the dense engine. The rule takes no argument.
134 /// The conversion has a memory budget: 32 MiB, or the bytes of the
135 /// compact matrix, whichever is larger. A matrix of mostly absent
136 /// pairs is low-density at any threshold, including an infinite one,
137 /// so it routes too.
138 #[default]
139 Auto,
140 /// Always reduce the distance matrix as it stands.
141 Dense,
142 /// Always convert to the thresholded graph and reduce that. The
143 /// conversion costs one pass over the matrix. This is an explicit
144 /// request, so the `Auto` memory budget does not apply.
145 Sparse,
146}
147
148/// Which storage form the dense engine reduces from.
149///
150/// A [`crate::DistanceMatrix`] is built compact: the condensed lower triangle,
151/// `n(n-1)/2` entries. The full form holds both triangles row-major,
152/// `n * n` entries, so that the cofacet diameter fold reads a contiguous
153/// row per simplex vertex where the compact form reads a strided column.
154/// The full form costs `n(n+1)/2` entries more.
155///
156/// The choice is per run and comes after the routing decision, so a run
157/// the router sends to the sparse engine never builds the full form.
158/// `Auto` selects it from the compact matrix size, a frozen budget on the
159/// added bytes, the edge count at the resolved threshold, and how many
160/// distances the fold reads. `Compact` forbids the conversion, which
161/// bounds what a run spends on the matrix. `Square` forces it and skips
162/// the budget.
163///
164/// The conversion runs once and the full form lives only as long as the
165/// run. The caller keeps its compact matrix, so a run in the full form
166/// holds `n * n + n(n-1)/2` entries: one and a half times the full form,
167/// three times the compact one.
168///
169/// The diagram is identical under all three, bit for bit.
170///
171/// A sparse input holds no distance matrix, so this setting does not reach
172/// [`crate::rips_persistence_sparse`].
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
174#[non_exhaustive]
175pub enum DenseStorage {
176 /// Convert to the full form when the frozen rule selects it, and keep
177 /// the compact form otherwise.
178 #[default]
179 Auto,
180 /// Always reduce from the compact form. No run adds the second
181 /// triangle.
182 Compact,
183 /// Always reduce from the full form. This is an explicit request, so
184 /// the `Auto` budget on the added bytes does not apply.
185 Square,
186}
187
188/// Structural routing for positive-dimensional sparse persistence.
189///
190/// Every terminal edge belongs to one vertex-biconnected block. Every
191/// terminal clique with at least two vertices lies in one such block, and
192/// every positive-dimensional cycle splits over the blocks. The engine can
193/// therefore compute H0 once on the whole graph and compute H1 and above on
194/// the cyclic blocks independently.
195///
196/// The diagram is identical under every setting. The automatic rule uses
197/// factorization only when there are at least two cyclic blocks and the
198/// largest holds at most nine tenths of their edges. A graph with one
199/// dominant block stays on the existing reducer.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
201#[non_exhaustive]
202pub enum GraphFactorization {
203 /// Use the frozen structural rule.
204 Auto,
205 /// Reduce the whole terminal graph.
206 #[default]
207 Off,
208 /// Split every terminal graph.
209 Force,
210}
211
212/// The collapse the pipeline runs with [`RipsParams::collapse_edges`] set.
213///
214/// Every schedule gives the same diagram. `Serial` is the default and, in
215/// the registered studies, the fastest end to end on most inputs.
216/// `Ordered` gives the serial result, bit for bit, from a parallel run.
217/// `Rounds` gives a result that does not depend on the worker count and,
218/// on some inputs, a smaller reduced graph; its cost grows faster with the
219/// edge count than the serial cost. See [`collapse`] for the schedules and
220/// their certificates.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
222#[non_exhaustive]
223pub enum CollapseSchedule {
224 /// The serial schedule on one worker. It writes an algorithm
225 /// version 1 certificate. The reduction still uses the whole thread
226 /// budget.
227 #[default]
228 Serial,
229 /// The ordered schedule on the run's worker budget. It gives the same
230 /// reduced graph and the same certificate as `Serial`.
231 Ordered,
232 /// The rounds schedule on the run's worker budget. It writes an
233 /// algorithm version 2 certificate and gives the same result at every
234 /// worker count, but not the serial result.
235 Rounds,
236 /// The adaptive version 3 schedule. It ranks currently valid removals
237 /// by estimated downstream H1 or H2 work and can stop at a declared
238 /// work limit. It runs serially; the reduction still uses the whole
239 /// thread budget.
240 Adaptive,
241}
242
243impl Default for RipsParams {
244 fn default() -> Self {
245 Self {
246 max_dim: 1,
247 threshold: None,
248 modulus: 2,
249 threads: 1,
250 use_emergent_pairs: true,
251 use_apparent_pairs: true,
252 use_clearing: true,
253 use_adjacency_rows: true,
254 collapse_edges: false,
255 collapse_schedule: CollapseSchedule::Serial,
256 adaptive_collapse: collapse::AdaptiveCollapseParams::default(),
257 engine: Engine::Auto,
258 dense_storage: DenseStorage::Auto,
259 factorization: GraphFactorization::Off,
260 }
261 }
262}
263
264impl RipsParams {
265 /// Defaults with the given `max_dim`.
266 ///
267 /// The threshold is the input's default. Reduction shortcuts are on.
268 /// Edge collapse and structural factorization are off.
269 pub fn new(max_dim: usize) -> Self {
270 Self {
271 max_dim,
272 ..Self::default()
273 }
274 }
275
276 /// Truncate the filtration at `threshold`.
277 pub fn with_threshold(mut self, threshold: f64) -> Self {
278 self.threshold = Some(threshold);
279 self
280 }
281
282 /// Compute over Z/p instead of Z/2. `modulus` must be a prime below
283 /// 32768.
284 pub fn with_modulus(mut self, modulus: u32) -> Self {
285 self.modulus = modulus;
286 self
287 }
288
289 /// Reduce with `threads` workers. 1 keeps the serial engine. The diagram
290 /// is identical at any thread count.
291 pub fn with_threads(mut self, threads: usize) -> Self {
292 self.threads = threads.max(1);
293 self
294 }
295
296 /// Collapse dominated edges before the engine runs. The diagram is
297 /// identical either way. See [`collapse`].
298 pub fn with_edge_collapse(mut self) -> Self {
299 self.collapse_edges = true;
300 self
301 }
302
303 /// Collapse dominated edges with the given schedule before the engine
304 /// runs. Also sets [`RipsParams::collapse_edges`]. See
305 /// [`CollapseSchedule`].
306 pub fn with_collapse_schedule(mut self, schedule: CollapseSchedule) -> Self {
307 self.collapse_edges = true;
308 self.collapse_schedule = schedule;
309 self
310 }
311
312 /// Collapse with the adaptive version 3 schedule and the given
313 /// objective and work limit.
314 pub fn with_adaptive_collapse(mut self, params: collapse::AdaptiveCollapseParams) -> Self {
315 self.collapse_edges = true;
316 self.collapse_schedule = CollapseSchedule::Adaptive;
317 self.adaptive_collapse = params;
318 self
319 }
320
321 /// Choose the engine for a dense input. See [`Engine`].
322 pub fn with_engine(mut self, engine: Engine) -> Self {
323 self.engine = engine;
324 self
325 }
326
327 /// Choose the storage form the dense engine reduces from. See
328 /// [`DenseStorage`].
329 pub fn with_dense_storage(mut self, storage: DenseStorage) -> Self {
330 self.dense_storage = storage;
331 self
332 }
333
334 /// Choose structural factorization for sparse reduction. See
335 /// [`GraphFactorization`].
336 pub fn with_factorization(mut self, factorization: GraphFactorization) -> Self {
337 self.factorization = factorization;
338 self
339 }
340}
341
342/// Errors from construction, validation, and IO.
343#[derive(Debug, Clone, PartialEq)]
344#[allow(missing_docs)]
345pub enum Error {
346 InvalidDistance(String),
347 InvalidInput(String),
348 IndexOverflow { n: usize, dim: usize },
349 Io(String),
350}
351
352impl fmt::Display for Error {
353 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
354 match self {
355 Error::InvalidDistance(msg) => write!(f, "invalid distance: {msg}"),
356 Error::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
357 Error::IndexOverflow { n, dim } => write!(
358 f,
359 "simplex index space overflows u64 for {n} points in dimension {dim}"
360 ),
361 Error::Io(msg) => write!(f, "io error: {msg}"),
362 }
363 }
364}
365
366impl std::error::Error for Error {}
367
368/// Crate-wide result alias.
369pub type Result<T> = std::result::Result<T, Error>;