Skip to main content

scirs2_graph/
network_statistics.rs

1//! Network-level statistics and structural measures
2//!
3//! This module provides global network statistics that characterise the overall
4//! topology of a graph.  The functions operate on unweighted undirected graphs
5//! represented by `Graph<usize, f64>`, using **BFS distances** for all path-
6//! length metrics.
7//!
8//! ## Available statistics
9//!
10//! | Function | Description |
11//! |---|---|
12//! | [`eccentricity`] | Per-node eccentricity vector (BFS) |
13//! | [`diameter`] | Maximum eccentricity |
14//! | [`radius`] | Minimum eccentricity |
15//! | [`periphery`] | Nodes at maximum eccentricity |
16//! | [`center`] | Nodes at minimum eccentricity |
17//! | [`average_path_length`] | Mean shortest-path distance |
18//! | [`global_efficiency`] | Mean 1/d(i,j) over all pairs |
19//! | [`local_efficiency`] | Efficiency of node neighbourhood |
20//! | [`small_world_coefficient`] | σ = (C/C_rand)/(L/L_rand) |
21//! | [`scale_free_exponent`] | Power-law degree exponent via MLE |
22
23use crate::base::{EdgeWeight, Graph, IndexType, Node};
24use crate::error::{GraphError, Result};
25use scirs2_core::random::prelude::*;
26use std::collections::{HashMap, VecDeque};
27
28// ─────────────────────────────────────────────────────────────────────────────
29// Internal: BFS distance map from a single source
30// ─────────────────────────────────────────────────────────────────────────────
31
32/// Compute BFS distances from `source` to all reachable nodes.
33///
34/// Returns a `HashMap<N, usize>` where the value is the unweighted hop
35/// distance.  Nodes unreachable from `source` are absent from the map.
36fn bfs_distances<N, E, Ix>(graph: &Graph<N, E, Ix>, source: &N) -> HashMap<N, usize>
37where
38    N: Node + Clone + std::fmt::Debug,
39    E: EdgeWeight,
40    Ix: IndexType,
41{
42    let mut dist: HashMap<N, usize> = HashMap::new();
43    let mut queue: VecDeque<N> = VecDeque::new();
44
45    dist.insert(source.clone(), 0);
46    queue.push_back(source.clone());
47
48    while let Some(current) = queue.pop_front() {
49        let current_dist = *dist.get(&current).unwrap_or(&0);
50        let neighbors = match graph.neighbors(&current) {
51            Ok(nb) => nb,
52            Err(_) => continue,
53        };
54        for nb in neighbors {
55            if !dist.contains_key(&nb) {
56                dist.insert(nb.clone(), current_dist + 1);
57                queue.push_back(nb);
58            }
59        }
60    }
61    dist
62}
63
64// ─────────────────────────────────────────────────────────────────────────────
65// Eccentricity
66// ─────────────────────────────────────────────────────────────────────────────
67
68/// Compute the eccentricity of every node.
69///
70/// The *eccentricity* of node `u` is the maximum BFS distance from `u` to any
71/// other node in the graph.  For disconnected graphs the function returns
72/// `None` (an eccentricity-based diameter is only well-defined on connected
73/// graphs).
74///
75/// # Returns
76/// `Some(Vec<usize>)` — eccentricities indexed by the sorted node list, or
77/// `None` if the graph is disconnected or empty.
78pub fn eccentricity<N, E, Ix>(graph: &Graph<N, E, Ix>) -> Option<Vec<(N, usize)>>
79where
80    N: Node + Clone + std::fmt::Debug + Ord,
81    E: EdgeWeight,
82    Ix: IndexType,
83{
84    let mut nodes: Vec<N> = graph.nodes().into_iter().cloned().collect();
85    if nodes.is_empty() {
86        return Some(vec![]);
87    }
88    nodes.sort();
89    let n = nodes.len();
90
91    let mut result: Vec<(N, usize)> = Vec::with_capacity(n);
92
93    for u in &nodes {
94        let dist = bfs_distances(graph, u);
95        if dist.len() < n {
96            // Not all nodes reachable → disconnected
97            return None;
98        }
99        let ecc = dist.values().copied().max().unwrap_or(0);
100        result.push((u.clone(), ecc));
101    }
102    Some(result)
103}
104
105// ─────────────────────────────────────────────────────────────────────────────
106// Diameter
107// ─────────────────────────────────────────────────────────────────────────────
108
109/// Graph diameter: maximum eccentricity over all nodes (BFS).
110///
111/// Returns `None` for empty or disconnected graphs.
112///
113/// # Example
114/// ```rust
115/// use scirs2_graph::network_statistics::diameter;
116/// use scirs2_graph::generators::path_graph;
117/// let g = path_graph(5).unwrap();
118/// assert_eq!(diameter(&g), Some(4));
119/// ```
120pub fn diameter<N, E, Ix>(graph: &Graph<N, E, Ix>) -> Option<usize>
121where
122    N: Node + Clone + std::fmt::Debug + Ord,
123    E: EdgeWeight,
124    Ix: IndexType,
125{
126    eccentricity(graph).and_then(|eccs| eccs.into_iter().map(|(_, e)| e).max())
127}
128
129// ─────────────────────────────────────────────────────────────────────────────
130// Radius
131// ─────────────────────────────────────────────────────────────────────────────
132
133/// Graph radius: minimum eccentricity over all nodes (BFS).
134///
135/// Returns `None` for empty or disconnected graphs.
136///
137/// # Example
138/// ```rust
139/// use scirs2_graph::network_statistics::radius;
140/// use scirs2_graph::generators::cycle_graph;
141/// let g = cycle_graph(6).unwrap();
142/// assert_eq!(radius(&g), Some(3));
143/// ```
144pub fn radius<N, E, Ix>(graph: &Graph<N, E, Ix>) -> Option<usize>
145where
146    N: Node + Clone + std::fmt::Debug + Ord,
147    E: EdgeWeight,
148    Ix: IndexType,
149{
150    eccentricity(graph).and_then(|eccs| eccs.into_iter().map(|(_, e)| e).min())
151}
152
153// ─────────────────────────────────────────────────────────────────────────────
154// Periphery
155// ─────────────────────────────────────────────────────────────────────────────
156
157/// Graph periphery: set of nodes whose eccentricity equals the diameter.
158///
159/// Returns an empty vector for disconnected or empty graphs.
160pub fn periphery<N, E, Ix>(graph: &Graph<N, E, Ix>) -> Vec<N>
161where
162    N: Node + Clone + std::fmt::Debug + Ord,
163    E: EdgeWeight,
164    Ix: IndexType,
165{
166    let eccs = match eccentricity(graph) {
167        Some(e) if !e.is_empty() => e,
168        _ => return vec![],
169    };
170    let diam = eccs.iter().map(|(_, e)| *e).max().unwrap_or(0);
171    eccs.into_iter()
172        .filter_map(|(n, e)| if e == diam { Some(n) } else { None })
173        .collect()
174}
175
176// ─────────────────────────────────────────────────────────────────────────────
177// Center
178// ─────────────────────────────────────────────────────────────────────────────
179
180/// Graph center: set of nodes whose eccentricity equals the radius.
181///
182/// Returns an empty vector for disconnected or empty graphs.
183pub fn center<N, E, Ix>(graph: &Graph<N, E, Ix>) -> Vec<N>
184where
185    N: Node + Clone + std::fmt::Debug + Ord,
186    E: EdgeWeight,
187    Ix: IndexType,
188{
189    let eccs = match eccentricity(graph) {
190        Some(e) if !e.is_empty() => e,
191        _ => return vec![],
192    };
193    let rad = eccs.iter().map(|(_, e)| *e).min().unwrap_or(0);
194    eccs.into_iter()
195        .filter_map(|(n, e)| if e == rad { Some(n) } else { None })
196        .collect()
197}
198
199// ─────────────────────────────────────────────────────────────────────────────
200// Average path length
201// ─────────────────────────────────────────────────────────────────────────────
202
203/// Average shortest-path length of the graph.
204///
205/// Computed as the arithmetic mean of all pairwise BFS distances.  Returns
206/// `None` for empty or disconnected graphs.
207///
208/// # Example
209/// ```rust
210/// use scirs2_graph::network_statistics::average_path_length;
211/// use scirs2_graph::generators::complete_graph;
212/// let g = complete_graph(4).unwrap();
213/// // All paths in K_4 have length 1
214/// assert!((average_path_length(&g).unwrap() - 1.0).abs() < 1e-9);
215/// ```
216pub fn average_path_length<N, E, Ix>(graph: &Graph<N, E, Ix>) -> Option<f64>
217where
218    N: Node + Clone + std::fmt::Debug + Ord,
219    E: EdgeWeight,
220    Ix: IndexType,
221{
222    let nodes: Vec<N> = graph.nodes().into_iter().cloned().collect();
223    let n = nodes.len();
224    if n <= 1 {
225        return Some(0.0);
226    }
227
228    let mut total = 0u64;
229    let pairs = (n as u64) * (n as u64 - 1); // directed count; divide by 2 below
230
231    for u in &nodes {
232        let dist = bfs_distances(graph, u);
233        if dist.len() < n {
234            return None; // disconnected
235        }
236        for v in &nodes {
237            if u != v {
238                total += *dist.get(v).unwrap_or(&0) as u64;
239            }
240        }
241    }
242
243    Some(total as f64 / pairs as f64)
244}
245
246// ─────────────────────────────────────────────────────────────────────────────
247// Global efficiency
248// ─────────────────────────────────────────────────────────────────────────────
249
250/// Global efficiency of the graph.
251///
252/// Defined as the average of the reciprocals of shortest-path distances:
253///
254/// ```text
255/// E_glob = 1 / (n(n-1))  ·  Σ_{i≠j} 1/d(i,j)
256/// ```
257///
258/// For disconnected pairs d(i,j) = ∞, so 1/∞ = 0 — this is why global
259/// efficiency is a more robust metric than average path length for graphs
260/// that may be disconnected.  Returns `None` only for empty graphs.
261///
262/// # Example
263/// ```rust
264/// use scirs2_graph::network_statistics::global_efficiency;
265/// use scirs2_graph::generators::complete_graph;
266/// let g = complete_graph(5).unwrap();
267/// // All pairs at distance 1 → efficiency = 1.0
268/// assert!((global_efficiency(&g).unwrap() - 1.0).abs() < 1e-9);
269/// ```
270pub fn global_efficiency<N, E, Ix>(graph: &Graph<N, E, Ix>) -> Option<f64>
271where
272    N: Node + Clone + std::fmt::Debug + Ord,
273    E: EdgeWeight,
274    Ix: IndexType,
275{
276    let nodes: Vec<N> = graph.nodes().into_iter().cloned().collect();
277    let n = nodes.len();
278    if n <= 1 {
279        return Some(0.0);
280    }
281
282    let mut sum_inv = 0.0f64;
283    let pairs = (n * (n - 1)) as f64;
284
285    for u in &nodes {
286        let dist = bfs_distances(graph, u);
287        for v in &nodes {
288            if u != v {
289                if let Some(&d) = dist.get(v) {
290                    if d > 0 {
291                        sum_inv += 1.0 / d as f64;
292                    }
293                }
294                // unreachable: contributes 0
295            }
296        }
297    }
298
299    Some(sum_inv / pairs)
300}
301
302// ─────────────────────────────────────────────────────────────────────────────
303// Local efficiency
304// ─────────────────────────────────────────────────────────────────────────────
305
306/// Local efficiency of a single node.
307///
308/// The local efficiency of node `u` is the global efficiency of the induced
309/// subgraph on the neighbourhood of `u` (excluding `u` itself).  It
310/// quantifies how well information can be exchanged among `u`'s neighbours
311/// even if `u` were removed.
312///
313/// Returns `0.0` when the neighbourhood has fewer than 2 nodes (no pairs to
314/// measure).  Returns a `GraphError` if `node` is not in the graph.
315///
316/// # Reference
317/// Latora, V., & Marchiori, M. "Efficient behavior of small-world networks."
318/// Phys. Rev. Lett. 87(19), 198701, 2001.
319pub fn local_efficiency<N, E, Ix>(graph: &Graph<N, E, Ix>, node: &N) -> Result<f64>
320where
321    N: Node + Clone + std::fmt::Debug + Ord,
322    E: EdgeWeight,
323    Ix: IndexType,
324{
325    if !graph.has_node(node) {
326        return Err(GraphError::node_not_found(format!("{node:?}")));
327    }
328
329    let neighbors = graph.neighbors(node)?;
330    let k = neighbors.len();
331    if k < 2 {
332        return Ok(0.0);
333    }
334
335    // Build the induced subgraph on the neighbourhood set
336    let nb_set: std::collections::HashSet<N> = neighbors.iter().cloned().collect();
337    let mut sub: Graph<N, f64, u32> = Graph::new();
338    for nb in &neighbors {
339        sub.add_node(nb.clone());
340    }
341    for nb_u in &neighbors {
342        let nb_u_neighbors = graph.neighbors(nb_u).unwrap_or_default();
343        for nb_v in &nb_u_neighbors {
344            if nb_set.contains(nb_v) && nb_u < nb_v {
345                // Weight 1.0 for unweighted subgraph
346                let _ = sub.add_edge(nb_u.clone(), nb_v.clone(), 1.0f64);
347            }
348        }
349    }
350
351    // Global efficiency on the subgraph
352    let eff = global_efficiency(&sub).unwrap_or(0.0);
353    Ok(eff)
354}
355
356// ─────────────────────────────────────────────────────────────────────────────
357// Clustering coefficient helper
358// ─────────────────────────────────────────────────────────────────────────────
359
360/// Compute the average (global) clustering coefficient of a graph.
361///
362/// The clustering coefficient of node `u` is:
363/// ```text
364/// C(u) = 2 · T(u) / (k_u · (k_u − 1))
365/// ```
366/// where T(u) is the number of triangles through `u` and k_u is its degree.
367/// The network average is the mean of C(u) over all nodes with k_u ≥ 2.
368///
369/// Used internally by [`small_world_coefficient`].
370fn average_clustering<N, E, Ix>(graph: &Graph<N, E, Ix>) -> f64
371where
372    N: Node + Clone + std::fmt::Debug + Ord,
373    E: EdgeWeight,
374    Ix: IndexType,
375{
376    let nodes: Vec<N> = graph.nodes().into_iter().cloned().collect();
377    let mut total = 0.0f64;
378    let mut count = 0usize;
379
380    for u in &nodes {
381        let nb = graph.neighbors(u).unwrap_or_default();
382        let k = nb.len();
383        if k < 2 {
384            continue;
385        }
386        let nb_set: std::collections::HashSet<N> = nb.iter().cloned().collect();
387        let mut triangles = 0usize;
388        for i in 0..nb.len() {
389            for j in (i + 1)..nb.len() {
390                if graph.has_edge(&nb[i], &nb[j]) {
391                    triangles += 1;
392                }
393            }
394        }
395        let _ = nb_set; // suppress warning
396        let c_u = 2.0 * triangles as f64 / (k * (k - 1)) as f64;
397        total += c_u;
398        count += 1;
399    }
400
401    if count == 0 {
402        0.0
403    } else {
404        total / count as f64
405    }
406}
407
408// ─────────────────────────────────────────────────────────────────────────────
409// Small-world coefficient σ
410// ─────────────────────────────────────────────────────────────────────────────
411
412/// Compute the small-world coefficient σ for the graph.
413///
414/// The small-world coefficient (Humphries & Gurney, 2008) is:
415///
416/// ```text
417/// σ = (C / C_rand) / (L / L_rand)
418/// ```
419///
420/// where C and L are the clustering coefficient and average path length of
421/// the input graph, and C_rand, L_rand are the expected values of `n_random`
422/// Erdős–Rényi random graphs with the same number of nodes and edges.
423///
424/// σ > 1 indicates small-world structure.
425///
426/// # Arguments
427/// * `graph`    – undirected graph to characterise
428/// * `n_random` – number of random reference graphs to average over (≥ 1)
429/// * `rng`      – random-number generator
430///
431/// # Returns
432/// `Ok(sigma)` or a `GraphError` when the graph is too small / too sparse to
433/// compute meaningful path lengths.
434///
435/// # Reference
436/// Humphries, M. D., & Gurney, K. "Network 'small-world-ness': A quantitative
437/// method for determining canonical network equivalence." PLoS ONE, 3(4), 2008.
438pub fn small_world_coefficient<N, E, Ix, R>(
439    graph: &Graph<N, E, Ix>,
440    n_random: usize,
441    rng: &mut R,
442) -> Result<f64>
443where
444    N: Node + Clone + std::fmt::Debug + Ord,
445    E: EdgeWeight,
446    Ix: IndexType,
447    R: Rng,
448{
449    let n = graph.node_count();
450    let m = graph.edge_count();
451
452    if n < 3 {
453        return Err(GraphError::InvalidGraph(
454            "small_world_coefficient: graph must have ≥ 3 nodes".to_string(),
455        ));
456    }
457    if n_random == 0 {
458        return Err(GraphError::InvalidGraph(
459            "small_world_coefficient: n_random must be ≥ 1".to_string(),
460        ));
461    }
462
463    let c = average_clustering(graph);
464    let l = match average_path_length(graph) {
465        Some(v) if v > 0.0 => v,
466        _ => {
467            return Err(GraphError::InvalidGraph(
468                "small_world_coefficient: graph is disconnected or trivial".to_string(),
469            ))
470        }
471    };
472
473    // Compute reference C_rand and L_rand from Erdős–Rényi graphs
474    let max_edges = n * (n - 1) / 2;
475    if m > max_edges {
476        return Err(GraphError::InvalidGraph(
477            "small_world_coefficient: edge count exceeds maximum for simple graph".to_string(),
478        ));
479    }
480
481    let mut sum_c_rand = 0.0f64;
482    let mut sum_l_rand = 0.0f64;
483    let mut valid_samples = 0usize;
484
485    for _ in 0..n_random {
486        // Build G(n,m) random reference
487        let rg =
488            crate::generators::random_graphs::erdos_renyi_g_nm(n, m, rng).unwrap_or_else(|_| {
489                crate::generators::erdos_renyi_graph(n, m as f64 / (max_edges as f64).max(1.0), rng)
490                    .unwrap_or_default()
491            });
492
493        if let Some(l_r) = average_path_length(&rg) {
494            if l_r > 0.0 {
495                sum_c_rand += average_clustering(&rg);
496                sum_l_rand += l_r;
497                valid_samples += 1;
498            }
499        }
500    }
501
502    if valid_samples == 0 {
503        return Err(GraphError::ComputationError(
504            "small_world_coefficient: all random reference graphs were disconnected".to_string(),
505        ));
506    }
507
508    let c_rand = sum_c_rand / valid_samples as f64;
509    let l_rand = sum_l_rand / valid_samples as f64;
510
511    // Guard against division by zero
512    if c_rand <= 0.0 || l_rand <= 0.0 || l <= 0.0 {
513        return Err(GraphError::ComputationError(
514            "small_world_coefficient: reference graph has degenerate clustering or path length"
515                .to_string(),
516        ));
517    }
518
519    let sigma = (c / c_rand) / (l / l_rand);
520    Ok(sigma)
521}
522
523// ─────────────────────────────────────────────────────────────────────────────
524// Scale-free exponent (power-law MLE)
525// ─────────────────────────────────────────────────────────────────────────────
526
527/// Estimate the power-law exponent of a degree distribution.
528///
529/// Uses the maximum-likelihood estimator (MLE) for a discrete power law derived
530/// by Clauset, Shalizi & Newman (2009):
531///
532/// ```text
533/// gamma_hat = 1 + n · ( Σ_{i=1}^{n} ln(k_i / (k_min - 0.5)) )^{-1}
534/// ```
535///
536/// where k_min is the minimum degree included in the fit.  The estimate is
537/// valid for the **tail** of the distribution: degrees below `k_min` are
538/// excluded.  The function automatically selects k_min as the value that
539/// minimises the Kolmogorov–Smirnov distance between the empirical CDF and the
540/// fitted power law.
541///
542/// # Arguments
543/// * `degree_dist` – observed degree sequence (raw degrees, **not** counts);
544///   all values must be ≥ 1
545///
546/// # Returns
547/// `Some(gamma)` where gamma > 1 is the scale-free exponent, or `None` if the
548/// distribution has fewer than 2 distinct values, the sequence is empty, or
549/// the resulting exponent is not finite.
550///
551/// # Reference
552/// Clauset, A., Shalizi, C. R., & Newman, M. E. J. "Power-law distributions
553/// in empirical data." SIAM Review, 51(4), 661–703, 2009.
554pub fn scale_free_exponent(degree_dist: &[usize]) -> Option<f64> {
555    if degree_dist.is_empty() {
556        return None;
557    }
558
559    // Filter out degree-0 nodes
560    let degrees: Vec<usize> = degree_dist.iter().copied().filter(|&d| d >= 1).collect();
561    if degrees.is_empty() {
562        return None;
563    }
564
565    let mut sorted = degrees.clone();
566    sorted.sort_unstable();
567
568    let distinct: Vec<usize> = {
569        let mut v: Vec<usize> = sorted.to_vec();
570        v.dedup();
571        v
572    };
573    if distinct.len() < 2 {
574        return None;
575    }
576
577    // Candidate k_min values: all distinct degrees up to the largest-1
578    let candidates = &distinct[..distinct.len() - 1];
579
580    let mut best_ks_stat = f64::INFINITY;
581    let mut best_gamma: Option<f64> = None;
582
583    for &k_min in candidates {
584        let tail: Vec<usize> = sorted.iter().copied().filter(|&d| d >= k_min).collect();
585        let n_tail = tail.len();
586        if n_tail < 5 {
587            continue;
588        }
589
590        // MLE for discrete power law (Eq. 3.6 in Clauset et al.)
591        let ln_sum: f64 = tail
592            .iter()
593            .map(|&k| ((k as f64) / (k_min as f64 - 0.5)).ln())
594            .sum();
595        if ln_sum <= 0.0 {
596            continue;
597        }
598        let gamma = 1.0 + n_tail as f64 * (1.0 / ln_sum);
599        if !gamma.is_finite() || gamma <= 1.0 {
600            continue;
601        }
602
603        // KS statistic between empirical CDF and theoretical power-law CDF
604        let ks = ks_statistic_discrete(&tail, gamma, k_min);
605        if ks < best_ks_stat {
606            best_ks_stat = ks;
607            best_gamma = Some(gamma);
608        }
609    }
610
611    best_gamma
612}
613
614/// Kolmogorov–Smirnov statistic for a discrete power law fitted to `data`.
615///
616/// data must be sorted ascending and all values ≥ k_min.
617fn ks_statistic_discrete(data: &[usize], gamma: f64, k_min: usize) -> f64 {
618    let n = data.len();
619    if n == 0 {
620        return f64::INFINITY;
621    }
622
623    // Theoretical CDF: P(K ≤ k) ≈ 1 − (k / k_min)^{1−γ}  (Pareto approx.)
624    // For the discrete case we use the Hurwitz zeta approximation.
625    // For our purposes the continuous approximation is sufficient.
626    let theoretical_cdf = |k: usize| -> f64 {
627        if k < k_min {
628            return 0.0;
629        }
630        // P(K ≥ k_min) = 1 by definition; P(K ≤ k) = 1 − (k+1/k_min)^{1-gamma}
631        let ratio = (k as f64 + 0.5) / (k_min as f64 - 0.5);
632        (1.0 - ratio.powf(1.0 - gamma)).clamp(0.0, 1.0)
633    };
634
635    let mut max_diff = 0.0f64;
636    for (i, &k) in data.iter().enumerate() {
637        let emp_cdf = (i + 1) as f64 / n as f64;
638        let theo_cdf = theoretical_cdf(k);
639        let diff = (emp_cdf - theo_cdf).abs();
640        if diff > max_diff {
641            max_diff = diff;
642        }
643    }
644    max_diff
645}
646
647// ─────────────────────────────────────────────────────────────────────────────
648// Tests
649// ─────────────────────────────────────────────────────────────────────────────
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use crate::generators::random_graphs::erdos_renyi_g_np;
655    use crate::generators::{complete_graph, cycle_graph, path_graph, star_graph};
656    use scirs2_core::random::prelude::*;
657
658    // ── Diameter / Radius ────────────────────────────────────────────────────
659
660    #[test]
661    fn test_diameter_path() {
662        let g = path_graph(6).expect("path_graph failed");
663        assert_eq!(diameter(&g), Some(5));
664    }
665
666    #[test]
667    fn test_diameter_complete() {
668        let g = complete_graph(10).expect("complete_graph failed");
669        assert_eq!(diameter(&g), Some(1));
670    }
671
672    #[test]
673    fn test_radius_cycle() {
674        // For a cycle of even length n, radius = n/2
675        let g = cycle_graph(8).expect("cycle_graph failed");
676        assert_eq!(radius(&g), Some(4));
677    }
678
679    #[test]
680    fn test_radius_path() {
681        // Path P5: eccentricities are [4,3,2,3,4] → radius=2
682        let g = path_graph(5).expect("path_graph failed");
683        assert_eq!(radius(&g), Some(2));
684    }
685
686    // ── Center / Periphery ───────────────────────────────────────────────────
687
688    #[test]
689    fn test_center_star() {
690        // Star K_{1,4}: center is node 0 (eccentricity 1), leaves have ecc 2
691        let g = star_graph(5).expect("star_graph failed");
692        let c = center(&g);
693        assert!(!c.is_empty());
694        assert!(c.contains(&0));
695    }
696
697    #[test]
698    fn test_periphery_path() {
699        // P5 nodes 0 and 4 have max eccentricity 4
700        let g = path_graph(5).expect("path_graph failed");
701        let p = periphery(&g);
702        assert!(p.contains(&0));
703        assert!(p.contains(&4));
704        assert_eq!(p.len(), 2);
705    }
706
707    #[test]
708    fn test_center_and_periphery_empty_graph() {
709        let g: Graph<usize, f64> = Graph::new();
710        assert!(center(&g).is_empty());
711        assert!(periphery(&g).is_empty());
712    }
713
714    // ── Average path length ───────────────────────────────────────────────────
715
716    #[test]
717    fn test_apl_complete() {
718        let g = complete_graph(5).expect("complete_graph failed");
719        let apl = average_path_length(&g).expect("apl failed");
720        assert!((apl - 1.0).abs() < 1e-9, "K_n has APL=1, got {apl}");
721    }
722
723    #[test]
724    fn test_apl_path() {
725        // P4: sum of distances = 1+2+3 + 1+2 + 1 = 10 (one direction),
726        // × 2 / 12 = 20/12 ≈ 1.667
727        let g = path_graph(4).expect("path_graph failed");
728        let apl = average_path_length(&g).expect("apl failed");
729        // Sum of all directed pair distances: 2*(1+2+3+1+2+1) = 2*10 = 20
730        // Divided by 4*3 = 12 → ≈1.667
731        assert!((apl - 20.0 / 12.0).abs() < 1e-9, "got {apl}");
732    }
733
734    #[test]
735    fn test_apl_trivial() {
736        let mut g: Graph<usize, f64> = Graph::new();
737        g.add_node(0);
738        assert_eq!(average_path_length(&g), Some(0.0));
739    }
740
741    // ── Global efficiency ─────────────────────────────────────────────────────
742
743    #[test]
744    fn test_global_efficiency_complete() {
745        let g = complete_graph(5).expect("complete_graph failed");
746        let eff = global_efficiency(&g).expect("eff failed");
747        assert!((eff - 1.0).abs() < 1e-9, "K_n has efficiency=1, got {eff}");
748    }
749
750    #[test]
751    fn test_global_efficiency_path() {
752        // P3: pairs (0,1)=1, (1,2)=1, (0,2)=2 → efficiency = (1+1+0.5+1+1+0.5)/6 = 5/6
753        let g = path_graph(3).expect("path_graph failed");
754        let eff = global_efficiency(&g).expect("eff failed");
755        let expected = (1.0 + 0.5 + 1.0 + 1.0 + 0.5 + 1.0) / 6.0;
756        assert!(
757            (eff - expected).abs() < 1e-9,
758            "got {eff}, expected {expected}"
759        );
760    }
761
762    #[test]
763    fn test_global_efficiency_disconnected() {
764        // Two disconnected triangles: unreachable pairs contribute 0
765        let mut g: Graph<usize, f64> = Graph::new();
766        for i in 0..6usize {
767            g.add_node(i);
768        }
769        // Component 1: 0-1-2-0
770        g.add_edge(0, 1, 1.0).unwrap();
771        g.add_edge(1, 2, 1.0).unwrap();
772        g.add_edge(0, 2, 1.0).unwrap();
773        // Component 2: 3-4-5-3
774        g.add_edge(3, 4, 1.0).unwrap();
775        g.add_edge(4, 5, 1.0).unwrap();
776        g.add_edge(3, 5, 1.0).unwrap();
777
778        let eff = global_efficiency(&g).expect("eff failed");
779        // Within each triangle: 3 pairs at distance 1 each, 6 directed
780        // Cross-component: 0 contribution
781        // Total = 6 + 6 = 12 directed pairs at distance 1, out of 6*5=30
782        let expected = 12.0 / 30.0;
783        assert!((eff - expected).abs() < 1e-9, "got {eff}");
784    }
785
786    // ── Local efficiency ──────────────────────────────────────────────────────
787
788    #[test]
789    fn test_local_efficiency_triangle() {
790        // In a triangle: neighbourhood of node 0 is {1,2} which are connected
791        // → neighbourhood is K_2 → efficiency = 1.0
792        let g = complete_graph(3).expect("complete_graph failed");
793        let le = local_efficiency(&g, &0).expect("le failed");
794        assert!((le - 1.0).abs() < 1e-9, "got {le}");
795    }
796
797    #[test]
798    fn test_local_efficiency_star_center() {
799        // Star center: neighbours form an independent set → subgraph has no edges → eff = 0
800        let g = star_graph(5).expect("star_graph failed");
801        let le = local_efficiency(&g, &0).expect("le failed");
802        assert!((le - 0.0).abs() < 1e-9, "got {le}");
803    }
804
805    #[test]
806    fn test_local_efficiency_missing_node() {
807        let g = path_graph(4).expect("path_graph failed");
808        assert!(local_efficiency(&g, &99).is_err());
809    }
810
811    // ── Small-world coefficient ───────────────────────────────────────────────
812
813    #[test]
814    fn test_small_world_coefficient_runs() {
815        let mut rng = StdRng::seed_from_u64(42);
816        // Watts-Strogatz β=0.1 is a canonical small-world network
817        let g = crate::generators::watts_strogatz_graph(30, 4, 0.1, &mut rng).expect("ws failed");
818        let sigma = small_world_coefficient(&g, 5, &mut rng);
819        // Just check it runs without error; σ may vary
820        assert!(sigma.is_ok(), "small_world_coefficient error: {sigma:?}");
821    }
822
823    #[test]
824    fn test_small_world_invalid_input() {
825        let mut rng = StdRng::seed_from_u64(1);
826        let g: Graph<usize, f64> = Graph::new();
827        assert!(small_world_coefficient(&g, 5, &mut rng).is_err());
828        let tiny_g = path_graph(2).expect("path failed");
829        assert!(small_world_coefficient(&tiny_g, 5, &mut rng).is_err());
830    }
831
832    // ── Scale-free exponent ───────────────────────────────────────────────────
833
834    #[test]
835    fn test_scale_free_exponent_power_law() {
836        // Barabasi–Albert has gamma ≈ 3
837        let mut rng = StdRng::seed_from_u64(99);
838        let g =
839            crate::generators::random_graphs::barabasi_albert(200, 2, &mut rng).expect("ba failed");
840        let degrees: Vec<usize> = (0..g.node_count()).map(|i| g.degree(&i)).collect();
841        if let Some(gamma) = scale_free_exponent(&degrees) {
842            // BA model → exponent ≈ 3; allow generous bounds for small n
843            assert!(gamma > 1.5 && gamma < 8.0, "gamma={gamma}");
844        }
845        // If None, it means the dataset was too small; that's acceptable
846    }
847
848    #[test]
849    fn test_scale_free_exponent_empty() {
850        assert_eq!(scale_free_exponent(&[]), None);
851        assert_eq!(scale_free_exponent(&[0, 0, 0]), None);
852    }
853
854    #[test]
855    fn test_scale_free_exponent_uniform() {
856        // All degrees equal → no power law (single distinct value after 1 removed)
857        let degrees = vec![3usize; 20];
858        // May return None or a degenerate value — just check it doesn't panic
859        let _ = scale_free_exponent(&degrees);
860    }
861}