scirs2_transform/tda/zigzag.rs
1//! Zigzag persistence for sequences of simplicial complexes.
2//!
3//! Zigzag persistence generalises ordinary persistence to handle sequences where
4//! simplices can be both added and removed. The "zigzag" refers to the sequence
5//! of maps alternating between forward (inclusion) and backward (deletion)
6//! directions:
7//!
8//! ```text
9//! K_0 → K_1 ← K_2 → K_3 ← K_4 → …
10//! ```
11//!
12//! This implementation uses a vineyard-inspired approach:
13//! 1. Maintain a totally ordered filtration of currently-active simplices.
14//! 2. On addition: append the simplex and reduce.
15//! 3. On removal: locate the simplex, perform transpositions to move it to the
16//! end, then delete it and close any open intervals.
17//!
18//! ## References
19//!
20//! - Carlsson & de Silva (2010). Zigzag Persistence. FoCM.
21//! - Cohen-Steiner, Edelsbrunner & Morozov (2006). Vines and Vineyards.
22
23use crate::tda::alpha_complex::{sym_diff_sorted, Simplex};
24use std::collections::HashMap;
25
26// ─── ZigzagDirection ─────────────────────────────────────────────────────────
27
28/// Direction of a zigzag step.
29#[non_exhaustive]
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum ZigzagDirection {
32 /// The simplex is being added (forward inclusion).
33 Forward,
34 /// The simplex is being removed (backward deletion).
35 Backward,
36}
37
38// ─── ZigzagStep ──────────────────────────────────────────────────────────────
39
40/// A single step in a zigzag filtration sequence.
41#[derive(Debug, Clone)]
42pub struct ZigzagStep {
43 /// Direction of this step.
44 pub direction: ZigzagDirection,
45 /// The simplex being added or removed.
46 pub simplex: Simplex,
47}
48
49// ─── ZigzagPersistence ───────────────────────────────────────────────────────
50
51/// Incremental zigzag persistence computation.
52///
53/// Maintains the current filtration and reduced boundary matrix, updating
54/// persistance intervals as simplices are added and removed.
55#[derive(Debug, Clone)]
56pub struct ZigzagPersistence {
57 /// Active simplices in filtration order.
58 filtration: Vec<Simplex>,
59 /// Boundary matrix (reduced), stored as columns of sorted row indices.
60 columns: Vec<Vec<usize>>,
61 /// Pivot map: pivot row → column index.
62 pivot_col: HashMap<usize, usize>,
63 /// Currently open intervals: simplex key → (birth_value, dimension).
64 open_intervals: HashMap<Vec<usize>, (f64, usize)>,
65 /// Completed persistence pairs collected so far.
66 completed: Vec<(f64, f64, usize)>,
67 /// Current "time" counter used as a synthetic filtration value when needed.
68 time: usize,
69}
70
71impl ZigzagPersistence {
72 /// Create a new empty zigzag persistence structure.
73 pub fn new() -> Self {
74 Self {
75 filtration: Vec::new(),
76 columns: Vec::new(),
77 pivot_col: HashMap::new(),
78 open_intervals: HashMap::new(),
79 completed: Vec::new(),
80 time: 0,
81 }
82 }
83
84 /// Process a forward step: add a simplex and return any newly closed intervals.
85 ///
86 /// In standard persistence, adding a simplex either:
87 /// - Creates a new homology class (no pivot after reduction) → opens an interval.
88 /// - Destroys an existing class (reduction yields a pivot) → closes an interval.
89 pub fn add_simplex(&mut self, s: Simplex) -> Vec<(f64, f64, usize)> {
90 self.time += 1;
91 let idx = self.filtration.len();
92 self.filtration.push(s.clone());
93
94 // Build boundary column for the new simplex
95 let simplex_index: HashMap<Vec<usize>, usize> = self
96 .filtration
97 .iter()
98 .enumerate()
99 .map(|(i, sx)| (sx.vertices.clone(), i))
100 .collect();
101
102 let mut col: Vec<usize> = s
103 .boundary_faces()
104 .iter()
105 .filter_map(|face| simplex_index.get(face).copied())
106 .collect();
107 col.sort_unstable();
108
109 // Reduce column
110 while let Some(&pivot) = col.last() {
111 if let Some(&k) = self.pivot_col.get(&pivot) {
112 let col_k = self.columns[k].clone();
113 sym_diff_sorted(&mut col, &col_k);
114 } else {
115 break;
116 }
117 }
118
119 self.columns.push(col.clone());
120
121 let mut newly_closed = Vec::new();
122
123 if let Some(&pivot) = col.last() {
124 // Simplex kills homology class born at `pivot`
125 self.pivot_col.insert(pivot, idx);
126 // Find the open interval for the simplex at position `pivot`
127 let birth_key = self.filtration[pivot].vertices.clone();
128 if let Some((birth_val, dim)) = self.open_intervals.remove(&birth_key) {
129 let death_val = s.filtration_value;
130 newly_closed.push((birth_val, death_val, dim));
131 self.completed.push((birth_val, death_val, dim));
132 }
133 } else {
134 // Simplex creates a new homology class
135 let dim = s.dimension();
136 self.open_intervals
137 .insert(s.vertices.clone(), (s.filtration_value, dim));
138 }
139
140 newly_closed
141 }
142
143 /// Process a backward step: remove a simplex and return any newly closed intervals.
144 ///
145 /// To remove simplex `s` at position `pos`, we perform adjacent transpositions
146 /// (vineyard updates) to bubble it to the last position, then delete it.
147 ///
148 /// After removal, if `s` had an open interval it gets closed with death = infinity
149 /// (the feature survives through the deletion). If `s` was killing another class,
150 /// the killed class re-opens.
151 pub fn remove_simplex(&mut self, s: Simplex) -> Vec<(f64, f64, usize)> {
152 self.time += 1;
153 let mut newly_closed = Vec::new();
154
155 // Find the simplex in the current filtration
156 let pos = match self
157 .filtration
158 .iter()
159 .position(|x| x.vertices == s.vertices)
160 {
161 Some(p) => p,
162 None => return newly_closed, // Simplex not found, nothing to do
163 };
164
165 // Bubble the simplex to the end via adjacent transpositions
166 let last = self.filtration.len() - 1;
167 for i in pos..last {
168 self.transpose_adjacent(i);
169 }
170
171 // The simplex is now at the last position; remove it
172 let removed = self.filtration.pop();
173 self.columns.pop();
174
175 // Rebuild pivot map from scratch (cheaper than incremental for correctness)
176 self.rebuild_pivot_map();
177
178 // If the removed simplex had an open interval, close it with death = removal_value
179 if let Some(rem) = removed {
180 let death_val = rem.filtration_value;
181 if let Some((birth_val, dim)) = self.open_intervals.remove(&rem.vertices) {
182 // Feature born at birth_val, removed at death_val
183 newly_closed.push((birth_val, death_val, dim));
184 self.completed.push((birth_val, death_val, dim));
185 }
186 }
187
188 newly_closed
189 }
190
191 /// Return all completed persistence pairs collected so far.
192 pub fn pairs(&self) -> &[(f64, f64, usize)] {
193 &self.completed
194 }
195
196 // ─── Internal helpers ────────────────────────────────────────────────────
197
198 /// Perform a single adjacent transposition of simplices at positions i and i+1.
199 ///
200 /// This implements the core vineyard transposition step: swap the two simplices
201 /// in the filtration and update the boundary matrix accordingly.
202 fn transpose_adjacent(&mut self, i: usize) {
203 // Swap simplices in the filtration
204 self.filtration.swap(i, i + 1);
205
206 // Update boundary matrix columns for i and i+1
207 // The boundary of the swapped simplices may reference each other.
208
209 // Check if column i+1 has a pivot at i (i.e., column i+1 contains row i)
210 let col_i1_has_i = self.columns[i + 1].binary_search(&i).is_ok();
211 // Check if column i has a pivot at i+1
212 let col_i_has_i1 = self.columns[i].binary_search(&(i + 1)).is_ok();
213
214 if col_i1_has_i {
215 // Add column i to column i+1 (mod-2) to remove row i
216 let col_i = self.columns[i].clone();
217 sym_diff_sorted(&mut self.columns[i + 1], &col_i);
218 }
219
220 // Swap row references: replace any occurrence of i with i+1 and vice versa
221 // in ALL columns (since row i and row i+1 swapped their simplices)
222 for col in self.columns.iter_mut() {
223 let had_i = col.binary_search(&i).is_ok();
224 let had_i1 = col.binary_search(&(i + 1)).is_ok();
225
226 if had_i && !had_i1 {
227 // Replace i with i+1
228 if let Ok(pos) = col.binary_search(&i) {
229 col[pos] = i + 1;
230 col.sort_unstable();
231 }
232 } else if had_i1 && !had_i {
233 // Replace i+1 with i
234 if let Ok(pos) = col.binary_search(&(i + 1)) {
235 col[pos] = i;
236 col.sort_unstable();
237 }
238 } else if had_i && had_i1 {
239 // Both present: they cancel each other (mod-2 swap has no effect)
240 // but positions are already correct since we just swapped simplices
241 // Nothing to do — both row indices present means they cancel
242 }
243 }
244
245 // Swap the columns themselves
246 self.columns.swap(i, i + 1);
247
248 // If the new column i+1 had a pivot issue (was killing something via i), fix it
249 if col_i_has_i1 {
250 // Column i used to reference row i+1; after swap, this reference is still valid
251 // but needs to remain reduced
252 let last_i = self.columns[i].last().copied();
253 if let Some(piv) = last_i {
254 if let Some(&k) = self.pivot_col.get(&piv) {
255 if k != i {
256 let col_k = self.columns[k].clone();
257 sym_diff_sorted(&mut self.columns[i], &col_k);
258 }
259 }
260 }
261 }
262 }
263
264 /// Rebuild the pivot map from the current (partially reduced) columns.
265 fn rebuild_pivot_map(&mut self) {
266 self.pivot_col.clear();
267 for (j, col) in self.columns.iter().enumerate() {
268 if let Some(&pivot) = col.last() {
269 self.pivot_col.insert(pivot, j);
270 }
271 }
272 }
273}
274
275impl Default for ZigzagPersistence {
276 fn default() -> Self {
277 Self::new()
278 }
279}
280
281// ─── Batch computation ────────────────────────────────────────────────────────
282
283/// Compute zigzag persistence for a sequence of steps.
284///
285/// Processes all steps in order and returns all completed `(birth, death, dim)` pairs.
286pub fn compute_zigzag(steps: &[ZigzagStep]) -> Vec<(f64, f64, usize)> {
287 let mut zz = ZigzagPersistence::new();
288 for step in steps {
289 match step.direction {
290 ZigzagDirection::Forward => {
291 zz.add_simplex(step.simplex.clone());
292 }
293 ZigzagDirection::Backward => {
294 zz.remove_simplex(step.simplex.clone());
295 }
296 }
297 }
298 // Close any surviving open intervals (essential features)
299 // These are not added to completed; return only finite pairs.
300 zz.completed
301}
302
303// ─── Tests ────────────────────────────────────────────────────────────────────
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 fn make_simplex(verts: Vec<usize>, fv: f64) -> Simplex {
310 Simplex {
311 vertices: verts,
312 filtration_value: fv,
313 }
314 }
315
316 #[test]
317 fn test_add_vertices_creates_intervals() {
318 let mut zz = ZigzagPersistence::new();
319 let v0 = make_simplex(vec![0], 0.0);
320 let v1 = make_simplex(vec![1], 1.0);
321 zz.add_simplex(v0);
322 zz.add_simplex(v1);
323 // Two vertices added → two open intervals, no closed yet
324 assert_eq!(zz.open_intervals.len(), 2);
325 assert!(zz.completed.is_empty());
326 }
327
328 #[test]
329 fn test_add_edge_closes_one_component() {
330 let mut zz = ZigzagPersistence::new();
331 let v0 = make_simplex(vec![0], 0.0);
332 let v1 = make_simplex(vec![1], 0.0);
333 let edge = make_simplex(vec![0, 1], 1.0);
334
335 zz.add_simplex(v0);
336 zz.add_simplex(v1);
337 let closed = zz.add_simplex(edge);
338
339 // Adding edge should close one H0 interval
340 assert_eq!(closed.len(), 1, "Adding edge should close one interval");
341 let (birth, death, dim) = closed[0];
342 assert_eq!(dim, 0, "Should be H0 (connected component)");
343 assert!(birth < death, "birth < death expected");
344 }
345
346 #[test]
347 fn test_remove_simplex_closes_interval() {
348 let mut zz = ZigzagPersistence::new();
349 let v0 = make_simplex(vec![0], 0.0);
350 zz.add_simplex(v0.clone());
351
352 // The vertex has an open interval; removing it should close it
353 let closed = zz.remove_simplex(v0);
354 // After removal, the open interval for v0 is closed
355 assert_eq!(zz.open_intervals.len(), 0);
356 // The closed interval should appear
357 assert!(!closed.is_empty() || !zz.completed.is_empty());
358 }
359
360 #[test]
361 fn test_zigzag_batch_add_then_remove() {
362 let v0 = make_simplex(vec![0], 0.0);
363 let v1 = make_simplex(vec![1], 0.5);
364 let edge = make_simplex(vec![0, 1], 1.0);
365
366 let steps = vec![
367 ZigzagStep {
368 direction: ZigzagDirection::Forward,
369 simplex: v0.clone(),
370 },
371 ZigzagStep {
372 direction: ZigzagDirection::Forward,
373 simplex: v1.clone(),
374 },
375 ZigzagStep {
376 direction: ZigzagDirection::Forward,
377 simplex: edge.clone(),
378 },
379 ZigzagStep {
380 direction: ZigzagDirection::Backward,
381 simplex: edge,
382 },
383 ];
384
385 let pairs = compute_zigzag(&steps);
386 // Adding the edge closed one H0 interval
387 assert!(!pairs.is_empty(), "Expected at least one completed pair");
388 for (birth, death, _) in &pairs {
389 assert!(birth <= death, "birth={birth} > death={death}");
390 }
391 }
392
393 #[test]
394 fn test_directions_are_non_exhaustive() {
395 // Ensure the enum is non_exhaustive (just compile-check the variant usage)
396 let d = ZigzagDirection::Forward;
397 assert_eq!(d, ZigzagDirection::Forward);
398 }
399}