Skip to main content

rust_igraph/algorithms/properties/
spectral_metrics.rs

1//! Spectral graph metrics (ALGO-TR-021).
2//!
3//! Eigenvalue-based invariants derived from the adjacency spectrum.
4//! All metrics operate on the full adjacency eigenspectrum `{λ_1, …, λ_n}`
5//! (computed via the Lanczos solver), making them suitable for moderate-size
6//! graphs (say ≤ 5 000 vertices).
7//!
8//! - **Estrada index**: `EE(G) = Σ_i exp(λ_i)` — measures the "folded-ness"
9//!   of the network; related to subgraph counts.
10//! - **Subgraph centrality**: `SC(v) = Σ_k (e^A)_{vv}` — the diagonal of
11//!   the matrix exponential, a centrality measure counting closed walks.
12//! - **Natural connectivity**: `λ̄ = ln(EE(G) / n)` — a robust measure of
13//!   network structural redundancy / fault tolerance.
14//! - **Spectral radius**: `ρ(G) = max_i |λ_i|` — the largest eigenvalue
15//!   magnitude, upper-bounds epidemic thresholds and walk counts.
16//! - **Energy**: `E(G) = Σ_i |λ_i|` — the sum of absolute eigenvalues,
17//!   originating from mathematical chemistry (Hückel theory).
18//! - **Spectral gap**: `Δ = λ_1 - λ_2` — the difference between the two
19//!   largest eigenvalues; large gap implies good expansion / rapid mixing.
20
21#![allow(
22    clippy::cast_possible_truncation,
23    clippy::cast_precision_loss,
24    clippy::many_single_char_names,
25    clippy::needless_range_loop,
26    clippy::similar_names,
27    clippy::too_many_lines
28)]
29
30use crate::core::{Graph, IgraphError, IgraphResult};
31
32/// Build the dense adjacency matrix (row-major, n×n).
33fn dense_adjacency(graph: &Graph) -> Vec<f64> {
34    let n = graph.vcount() as usize;
35    let mut a = vec![0.0_f64; n * n];
36    for (u, v) in graph.edges() {
37        let ui = u as usize;
38        let vi = v as usize;
39        a[ui * n + vi] += 1.0;
40        if !graph.is_directed() && ui != vi {
41            a[vi * n + ui] += 1.0;
42        }
43    }
44    a
45}
46
47/// Jacobi eigenvalue algorithm for real symmetric matrices.
48///
49/// Returns all eigenvalues sorted in decreasing order, and (optionally)
50/// the eigenvector matrix in column-major order.
51///
52/// `mat` is row-major n×n. Overwrites it.
53fn jacobi_eigen(mat: &mut [f64], n: usize, need_vectors: bool) -> (Vec<f64>, Vec<Vec<f64>>) {
54    if n == 0 {
55        return (Vec::new(), Vec::new());
56    }
57    if n == 1 {
58        let val = mat[0];
59        let vecs = if need_vectors {
60            vec![vec![1.0]]
61        } else {
62            Vec::new()
63        };
64        return (vec![val], vecs);
65    }
66
67    // Initialize eigenvector matrix to identity
68    let mut v = vec![0.0_f64; n * n];
69    if need_vectors {
70        for i in 0..n {
71            v[i * n + i] = 1.0;
72        }
73    }
74
75    let max_sweeps = 100;
76    for _ in 0..max_sweeps {
77        // Find the off-diagonal element with largest absolute value
78        let mut max_off = 0.0_f64;
79        let mut p = 0;
80        let mut q = 1;
81        for i in 0..n {
82            for j in (i + 1)..n {
83                let val = mat[i * n + j].abs();
84                if val > max_off {
85                    max_off = val;
86                    p = i;
87                    q = j;
88                }
89            }
90        }
91
92        if max_off < 1e-14 {
93            break;
94        }
95
96        // Compute rotation angle
97        let app = mat[p * n + p];
98        let aqq = mat[q * n + q];
99        let apq = mat[p * n + q];
100
101        let (cos, sin) = if (app - aqq).abs() < 1e-300 {
102            let s = std::f64::consts::FRAC_1_SQRT_2;
103            (s, s)
104        } else {
105            let tau = (aqq - app) / (2.0 * apq);
106            let t = if tau >= 0.0 {
107                1.0 / (tau + (1.0 + tau * tau).sqrt())
108            } else {
109                -1.0 / (-tau + (1.0 + tau * tau).sqrt())
110            };
111            let c = 1.0 / (1.0 + t * t).sqrt();
112            (c, t * c)
113        };
114
115        // Apply Jacobi rotation
116        for i in 0..n {
117            if i == p || i == q {
118                continue;
119            }
120            let aip = mat[i * n + p];
121            let aiq = mat[i * n + q];
122            mat[i * n + p] = cos * aip - sin * aiq;
123            mat[p * n + i] = mat[i * n + p];
124            mat[i * n + q] = sin * aip + cos * aiq;
125            mat[q * n + i] = mat[i * n + q];
126        }
127
128        let new_pp = cos * cos * app - 2.0 * sin * cos * apq + sin * sin * aqq;
129        let new_qq = sin * sin * app + 2.0 * sin * cos * apq + cos * cos * aqq;
130        mat[p * n + p] = new_pp;
131        mat[q * n + q] = new_qq;
132        mat[p * n + q] = 0.0;
133        mat[q * n + p] = 0.0;
134
135        // Update eigenvectors
136        if need_vectors {
137            for i in 0..n {
138                let vip = v[i * n + p];
139                let viq = v[i * n + q];
140                v[i * n + p] = cos * vip - sin * viq;
141                v[i * n + q] = sin * vip + cos * viq;
142            }
143        }
144    }
145
146    // Extract eigenvalues (diagonal)
147    let mut eigenvalues: Vec<f64> = (0..n).map(|i| mat[i * n + i]).collect();
148
149    // Sort by decreasing eigenvalue
150    let mut indices: Vec<usize> = (0..n).collect();
151    indices.sort_by(|&a, &b| {
152        eigenvalues[b]
153            .partial_cmp(&eigenvalues[a])
154            .unwrap_or(std::cmp::Ordering::Equal)
155    });
156
157    let sorted_vals: Vec<f64> = indices.iter().map(|&i| eigenvalues[i]).collect();
158
159    let sorted_vecs = if need_vectors {
160        indices
161            .iter()
162            .map(|&idx| {
163                let mut col = vec![0.0_f64; n];
164                for i in 0..n {
165                    col[i] = v[i * n + idx];
166                }
167                col
168            })
169            .collect()
170    } else {
171        Vec::new()
172    };
173
174    eigenvalues = sorted_vals;
175    (eigenvalues, sorted_vecs)
176}
177
178/// Compute the full adjacency eigenspectrum, sorted in decreasing order.
179fn full_spectrum(graph: &Graph) -> Vec<f64> {
180    let n = graph.vcount() as usize;
181    if n == 0 {
182        return Vec::new();
183    }
184    let mut a = dense_adjacency(graph);
185    let (vals, _) = jacobi_eigen(&mut a, n, false);
186    vals
187}
188
189/// Full eigen decomposition (eigenvalues + eigenvectors).
190fn full_decomposition(graph: &Graph) -> (Vec<f64>, Vec<Vec<f64>>) {
191    let n = graph.vcount() as usize;
192    if n == 0 {
193        return (Vec::new(), Vec::new());
194    }
195    let mut a = dense_adjacency(graph);
196    jacobi_eigen(&mut a, n, true)
197}
198
199/// Compute the Estrada index of a graph.
200///
201/// `EE(G) = Σ_i exp(λ_i)` where `{λ_i}` are the adjacency eigenvalues.
202///
203/// For an empty graph (no vertices) returns `0.0`. For an edgeless graph
204/// on `n` vertices returns `n` (since all eigenvalues are zero).
205///
206/// # Examples
207///
208/// ```
209/// use rust_igraph::{Graph, estrada_index};
210///
211/// // K_3: eigenvalues {2, -1, -1} → EE = e² + 2·e⁻¹
212/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
213/// let ee = estrada_index(&g).unwrap();
214/// let expected = (2.0_f64).exp() + 2.0 * (-1.0_f64).exp();
215/// assert!((ee - expected).abs() < 0.01);
216/// ```
217pub fn estrada_index(graph: &Graph) -> IgraphResult<f64> {
218    let spectrum = full_spectrum(graph);
219    Ok(spectrum.iter().map(|&lam| lam.exp()).sum())
220}
221
222/// Compute the graph energy.
223///
224/// `E(G) = Σ_i |λ_i|` — the sum of absolute adjacency eigenvalues.
225/// Originates from Hückel molecular orbital theory.
226///
227/// # Examples
228///
229/// ```
230/// use rust_igraph::{Graph, graph_energy};
231///
232/// // K_3: eigenvalues {2, -1, -1} → energy = 2 + 1 + 1 = 4
233/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
234/// let e = graph_energy(&g).unwrap();
235/// assert!((e - 4.0).abs() < 0.01);
236/// ```
237pub fn graph_energy(graph: &Graph) -> IgraphResult<f64> {
238    let spectrum = full_spectrum(graph);
239    Ok(spectrum.iter().map(|&lam| lam.abs()).sum())
240}
241
242/// Compute the spectral radius of a graph.
243///
244/// `ρ(G) = max_i |λ_i|` — the largest eigenvalue in absolute value.
245/// For undirected graphs, this equals `λ_1` (the largest eigenvalue).
246///
247/// Returns `0.0` for an empty graph.
248///
249/// # Examples
250///
251/// ```
252/// use rust_igraph::{Graph, spectral_radius};
253///
254/// // K_3: eigenvalues {2, -1, -1} → spectral radius = 2
255/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
256/// let rho = spectral_radius(&g).unwrap();
257/// assert!((rho - 2.0).abs() < 0.01);
258/// ```
259pub fn spectral_radius(graph: &Graph) -> IgraphResult<f64> {
260    let spectrum = full_spectrum(graph);
261    Ok(spectrum
262        .iter()
263        .map(|&lam| lam.abs())
264        .fold(0.0_f64, f64::max))
265}
266
267/// Compute the spectral gap of a graph.
268///
269/// `Δ = λ_1 - λ_2` — the difference between the largest and
270/// second-largest eigenvalue. A large spectral gap implies good
271/// expansion properties and rapid mixing of random walks.
272///
273/// Returns `0.0` if the graph has fewer than 2 vertices.
274///
275/// # Examples
276///
277/// ```
278/// use rust_igraph::{Graph, spectral_gap};
279///
280/// // K_3: eigenvalues {2, -1, -1} → gap = 2 - (-1) = 3
281/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
282/// let gap = spectral_gap(&g).unwrap();
283/// assert!((gap - 3.0).abs() < 0.01);
284/// ```
285pub fn spectral_gap(graph: &Graph) -> IgraphResult<f64> {
286    let spectrum = full_spectrum(graph);
287    if spectrum.len() < 2 {
288        return Ok(0.0);
289    }
290    Ok(spectrum[0] - spectrum[1])
291}
292
293/// Compute the natural connectivity of a graph.
294///
295/// `λ̄ = ln(EE(G) / n) = ln((1/n) Σ_i exp(λ_i))`
296///
297/// A robust measure of network structural redundancy / fault tolerance.
298/// Higher values indicate more alternative paths.
299///
300/// Returns `0.0` if the graph has no vertices.
301///
302/// # Examples
303///
304/// ```
305/// use rust_igraph::{Graph, natural_connectivity};
306///
307/// // K_3: EE = e² + 2e⁻¹ → λ̄ = ln(EE/3)
308/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
309/// let nc = natural_connectivity(&g).unwrap();
310/// let expected = ((2.0_f64).exp() + 2.0 * (-1.0_f64).exp()).ln() - (3.0_f64).ln();
311/// assert!((nc - expected).abs() < 0.01);
312/// ```
313pub fn natural_connectivity(graph: &Graph) -> IgraphResult<f64> {
314    let n = graph.vcount() as usize;
315    if n == 0 {
316        return Ok(0.0);
317    }
318    let ee = estrada_index(graph)?;
319    Ok((ee / n as f64).ln())
320}
321
322/// Compute subgraph centrality for all vertices.
323///
324/// `SC(v) = (e^A)_{vv} = Σ_j (φ_j(v))² · exp(λ_j)`
325///
326/// where `φ_j` is the `j`-th eigenvector and `λ_j` the corresponding
327/// eigenvalue. Measures the participation of vertex `v` in all subgraphs
328/// (closed walks), weighted exponentially by length.
329///
330/// # Examples
331///
332/// ```
333/// use rust_igraph::{Graph, subgraph_centrality};
334///
335/// // K_3: all vertices are symmetric, so all SC values are equal
336/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
337/// let sc = subgraph_centrality(&g).unwrap();
338/// assert!((sc[0] - sc[1]).abs() < 0.01);
339/// assert!((sc[1] - sc[2]).abs() < 0.01);
340/// ```
341pub fn subgraph_centrality(graph: &Graph) -> IgraphResult<Vec<f64>> {
342    let n = graph.vcount() as usize;
343    if n == 0 {
344        return Ok(Vec::new());
345    }
346
347    let (eigenvalues, eigenvectors) = full_decomposition(graph);
348
349    let mut sc = vec![0.0_f64; n];
350
351    for (j, &lam) in eigenvalues.iter().enumerate() {
352        let exp_lam = lam.exp();
353        let phi = &eigenvectors[j];
354        for v in 0..n {
355            sc[v] += phi[v] * phi[v] * exp_lam;
356        }
357    }
358
359    Ok(sc)
360}
361
362/// Compute the communicability matrix between all pairs of vertices.
363///
364/// `C(u,v) = (e^A)_{uv} = Σ_j φ_j(u) · φ_j(v) · exp(λ_j)`
365///
366/// Returns a flattened `n × n` matrix in row-major order. The diagonal
367/// entries equal the subgraph centrality.
368///
369/// # Errors
370///
371/// Returns [`IgraphError::InvalidArgument`] if the graph has more than
372/// 10 000 vertices (the `n²` output would be impractical).
373///
374/// # Examples
375///
376/// ```
377/// use rust_igraph::{Graph, communicability_matrix};
378///
379/// let g = Graph::from_edges(&[(0,1),(1,2)], false, Some(3)).unwrap();
380/// let c = communicability_matrix(&g).unwrap();
381/// // Symmetric: C(0,1) == C(1,0)
382/// assert!((c[0 * 3 + 1] - c[1 * 3 + 0]).abs() < 1e-10);
383/// // Diagonal = subgraph centrality
384/// assert!(c[0] > 0.0);
385/// ```
386pub fn communicability_matrix(graph: &Graph) -> IgraphResult<Vec<f64>> {
387    let n = graph.vcount() as usize;
388    if n == 0 {
389        return Ok(Vec::new());
390    }
391
392    if n > 10_000 {
393        return Err(IgraphError::InvalidArgument(format!(
394            "communicability_matrix: graph has {n} vertices; n² = {} entries would be impractical",
395            (n as u64).saturating_mul(n as u64)
396        )));
397    }
398
399    let (eigenvalues, eigenvectors) = full_decomposition(graph);
400
401    let mut mat = vec![0.0_f64; n * n];
402
403    for (j, &lam) in eigenvalues.iter().enumerate() {
404        let exp_lam = lam.exp();
405        let phi = &eigenvectors[j];
406        for u in 0..n {
407            for v in u..n {
408                let contrib = phi[u] * phi[v] * exp_lam;
409                mat[u * n + v] += contrib;
410                if u != v {
411                    mat[v * n + u] += contrib;
412                }
413            }
414        }
415    }
416
417    Ok(mat)
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    fn k3() -> Graph {
425        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
426    }
427
428    fn path3() -> Graph {
429        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
430    }
431
432    fn k4() -> Graph {
433        Graph::from_edges(
434            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
435            false,
436            Some(4),
437        )
438        .unwrap()
439    }
440
441    fn star4() -> Graph {
442        Graph::from_edges(&[(0, 1), (0, 2), (0, 3)], false, Some(4)).unwrap()
443    }
444
445    fn cycle4() -> Graph {
446        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
447    }
448
449    // --- Estrada index ---
450
451    #[test]
452    fn estrada_empty() {
453        let g = Graph::with_vertices(0);
454        let ee = estrada_index(&g).unwrap();
455        assert!(ee.abs() < 1e-10);
456    }
457
458    #[test]
459    fn estrada_no_edges() {
460        let g = Graph::with_vertices(5);
461        let ee = estrada_index(&g).unwrap();
462        // All eigenvalues zero → EE = 5 * e^0 = 5
463        assert!((ee - 5.0).abs() < 0.1);
464    }
465
466    #[test]
467    fn estrada_k3() {
468        let g = k3();
469        let ee = estrada_index(&g).unwrap();
470        let expected = (2.0_f64).exp() + 2.0 * (-1.0_f64).exp();
471        assert!((ee - expected).abs() < 0.05);
472    }
473
474    #[test]
475    fn estrada_k4() {
476        let g = k4();
477        let ee = estrada_index(&g).unwrap();
478        // K_4: eigenvalues {3, -1, -1, -1}
479        let expected = (3.0_f64).exp() + 3.0 * (-1.0_f64).exp();
480        assert!((ee - expected).abs() < 0.1);
481    }
482
483    #[test]
484    fn estrada_positive() {
485        let g = cycle4();
486        let ee = estrada_index(&g).unwrap();
487        assert!(ee > 0.0);
488    }
489
490    // --- Graph energy ---
491
492    #[test]
493    fn energy_empty() {
494        let g = Graph::with_vertices(0);
495        let e = graph_energy(&g).unwrap();
496        assert!(e.abs() < 1e-10);
497    }
498
499    #[test]
500    fn energy_no_edges() {
501        let g = Graph::with_vertices(3);
502        let e = graph_energy(&g).unwrap();
503        assert!(e.abs() < 0.1);
504    }
505
506    #[test]
507    fn energy_k3() {
508        let g = k3();
509        let e = graph_energy(&g).unwrap();
510        // eigenvalues {2, -1, -1} → energy = 2+1+1 = 4
511        assert!((e - 4.0).abs() < 0.1);
512    }
513
514    #[test]
515    fn energy_k4() {
516        let g = k4();
517        let e = graph_energy(&g).unwrap();
518        // eigenvalues {3, -1, -1, -1} → energy = 3+1+1+1 = 6
519        assert!((e - 6.0).abs() < 0.1);
520    }
521
522    #[test]
523    fn energy_nonneg() {
524        let g = star4();
525        let e = graph_energy(&g).unwrap();
526        assert!(e >= -1e-10);
527    }
528
529    // --- Spectral radius ---
530
531    #[test]
532    fn radius_empty() {
533        let g = Graph::with_vertices(0);
534        let r = spectral_radius(&g).unwrap();
535        assert!(r.abs() < 1e-10);
536    }
537
538    #[test]
539    fn radius_k3() {
540        let g = k3();
541        let r = spectral_radius(&g).unwrap();
542        assert!((r - 2.0).abs() < 0.01);
543    }
544
545    #[test]
546    fn radius_complete_n() {
547        // K_n has spectral radius n-1
548        let g = k4();
549        let r = spectral_radius(&g).unwrap();
550        assert!((r - 3.0).abs() < 0.01);
551    }
552
553    #[test]
554    fn radius_path() {
555        let g = path3();
556        let r = spectral_radius(&g).unwrap();
557        // P_3: eigenvalues are {√2, 0, -√2} → ρ = √2
558        assert!((r - std::f64::consts::SQRT_2).abs() < 0.01);
559    }
560
561    // --- Spectral gap ---
562
563    #[test]
564    fn gap_empty() {
565        let g = Graph::with_vertices(0);
566        let gap = spectral_gap(&g).unwrap();
567        assert!(gap.abs() < 1e-10);
568    }
569
570    #[test]
571    fn gap_single() {
572        let g = Graph::with_vertices(1);
573        let gap = spectral_gap(&g).unwrap();
574        assert!(gap.abs() < 1e-10);
575    }
576
577    #[test]
578    fn gap_k3() {
579        let g = k3();
580        let gap = spectral_gap(&g).unwrap();
581        // eigenvalues {2, -1, -1} → gap = 2 - (-1) = 3
582        assert!((gap - 3.0).abs() < 0.1);
583    }
584
585    #[test]
586    fn gap_complete_is_n() {
587        // K_n: eigenvalues {n-1, -1, ..., -1} → gap = n
588        let g = k4();
589        let gap = spectral_gap(&g).unwrap();
590        assert!((gap - 4.0).abs() < 0.1);
591    }
592
593    // --- Natural connectivity ---
594
595    #[test]
596    fn nc_empty() {
597        let g = Graph::with_vertices(0);
598        let nc = natural_connectivity(&g).unwrap();
599        assert!(nc.abs() < 1e-10);
600    }
601
602    #[test]
603    fn nc_no_edges() {
604        let g = Graph::with_vertices(4);
605        let nc = natural_connectivity(&g).unwrap();
606        // EE = 4, nc = ln(4/4) = ln(1) = 0
607        assert!(nc.abs() < 0.1);
608    }
609
610    #[test]
611    fn nc_k3() {
612        let g = k3();
613        let nc = natural_connectivity(&g).unwrap();
614        let expected = ((2.0_f64).exp() + 2.0 * (-1.0_f64).exp()).ln() - (3.0_f64).ln();
615        assert!((nc - expected).abs() < 0.05);
616    }
617
618    #[test]
619    fn nc_more_edges_higher() {
620        // Adding edges should not decrease natural connectivity
621        let p = path3();
622        let k = k3();
623        let nc_p = natural_connectivity(&p).unwrap();
624        let nc_k = natural_connectivity(&k).unwrap();
625        assert!(nc_k > nc_p - 0.01);
626    }
627
628    // --- Subgraph centrality ---
629
630    #[test]
631    fn sc_empty() {
632        let g = Graph::with_vertices(0);
633        let sc = subgraph_centrality(&g).unwrap();
634        assert!(sc.is_empty());
635    }
636
637    #[test]
638    fn sc_no_edges() {
639        let g = Graph::with_vertices(3);
640        let sc = subgraph_centrality(&g).unwrap();
641        // (e^0)_{vv} = 1 for all v
642        for &v in &sc {
643            assert!((v - 1.0).abs() < 0.1);
644        }
645    }
646
647    #[test]
648    fn sc_k3_symmetric() {
649        let g = k3();
650        let sc = subgraph_centrality(&g).unwrap();
651        // All vertices equivalent
652        assert!((sc[0] - sc[1]).abs() < 0.01);
653        assert!((sc[1] - sc[2]).abs() < 0.01);
654    }
655
656    #[test]
657    fn sc_star_center_highest() {
658        let g = star4();
659        let sc = subgraph_centrality(&g).unwrap();
660        // Center vertex (0) should have highest SC
661        assert!(sc[0] > sc[1]);
662        assert!(sc[0] > sc[2]);
663        assert!(sc[0] > sc[3]);
664    }
665
666    #[test]
667    fn sc_all_positive() {
668        let g = cycle4();
669        let sc = subgraph_centrality(&g).unwrap();
670        for &v in &sc {
671            assert!(v > 0.0);
672        }
673    }
674
675    #[test]
676    fn sc_sum_equals_estrada() {
677        let g = k3();
678        let sc = subgraph_centrality(&g).unwrap();
679        let ee = estrada_index(&g).unwrap();
680        let sc_sum: f64 = sc.iter().sum();
681        assert!((sc_sum - ee).abs() < 0.1);
682    }
683
684    // --- Communicability matrix ---
685
686    #[test]
687    fn comm_empty() {
688        let g = Graph::with_vertices(0);
689        let c = communicability_matrix(&g).unwrap();
690        assert!(c.is_empty());
691    }
692
693    #[test]
694    fn comm_symmetric() {
695        let g = path3();
696        let c = communicability_matrix(&g).unwrap();
697        let n = 3;
698        for u in 0..n {
699            for v in 0..n {
700                assert!(
701                    (c[u * n + v] - c[v * n + u]).abs() < 0.01,
702                    "C({u},{v}) != C({v},{u})"
703                );
704            }
705        }
706    }
707
708    #[test]
709    fn comm_diagonal_is_sc() {
710        let g = k3();
711        let c = communicability_matrix(&g).unwrap();
712        let sc = subgraph_centrality(&g).unwrap();
713        let n = 3;
714        for v in 0..n {
715            assert!(
716                (c[v * n + v] - sc[v]).abs() < 0.1,
717                "Diagonal mismatch at vertex {v}"
718            );
719        }
720    }
721
722    #[test]
723    fn comm_k3_all_positive() {
724        let g = k3();
725        let c = communicability_matrix(&g).unwrap();
726        for &val in &c {
727            assert!(val > 0.0);
728        }
729    }
730
731    #[test]
732    fn comm_neighbors_higher_than_distant() {
733        let g = path3();
734        let c = communicability_matrix(&g).unwrap();
735        // C(0,1) > C(0,2) since 0 is adjacent to 1 but not 2
736        assert!(c[1] > c[2]);
737    }
738
739    #[test]
740    fn comm_too_large() {
741        // Test the size guard
742        assert!(communicability_matrix(&Graph::with_vertices(10_001)).is_err());
743    }
744}