Skip to main content

scirs2_graph/hypergraph/
core.rs

1//! Core hypergraph data structures and expansions.
2//!
3//! Provides two complementary representations:
4//!
5//! * [`IndexedHypergraph`] – usize-indexed, weight-carrying structure used by
6//!   spectral and algorithmic routines (the original workhorse).
7//! * [`Hypergraph`] – generic `<N, E>` structure whose nodes carry arbitrary
8//!   data and whose hyperedges carry metadata of type `E`.
9//!
10//! Both expose clique expansion, star expansion, and bipartite representation.
11
12use crate::base::Hypergraph as BaseHypergraph;
13use crate::base::{EdgeWeight, Graph, IndexType, Node};
14use crate::error::{GraphError, Result};
15use scirs2_core::ndarray::Array2;
16use scirs2_core::random::{Rng, RngExt, SeedableRng};
17use std::collections::{HashMap, HashSet};
18
19// ============================================================================
20// Hyperedge (used by IndexedHypergraph)
21// ============================================================================
22
23/// A hyperedge in an indexed hypergraph.
24///
25/// Nodes within a hyperedge are stored in **sorted order** so that equality
26/// checks and set operations are efficient.
27#[derive(Debug, Clone, PartialEq)]
28pub struct Hyperedge {
29    /// Unique identifier (sequential, assigned at insertion time)
30    pub id: usize,
31    /// Nodes belonging to this hyperedge (sorted, deduplicated)
32    pub nodes: Vec<usize>,
33    /// Non-negative weight (default 1.0)
34    pub weight: f64,
35}
36
37impl Hyperedge {
38    /// Create a new hyperedge with automatic deduplication and sorting.
39    pub fn new(id: usize, mut nodes: Vec<usize>, weight: f64) -> Self {
40        nodes.sort_unstable();
41        nodes.dedup();
42        Hyperedge { id, nodes, weight }
43    }
44
45    /// Cardinality (number of nodes in this hyperedge).
46    pub fn size(&self) -> usize {
47        self.nodes.len()
48    }
49
50    /// Check whether `node` is a member of this hyperedge.
51    pub fn contains(&self, node: usize) -> bool {
52        self.nodes.binary_search(&node).is_ok()
53    }
54
55    /// Number of nodes shared with another hyperedge.
56    pub fn intersection_size(&self, other: &Hyperedge) -> usize {
57        let mut i = 0;
58        let mut j = 0;
59        let mut count = 0;
60        while i < self.nodes.len() && j < other.nodes.len() {
61            match self.nodes[i].cmp(&other.nodes[j]) {
62                std::cmp::Ordering::Less => i += 1,
63                std::cmp::Ordering::Greater => j += 1,
64                std::cmp::Ordering::Equal => {
65                    count += 1;
66                    i += 1;
67                    j += 1;
68                }
69            }
70        }
71        count
72    }
73}
74
75// ============================================================================
76// IndexedHypergraph – usize-indexed, algorithm-friendly
77// ============================================================================
78
79/// A hypergraph with usize-indexed nodes and rich algorithmic support.
80///
81/// ## Example
82/// ```
83/// use scirs2_graph::hypergraph::{IndexedHypergraph, clique_expansion};
84///
85/// let mut hg = IndexedHypergraph::new(5);
86/// hg.add_hyperedge(vec![0, 1, 2], 1.0).unwrap();
87/// hg.add_hyperedge(vec![2, 3, 4], 1.0).unwrap();
88///
89/// let g = clique_expansion(&hg);
90/// assert!(g.edge_count() >= 3);
91/// ```
92#[derive(Debug, Clone)]
93pub struct IndexedHypergraph {
94    /// Total number of nodes (fixed at construction time)
95    n_nodes: usize,
96    /// All hyperedges in insertion order
97    hyperedges: Vec<Hyperedge>,
98    /// Inverted index: node → list of hyperedge ids containing that node
99    node_to_hyperedges: Vec<Vec<usize>>,
100}
101
102impl IndexedHypergraph {
103    /// Create an empty hypergraph with `n_nodes` nodes indexed `0..n_nodes`.
104    pub fn new(n_nodes: usize) -> Self {
105        IndexedHypergraph {
106            n_nodes,
107            hyperedges: Vec::new(),
108            node_to_hyperedges: vec![Vec::new(); n_nodes],
109        }
110    }
111
112    /// Number of nodes (constant after construction).
113    pub fn n_nodes(&self) -> usize {
114        self.n_nodes
115    }
116
117    /// Number of hyperedges.
118    pub fn n_hyperedges(&self) -> usize {
119        self.hyperedges.len()
120    }
121
122    /// Immutable reference to all hyperedges.
123    pub fn hyperedges(&self) -> &[Hyperedge] {
124        &self.hyperedges
125    }
126
127    /// Add a new hyperedge.  Nodes that exceed `n_nodes` are rejected.
128    ///
129    /// Returns the newly assigned hyperedge id.
130    pub fn add_hyperedge(&mut self, nodes: Vec<usize>, weight: f64) -> Result<usize> {
131        if weight < 0.0 {
132            return Err(GraphError::InvalidGraph(
133                "hyperedge weight must be non-negative".to_string(),
134            ));
135        }
136        for &n in &nodes {
137            if n >= self.n_nodes {
138                return Err(GraphError::InvalidGraph(format!(
139                    "node {n} out of range (n_nodes = {})",
140                    self.n_nodes
141                )));
142            }
143        }
144        let id = self.hyperedges.len();
145        let he = Hyperedge::new(id, nodes, weight);
146        for &n in &he.nodes {
147            self.node_to_hyperedges[n].push(id);
148        }
149        self.hyperedges.push(he);
150        Ok(id)
151    }
152
153    /// Return all hyperedge ids that contain `node`.
154    pub fn hyperedges_of_node(&self, node: usize) -> Option<&[usize]> {
155        if node < self.n_nodes {
156            Some(&self.node_to_hyperedges[node])
157        } else {
158            None
159        }
160    }
161
162    /// Degree of a node = number of hyperedges it belongs to (unweighted).
163    pub fn degree(&self, node: usize) -> usize {
164        self.node_to_hyperedges
165            .get(node)
166            .map(|v| v.len())
167            .unwrap_or(0)
168    }
169
170    /// Weighted degree of a node = sum of weights of incident hyperedges.
171    pub fn weighted_degree(&self, node: usize) -> f64 {
172        self.node_to_hyperedges
173            .get(node)
174            .map(|ids| ids.iter().map(|&id| self.hyperedges[id].weight).sum())
175            .unwrap_or(0.0)
176    }
177
178    /// Compute the **node–hyperedge incidence matrix** B of shape `(n_nodes × n_hyperedges)`.
179    ///
180    /// `B[i, e] = sqrt(w_e)` when node `i` is in hyperedge `e`, else `0`.
181    pub fn incidence_matrix(&self) -> Array2<f64> {
182        let m = self.n_nodes;
183        let e = self.hyperedges.len();
184        let mut b = Array2::<f64>::zeros((m, e));
185        for (eid, he) in self.hyperedges.iter().enumerate() {
186            let sw = he.weight.sqrt();
187            for &n in &he.nodes {
188                b[[n, eid]] = sw;
189            }
190        }
191        b
192    }
193
194    /// Compute the raw (unweighted) incidence matrix B where `B[i,e] ∈ {0,1}`.
195    pub fn incidence_matrix_binary(&self) -> Array2<f64> {
196        let m = self.n_nodes;
197        let e = self.hyperedges.len();
198        let mut b = Array2::<f64>::zeros((m, e));
199        for (eid, he) in self.hyperedges.iter().enumerate() {
200            for &n in &he.nodes {
201                b[[n, eid]] = 1.0;
202            }
203        }
204        b
205    }
206
207    /// Build the **star expansion** of this hypergraph.
208    ///
209    /// Each hyperedge `e` is replaced by a new auxiliary node (indexed
210    /// `n_nodes + e`).  The auxiliary node is connected to every member of
211    /// `e` with weight `w_e`.  The resulting graph has `n_nodes + n_hyperedges`
212    /// nodes.
213    pub fn star_expansion(&self) -> Graph<usize, f64> {
214        let total = self.n_nodes + self.hyperedges.len();
215        let mut g: Graph<usize, f64> = Graph::new();
216        for i in 0..total {
217            g.add_node(i);
218        }
219        for (eid, he) in self.hyperedges.iter().enumerate() {
220            let aux = self.n_nodes + eid;
221            for &n in &he.nodes {
222                let _ = g.add_edge(n, aux, he.weight);
223            }
224        }
225        g
226    }
227
228    /// Build the **bipartite representation** of this hypergraph.
229    ///
230    /// Returns a hypergraph where the left partition contains original
231    /// nodes (indices 0..n_nodes) and the right partition contains hyperedge
232    /// auxiliary nodes (indices n_nodes..n_nodes+n_hyperedges).  Each
233    /// 2-element hyperedge {original_node, aux_hyperedge_node} encodes a
234    /// membership relation.
235    pub fn bipartite_representation(&self) -> BaseHypergraph<usize, f64> {
236        let mut bg: BaseHypergraph<usize, f64> = BaseHypergraph::new();
237        // Add original nodes
238        for i in 0..self.n_nodes {
239            bg.add_node(i);
240        }
241        // Add hyperedge auxiliary nodes and membership hyperedges
242        for (eid, he) in self.hyperedges.iter().enumerate() {
243            let he_node = self.n_nodes + eid;
244            bg.add_node(he_node);
245            for &n in &he.nodes {
246                let _ = bg.add_hyperedge_from_vec(vec![n, he_node], he.weight);
247            }
248        }
249        bg
250    }
251}
252
253// ============================================================================
254// Generic Hypergraph<N, E>
255// ============================================================================
256
257/// Generic hypergraph whose nodes carry data of type `N` and hyperedges carry
258/// metadata of type `E`.
259///
260/// Node and hyperedge indices are plain `usize` values assigned at insertion
261/// time (monotonically increasing).
262///
263/// ## Example
264/// ```
265/// use scirs2_graph::hypergraph::Hypergraph;
266///
267/// let mut hg: Hypergraph<&str, &str> = Hypergraph::new();
268/// let a = hg.add_node("Alice");
269/// let b = hg.add_node("Bob");
270/// let c = hg.add_node("Carol");
271/// let e = hg.add_hyperedge("team", vec![a, b, c]);
272/// assert_eq!(hg.node_degree(a), 1);
273/// assert_eq!(hg.incident_edges(b), vec![e]);
274/// ```
275#[derive(Debug, Clone)]
276pub struct Hypergraph<N, E> {
277    /// Node data store (index = node id)
278    nodes: Vec<N>,
279    /// Hyperedge store: (metadata, sorted node indices)
280    hyperedges: Vec<(E, Vec<usize>)>,
281    /// Inverted index: node id → hyperedge ids
282    node_to_edges: Vec<Vec<usize>>,
283}
284
285impl<N: Clone, E: Clone> Default for Hypergraph<N, E> {
286    fn default() -> Self {
287        Self::new()
288    }
289}
290
291impl<N: Clone, E: Clone> Hypergraph<N, E> {
292    /// Create an empty generic hypergraph.
293    pub fn new() -> Self {
294        Hypergraph {
295            nodes: Vec::new(),
296            hyperedges: Vec::new(),
297            node_to_edges: Vec::new(),
298        }
299    }
300
301    /// Add a node and return its index.
302    pub fn add_node(&mut self, data: N) -> usize {
303        let id = self.nodes.len();
304        self.nodes.push(data);
305        self.node_to_edges.push(Vec::new());
306        id
307    }
308
309    /// Add a hyperedge and return its index.
310    ///
311    /// Duplicate nodes within `nodes` are silently deduplicated; order is
312    /// normalised to sorted.  Panics if any node index is out of range (use
313    /// [`try_add_hyperedge`] for the fallible version).
314    pub fn add_hyperedge(&mut self, data: E, mut nodes: Vec<usize>) -> usize {
315        nodes.sort_unstable();
316        nodes.dedup();
317        let id = self.hyperedges.len();
318        for &n in &nodes {
319            if n < self.node_to_edges.len() {
320                self.node_to_edges[n].push(id);
321            }
322        }
323        self.hyperedges.push((data, nodes));
324        id
325    }
326
327    /// Fallible version of [`add_hyperedge`] that returns an error when a node
328    /// index is out of range.
329    pub fn try_add_hyperedge(&mut self, data: E, mut nodes: Vec<usize>) -> Result<usize> {
330        nodes.sort_unstable();
331        nodes.dedup();
332        for &n in &nodes {
333            if n >= self.nodes.len() {
334                return Err(GraphError::InvalidGraph(format!(
335                    "node index {n} out of range (n_nodes = {})",
336                    self.nodes.len()
337                )));
338            }
339        }
340        let id = self.hyperedges.len();
341        for &n in &nodes {
342            self.node_to_edges[n].push(id);
343        }
344        self.hyperedges.push((data, nodes));
345        Ok(id)
346    }
347
348    /// Return the ids of all hyperedges incident to `node`.
349    pub fn incident_edges(&self, node: usize) -> Vec<usize> {
350        self.node_to_edges.get(node).cloned().unwrap_or_default()
351    }
352
353    /// Return the degree (number of incident hyperedges) of a node.
354    pub fn node_degree(&self, node: usize) -> usize {
355        self.node_to_edges.get(node).map(|v| v.len()).unwrap_or(0)
356    }
357
358    /// Number of nodes.
359    pub fn node_count(&self) -> usize {
360        self.nodes.len()
361    }
362
363    /// Number of hyperedges.
364    pub fn hyperedge_count(&self) -> usize {
365        self.hyperedges.len()
366    }
367
368    /// Access node data by index.
369    pub fn node_data(&self, id: usize) -> Option<&N> {
370        self.nodes.get(id)
371    }
372
373    /// Access hyperedge data and members by index.
374    pub fn hyperedge_data(&self, id: usize) -> Option<(&E, &[usize])> {
375        self.hyperedges.get(id).map(|(e, ns)| (e, ns.as_slice()))
376    }
377
378    /// Return the member node indices of hyperedge `id`.
379    pub fn hyperedge_nodes(&self, id: usize) -> Option<&[usize]> {
380        self.hyperedges.get(id).map(|(_, ns)| ns.as_slice())
381    }
382}
383
384impl<N: Clone + std::fmt::Debug + std::hash::Hash + Eq + Ord, E: Clone> Hypergraph<N, E> {
385    /// Build the **clique expansion** (2-section) of this hypergraph as a
386    /// `Graph<usize, f64>` where nodes are identified by their index.
387    ///
388    /// Edge weight between `u` and `v` accumulates across all hyperedges that
389    /// contain both.
390    pub fn clique_expansion_indexed(&self) -> Graph<usize, f64> {
391        let mut g: Graph<usize, f64> = Graph::new();
392        for i in 0..self.nodes.len() {
393            g.add_node(i);
394        }
395        let mut weights: HashMap<(usize, usize), f64> = HashMap::new();
396        for (_, nodes) in &self.hyperedges {
397            let k = nodes.len();
398            if k < 2 {
399                continue;
400            }
401            let contrib = 1.0 / (k - 1) as f64;
402            for i in 0..k {
403                for j in (i + 1)..k {
404                    *weights.entry((nodes[i], nodes[j])).or_insert(0.0) += contrib;
405                }
406            }
407        }
408        for ((u, v), w) in weights {
409            let _ = g.add_edge(u, v, w);
410        }
411        g
412    }
413}
414
415// ============================================================================
416// clique_expansion (free function operating on IndexedHypergraph)
417// ============================================================================
418
419/// Build the **2-section graph** (clique expansion) of an [`IndexedHypergraph`].
420///
421/// For every hyperedge `e = {v_1, …, v_k, weight w_e}`, add a clique edge
422/// `(v_i, v_j)` with weight `w_e / (k - 1)`.  When multiple hyperedges
423/// connect the same pair their contributions are summed.
424pub fn clique_expansion(hg: &IndexedHypergraph) -> Graph<usize, f64> {
425    let mut graph: Graph<usize, f64> = Graph::new();
426    for i in 0..hg.n_nodes() {
427        graph.add_node(i);
428    }
429    let mut edge_weights: HashMap<(usize, usize), f64> = HashMap::new();
430    for he in &hg.hyperedges {
431        let k = he.nodes.len();
432        if k < 2 {
433            continue;
434        }
435        let contrib = he.weight / (k - 1) as f64;
436        for i in 0..k {
437            for j in (i + 1)..k {
438                let key = (he.nodes[i], he.nodes[j]);
439                *edge_weights.entry(key).or_insert(0.0) += contrib;
440            }
441        }
442    }
443    for ((u, v), w) in edge_weights {
444        let _ = graph.add_edge(u, v, w);
445    }
446    graph
447}
448
449// ============================================================================
450// line_graph
451// ============================================================================
452
453/// Build the **line graph** of an [`IndexedHypergraph`].
454///
455/// Each hyperedge becomes a node.  Two hyperedge-nodes are connected iff their
456/// corresponding hyperedges share at least one node; the edge weight equals the
457/// number of shared nodes.
458pub fn line_graph(hg: &IndexedHypergraph) -> Graph<usize, f64> {
459    let e = hg.n_hyperedges();
460    let mut graph: Graph<usize, f64> = Graph::new();
461    for i in 0..e {
462        graph.add_node(i);
463    }
464    for i in 0..e {
465        for j in (i + 1)..e {
466            let shared = hg.hyperedges[i].intersection_size(&hg.hyperedges[j]);
467            if shared > 0 {
468                let _ = graph.add_edge(i, j, shared as f64);
469            }
470        }
471    }
472    graph
473}
474
475// ============================================================================
476// hypergraph_random_walk
477// ============================================================================
478
479/// Perform a random walk on an [`IndexedHypergraph`].
480///
481/// At each step the walker at node `v`:
482/// 1. Selects a hyperedge `e` containing `v` with probability ∝ `w_e / deg_w(v)`.
483/// 2. Moves uniformly to one of the other nodes in `e` (stays if singleton).
484///
485/// Returns the visited node sequence of length `n_steps + 1`.
486pub fn hypergraph_random_walk<R: Rng>(
487    hg: &IndexedHypergraph,
488    start: usize,
489    n_steps: usize,
490    rng: &mut R,
491) -> Result<Vec<usize>> {
492    if hg.n_nodes() == 0 {
493        return Err(GraphError::InvalidGraph(
494            "hypergraph has no nodes".to_string(),
495        ));
496    }
497    if start >= hg.n_nodes() {
498        return Err(GraphError::InvalidGraph(format!(
499            "start node {start} >= n_nodes {}",
500            hg.n_nodes()
501        )));
502    }
503    let mut path = Vec::with_capacity(n_steps + 1);
504    let mut current = start;
505    path.push(current);
506
507    for _ in 0..n_steps {
508        let ids = match hg.hyperedges_of_node(current) {
509            Some(ids) if !ids.is_empty() => ids,
510            _ => {
511                path.push(current);
512                continue;
513            }
514        };
515        let total_w: f64 = ids.iter().map(|&id| hg.hyperedges[id].weight).sum();
516        let threshold = rng.random::<f64>() * total_w;
517        let mut accum = 0.0;
518        // Safety: ids is non-empty so last() always returns Some
519        let mut chosen_id = ids[ids.len() - 1];
520        for &id in ids {
521            accum += hg.hyperedges[id].weight;
522            if accum >= threshold {
523                chosen_id = id;
524                break;
525            }
526        }
527        let he = &hg.hyperedges[chosen_id];
528        let candidates: Vec<usize> = he.nodes.iter().copied().filter(|&n| n != current).collect();
529        if candidates.is_empty() {
530            path.push(current);
531        } else {
532            let idx = rng.random_range(0..candidates.len());
533            current = candidates[idx];
534            path.push(current);
535        }
536    }
537    Ok(path)
538}
539
540/// Convenience wrapper: run [`hypergraph_random_walk`] with a seeded RNG.
541pub fn hypergraph_random_walk_seeded(
542    hg: &IndexedHypergraph,
543    start: usize,
544    n_steps: usize,
545    seed: u64,
546) -> Result<Vec<usize>> {
547    use scirs2_core::random::ChaCha20Rng;
548    let mut rng = ChaCha20Rng::seed_from_u64(seed);
549    hypergraph_random_walk(hg, start, n_steps, &mut rng)
550}
551
552// ============================================================================
553// hyperedge_centrality
554// ============================================================================
555
556/// Compute **hyperedge centrality** via power iteration on `B^T D_v^{-1} B D_e^{-1}`.
557///
558/// Returns a vector of length `n_hyperedges` normalised to sum to 1.
559pub fn hyperedge_centrality(hg: &IndexedHypergraph) -> Vec<f64> {
560    let m = hg.n_nodes();
561    let e = hg.n_hyperedges();
562    if e == 0 || m == 0 {
563        return Vec::new();
564    }
565    let dv: Vec<f64> = (0..m).map(|i| hg.weighted_degree(i)).collect();
566    let de: Vec<f64> = hg.hyperedges.iter().map(|h| h.nodes.len() as f64).collect();
567
568    let mut c = vec![1.0 / e as f64; e];
569    let max_iter = 1000;
570    let tol = 1e-9;
571
572    for _ in 0..max_iter {
573        let x: Vec<f64> = c
574            .iter()
575            .enumerate()
576            .map(|(eid, &cv)| if de[eid] > 0.0 { cv / de[eid] } else { 0.0 })
577            .collect();
578        let mut y = vec![0.0f64; m];
579        for (eid, he) in hg.hyperedges.iter().enumerate() {
580            let sw = he.weight.sqrt();
581            for &n in &he.nodes {
582                y[n] += sw * x[eid];
583            }
584        }
585        let z: Vec<f64> = y
586            .iter()
587            .enumerate()
588            .map(|(i, &yi)| if dv[i] > 0.0 { yi / dv[i] } else { 0.0 })
589            .collect();
590        let mut c_new = vec![0.0f64; e];
591        for (eid, he) in hg.hyperedges.iter().enumerate() {
592            let sw = he.weight.sqrt();
593            for &n in &he.nodes {
594                c_new[eid] += sw * z[n];
595            }
596        }
597        let norm: f64 = c_new.iter().map(|v| v * v).sum::<f64>().sqrt();
598        if norm == 0.0 {
599            return vec![0.0; e];
600        }
601        for v in &mut c_new {
602            *v /= norm;
603        }
604        let diff: f64 = c_new.iter().zip(c.iter()).map(|(a, b)| (a - b).abs()).sum();
605        c = c_new;
606        if diff < tol {
607            break;
608        }
609    }
610
611    let total: f64 = c.iter().map(|v| v.abs()).sum();
612    if total > 0.0 {
613        c.iter().map(|v| v.abs() / total).collect()
614    } else {
615        vec![0.0; e]
616    }
617}
618
619// ============================================================================
620// hypergraph_clustering_coefficient
621// ============================================================================
622
623/// Compute the **local clustering coefficient** of a node in an [`IndexedHypergraph`].
624///
625/// Defined as the fraction of neighbour pairs (in the 2-section) that are also
626/// connected.  Returns `0.0` for isolated nodes or nodes with fewer than 2 neighbours.
627pub fn hypergraph_clustering_coefficient(node: usize, hg: &IndexedHypergraph) -> f64 {
628    if node >= hg.n_nodes() {
629        return 0.0;
630    }
631    let mut neighbour_set: HashSet<usize> = HashSet::new();
632    if let Some(ids) = hg.hyperedges_of_node(node) {
633        for &eid in ids {
634            for &n in &hg.hyperedges[eid].nodes {
635                if n != node {
636                    neighbour_set.insert(n);
637                }
638            }
639        }
640    }
641    let neighbours: Vec<usize> = neighbour_set.into_iter().collect();
642    let k = neighbours.len();
643    if k < 2 {
644        return 0.0;
645    }
646    let mut connected_pairs: usize = 0;
647    let total_pairs = k * (k - 1) / 2;
648    for i in 0..k {
649        for j in (i + 1)..k {
650            let u = neighbours[i];
651            let v = neighbours[j];
652            let u_edges: HashSet<usize> = hg
653                .hyperedges_of_node(u)
654                .unwrap_or(&[])
655                .iter()
656                .copied()
657                .collect();
658            let v_edges = hg.hyperedges_of_node(v).unwrap_or(&[]);
659            if v_edges.iter().any(|id| u_edges.contains(id)) {
660                connected_pairs += 1;
661            }
662        }
663    }
664    connected_pairs as f64 / total_pairs as f64
665}
666
667// ============================================================================
668// Tests
669// ============================================================================
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use approx::assert_relative_eq;
675
676    fn triangle_hg() -> IndexedHypergraph {
677        let mut hg = IndexedHypergraph::new(3);
678        hg.add_hyperedge(vec![0, 1], 1.0).expect("add ok");
679        hg.add_hyperedge(vec![1, 2], 1.0).expect("add ok");
680        hg.add_hyperedge(vec![0, 2], 1.0).expect("add ok");
681        hg
682    }
683
684    #[test]
685    fn test_hyperedge_dedup() {
686        let he = Hyperedge::new(0, vec![3, 1, 2, 1], 1.0);
687        assert_eq!(he.nodes, vec![1, 2, 3]);
688    }
689
690    #[test]
691    fn test_hyperedge_contains() {
692        let he = Hyperedge::new(0, vec![0, 2, 4], 1.0);
693        assert!(he.contains(0));
694        assert!(!he.contains(1));
695    }
696
697    #[test]
698    fn test_intersection_size() {
699        let a = Hyperedge::new(0, vec![0, 1, 2], 1.0);
700        let b = Hyperedge::new(1, vec![1, 2, 3], 1.0);
701        assert_eq!(a.intersection_size(&b), 2);
702    }
703
704    #[test]
705    fn test_add_hyperedge_invalid_node() {
706        let mut hg = IndexedHypergraph::new(3);
707        assert!(hg.add_hyperedge(vec![0, 5], 1.0).is_err());
708    }
709
710    #[test]
711    fn test_add_hyperedge_negative_weight() {
712        let mut hg = IndexedHypergraph::new(3);
713        assert!(hg.add_hyperedge(vec![0, 1], -1.0).is_err());
714    }
715
716    #[test]
717    fn test_degree_and_weighted_degree() {
718        let mut hg = IndexedHypergraph::new(3);
719        hg.add_hyperedge(vec![0, 1], 2.0).expect("ok");
720        hg.add_hyperedge(vec![0, 2], 3.0).expect("ok");
721        assert_eq!(hg.degree(0), 2);
722        assert_relative_eq!(hg.weighted_degree(0), 5.0, epsilon = 1e-10);
723    }
724
725    #[test]
726    fn test_incidence_matrix_shape() {
727        let hg = triangle_hg();
728        assert_eq!(hg.incidence_matrix().shape(), &[3, 3]);
729    }
730
731    #[test]
732    fn test_clique_expansion_triangle() {
733        let hg = triangle_hg();
734        let g = clique_expansion(&hg);
735        assert_eq!(g.node_count(), 3);
736        assert_eq!(g.edge_count(), 3);
737    }
738
739    #[test]
740    fn test_star_expansion_node_count() {
741        let hg = triangle_hg(); // 3 nodes + 3 hyperedges
742        let g = hg.star_expansion();
743        assert_eq!(g.node_count(), 6);
744        // Each 2-node hyperedge contributes 2 edges → 6 total
745        assert_eq!(g.edge_count(), 6);
746    }
747
748    #[test]
749    fn test_bipartite_representation() {
750        let mut hg = IndexedHypergraph::new(3);
751        hg.add_hyperedge(vec![0, 1], 1.0).expect("ok");
752        let bg = hg.bipartite_representation();
753        // 3 original nodes + 1 hyperedge aux node = 4
754        assert_eq!(bg.node_count(), 4);
755    }
756
757    #[test]
758    fn test_generic_hypergraph_degree() {
759        let mut hg: Hypergraph<&str, &str> = Hypergraph::new();
760        let a = hg.add_node("a");
761        let b = hg.add_node("b");
762        let c = hg.add_node("c");
763        hg.add_hyperedge("e1", vec![a, b, c]);
764        hg.add_hyperedge("e2", vec![a, b]);
765        assert_eq!(hg.node_degree(a), 2);
766        assert_eq!(hg.node_degree(c), 1);
767    }
768
769    #[test]
770    fn test_generic_try_add_hyperedge_oob() {
771        let mut hg: Hypergraph<i32, i32> = Hypergraph::new();
772        hg.add_node(0);
773        assert!(hg.try_add_hyperedge(99, vec![0, 5]).is_err());
774    }
775
776    #[test]
777    fn test_line_graph_triangle() {
778        let hg = triangle_hg();
779        let lg = line_graph(&hg);
780        assert_eq!(lg.node_count(), 3);
781        assert_eq!(lg.edge_count(), 3);
782    }
783
784    #[test]
785    fn test_random_walk_length() {
786        let hg = triangle_hg();
787        let path = hypergraph_random_walk_seeded(&hg, 0, 10, 99).expect("ok");
788        assert_eq!(path.len(), 11);
789    }
790
791    #[test]
792    fn test_hyperedge_centrality_sums_to_one() {
793        let hg = triangle_hg();
794        let c = hyperedge_centrality(&hg);
795        let sum: f64 = c.iter().sum();
796        assert_relative_eq!(sum, 1.0, epsilon = 1e-6);
797    }
798
799    #[test]
800    fn test_clustering_coeff_full_triangle() {
801        let hg = triangle_hg();
802        assert_relative_eq!(
803            hypergraph_clustering_coefficient(0, &hg),
804            1.0,
805            epsilon = 1e-10
806        );
807    }
808}