Skip to main content

rust_igraph/algorithms/properties/
distance_distribution_ratios.rs

1//! Distance distribution ratio indices (ALGO-TR-112).
2//!
3//! Shape measures of the all-pairs shortest path length distribution:
4//!
5//! - **Distance skewness** — skewness of the distance distribution
6//! - **Distance kurtosis** — excess kurtosis of the distance distribution
7//! - **Diameter ratio** — diameter / n (normalized longest shortest path)
8//! - **Mean eccentricity ratio** — mean eccentricity / diameter
9
10#![allow(
11    clippy::cast_lossless,
12    clippy::cast_possible_truncation,
13    clippy::cast_precision_loss,
14    clippy::many_single_char_names,
15    clippy::needless_range_loop,
16    clippy::similar_names,
17    clippy::too_many_lines
18)]
19
20use crate::core::{Graph, IgraphResult};
21
22/// Compute the distance skewness.
23///
24/// Skewness (third standardized moment) of the distribution of all
25/// pairwise shortest path lengths. Positive skew indicates most pairs
26/// are close with a long tail; negative skew indicates most pairs are
27/// far apart. Returns 0.0 for disconnected or trivial graphs.
28///
29/// # Examples
30///
31/// ```
32/// use rust_igraph::{Graph, distance_skewness};
33///
34/// // K_4: all distances = 1, zero variance → skewness = 0
35/// let g = Graph::from_edges(
36///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
37/// ).unwrap();
38/// assert!(distance_skewness(&g).unwrap().abs() < 1e-10);
39/// ```
40pub fn distance_skewness(graph: &Graph) -> IgraphResult<f64> {
41    let moments = distance_moments(graph)?;
42    match moments {
43        None => Ok(0.0),
44        Some((_, variance, skew, _)) => {
45            if variance < 1e-30 {
46                return Ok(0.0);
47            }
48            Ok(skew)
49        }
50    }
51}
52
53/// Compute the distance kurtosis.
54///
55/// Excess kurtosis (fourth standardized moment minus 3) of the
56/// distribution of all pairwise shortest path lengths. Positive values
57/// indicate heavy tails; negative values indicate light tails relative
58/// to a normal distribution. Returns 0.0 for disconnected or trivial
59/// graphs.
60///
61/// # Examples
62///
63/// ```
64/// use rust_igraph::{Graph, distance_kurtosis};
65///
66/// // K_4: all distances = 1, zero variance → kurtosis = 0
67/// let g = Graph::from_edges(
68///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
69/// ).unwrap();
70/// assert!(distance_kurtosis(&g).unwrap().abs() < 1e-10);
71/// ```
72pub fn distance_kurtosis(graph: &Graph) -> IgraphResult<f64> {
73    let moments = distance_moments(graph)?;
74    match moments {
75        None => Ok(0.0),
76        Some((_, variance, _, kurt)) => {
77            if variance < 1e-30 {
78                return Ok(0.0);
79            }
80            Ok(kurt)
81        }
82    }
83}
84
85/// Compute the diameter ratio.
86///
87/// `diameter / (n - 1)` — the diameter normalized by the maximum
88/// possible diameter (a path graph). Values near 1 indicate the graph
89/// is elongated; values near 0 indicate short diameters (e.g. complete
90/// graphs). Returns 0.0 for disconnected or trivial graphs.
91///
92/// # Examples
93///
94/// ```
95/// use rust_igraph::{Graph, diameter_ratio};
96///
97/// // Path 0-1-2-3: diameter=3, n=4 → 3/3 = 1.0
98/// let g = Graph::from_edges(&[(0,1),(1,2),(2,3)], false, Some(4)).unwrap();
99/// assert!((diameter_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
100/// ```
101pub fn diameter_ratio(graph: &Graph) -> IgraphResult<f64> {
102    let n = graph.vcount() as usize;
103    if n < 2 {
104        return Ok(0.0);
105    }
106
107    let diam = compute_diameter_bfs(graph)?;
108    if diam == 0 {
109        return Ok(0.0);
110    }
111
112    Ok(diam as f64 / (n - 1) as f64)
113}
114
115/// Compute the mean eccentricity ratio.
116///
117/// `mean_eccentricity / diameter` — how close the average vertex's
118/// eccentricity is to the maximum (diameter). Values near 1 indicate
119/// most vertices are far from the center; values near radius/diameter
120/// indicate a compact center. Returns 0.0 for disconnected or trivial
121/// graphs.
122///
123/// # Examples
124///
125/// ```
126/// use rust_igraph::{Graph, mean_eccentricity_ratio};
127///
128/// // K_4: all eccentricities = 1, diameter = 1 → ratio = 1.0
129/// let g = Graph::from_edges(
130///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
131/// ).unwrap();
132/// assert!((mean_eccentricity_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
133/// ```
134pub fn mean_eccentricity_ratio(graph: &Graph) -> IgraphResult<f64> {
135    let n = graph.vcount() as usize;
136    if n < 2 {
137        return Ok(0.0);
138    }
139
140    let eccs = compute_eccentricities(graph)?;
141    match eccs {
142        None => Ok(0.0),
143        Some(ecc_vec) => {
144            let diam = ecc_vec.iter().copied().max().unwrap_or(0);
145            if diam == 0 {
146                return Ok(0.0);
147            }
148            let mean_ecc = ecc_vec.iter().copied().sum::<u32>() as f64 / n as f64;
149            Ok(mean_ecc / diam as f64)
150        }
151    }
152}
153
154/// Compute distance moments (mean, variance, skewness, kurtosis).
155/// Returns None for disconnected or trivial graphs.
156fn distance_moments(graph: &Graph) -> IgraphResult<Option<(f64, f64, f64, f64)>> {
157    let n = graph.vcount() as usize;
158    if n < 2 {
159        return Ok(None);
160    }
161
162    let mut sum = 0_u64;
163    let mut count = 0_u64;
164    let mut distances = Vec::new();
165
166    for v in 0..n {
167        let dist = bfs_distances(graph, v)?;
168        for u in (v + 1)..n {
169            if dist[u] == u32::MAX {
170                return Ok(None);
171            }
172            let d = dist[u] as u64;
173            sum += d;
174            count += 1;
175            distances.push(d as f64);
176        }
177    }
178
179    if count < 2 {
180        return Ok(None);
181    }
182
183    let mean = sum as f64 / count as f64;
184
185    let mut m2 = 0.0_f64;
186    let mut m3 = 0.0_f64;
187    let mut m4 = 0.0_f64;
188    for &d in &distances {
189        let diff = d - mean;
190        let d2 = diff * diff;
191        m2 += d2;
192        m3 += d2 * diff;
193        m4 += d2 * d2;
194    }
195    m2 /= count as f64;
196    m3 /= count as f64;
197    m4 /= count as f64;
198
199    let variance = m2;
200    let skewness = if variance < 1e-30 {
201        0.0
202    } else {
203        m3 / (variance * variance.sqrt())
204    };
205    let kurtosis = if variance < 1e-30 {
206        0.0
207    } else {
208        m4 / (variance * variance) - 3.0
209    };
210
211    Ok(Some((mean, variance, skewness, kurtosis)))
212}
213
214/// BFS from a single source, returns distance array.
215fn bfs_distances(graph: &Graph, source: usize) -> IgraphResult<Vec<u32>> {
216    let n = graph.vcount() as usize;
217    let mut dist = vec![u32::MAX; n];
218    dist[source] = 0;
219    let mut queue = std::collections::VecDeque::new();
220    queue.push_back(source);
221    while let Some(v) = queue.pop_front() {
222        let cd = dist[v];
223        let nbrs = graph.neighbors(v as u32)?;
224        for &u in &nbrs {
225            let ui = u as usize;
226            if dist[ui] == u32::MAX {
227                dist[ui] = cd + 1;
228                queue.push_back(ui);
229            }
230        }
231    }
232    Ok(dist)
233}
234
235/// Compute diameter via all-pairs BFS. Returns 0 for disconnected graphs.
236fn compute_diameter_bfs(graph: &Graph) -> IgraphResult<u32> {
237    let n = graph.vcount() as usize;
238    let mut diam = 0_u32;
239    for v in 0..n {
240        let dist = bfs_distances(graph, v)?;
241        for u in (v + 1)..n {
242            if dist[u] == u32::MAX {
243                return Ok(0);
244            }
245            if dist[u] > diam {
246                diam = dist[u];
247            }
248        }
249    }
250    Ok(diam)
251}
252
253/// Compute eccentricities. Returns None if disconnected.
254fn compute_eccentricities(graph: &Graph) -> IgraphResult<Option<Vec<u32>>> {
255    let n = graph.vcount() as usize;
256    let mut eccs = vec![0_u32; n];
257    for v in 0..n {
258        let dist = bfs_distances(graph, v)?;
259        let mut max_d = 0_u32;
260        for u in 0..n {
261            if u == v {
262                continue;
263            }
264            if dist[u] == u32::MAX {
265                return Ok(None);
266            }
267            if dist[u] > max_d {
268                max_d = dist[u];
269            }
270        }
271        eccs[v] = max_d;
272    }
273    Ok(Some(eccs))
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn empty() -> Graph {
281        Graph::with_vertices(0)
282    }
283
284    fn single() -> Graph {
285        Graph::with_vertices(1)
286    }
287
288    fn single_edge() -> Graph {
289        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
290    }
291
292    fn path3() -> Graph {
293        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
294    }
295
296    fn path4() -> Graph {
297        Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap()
298    }
299
300    fn k3() -> Graph {
301        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
302    }
303
304    fn k4() -> Graph {
305        Graph::from_edges(
306            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
307            false,
308            Some(4),
309        )
310        .unwrap()
311    }
312
313    fn cycle4() -> Graph {
314        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
315    }
316
317    fn cycle5() -> Graph {
318        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)], false, Some(5)).unwrap()
319    }
320
321    fn star5() -> Graph {
322        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
323    }
324
325    fn disconnected() -> Graph {
326        Graph::from_edges(&[(0, 1), (2, 3)], false, Some(4)).unwrap()
327    }
328
329    // --- distance_skewness ---
330
331    #[test]
332    fn ds_empty() {
333        assert!(distance_skewness(&empty()).unwrap().abs() < 1e-10);
334    }
335
336    #[test]
337    fn ds_single() {
338        assert!(distance_skewness(&single()).unwrap().abs() < 1e-10);
339    }
340
341    #[test]
342    fn ds_single_edge() {
343        // Only one pair, zero variance → 0
344        assert!(distance_skewness(&single_edge()).unwrap().abs() < 1e-10);
345    }
346
347    #[test]
348    fn ds_k3() {
349        // All distances = 1, zero variance → 0
350        assert!(distance_skewness(&k3()).unwrap().abs() < 1e-10);
351    }
352
353    #[test]
354    fn ds_k4() {
355        assert!(distance_skewness(&k4()).unwrap().abs() < 1e-10);
356    }
357
358    #[test]
359    fn ds_cycle4() {
360        // Distances: 1,2,1,1,2,1 → [1,1,1,1,2,2], mean=4/3
361        // Symmetric around mean → skewness > 0 (more 1s than 2s)
362        let s = distance_skewness(&cycle4()).unwrap();
363        assert!(s > -1e-10); // non-negative for this shape
364    }
365
366    #[test]
367    fn ds_disconnected() {
368        assert!(distance_skewness(&disconnected()).unwrap().abs() < 1e-10);
369    }
370
371    #[test]
372    fn ds_path3() {
373        // Distances: (0,1)=1, (0,2)=2, (1,2)=1 → [1,1,2]
374        // mean=4/3, has positive skew
375        let s = distance_skewness(&path3()).unwrap();
376        assert!(s.is_finite());
377    }
378
379    // --- distance_kurtosis ---
380
381    #[test]
382    fn dk_empty() {
383        assert!(distance_kurtosis(&empty()).unwrap().abs() < 1e-10);
384    }
385
386    #[test]
387    fn dk_single() {
388        assert!(distance_kurtosis(&single()).unwrap().abs() < 1e-10);
389    }
390
391    #[test]
392    fn dk_k4() {
393        // Zero variance → 0
394        assert!(distance_kurtosis(&k4()).unwrap().abs() < 1e-10);
395    }
396
397    #[test]
398    fn dk_disconnected() {
399        assert!(distance_kurtosis(&disconnected()).unwrap().abs() < 1e-10);
400    }
401
402    #[test]
403    fn dk_path4() {
404        // Distances: 1,2,3,1,2,1 → mean=10/6=5/3
405        let k = distance_kurtosis(&path4()).unwrap();
406        assert!(k.is_finite());
407    }
408
409    #[test]
410    fn dk_finite() {
411        for g in &[path3(), k3(), k4(), cycle4(), cycle5(), star5()] {
412            assert!(distance_kurtosis(g).unwrap().is_finite());
413        }
414    }
415
416    // --- diameter_ratio ---
417
418    #[test]
419    fn dr_empty() {
420        assert!(diameter_ratio(&empty()).unwrap().abs() < 1e-10);
421    }
422
423    #[test]
424    fn dr_single() {
425        assert!(diameter_ratio(&single()).unwrap().abs() < 1e-10);
426    }
427
428    #[test]
429    fn dr_single_edge() {
430        // diameter=1, n=2 → 1/(2-1) = 1.0
431        assert!((diameter_ratio(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
432    }
433
434    #[test]
435    fn dr_path3() {
436        // diameter=2, n=3 → 2/2 = 1.0
437        assert!((diameter_ratio(&path3()).unwrap() - 1.0).abs() < 1e-10);
438    }
439
440    #[test]
441    fn dr_path4() {
442        // diameter=3, n=4 → 3/3 = 1.0
443        assert!((diameter_ratio(&path4()).unwrap() - 1.0).abs() < 1e-10);
444    }
445
446    #[test]
447    fn dr_k3() {
448        // diameter=1, n=3 → 1/2 = 0.5
449        assert!((diameter_ratio(&k3()).unwrap() - 0.5).abs() < 1e-10);
450    }
451
452    #[test]
453    fn dr_k4() {
454        // diameter=1, n=4 → 1/3
455        assert!((diameter_ratio(&k4()).unwrap() - 1.0 / 3.0).abs() < 1e-10);
456    }
457
458    #[test]
459    fn dr_cycle4() {
460        // diameter=2, n=4 → 2/3
461        assert!((diameter_ratio(&cycle4()).unwrap() - 2.0 / 3.0).abs() < 1e-10);
462    }
463
464    #[test]
465    fn dr_star5() {
466        // diameter=2, n=5 → 2/4 = 0.5
467        assert!((diameter_ratio(&star5()).unwrap() - 0.5).abs() < 1e-10);
468    }
469
470    #[test]
471    fn dr_disconnected() {
472        assert!(diameter_ratio(&disconnected()).unwrap().abs() < 1e-10);
473    }
474
475    #[test]
476    fn dr_in_01() {
477        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5()] {
478            let r = diameter_ratio(g).unwrap();
479            assert!(r >= -1e-10);
480            assert!(r <= 1.0 + 1e-10);
481        }
482    }
483
484    // --- mean_eccentricity_ratio ---
485
486    #[test]
487    fn mer_empty() {
488        assert!(mean_eccentricity_ratio(&empty()).unwrap().abs() < 1e-10);
489    }
490
491    #[test]
492    fn mer_single() {
493        assert!(mean_eccentricity_ratio(&single()).unwrap().abs() < 1e-10);
494    }
495
496    #[test]
497    fn mer_k3() {
498        // All eccentricities = 1, diameter = 1 → 1.0
499        assert!((mean_eccentricity_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
500    }
501
502    #[test]
503    fn mer_k4() {
504        assert!((mean_eccentricity_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
505    }
506
507    #[test]
508    fn mer_path3() {
509        // Eccentricities: [2,1,2], diameter=2, mean=5/3, ratio=5/6
510        assert!((mean_eccentricity_ratio(&path3()).unwrap() - 5.0 / 6.0).abs() < 1e-10);
511    }
512
513    #[test]
514    fn mer_path4() {
515        // Eccentricities: [3,2,2,3], diameter=3, mean=10/4=2.5, ratio=2.5/3=5/6
516        assert!((mean_eccentricity_ratio(&path4()).unwrap() - 5.0 / 6.0).abs() < 1e-10);
517    }
518
519    #[test]
520    fn mer_cycle4() {
521        // All eccentricities = 2, diameter = 2 → 1.0
522        assert!((mean_eccentricity_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
523    }
524
525    #[test]
526    fn mer_star5() {
527        // Eccentricities: center=1, leaves=2 → mean=(1+2*4)/5=9/5=1.8
528        // diameter=2, ratio=1.8/2=0.9
529        assert!((mean_eccentricity_ratio(&star5()).unwrap() - 0.9).abs() < 1e-10);
530    }
531
532    #[test]
533    fn mer_disconnected() {
534        assert!(mean_eccentricity_ratio(&disconnected()).unwrap().abs() < 1e-10);
535    }
536
537    #[test]
538    fn mer_in_01() {
539        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5()] {
540            let r = mean_eccentricity_ratio(g).unwrap();
541            assert!(r >= -1e-10);
542            assert!(r <= 1.0 + 1e-10);
543        }
544    }
545
546    // --- cross-consistency ---
547
548    #[test]
549    fn complete_zero_skew_and_kurt() {
550        assert!(distance_skewness(&k3()).unwrap().abs() < 1e-10);
551        assert!(distance_skewness(&k4()).unwrap().abs() < 1e-10);
552        assert!(distance_kurtosis(&k3()).unwrap().abs() < 1e-10);
553        assert!(distance_kurtosis(&k4()).unwrap().abs() < 1e-10);
554    }
555
556    #[test]
557    fn path_max_diameter_ratio() {
558        // Path graphs have diameter = n-1 → ratio = 1.0
559        assert!((diameter_ratio(&path3()).unwrap() - 1.0).abs() < 1e-10);
560        assert!((diameter_ratio(&path4()).unwrap() - 1.0).abs() < 1e-10);
561    }
562
563    #[test]
564    fn regular_full_ecc_ratio() {
565        // Regular graphs where all eccentricities equal → ratio = 1.0
566        assert!((mean_eccentricity_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
567        assert!((mean_eccentricity_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
568        assert!((mean_eccentricity_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
569    }
570}