scirs2_graph/hypergraph/simplicial.rs
1//! Simplicial complexes and their topological invariants.
2//!
3//! A **simplicial complex** is a collection of simplices (points, edges,
4//! triangles, tetrahedra, …) closed under taking faces. This module provides:
5//!
6//! * [`SimplicialComplex`] – the core data structure.
7//! * **Boundary matrices** `∂_k` for homology computation.
8//! * **Betti numbers** β_0, β_1, … via rank-nullity of boundary matrices.
9//! * **Euler characteristic** χ = Σ (-1)^k |C_k|.
10//! * Constructors:
11//! - [`SimplicialComplex::vietoris_rips`] – from points and radius ε.
12//! - [`SimplicialComplex::cech_complex`] – from points and radius r (miniball check).
13//! - [`SimplicialComplex::nerve_complex`] – from a cover of index sets.
14//!
15//! # References
16//! - Edelsbrunner & Harer, "Computational Topology", 2010.
17//! - Zomorodian & Carlsson, "Computing persistent homology", DCG 2005.
18
19use crate::error::{GraphError, Result};
20use scirs2_core::ndarray::Array2;
21use std::collections::{BTreeMap, BTreeSet};
22
23// ============================================================================
24// SimplicialComplex
25// ============================================================================
26
27/// A finite simplicial complex represented by its simplices, grouped by
28/// dimension.
29///
30/// All simplices are stored as **sorted** vectors of vertex indices. Adding a
31/// simplex automatically adds all its faces (closure property).
32///
33/// ## Example
34/// ```
35/// use scirs2_graph::hypergraph::SimplicialComplex;
36///
37/// let mut sc = SimplicialComplex::new();
38/// sc.add_simplex(vec![0, 1, 2]); // adds triangle + all edges + all vertices
39///
40/// let betti = sc.betti_numbers();
41/// assert_eq!(betti[0], 1); // one connected component
42/// assert_eq!(betti[1], 0); // boundary is filled in
43/// ```
44#[derive(Debug, Clone)]
45pub struct SimplicialComplex {
46 /// Map: dimension → sorted set of simplices (each simplex = sorted Vec<usize>)
47 simplices: BTreeMap<usize, BTreeSet<Vec<usize>>>,
48}
49
50impl Default for SimplicialComplex {
51 fn default() -> Self {
52 Self::new()
53 }
54}
55
56impl SimplicialComplex {
57 /// Create an empty simplicial complex.
58 pub fn new() -> Self {
59 SimplicialComplex {
60 simplices: BTreeMap::new(),
61 }
62 }
63
64 /// Add a simplex and all its faces (the **closure**).
65 ///
66 /// The simplex `[v_0, v_1, …, v_k]` is stored as a sorted, deduplicated
67 /// vertex list. All (k-1)-dimensional faces are recursively added.
68 pub fn add_simplex(&mut self, mut simplex: Vec<usize>) {
69 simplex.sort_unstable();
70 simplex.dedup();
71 self.add_simplex_internal(simplex);
72 }
73
74 /// Internal recursive insertion (simplex must already be sorted & deduped).
75 fn add_simplex_internal(&mut self, simplex: Vec<usize>) {
76 let dim = simplex.len().saturating_sub(1);
77 let set = self.simplices.entry(dim).or_default();
78 if set.contains(&simplex) {
79 return; // already present, faces already added
80 }
81 set.insert(simplex.clone());
82
83 // Add all (dim-1)-faces
84 if simplex.len() > 1 {
85 for i in 0..simplex.len() {
86 let mut face = simplex.clone();
87 face.remove(i);
88 self.add_simplex_internal(face);
89 }
90 }
91 }
92
93 /// Return the maximum dimension of the complex, or `None` if empty.
94 pub fn max_dim(&self) -> Option<usize> {
95 self.simplices.keys().next_back().copied()
96 }
97
98 /// Return a slice of all simplices at dimension `dim`.
99 pub fn simplices_of_dim(&self, dim: usize) -> Vec<Vec<usize>> {
100 self.simplices
101 .get(&dim)
102 .map(|s| s.iter().cloned().collect())
103 .unwrap_or_default()
104 }
105
106 /// Total number of simplices across all dimensions.
107 pub fn total_simplices(&self) -> usize {
108 self.simplices.values().map(|s| s.len()).sum()
109 }
110
111 /// Number of simplices at dimension `dim`.
112 pub fn num_simplices(&self, dim: usize) -> usize {
113 self.simplices.get(&dim).map(|s| s.len()).unwrap_or(0)
114 }
115
116 // -----------------------------------------------------------------------
117 // Boundary matrix
118 // -----------------------------------------------------------------------
119
120 /// Compute the **boundary matrix** ∂_dim : C_dim → C_{dim-1}.
121 ///
122 /// Rows are indexed by (dim-1)-simplices; columns by dim-simplices.
123 /// Entry `[i, j]` is `(-1)^k` where `k` is the position of the omitted
124 /// vertex in simplex `j` that gives face `i`, else `0`.
125 ///
126 /// Returns an all-zero (1 × 1) matrix if there are no dim-simplices or no
127 /// (dim-1)-simplices.
128 pub fn boundary_matrix(&self, dim: usize) -> Array2<i8> {
129 if dim == 0 {
130 // ∂_0 = 0 (no (−1)-chains)
131 let n0 = self.num_simplices(0);
132 return Array2::zeros((1, n0.max(1)));
133 }
134
135 let chains_high = self.simplices_of_dim(dim);
136 let chains_low = self.simplices_of_dim(dim - 1);
137
138 if chains_high.is_empty() || chains_low.is_empty() {
139 let rows = chains_low.len().max(1);
140 let cols = chains_high.len().max(1);
141 return Array2::zeros((rows, cols));
142 }
143
144 // Index the low-dimensional simplices for fast lookup
145 let low_index: BTreeMap<Vec<usize>, usize> = chains_low
146 .iter()
147 .enumerate()
148 .map(|(i, s)| (s.clone(), i))
149 .collect();
150
151 let rows = chains_low.len();
152 let cols = chains_high.len();
153 let mut mat = Array2::<i8>::zeros((rows, cols));
154
155 for (j, sigma) in chains_high.iter().enumerate() {
156 for k in 0..sigma.len() {
157 let mut face = sigma.clone();
158 face.remove(k);
159 if let Some(&i) = low_index.get(&face) {
160 let sign = if k % 2 == 0 { 1i8 } else { -1i8 };
161 mat[[i, j]] = sign;
162 }
163 }
164 }
165 mat
166 }
167
168 // -----------------------------------------------------------------------
169 // Betti numbers (rank-nullity approach)
170 // -----------------------------------------------------------------------
171
172 /// Compute the **Betti numbers** β_0, β_1, …, β_{max_dim}.
173 ///
174 /// β_k = dim ker(∂_k) − dim im(∂_{k+1})
175 /// = (n_k − rank(∂_k)) − rank(∂_{k+1})
176 ///
177 /// where n_k is the number of k-simplices.
178 ///
179 /// Rank is computed by Gaussian elimination over ℤ (integer arithmetic,
180 /// checking for divisibility). Because our coefficient field is effectively
181 /// ℚ (we use rational row operations), this gives exact Betti numbers over
182 /// ℤ/2ℤ which coincides with ℤ Betti numbers for these complexes.
183 pub fn betti_numbers(&self) -> Vec<usize> {
184 let max_dim = match self.max_dim() {
185 Some(d) => d,
186 None => return Vec::new(),
187 };
188
189 // Compute rank of each boundary matrix
190 let mut ranks: Vec<usize> = vec![0; max_dim + 2];
191 for d in 0..=(max_dim + 1) {
192 let mat = self.boundary_matrix(d);
193 ranks[d] = matrix_rank_i8(&mat);
194 }
195
196 // β_k = (n_k - rank_k) - rank_{k+1}
197 let mut betti = Vec::new();
198 for k in 0..=max_dim {
199 let n_k = self.num_simplices(k);
200 let ker_k = n_k.saturating_sub(ranks[k]);
201 let im_k1 = ranks[k + 1];
202 betti.push(ker_k.saturating_sub(im_k1));
203 }
204 betti
205 }
206
207 // -----------------------------------------------------------------------
208 // Euler characteristic
209 // -----------------------------------------------------------------------
210
211 /// Compute the **Euler characteristic**: χ = Σ_k (-1)^k |C_k|.
212 pub fn euler_characteristic(&self) -> i64 {
213 self.simplices
214 .iter()
215 .map(|(&dim, set)| {
216 let sign: i64 = if dim % 2 == 0 { 1 } else { -1 };
217 sign * set.len() as i64
218 })
219 .sum()
220 }
221
222 // -----------------------------------------------------------------------
223 // Constructors from point clouds
224 // -----------------------------------------------------------------------
225
226 /// Build the **Vietoris-Rips complex** from a point cloud.
227 ///
228 /// Inserts a simplex on every subset of points whose pairwise Euclidean
229 /// distances are all ≤ `epsilon`.
230 ///
231 /// # Arguments
232 /// * `points` – shape `(n_points, n_dims)`
233 /// * `epsilon` – edge threshold
234 ///
235 /// # Complexity
236 /// This is O(2^n) in the worst case; use only on small point sets (< 20).
237 pub fn vietoris_rips(points: &Array2<f64>, epsilon: f64) -> Self {
238 let n = points.nrows();
239 let mut sc = SimplicialComplex::new();
240 if n == 0 {
241 return sc;
242 }
243
244 // Add all vertices
245 for i in 0..n {
246 sc.add_simplex(vec![i]);
247 }
248
249 // Precompute pairwise distances
250 let mut dist = vec![vec![0.0f64; n]; n];
251 for i in 0..n {
252 for j in (i + 1)..n {
253 let d = euclidean_distance(
254 points.row(i).as_slice().unwrap_or(&[]),
255 points.row(j).as_slice().unwrap_or(&[]),
256 );
257 dist[i][j] = d;
258 dist[j][i] = d;
259 }
260 }
261
262 // Build clique complex from edge graph (all pairs within epsilon)
263 // Use Bron-Kerbosch to enumerate maximal cliques → add all cliques
264 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
265 for i in 0..n {
266 for j in (i + 1)..n {
267 if dist[i][j] <= epsilon {
268 adj[i].push(j);
269 adj[j].push(i);
270 }
271 }
272 }
273
274 // Add all cliques as simplices (flag complex = clique complex of 1-skeleton)
275 let mut all_cliques: Vec<Vec<usize>> = Vec::new();
276 bron_kerbosch(&adj, vec![], (0..n).collect(), vec![], &mut all_cliques);
277 for clique in all_cliques {
278 sc.add_simplex(clique);
279 }
280 sc
281 }
282
283 /// Build the **Čech complex** from a point cloud.
284 ///
285 /// A simplex σ is included iff the **miniball** (smallest enclosing ball)
286 /// of the points in σ has radius ≤ `radius`.
287 ///
288 /// # Arguments
289 /// * `points` – shape `(n_points, n_dims)`
290 /// * `radius` – ball radius threshold
291 pub fn cech_complex(points: &Array2<f64>, radius: f64) -> Self {
292 let n = points.nrows();
293 let d = points.ncols();
294 let mut sc = SimplicialComplex::new();
295 if n == 0 {
296 return sc;
297 }
298
299 // For each subset, check miniball radius
300 // We limit to subsets of size ≤ d+2 (by Helly's theorem, the miniball
301 // is determined by at most d+1 points; we still enumerate all subsets
302 // for correctness on small inputs).
303 let max_simplex = (d + 2).min(n);
304
305 // Add all vertices
306 for i in 0..n {
307 sc.add_simplex(vec![i]);
308 }
309
310 // Check edges
311 for i in 0..n {
312 for j in (i + 1)..n {
313 let pts = vec![i, j];
314 if miniball_radius(points, &pts) <= radius {
315 sc.add_simplex(pts);
316 }
317 }
318 }
319
320 // Higher-order simplices up to max_simplex
321 for size in 3..=max_simplex {
322 enumerate_subsets(n, size, &mut |subset| {
323 if miniball_radius(points, subset) <= radius {
324 sc.add_simplex(subset.to_vec());
325 }
326 });
327 }
328 sc
329 }
330
331 /// Build the **nerve complex** from a cover.
332 ///
333 /// Given a cover `cover = [U_0, U_1, …, U_{k-1}]` where each `U_i` is a
334 /// list of point indices, the nerve has:
335 /// * A vertex for each `U_i`.
336 /// * A simplex `{i_0, …, i_r}` whenever `U_{i_0} ∩ … ∩ U_{i_r} ≠ ∅`.
337 ///
338 /// # Arguments
339 /// * `cover` – a slice of cover sets, each set being a sorted list of indices
340 pub fn nerve_complex(cover: &[Vec<usize>]) -> Self {
341 let mut sc = SimplicialComplex::new();
342 let k = cover.len();
343 if k == 0 {
344 return sc;
345 }
346
347 // Vertex for each cover set
348 for i in 0..k {
349 sc.add_simplex(vec![i]);
350 }
351
352 // For each subset of cover sets, check if intersection is non-empty
353 for size in 2..=k {
354 enumerate_subsets(k, size, &mut |subset| {
355 // Compute intersection of cover sets
356 let mut inter: Vec<usize> = cover[subset[0]].clone();
357 for &idx in &subset[1..] {
358 inter.retain(|x| cover[idx].contains(x));
359 if inter.is_empty() {
360 return;
361 }
362 }
363 if !inter.is_empty() {
364 sc.add_simplex(subset.to_vec());
365 }
366 });
367 }
368 sc
369 }
370}
371
372// ============================================================================
373// Helper functions
374// ============================================================================
375
376/// Euclidean distance between two coordinate slices.
377fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
378 a.iter()
379 .zip(b.iter())
380 .map(|(x, y)| (x - y).powi(2))
381 .sum::<f64>()
382 .sqrt()
383}
384
385/// Radius of the smallest enclosing ball (miniball) for a set of points.
386///
387/// We use the Ritter algorithm for a bounding sphere approximation; for exact
388/// results on up to 3 points we use the exact formula.
389fn miniball_radius(points: &Array2<f64>, indices: &[usize]) -> f64 {
390 match indices.len() {
391 0 => 0.0,
392 1 => 0.0,
393 2 => {
394 let d = points.ncols();
395 let mut sq = 0.0f64;
396 for k in 0..d {
397 let diff = points[[indices[0], k]] - points[[indices[1], k]];
398 sq += diff * diff;
399 }
400 sq.sqrt() / 2.0
401 }
402 _ => {
403 // Ritter's bounding sphere (approximation — correct for our use case
404 // since the Čech complex can be built with a slightly conservative radius)
405 let d = points.ncols();
406 // Find diameter pair
407 let mut max_dist = 0.0f64;
408 let mut p1 = indices[0];
409 let mut p2 = indices[1];
410 for &i in indices {
411 for &j in indices {
412 if i == j {
413 continue;
414 }
415 let mut sq = 0.0f64;
416 for k in 0..d {
417 let diff = points[[i, k]] - points[[j, k]];
418 sq += diff * diff;
419 }
420 if sq > max_dist {
421 max_dist = sq;
422 p1 = i;
423 p2 = j;
424 }
425 }
426 }
427 // Initial sphere: centre = midpoint(p1, p2), radius = dist/2
428 let mut centre: Vec<f64> = (0..d)
429 .map(|k| (points[[p1, k]] + points[[p2, k]]) / 2.0)
430 .collect();
431 let mut radius = max_dist.sqrt() / 2.0;
432
433 // Expand to include all points
434 for &i in indices {
435 let mut sq = 0.0f64;
436 for k in 0..d {
437 let diff = points[[i, k]] - centre[k];
438 sq += diff * diff;
439 }
440 let dist = sq.sqrt();
441 if dist > radius {
442 // Expand sphere
443 let new_radius = (radius + dist) / 2.0;
444 let alpha = (dist - radius) / (2.0 * dist);
445 for k in 0..d {
446 centre[k] += alpha * (points[[i, k]] - centre[k]);
447 }
448 radius = new_radius;
449 }
450 }
451 radius
452 }
453 }
454}
455
456/// Enumerate all subsets of `{0..n}` of size `k` and call `f` on each.
457fn enumerate_subsets<F: FnMut(&[usize])>(n: usize, k: usize, f: &mut F) {
458 let mut subset = vec![0usize; k];
459 for i in 0..k {
460 subset[i] = i;
461 }
462 loop {
463 f(&subset);
464 // Increment
465 let mut i = k;
466 loop {
467 if i == 0 {
468 return;
469 }
470 i -= 1;
471 if subset[i] < n - k + i {
472 subset[i] += 1;
473 for j in (i + 1)..k {
474 subset[j] = subset[j - 1] + 1;
475 }
476 break;
477 }
478 }
479 }
480}
481
482/// Bron-Kerbosch algorithm for enumerating all maximal cliques.
483fn bron_kerbosch(
484 adj: &[Vec<usize>],
485 r: Vec<usize>,
486 mut p: Vec<usize>,
487 mut x: Vec<usize>,
488 result: &mut Vec<Vec<usize>>,
489) {
490 if p.is_empty() && x.is_empty() {
491 if !r.is_empty() {
492 result.push(r);
493 }
494 return;
495 }
496 // Choose pivot u ∈ P ∪ X that maximises |P ∩ N(u)|
497 let pivot = {
498 let all: Vec<usize> = p.iter().chain(x.iter()).copied().collect();
499 *all.iter()
500 .max_by_key(|&&u| p.iter().filter(|&&v| adj[u].contains(&v)).count())
501 .unwrap_or(&all[0])
502 };
503
504 let candidates: Vec<usize> = p
505 .iter()
506 .copied()
507 .filter(|&v| !adj[pivot].contains(&v))
508 .collect();
509
510 for v in candidates {
511 let mut r_new = r.clone();
512 r_new.push(v);
513 let p_new: Vec<usize> = p.iter().copied().filter(|&u| adj[v].contains(&u)).collect();
514 let x_new: Vec<usize> = x.iter().copied().filter(|&u| adj[v].contains(&u)).collect();
515 bron_kerbosch(adj, r_new, p_new, x_new, result);
516 p.retain(|&u| u != v);
517 x.push(v);
518 }
519}
520
521/// Compute the rank of an integer matrix over ℤ (by viewing entries as rationals).
522///
523/// We perform Gaussian elimination tracking exact integer fractions.
524/// The result is the column rank = row rank.
525fn matrix_rank_i8(mat: &Array2<i8>) -> usize {
526 let (rows, cols) = (mat.nrows(), mat.ncols());
527 if rows == 0 || cols == 0 {
528 return 0;
529 }
530 // Convert to i64 for elimination
531 let mut m: Vec<Vec<i64>> = (0..rows)
532 .map(|i| (0..cols).map(|j| mat[[i, j]] as i64).collect())
533 .collect();
534
535 let mut rank = 0usize;
536 let mut pivot_row = 0usize;
537 for col in 0..cols {
538 // Find pivot
539 let pivot = (pivot_row..rows).find(|&r| m[r][col] != 0);
540 if let Some(p) = pivot {
541 m.swap(pivot_row, p);
542 // Eliminate all other rows
543 let piv = m[pivot_row][col];
544 for r in 0..rows {
545 if r == pivot_row {
546 continue;
547 }
548 let factor = m[r][col];
549 if factor == 0 {
550 continue;
551 }
552 for c in 0..cols {
553 m[r][c] = m[r][c] * piv - factor * m[pivot_row][c];
554 }
555 // Divide by gcd to prevent explosion
556 let g = row_gcd(&m[r]);
557 if g > 1 {
558 for c in 0..cols {
559 m[r][c] /= g;
560 }
561 }
562 }
563 pivot_row += 1;
564 rank += 1;
565 }
566 }
567 rank
568}
569
570fn row_gcd(row: &[i64]) -> i64 {
571 row.iter()
572 .filter(|&&x| x != 0)
573 .map(|x| x.unsigned_abs())
574 .fold(0u64, gcd) as i64
575}
576
577fn gcd(a: u64, b: u64) -> u64 {
578 if b == 0 {
579 a
580 } else {
581 gcd(b, a % b)
582 }
583}
584
585// ============================================================================
586// Tests
587// ============================================================================
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use approx::assert_relative_eq;
593
594 #[test]
595 fn test_add_simplex_closure() {
596 let mut sc = SimplicialComplex::new();
597 sc.add_simplex(vec![0, 1, 2]);
598 // Should have 0-simplices: {0},{1},{2}; 1-simplices: {0,1},{0,2},{1,2}; 2-simplex {0,1,2}
599 assert_eq!(sc.num_simplices(0), 3);
600 assert_eq!(sc.num_simplices(1), 3);
601 assert_eq!(sc.num_simplices(2), 1);
602 }
603
604 #[test]
605 fn test_euler_characteristic_triangle_surface() {
606 // A hollow triangle (just the boundary): V=3, E=3 → χ = 3-3 = 0
607 let mut sc = SimplicialComplex::new();
608 sc.add_simplex(vec![0, 1]);
609 sc.add_simplex(vec![1, 2]);
610 sc.add_simplex(vec![0, 2]);
611 assert_eq!(sc.euler_characteristic(), 0);
612 }
613
614 #[test]
615 fn test_euler_characteristic_filled_triangle() {
616 // Filled triangle: V=3, E=3, F=1 → χ = 3-3+1 = 1
617 let mut sc = SimplicialComplex::new();
618 sc.add_simplex(vec![0, 1, 2]);
619 assert_eq!(sc.euler_characteristic(), 1);
620 }
621
622 #[test]
623 fn test_betti_numbers_point() {
624 // Single point: β_0 = 1
625 let mut sc = SimplicialComplex::new();
626 sc.add_simplex(vec![0]);
627 let b = sc.betti_numbers();
628 assert_eq!(b[0], 1);
629 }
630
631 #[test]
632 fn test_betti_numbers_edge() {
633 // Edge (filled): one connected component, no loops → β_0=1, β_1=0
634 let mut sc = SimplicialComplex::new();
635 sc.add_simplex(vec![0, 1]);
636 let b = sc.betti_numbers();
637 assert_eq!(b[0], 1);
638 assert_eq!(b.get(1).copied().unwrap_or(0), 0);
639 }
640
641 #[test]
642 fn test_betti_numbers_hollow_triangle() {
643 // Hollow triangle: β_0=1 (connected), β_1=1 (one loop)
644 let mut sc = SimplicialComplex::new();
645 sc.add_simplex(vec![0, 1]);
646 sc.add_simplex(vec![1, 2]);
647 sc.add_simplex(vec![0, 2]);
648 let b = sc.betti_numbers();
649 assert_eq!(b[0], 1);
650 assert_eq!(b.get(1).copied().unwrap_or(0), 1);
651 }
652
653 #[test]
654 fn test_betti_numbers_filled_triangle() {
655 // Filled triangle: β_0=1, β_1=0
656 let mut sc = SimplicialComplex::new();
657 sc.add_simplex(vec![0, 1, 2]);
658 let b = sc.betti_numbers();
659 assert_eq!(b[0], 1);
660 assert_eq!(b.get(1).copied().unwrap_or(0), 0);
661 }
662
663 #[test]
664 fn test_betti_two_components() {
665 // Two disjoint points: β_0 = 2
666 let mut sc = SimplicialComplex::new();
667 sc.add_simplex(vec![0]);
668 sc.add_simplex(vec![1]);
669 let b = sc.betti_numbers();
670 assert_eq!(b[0], 2);
671 }
672
673 #[test]
674 fn test_boundary_matrix_dim0() {
675 let mut sc = SimplicialComplex::new();
676 sc.add_simplex(vec![0]);
677 let mat = sc.boundary_matrix(0);
678 // Boundary of 0-chains is trivially 0
679 assert_eq!(mat.nrows(), 1);
680 }
681
682 #[test]
683 fn test_boundary_matrix_dim1() {
684 // Edge {0,1}: ∂_1({0,1}) = {1} - {0}
685 let mut sc = SimplicialComplex::new();
686 sc.add_simplex(vec![0, 1]);
687 let mat = sc.boundary_matrix(1);
688 assert_eq!(mat.nrows(), 2); // two 0-simplices
689 assert_eq!(mat.ncols(), 1); // one 1-simplex
690 // One entry should be +1 and one -1
691 let entries: Vec<i8> = vec![mat[[0, 0]], mat[[1, 0]]];
692 assert!(entries.contains(&1));
693 assert!(entries.contains(&-1));
694 }
695
696 #[test]
697 fn test_vietoris_rips_collinear() {
698 use scirs2_core::ndarray::array;
699 // Three collinear points at distances 1,1 → VR(ε=1.5) should give 2 edges
700 let pts = array![[0.0_f64, 0.0], [1.0, 0.0], [2.0, 0.0]];
701 let sc = SimplicialComplex::vietoris_rips(&pts, 1.5);
702 // Expect edges {0,1} and {1,2} but not {0,2} (distance 2 > 1.5)
703 assert_eq!(sc.num_simplices(1), 2);
704 assert_eq!(sc.num_simplices(2), 0);
705 }
706
707 #[test]
708 fn test_vietoris_rips_triangle() {
709 use scirs2_core::ndarray::array;
710 // Equilateral triangle with side 1 → VR(ε=1) = hollow triangle
711 let pts = array![[0.0_f64, 0.0], [1.0, 0.0], [0.5, 0.866_025_403_784_438_6]];
712 let sc = SimplicialComplex::vietoris_rips(&pts, 1.0001);
713 // All three edges present; filled (clique complex → add 2-simplex)
714 assert_eq!(sc.num_simplices(0), 3);
715 assert_eq!(sc.num_simplices(1), 3);
716 assert_eq!(sc.num_simplices(2), 1);
717 }
718
719 #[test]
720 fn test_nerve_complex_basic() {
721 // U_0={0,1}, U_1={1,2}, U_2={2,3}
722 // U_0 ∩ U_1 = {1} ≠ ∅ → edge {0,1}
723 // U_1 ∩ U_2 = {2} ≠ ∅ → edge {1,2}
724 // U_0 ∩ U_2 = ∅ → no edge {0,2}
725 let cover = vec![vec![0, 1], vec![1, 2], vec![2, 3]];
726 let sc = SimplicialComplex::nerve_complex(&cover);
727 assert_eq!(sc.num_simplices(0), 3);
728 assert_eq!(sc.num_simplices(1), 2);
729 assert_eq!(sc.num_simplices(2), 0);
730 }
731
732 #[test]
733 fn test_nerve_triple_overlap() {
734 // All three sets share node 0 → 2-simplex
735 let cover = vec![vec![0, 1], vec![0, 2], vec![0, 3]];
736 let sc = SimplicialComplex::nerve_complex(&cover);
737 assert_eq!(sc.num_simplices(2), 1);
738 }
739
740 #[test]
741 fn test_cech_complex_two_points() {
742 use scirs2_core::ndarray::array;
743 let pts = array![[0.0_f64], [1.0]];
744 let sc = SimplicialComplex::cech_complex(&pts, 0.6); // miniball radius = 0.5 ≤ 0.6
745 assert_eq!(sc.num_simplices(1), 1);
746 }
747
748 #[test]
749 fn test_cech_complex_too_small_radius() {
750 use scirs2_core::ndarray::array;
751 let pts = array![[0.0_f64], [1.0]];
752 let sc = SimplicialComplex::cech_complex(&pts, 0.4); // miniball radius = 0.5 > 0.4
753 assert_eq!(sc.num_simplices(1), 0);
754 }
755}