Skip to main content

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>;