Skip to main content

rust_igraph/algorithms/properties/
distance_spectrum.rs

1//! Distance matrix spectrum and derived metrics (ALGO-TR-025).
2//!
3//! The **distance matrix** `D` has entries `D(u,v) = d(u,v)` (shortest
4//! path length). Its spectral properties reveal global structural
5//! information complementary to the adjacency and Laplacian spectra.
6//!
7//! - **Distance spectrum**: eigenvalues of `D` sorted in decreasing
8//!   order of absolute value.
9//! - **Distance spectral radius**: largest eigenvalue `ρ_D = λ_1(D)`.
10//! - **Distance energy**: `E_D = Σ |λ_i(D)|` — the distance analogue
11//!   of graph energy.
12//! - **Distance Estrada index**: `DEE = Σ exp(λ_i(D))`.
13//! - **Wiener index**: `W(G) = Σ_{u<v} d(u,v)` — the sum of all
14//!   pairwise distances. Also equals half the sum of all distance
15//!   matrix entries.
16//!
17//! All functions require connected undirected graphs.
18
19#![allow(
20    clippy::cast_possible_truncation,
21    clippy::cast_precision_loss,
22    clippy::many_single_char_names,
23    clippy::needless_range_loop,
24    clippy::similar_names,
25    clippy::too_many_lines
26)]
27
28use crate::core::{Graph, IgraphError, IgraphResult};
29use std::collections::VecDeque;
30
31/// Build the dense distance matrix via multi-source BFS.
32/// Returns `None` if the graph is disconnected (any pair has infinite distance).
33fn dense_distance_matrix(graph: &Graph) -> Option<Vec<f64>> {
34    let n = graph.vcount() as usize;
35    if n == 0 {
36        return Some(Vec::new());
37    }
38
39    let mut dist = vec![f64::INFINITY; n * n];
40    for s in 0..n {
41        dist[s * n + s] = 0.0;
42        let mut queue = VecDeque::new();
43        queue.push_back(s as u32);
44        let mut visited = vec![false; n];
45        visited[s] = true;
46
47        while let Some(u) = queue.pop_front() {
48            let ui = u as usize;
49            if let Ok(nbrs) = graph.neighbors(u) {
50                for &v in &nbrs {
51                    let vi = v as usize;
52                    if !visited[vi] {
53                        visited[vi] = true;
54                        dist[s * n + vi] = dist[s * n + ui] + 1.0;
55                        queue.push_back(v);
56                    }
57                }
58            }
59        }
60
61        for j in 0..n {
62            if dist[s * n + j].is_infinite() {
63                return None;
64            }
65        }
66    }
67
68    Some(dist)
69}
70
71/// Jacobi eigenvalue algorithm for real symmetric matrices.
72/// Returns eigenvalues sorted in **decreasing** order.
73fn jacobi_eigen_descending(mat: &mut [f64], n: usize) -> Vec<f64> {
74    if n == 0 {
75        return Vec::new();
76    }
77    if n == 1 {
78        return vec![mat[0]];
79    }
80
81    let max_sweeps = 100;
82    for _ in 0..max_sweeps {
83        let mut max_off = 0.0_f64;
84        let mut p = 0;
85        let mut q = 1;
86        for i in 0..n {
87            for j in (i + 1)..n {
88                let val = mat[i * n + j].abs();
89                if val > max_off {
90                    max_off = val;
91                    p = i;
92                    q = j;
93                }
94            }
95        }
96
97        if max_off < 1e-14 {
98            break;
99        }
100
101        let app = mat[p * n + p];
102        let aqq = mat[q * n + q];
103        let apq = mat[p * n + q];
104
105        let (cos, sin) = if (app - aqq).abs() < 1e-300 {
106            let s = std::f64::consts::FRAC_1_SQRT_2;
107            (s, s)
108        } else {
109            let tau = (aqq - app) / (2.0 * apq);
110            let t = if tau >= 0.0 {
111                1.0 / (tau + (1.0 + tau * tau).sqrt())
112            } else {
113                -1.0 / (-tau + (1.0 + tau * tau).sqrt())
114            };
115            let c = 1.0 / (1.0 + t * t).sqrt();
116            (c, t * c)
117        };
118
119        for i in 0..n {
120            if i == p || i == q {
121                continue;
122            }
123            let aip = mat[i * n + p];
124            let aiq = mat[i * n + q];
125            mat[i * n + p] = cos * aip - sin * aiq;
126            mat[p * n + i] = mat[i * n + p];
127            mat[i * n + q] = sin * aip + cos * aiq;
128            mat[q * n + i] = mat[i * n + q];
129        }
130
131        let new_pp = cos * cos * app - 2.0 * sin * cos * apq + sin * sin * aqq;
132        let new_qq = sin * sin * app + 2.0 * sin * cos * apq + cos * cos * aqq;
133        mat[p * n + p] = new_pp;
134        mat[q * n + q] = new_qq;
135        mat[p * n + q] = 0.0;
136        mat[q * n + p] = 0.0;
137    }
138
139    let mut eigenvalues: Vec<f64> = (0..n).map(|i| mat[i * n + i]).collect();
140    eigenvalues.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
141    eigenvalues
142}
143
144/// Compute the Wiener index of a connected graph.
145///
146/// `W(G) = Σ_{u<v} d(u,v)` — the sum of all pairwise shortest-path
147/// distances. Returns an error for disconnected or directed graphs.
148///
149/// # Examples
150///
151/// ```
152/// use rust_igraph::{Graph, wiener_index};
153///
154/// // Path 0-1-2: W = d(0,1) + d(0,2) + d(1,2) = 1 + 2 + 1 = 4
155/// let g = Graph::from_edges(&[(0,1),(1,2)], false, Some(3)).unwrap();
156/// let w = wiener_index(&g).unwrap();
157/// assert!((w - 4.0).abs() < 0.01);
158/// ```
159pub fn wiener_index(graph: &Graph) -> IgraphResult<f64> {
160    if graph.is_directed() {
161        return Err(IgraphError::InvalidArgument(
162            "wiener_index is defined for connected undirected graphs only".into(),
163        ));
164    }
165    let n = graph.vcount() as usize;
166    if n <= 1 {
167        return Ok(0.0);
168    }
169
170    let dist = dense_distance_matrix(graph).ok_or_else(|| {
171        IgraphError::InvalidArgument("wiener_index requires a connected graph".into())
172    })?;
173
174    let mut w = 0.0_f64;
175    for u in 0..n {
176        for v in (u + 1)..n {
177            w += dist[u * n + v];
178        }
179    }
180    Ok(w)
181}
182
183/// Compute the distance spectrum, sorted in decreasing order.
184///
185/// Returns the eigenvalues of the distance matrix. Requires a
186/// connected undirected graph.
187///
188/// # Examples
189///
190/// ```
191/// use rust_igraph::{Graph, distance_spectrum};
192///
193/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
194/// let spec = distance_spectrum(&g).unwrap();
195/// assert_eq!(spec.len(), 3);
196/// // Largest eigenvalue is positive
197/// assert!(spec[0] > 0.0);
198/// ```
199pub fn distance_spectrum(graph: &Graph) -> IgraphResult<Vec<f64>> {
200    if graph.is_directed() {
201        return Err(IgraphError::InvalidArgument(
202            "distance_spectrum is defined for connected undirected graphs only".into(),
203        ));
204    }
205    let n = graph.vcount() as usize;
206    if n == 0 {
207        return Ok(Vec::new());
208    }
209    if n == 1 {
210        return Ok(vec![0.0]);
211    }
212
213    let mut dist = dense_distance_matrix(graph).ok_or_else(|| {
214        IgraphError::InvalidArgument("distance_spectrum requires a connected graph".into())
215    })?;
216
217    Ok(jacobi_eigen_descending(&mut dist, n))
218}
219
220/// Compute the distance spectral radius `ρ_D = λ_1(D)`.
221///
222/// The largest eigenvalue of the distance matrix. For connected
223/// undirected graphs only.
224///
225/// # Examples
226///
227/// ```
228/// use rust_igraph::{Graph, distance_spectral_radius};
229///
230/// // K_3: distance matrix has all off-diag = 1, so ρ_D = 2
231/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
232/// let rho = distance_spectral_radius(&g).unwrap();
233/// assert!((rho - 2.0).abs() < 0.1);
234/// ```
235pub fn distance_spectral_radius(graph: &Graph) -> IgraphResult<f64> {
236    let spec = distance_spectrum(graph)?;
237    if spec.is_empty() {
238        return Ok(0.0);
239    }
240    Ok(spec[0])
241}
242
243/// Compute the distance energy `E_D = Σ |λ_i(D)|`.
244///
245/// The sum of absolute values of all distance matrix eigenvalues.
246///
247/// # Examples
248///
249/// ```
250/// use rust_igraph::{Graph, distance_energy};
251///
252/// // K_3: eigenvalues {2, -1, -1}, E_D = 4
253/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
254/// let e = distance_energy(&g).unwrap();
255/// assert!((e - 4.0).abs() < 0.1);
256/// ```
257pub fn distance_energy(graph: &Graph) -> IgraphResult<f64> {
258    let spec = distance_spectrum(graph)?;
259    Ok(spec.iter().map(|v| v.abs()).sum())
260}
261
262/// Compute the distance Estrada index `DEE = Σ exp(λ_i(D))`.
263///
264/// # Examples
265///
266/// ```
267/// use rust_igraph::{Graph, distance_estrada_index};
268///
269/// // K_3: eigenvalues {2, -1, -1}, DEE = e^2 + 2e^{-1}
270/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
271/// let dee = distance_estrada_index(&g).unwrap();
272/// let expected = 2.0_f64.exp() + 2.0 * (-1.0_f64).exp();
273/// assert!((dee - expected).abs() < 0.1);
274/// ```
275pub fn distance_estrada_index(graph: &Graph) -> IgraphResult<f64> {
276    let spec = distance_spectrum(graph)?;
277    Ok(spec.iter().map(|v| v.exp()).sum())
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn path3() -> Graph {
285        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
286    }
287
288    fn path4() -> Graph {
289        Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap()
290    }
291
292    fn k3() -> Graph {
293        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
294    }
295
296    fn k4() -> Graph {
297        Graph::from_edges(
298            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
299            false,
300            Some(4),
301        )
302        .unwrap()
303    }
304
305    fn cycle4() -> Graph {
306        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
307    }
308
309    fn star4() -> Graph {
310        Graph::from_edges(&[(0, 1), (0, 2), (0, 3)], false, Some(4)).unwrap()
311    }
312
313    // --- wiener_index ---
314
315    #[test]
316    fn wi_path3() {
317        let g = path3();
318        let w = wiener_index(&g).unwrap();
319        // d(0,1)=1, d(0,2)=2, d(1,2)=1 → W = 4
320        assert!((w - 4.0).abs() < 0.01);
321    }
322
323    #[test]
324    fn wi_path4() {
325        let g = path4();
326        let w = wiener_index(&g).unwrap();
327        // d(0,1)=1, d(0,2)=2, d(0,3)=3, d(1,2)=1, d(1,3)=2, d(2,3)=1 → W = 10
328        assert!((w - 10.0).abs() < 0.01);
329    }
330
331    #[test]
332    fn wi_k3() {
333        let g = k3();
334        let w = wiener_index(&g).unwrap();
335        // All pairs distance 1: C(3,2) = 3
336        assert!((w - 3.0).abs() < 0.01);
337    }
338
339    #[test]
340    fn wi_k4() {
341        let g = k4();
342        let w = wiener_index(&g).unwrap();
343        // C(4,2) = 6 pairs, each distance 1 → W = 6
344        assert!((w - 6.0).abs() < 0.01);
345    }
346
347    #[test]
348    fn wi_cycle4() {
349        let g = cycle4();
350        let w = wiener_index(&g).unwrap();
351        // 4 adj pairs dist 1, 2 opposite pairs dist 2 → W = 4+4 = 8
352        assert!((w - 8.0).abs() < 0.01);
353    }
354
355    #[test]
356    fn wi_star4() {
357        let g = star4();
358        let w = wiener_index(&g).unwrap();
359        // 3 pairs (center,leaf) dist 1, 3 pairs (leaf,leaf) dist 2 → W = 3+6 = 9
360        assert!((w - 9.0).abs() < 0.01);
361    }
362
363    #[test]
364    fn wi_single() {
365        let g = Graph::with_vertices(1);
366        let w = wiener_index(&g).unwrap();
367        assert!(w.abs() < 1e-10);
368    }
369
370    #[test]
371    fn wi_disconnected_error() {
372        let g = Graph::with_vertices(3);
373        assert!(wiener_index(&g).is_err());
374    }
375
376    #[test]
377    fn wi_directed_error() {
378        let g = Graph::from_edges(&[(0, 1)], true, Some(2)).unwrap();
379        assert!(wiener_index(&g).is_err());
380    }
381
382    // --- distance_spectrum ---
383
384    #[test]
385    fn ds_k3() {
386        let g = k3();
387        let spec = distance_spectrum(&g).unwrap();
388        // Distance matrix of K_3: J - I with eigenvalues {2, -1, -1}
389        assert_eq!(spec.len(), 3);
390        assert!((spec[0] - 2.0).abs() < 0.1);
391        assert!((spec[1] - (-1.0)).abs() < 0.1);
392        assert!((spec[2] - (-1.0)).abs() < 0.1);
393    }
394
395    #[test]
396    fn ds_decreasing() {
397        let g = path4();
398        let spec = distance_spectrum(&g).unwrap();
399        for i in 1..spec.len() {
400            assert!(spec[i] <= spec[i - 1] + 1e-10);
401        }
402    }
403
404    #[test]
405    fn ds_empty() {
406        let g = Graph::with_vertices(0);
407        let spec = distance_spectrum(&g).unwrap();
408        assert!(spec.is_empty());
409    }
410
411    #[test]
412    fn ds_single() {
413        let g = Graph::with_vertices(1);
414        let spec = distance_spectrum(&g).unwrap();
415        assert_eq!(spec.len(), 1);
416        assert!(spec[0].abs() < 1e-10);
417    }
418
419    #[test]
420    fn ds_disconnected_error() {
421        let g = Graph::with_vertices(3);
422        assert!(distance_spectrum(&g).is_err());
423    }
424
425    // --- distance_spectral_radius ---
426
427    #[test]
428    fn dsr_k3() {
429        let g = k3();
430        let rho = distance_spectral_radius(&g).unwrap();
431        assert!((rho - 2.0).abs() < 0.1);
432    }
433
434    #[test]
435    fn dsr_k4() {
436        let g = k4();
437        let rho = distance_spectral_radius(&g).unwrap();
438        // K_4: distance matrix = J - I, eigenvalues {3, -1, -1, -1}
439        assert!((rho - 3.0).abs() < 0.1);
440    }
441
442    #[test]
443    fn dsr_positive() {
444        let g = star4();
445        let rho = distance_spectral_radius(&g).unwrap();
446        assert!(rho > 0.0);
447    }
448
449    // --- distance_energy ---
450
451    #[test]
452    fn de_k3() {
453        let g = k3();
454        let e = distance_energy(&g).unwrap();
455        // eigenvalues {2, -1, -1}: E_D = 2 + 1 + 1 = 4
456        assert!((e - 4.0).abs() < 0.1);
457    }
458
459    #[test]
460    fn de_k4() {
461        let g = k4();
462        let e = distance_energy(&g).unwrap();
463        // eigenvalues {3, -1, -1, -1}: E_D = 3 + 1 + 1 + 1 = 6
464        assert!((e - 6.0).abs() < 0.1);
465    }
466
467    #[test]
468    fn de_nonneg() {
469        let g = cycle4();
470        let e = distance_energy(&g).unwrap();
471        assert!(e >= -1e-10);
472    }
473
474    // --- distance_estrada_index ---
475
476    #[test]
477    fn dee_k3() {
478        let g = k3();
479        let dee = distance_estrada_index(&g).unwrap();
480        let expected = 2.0_f64.exp() + 2.0 * (-1.0_f64).exp();
481        assert!((dee - expected).abs() < 0.1);
482    }
483
484    #[test]
485    fn dee_positive() {
486        let g = path4();
487        let dee = distance_estrada_index(&g).unwrap();
488        assert!(dee > 0.0);
489    }
490
491    // --- cross-consistency ---
492
493    #[test]
494    fn wiener_equals_half_trace_sum() {
495        let g = star4();
496        let w = wiener_index(&g).unwrap();
497        let dist = dense_distance_matrix(&g).unwrap();
498        let n = 4;
499        let total: f64 = dist.iter().sum();
500        assert!((w - total / 2.0).abs() < 0.01);
501
502        let _ = n;
503    }
504
505    #[test]
506    fn kn_distance_spectral_radius_is_n_minus_1() {
507        for n in 2_u32..=5 {
508            let mut edges = Vec::new();
509            for u in 0..n {
510                for v in (u + 1)..n {
511                    edges.push((u, v));
512                }
513            }
514            let g = Graph::from_edges(&edges, false, Some(n)).unwrap();
515            let rho = distance_spectral_radius(&g).unwrap();
516            assert!(
517                (rho - f64::from(n - 1)).abs() < 0.5,
518                "K_{n}: ρ_D = {rho}, expected {}",
519                n - 1
520            );
521        }
522    }
523
524    #[test]
525    fn trace_of_distance_matrix_is_zero() {
526        let g = path4();
527        let spec = distance_spectrum(&g).unwrap();
528        let trace: f64 = spec.iter().sum();
529        assert!(trace.abs() < 0.1);
530    }
531}