Skip to main content

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}