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(¤t).unwrap_or(&0);
50 let neighbors = match graph.neighbors(¤t) {
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(°rees) {
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(°rees);
860 }
861}