Skip to main content

scirs2_graph/hypergraph/
higher_order.rs

1//! Higher-order network analysis.
2//!
3//! Implements:
4//! * **Motif tensors** – third-order adjacency tensors capturing 3-node motifs.
5//! * **Topological features** – Betti numbers from a simplicial complex bridge.
6//! * **Cellular sheaves** – basic sheaf theory on graphs (restriction maps,
7//!   coboundary operator, Hodge Laplacian).
8//!
9//! # References
10//! - Benson et al., "Three hypergraph eigenvector centralities", SIAM J. Math.
11//!   Data Sci. 2019.
12//! - Hansen & Ghrist, "Toward a spectral theory of cellular sheaves", J. Applied
13//!   and Computational Topology, 2019.
14//! - Battiston et al., "Networks beyond pairwise interactions", Physics Reports
15//!   2020.
16
17use super::simplicial::SimplicialComplex;
18use crate::base::Graph;
19use crate::error::{GraphError, Result};
20use scirs2_core::ndarray::{Array2, Array3};
21use std::collections::HashMap;
22
23// ============================================================================
24// Motif tensors
25// ============================================================================
26
27/// A third-order motif tensor T[i,j,k] encoding 3-node interaction patterns.
28///
29/// For an undirected graph, entry T[i,j,k] = 1 iff the induced subgraph on
30/// {i,j,k} forms a **triangle** (all three edges present).
31///
32/// For directed/weighted variants, use [`directed_motif_tensor`].
33pub struct MotifTensor {
34    /// Shape: (n, n, n) where n = number of nodes
35    pub data: Array3<f64>,
36    /// Number of nodes
37    pub n: usize,
38}
39
40impl MotifTensor {
41    /// Build the **triangle motif tensor** from a graph adjacency matrix.
42    ///
43    /// T[i,j,k] = A[i,j] * A[j,k] * A[i,k]  (all three edges present).
44    ///
45    /// For a 0/1 adjacency matrix this is exactly 1 for triangles and 0
46    /// otherwise.  For weighted graphs the entry equals the product of the
47    /// three edge weights.
48    ///
49    /// # Arguments
50    /// * `adj` – symmetric adjacency matrix (shape n × n)
51    pub fn from_adjacency(adj: &Array2<f64>) -> Self {
52        let n = adj.nrows();
53        let mut data = Array3::<f64>::zeros((n, n, n));
54        for i in 0..n {
55            for j in 0..n {
56                if adj[[i, j]] == 0.0 {
57                    continue;
58                }
59                for k in 0..n {
60                    if i == j || j == k || i == k {
61                        continue;
62                    }
63                    let val = adj[[i, j]] * adj[[j, k]] * adj[[i, k]];
64                    if val != 0.0 {
65                        data[[i, j, k]] = val;
66                    }
67                }
68            }
69        }
70        MotifTensor { data, n }
71    }
72
73    /// Count the total number of directed triangles encoded in the tensor.
74    ///
75    /// Each undirected triangle {i,j,k} contributes 6 directed entries
76    /// (all permutations), so divide by 6 to get the undirected count.
77    pub fn triangle_count(&self) -> f64 {
78        self.data.iter().sum::<f64>() / 6.0
79    }
80
81    /// Compute the **tensor-vector product** T ×_1 x ×_2 x:
82    ///
83    /// (T x^2)[i] = Σ_{j,k} T[i,j,k] x[j] x[k]
84    ///
85    /// This is the "mode-1 multilinear product" used in tensor eigenvector
86    /// centrality.
87    pub fn tensor_vec_product(&self, x: &[f64]) -> Vec<f64> {
88        let n = self.n;
89        let mut result = vec![0.0f64; n];
90        for i in 0..n {
91            for j in 0..n {
92                for k in 0..n {
93                    result[i] += self.data[[i, j, k]] * x[j] * x[k];
94                }
95            }
96        }
97        result
98    }
99
100    /// Compute the **Z-eigenvector centrality** of the motif tensor using the
101    /// shifted power method.
102    ///
103    /// Returns `(eigenvalue, eigenvector)` where the eigenvector satisfies
104    /// T x^2 = λ x.  All entries of the returned vector are non-negative.
105    ///
106    /// # Errors
107    /// Returns `GraphError::InvalidGraph` if n = 0.
108    pub fn z_eigenvector_centrality(&self) -> Result<(f64, Vec<f64>)> {
109        let n = self.n;
110        if n == 0 {
111            return Err(GraphError::InvalidGraph("empty motif tensor".to_string()));
112        }
113
114        // Initialise uniformly
115        let mut x = vec![1.0 / (n as f64).sqrt(); n];
116        let max_iter = 2000;
117        let tol = 1e-9;
118
119        for _ in 0..max_iter {
120            let mut tx2 = self.tensor_vec_product(&x);
121            // Compute Rayleigh quotient λ = x^T (T x^2)
122            let lambda: f64 = tx2.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
123            // Project to non-negative orthant
124            for v in &mut tx2 {
125                if *v < 0.0 {
126                    *v = 0.0;
127                }
128            }
129            let norm: f64 = tx2.iter().map(|v| v * v).sum::<f64>().sqrt();
130            if norm < 1e-15 {
131                return Ok((0.0, vec![0.0; n]));
132            }
133            let x_new: Vec<f64> = tx2.iter().map(|v| v / norm).collect();
134            let diff: f64 = x_new.iter().zip(x.iter()).map(|(a, b)| (a - b).abs()).sum();
135            x = x_new;
136            if diff < tol {
137                return Ok((lambda, x));
138            }
139        }
140        let lambda: f64 = {
141            let tx2 = self.tensor_vec_product(&x);
142            tx2.iter().zip(x.iter()).map(|(a, b)| a * b).sum()
143        };
144        Ok((lambda, x))
145    }
146}
147
148/// Build a **directed motif tensor** that distinguishes different 3-node motif
149/// types (feed-forward, cycle, etc.) in a directed graph.
150///
151/// The function encodes motif type as integer codes in the returned `Array3<u8>`:
152/// * 0 – no directed edges
153/// * 1 – cycle (i→j, j→k, k→i)
154/// * 2 – feed-forward chain (i→j, j→k)
155/// * 3 – other configurations
156///
157/// # Arguments
158/// * `adj_directed` – (possibly asymmetric) adjacency matrix (n × n)
159pub fn directed_motif_tensor(adj: &Array2<f64>) -> Array3<u8> {
160    let n = adj.nrows();
161    let mut tensor = Array3::<u8>::zeros((n, n, n));
162    for i in 0..n {
163        for j in 0..n {
164            if i == j || adj[[i, j]] == 0.0 {
165                continue;
166            }
167            for k in 0..n {
168                if k == i || k == j {
169                    continue;
170                }
171                if adj[[j, k]] > 0.0 {
172                    if adj[[k, i]] > 0.0 {
173                        // Directed 3-cycle: i→j→k→i
174                        tensor[[i, j, k]] = 1;
175                    } else {
176                        // Feed-forward: i→j→k (no back edge k→i)
177                        tensor[[i, j, k]] = 2;
178                    }
179                } else if adj[[i, k]] > 0.0 || adj[[k, j]] > 0.0 {
180                    tensor[[i, j, k]] = 3;
181                }
182            }
183        }
184    }
185    tensor
186}
187
188// ============================================================================
189// Topological features bridge
190// ============================================================================
191
192/// Topological feature vector derived from a [`SimplicialComplex`].
193#[derive(Debug, Clone)]
194pub struct TopologicalFeatures {
195    /// Betti numbers β_0, β_1, …
196    pub betti_numbers: Vec<usize>,
197    /// Euler characteristic χ
198    pub euler_characteristic: i64,
199    /// Number of simplices per dimension
200    pub simplex_counts: Vec<usize>,
201    /// Maximum dimension
202    pub max_dim: usize,
203}
204
205impl TopologicalFeatures {
206    /// Extract topological features from a [`SimplicialComplex`].
207    pub fn from_complex(sc: &SimplicialComplex) -> Self {
208        let max_dim = sc.max_dim().unwrap_or(0);
209        let betti_numbers = sc.betti_numbers();
210        let euler_characteristic = sc.euler_characteristic();
211        let simplex_counts = (0..=max_dim).map(|d| sc.num_simplices(d)).collect();
212        TopologicalFeatures {
213            betti_numbers,
214            euler_characteristic,
215            simplex_counts,
216            max_dim,
217        }
218    }
219
220    /// Compute a simple **topological signature** vector of fixed length `len`.
221    ///
222    /// The signature concatenates (betti_numbers padded/truncated to `len/2`)
223    /// and (simplex_counts padded/truncated to `len/2`), normalised to unit
224    /// L2 norm.
225    pub fn signature(&self, len: usize) -> Vec<f64> {
226        let half = len / 2;
227        let mut sig = Vec::with_capacity(len);
228
229        // First half: Betti numbers
230        for i in 0..half {
231            sig.push(self.betti_numbers.get(i).copied().unwrap_or(0) as f64);
232        }
233        // Second half: simplex counts
234        for i in 0..(len - half) {
235            sig.push(self.simplex_counts.get(i).copied().unwrap_or(0) as f64);
236        }
237
238        // Normalise
239        let norm: f64 = sig.iter().map(|x| x * x).sum::<f64>().sqrt();
240        if norm > 0.0 {
241            for v in &mut sig {
242                *v /= norm;
243            }
244        }
245        sig
246    }
247}
248
249// ============================================================================
250// Cellular sheaves (basic implementation)
251// ============================================================================
252
253/// A **cellular sheaf** on an undirected graph.
254///
255/// A sheaf assigns:
256/// * A vector space `ℝ^{d_v}` to each vertex `v` (vertex stalk).
257/// * A vector space `ℝ^{d_e}` to each edge `e` (edge stalk).
258/// * **Restriction maps** `F_{v ◁ e} : ℝ^{d_e} → ℝ^{d_v}` for each
259///   vertex-edge incidence.
260///
261/// The **coboundary operator** δ : C^0 → C^1 and the resulting **Hodge-0
262/// sheaf Laplacian** L = δ^T δ capture the sheaf's cohomology.
263///
264/// Reference: Hansen & Ghrist (2019).
265#[derive(Debug, Clone)]
266pub struct CellularSheaf {
267    /// Number of nodes
268    pub n_nodes: usize,
269    /// Number of edges (oriented pairs (u,v) with u < v)
270    pub n_edges: usize,
271    /// Stalks at nodes: node_stalks[v] = dimension of the stalk at v
272    pub node_stalks: Vec<usize>,
273    /// Stalks at edges: edge_stalks[e] = dimension of the stalk at edge e
274    pub edge_stalks: Vec<usize>,
275    /// Oriented edge list: edges[e] = (tail, head) with tail < head
276    pub edges: Vec<(usize, usize)>,
277    /// Restriction maps: maps[(v, e)] = the matrix F_{v◁e} : ℝ^{d_e} → ℝ^{d_v}
278    /// stored row-major as Vec<Vec<f64>> of shape (d_v, d_e)
279    pub restriction_maps: HashMap<(usize, usize), Vec<Vec<f64>>>,
280}
281
282impl CellularSheaf {
283    /// Create a trivial sheaf on a graph where all stalks have dimension 1.
284    ///
285    /// The restriction maps are initialised to the identity (or ±1 depending
286    /// on orientation).
287    ///
288    /// # Arguments
289    /// * `n_nodes` – number of nodes
290    /// * `edges`   – oriented edge list `(tail, head)`, both < n_nodes
291    pub fn trivial(n_nodes: usize, edges: Vec<(usize, usize)>) -> Result<Self> {
292        for &(u, v) in &edges {
293            if u >= n_nodes || v >= n_nodes {
294                return Err(GraphError::InvalidGraph(format!(
295                    "edge ({u},{v}) references node ≥ n_nodes={n_nodes}"
296                )));
297            }
298        }
299        let n_edges = edges.len();
300        let node_stalks = vec![1; n_nodes];
301        let edge_stalks = vec![1; n_edges];
302        let mut restriction_maps: HashMap<(usize, usize), Vec<Vec<f64>>> = HashMap::new();
303        for (eid, &(u, v)) in edges.iter().enumerate() {
304            // F_{v ◁ e} = [[1]] for all incidences; the sign comes from the
305            // coboundary formula δ(x)_e = F_{head◁e} x_{head} - F_{tail◁e} x_{tail}.
306            restriction_maps.insert((u, eid), vec![vec![1.0]]);
307            restriction_maps.insert((v, eid), vec![vec![1.0]]);
308        }
309        Ok(CellularSheaf {
310            n_nodes,
311            n_edges,
312            node_stalks,
313            edge_stalks,
314            edges,
315            restriction_maps,
316        })
317    }
318
319    /// Create a sheaf with user-specified stalk dimensions and restriction maps.
320    ///
321    /// `node_dim` and `edge_dim` give the stalk dimensions.
322    /// `maps` is a map from `(node_id, edge_id)` to a matrix of shape
323    /// `(node_dim, edge_dim)`.
324    pub fn new(
325        n_nodes: usize,
326        node_dim: usize,
327        edges: Vec<(usize, usize)>,
328        edge_dim: usize,
329        maps: HashMap<(usize, usize), Vec<Vec<f64>>>,
330    ) -> Result<Self> {
331        let n_edges = edges.len();
332        for &(u, v) in &edges {
333            if u >= n_nodes || v >= n_nodes {
334                return Err(GraphError::InvalidGraph(format!(
335                    "edge ({u},{v}) out of range"
336                )));
337            }
338        }
339        Ok(CellularSheaf {
340            n_nodes,
341            n_edges,
342            node_stalks: vec![node_dim; n_nodes],
343            edge_stalks: vec![edge_dim; n_edges],
344            edges,
345            restriction_maps: maps,
346        })
347    }
348
349    /// Set the restriction map for vertex `v` and edge `e`.
350    ///
351    /// The matrix `map` should have shape `(stalk_dim[v], edge_stalk[e])`.
352    pub fn set_restriction(&mut self, v: usize, e: usize, map: Vec<Vec<f64>>) -> Result<()> {
353        if v >= self.n_nodes {
354            return Err(GraphError::InvalidGraph(format!(
355                "vertex {v} >= n_nodes {}",
356                self.n_nodes
357            )));
358        }
359        if e >= self.n_edges {
360            return Err(GraphError::InvalidGraph(format!(
361                "edge {e} >= n_edges {}",
362                self.n_edges
363            )));
364        }
365        self.restriction_maps.insert((v, e), map);
366        Ok(())
367    }
368
369    /// Compute the **coboundary matrix** δ : C^0 → C^1.
370    ///
371    /// The full coboundary operator has shape
372    /// `(sum_e d_e) × (sum_v d_v)`.
373    ///
374    /// For a section x = (x_v)_{v}, the coboundary δ(x) at edge e = (u,v) is:
375    /// `δ(x)_e = F_{v◁e} x_v − F_{u◁e} x_u`
376    ///
377    /// Returns the matrix as `Array2<f64>`.
378    pub fn coboundary_operator(&self) -> Array2<f64> {
379        let total_node = self.node_stalks.iter().sum::<usize>();
380        let total_edge = self.edge_stalks.iter().sum::<usize>();
381
382        if total_node == 0 || total_edge == 0 {
383            return Array2::zeros((total_edge.max(1), total_node.max(1)));
384        }
385
386        // Compute offset arrays
387        let mut node_offset = vec![0usize; self.n_nodes + 1];
388        for i in 0..self.n_nodes {
389            node_offset[i + 1] = node_offset[i] + self.node_stalks[i];
390        }
391        let mut edge_offset = vec![0usize; self.n_edges + 1];
392        for i in 0..self.n_edges {
393            edge_offset[i + 1] = edge_offset[i] + self.edge_stalks[i];
394        }
395
396        let mut delta = Array2::<f64>::zeros((total_edge, total_node));
397
398        for (eid, &(u, v)) in self.edges.iter().enumerate() {
399            let e_rows = self.edge_stalks[eid];
400            let e_off = edge_offset[eid];
401
402            // δ(x)_e = F_{head◁e} x_{head} - F_{tail◁e} x_{tail}
403            // (standard orientation: positive end = head = v, negative end = tail = u)
404            if let Some(fu) = self.restriction_maps.get(&(u, eid)) {
405                let u_off = node_offset[u];
406                let u_cols = self.node_stalks[u];
407                // Tail contributes negatively
408                for r in 0..e_rows.min(fu.len()) {
409                    for c in 0..u_cols.min(fu[r].len()) {
410                        delta[[e_off + r, u_off + c]] -= fu[r][c];
411                    }
412                }
413            }
414            // Head contributes positively
415            if let Some(fv) = self.restriction_maps.get(&(v, eid)) {
416                let v_off = node_offset[v];
417                let v_cols = self.node_stalks[v];
418                for r in 0..e_rows.min(fv.len()) {
419                    for c in 0..v_cols.min(fv[r].len()) {
420                        delta[[e_off + r, v_off + c]] += fv[r][c];
421                    }
422                }
423            }
424        }
425        delta
426    }
427
428    /// Compute the **Hodge-0 sheaf Laplacian** L_0 = δ^T δ.
429    ///
430    /// Shape: `(sum_v d_v) × (sum_v d_v)`.
431    pub fn hodge_laplacian_0(&self) -> Array2<f64> {
432        let delta = self.coboundary_operator();
433        let rows = delta.nrows();
434        let cols = delta.ncols();
435        // L = delta^T * delta
436        let mut l = Array2::<f64>::zeros((cols, cols));
437        for i in 0..cols {
438            for j in 0..cols {
439                let mut val = 0.0f64;
440                for r in 0..rows {
441                    val += delta[[r, i]] * delta[[r, j]];
442                }
443                l[[i, j]] = val;
444            }
445        }
446        l
447    }
448
449    /// Compute the dimension of the **0th sheaf cohomology** H^0(G; F).
450    ///
451    /// dim H^0 = dim ker(δ) = total_node_dim − rank(δ).
452    ///
453    /// A dimension of 1 corresponds to a "consistent" global section (like a
454    /// constant function on a constant sheaf).
455    pub fn cohomology_h0(&self) -> usize {
456        let delta = self.coboundary_operator();
457        let total_node: usize = self.node_stalks.iter().sum();
458        let rank = matrix_rank_f64(&delta);
459        total_node.saturating_sub(rank)
460    }
461
462    /// Compute the dimension of the **1st sheaf cohomology** H^1(G; F).
463    ///
464    /// dim H^1 = dim ker(δ^T) restricted to the image complement
465    ///          = total_edge_dim − rank(δ).
466    pub fn cohomology_h1(&self) -> usize {
467        let delta = self.coboundary_operator();
468        let total_edge: usize = self.edge_stalks.iter().sum();
469        let rank = matrix_rank_f64(&delta);
470        total_edge.saturating_sub(rank)
471    }
472}
473
474/// Build a trivial sheaf from an existing graph (uses usize-indexed nodes).
475///
476/// This is a convenience constructor for the common case of constant stalks.
477pub fn trivial_sheaf_from_graph(n: usize, edge_list: &[(usize, usize)]) -> Result<CellularSheaf> {
478    let mut oriented: Vec<(usize, usize)> = edge_list
479        .iter()
480        .map(|&(u, v)| (u.min(v), u.max(v)))
481        .collect();
482    oriented.sort_unstable();
483    oriented.dedup();
484    CellularSheaf::trivial(n, oriented)
485}
486
487// ============================================================================
488// Helpers
489// ============================================================================
490
491/// Gaussian elimination rank of a real matrix (with floating-point pivoting).
492fn matrix_rank_f64(mat: &Array2<f64>) -> usize {
493    let (rows, cols) = (mat.nrows(), mat.ncols());
494    if rows == 0 || cols == 0 {
495        return 0;
496    }
497    let mut m: Vec<Vec<f64>> = (0..rows)
498        .map(|i| (0..cols).map(|j| mat[[i, j]]).collect())
499        .collect();
500
501    let tol = 1e-10;
502    let mut rank = 0usize;
503    let mut pivot_row = 0usize;
504    for col in 0..cols {
505        // Find max pivot
506        let (best, best_val) = (pivot_row..rows).fold((pivot_row, 0.0f64), |(bi, bv), r| {
507            let v = m[r][col].abs();
508            if v > bv {
509                (r, v)
510            } else {
511                (bi, bv)
512            }
513        });
514        if best_val < tol {
515            continue;
516        }
517        m.swap(pivot_row, best);
518        let piv = m[pivot_row][col];
519        for c in 0..cols {
520            m[pivot_row][c] /= piv;
521        }
522        for r in 0..rows {
523            if r == pivot_row {
524                continue;
525            }
526            let factor = m[r][col];
527            if factor.abs() < tol {
528                continue;
529            }
530            for c in 0..cols {
531                let sub = factor * m[pivot_row][c];
532                m[r][c] -= sub;
533            }
534        }
535        pivot_row += 1;
536        rank += 1;
537    }
538    rank
539}
540
541// ============================================================================
542// Tests
543// ============================================================================
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use approx::assert_relative_eq;
549    use scirs2_core::ndarray::array;
550
551    // ----- MotifTensor -------------------------------------------------------
552
553    #[test]
554    fn test_motif_tensor_triangle_count() {
555        // Complete graph K3: 1 triangle
556        let adj = array![[0.0_f64, 1., 1.], [1., 0., 1.], [1., 1., 0.]];
557        let mt = MotifTensor::from_adjacency(&adj);
558        assert_relative_eq!(mt.triangle_count(), 1.0, epsilon = 1e-10);
559    }
560
561    #[test]
562    fn test_motif_tensor_no_triangle() {
563        // Path P3: no triangle
564        let adj = array![[0.0_f64, 1., 0.], [1., 0., 1.], [0., 1., 0.]];
565        let mt = MotifTensor::from_adjacency(&adj);
566        assert_relative_eq!(mt.triangle_count(), 0.0, epsilon = 1e-10);
567    }
568
569    #[test]
570    fn test_z_eigenvector_centrality_k3() {
571        let adj = array![[0.0_f64, 1., 1.], [1., 0., 1.], [1., 1., 0.]];
572        let mt = MotifTensor::from_adjacency(&adj);
573        let (lambda, x) = mt.z_eigenvector_centrality().expect("ok");
574        assert!(lambda >= 0.0);
575        // Symmetric graph → uniform eigenvector
576        for &xi in &x {
577            assert!(xi >= 0.0);
578        }
579        let norm: f64 = x.iter().map(|v| v * v).sum::<f64>().sqrt();
580        assert_relative_eq!(norm, 1.0, epsilon = 1e-6);
581    }
582
583    #[test]
584    fn test_directed_motif_tensor_cycle() {
585        // Directed 3-cycle: 0→1→2→0
586        let mut adj = Array2::<f64>::zeros((3, 3));
587        adj[[0, 1]] = 1.0;
588        adj[[1, 2]] = 1.0;
589        adj[[2, 0]] = 1.0;
590        let mt = directed_motif_tensor(&adj);
591        assert_eq!(mt[[0, 1, 2]], 1); // cycle motif
592    }
593
594    // ----- TopologicalFeatures -----------------------------------------------
595
596    #[test]
597    fn test_topological_features_triangle() {
598        let mut sc = SimplicialComplex::new();
599        sc.add_simplex(vec![0, 1, 2]);
600        let tf = TopologicalFeatures::from_complex(&sc);
601        assert_eq!(tf.betti_numbers[0], 1);
602        assert_eq!(tf.euler_characteristic, 1); // 3-3+1=1
603    }
604
605    #[test]
606    fn test_signature_length() {
607        let mut sc = SimplicialComplex::new();
608        sc.add_simplex(vec![0, 1]);
609        let tf = TopologicalFeatures::from_complex(&sc);
610        let sig = tf.signature(8);
611        assert_eq!(sig.len(), 8);
612    }
613
614    // ----- CellularSheaf -----------------------------------------------------
615
616    #[test]
617    fn test_trivial_sheaf_path() {
618        // Path 0-1-2 as a trivial sheaf with constant stalks ℝ^1
619        let sheaf = CellularSheaf::trivial(3, vec![(0, 1), (1, 2)]).expect("ok");
620        let delta = sheaf.coboundary_operator();
621        // delta should be 2×3
622        assert_eq!(delta.nrows(), 2);
623        assert_eq!(delta.ncols(), 3);
624    }
625
626    #[test]
627    fn test_trivial_sheaf_h0_path() {
628        // Path graph: H^0 = 1 (connected)
629        let sheaf = CellularSheaf::trivial(3, vec![(0, 1), (1, 2)]).expect("ok");
630        assert_eq!(sheaf.cohomology_h0(), 1);
631    }
632
633    #[test]
634    fn test_trivial_sheaf_h0_two_components() {
635        // Two isolated edges: H^0 = 2
636        let sheaf = CellularSheaf::trivial(4, vec![(0, 1), (2, 3)]).expect("ok");
637        assert_eq!(sheaf.cohomology_h0(), 2);
638    }
639
640    #[test]
641    fn test_trivial_sheaf_h1_triangle() {
642        // Triangle with constant sheaf: H^1 = 1 (one loop)
643        let sheaf = CellularSheaf::trivial(3, vec![(0, 1), (0, 2), (1, 2)]).expect("ok");
644        assert_eq!(sheaf.cohomology_h1(), 1);
645    }
646
647    #[test]
648    fn test_hodge_laplacian_symmetric() {
649        let sheaf = CellularSheaf::trivial(3, vec![(0, 1), (1, 2)]).expect("ok");
650        let l = sheaf.hodge_laplacian_0();
651        // Symmetric
652        for i in 0..l.nrows() {
653            for j in 0..l.ncols() {
654                assert_relative_eq!(l[[i, j]], l[[j, i]], epsilon = 1e-10);
655            }
656        }
657    }
658
659    #[test]
660    fn test_trivial_sheaf_from_graph() {
661        let sheaf = trivial_sheaf_from_graph(4, &[(0, 1), (1, 2), (2, 3)]).expect("ok");
662        assert_eq!(sheaf.n_nodes, 4);
663        assert_eq!(sheaf.n_edges, 3);
664    }
665
666    #[test]
667    fn test_sheaf_invalid_node() {
668        let err = CellularSheaf::trivial(3, vec![(0, 5)]);
669        assert!(err.is_err());
670    }
671}