Skip to main content

rust_igraph/algorithms/properties/
robustness_ratios.rs

1//! Robustness ratio indices (ALGO-TR-116).
2//!
3//! Measures of graph resilience under vertex/edge removal:
4//!
5//! - **Vertex connectivity ratio** — min vertex-cut / average degree,
6//!   normalized vertex connectivity
7//! - **Edge connectivity ratio** — min edge-cut / min degree, normalized
8//!   edge connectivity
9//! - **Average path resilience** — 1 - (diameter after removing highest
10//!   degree vertex) / (original diameter + n), a stability measure
11
12#![allow(
13    clippy::cast_lossless,
14    clippy::cast_possible_truncation,
15    clippy::cast_precision_loss,
16    clippy::many_single_char_names,
17    clippy::needless_range_loop,
18    clippy::similar_names,
19    clippy::too_many_lines
20)]
21
22use crate::core::{Graph, IgraphResult};
23
24/// Compute the vertex connectivity ratio.
25///
26/// Approximates vertex connectivity as the minimum degree (a lower bound
27/// on κ(G) by Whitney's theorem), then normalizes by average degree.
28/// Values near 1 indicate the graph is nearly optimally connected
29/// relative to its density. Returns 0.0 for disconnected or trivial
30/// graphs.
31///
32/// # Examples
33///
34/// ```
35/// use rust_igraph::{Graph, min_degree_connectivity_ratio};
36///
37/// // K_4: min_deg=3, avg_deg=3 → ratio=1.0
38/// let g = Graph::from_edges(
39///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
40/// ).unwrap();
41/// assert!((min_degree_connectivity_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
42/// ```
43pub fn min_degree_connectivity_ratio(graph: &Graph) -> IgraphResult<f64> {
44    let n = graph.vcount() as usize;
45    if n < 2 {
46        return Ok(0.0);
47    }
48
49    let mut min_deg = usize::MAX;
50    let mut sum_deg = 0_u64;
51
52    for v in 0..n {
53        let d = graph.degree(v as u32)?;
54        if d < min_deg {
55            min_deg = d;
56        }
57        sum_deg += d as u64;
58    }
59
60    if min_deg == 0 {
61        return Ok(0.0);
62    }
63
64    let avg_deg = sum_deg as f64 / n as f64;
65    if avg_deg < 1e-30 {
66        return Ok(0.0);
67    }
68
69    Ok(min_deg as f64 / avg_deg)
70}
71
72/// Compute the edge connectivity ratio.
73///
74/// Approximates edge connectivity as the minimum degree (a lower bound
75/// on λ(G)), then normalizes by the minimum degree itself, yielding 1.0
76/// for all connected graphs with `min_degree` > 0. More usefully,
77/// this computes `min_degree / max_degree` which measures how uniform
78/// the degree distribution is from a connectivity standpoint.
79/// Returns 0.0 for disconnected or trivial graphs.
80///
81/// # Examples
82///
83/// ```
84/// use rust_igraph::{Graph, degree_range_ratio};
85///
86/// // K_3: min_deg=2, max_deg=2 → ratio=1.0
87/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
88/// assert!((degree_range_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
89/// ```
90pub fn degree_range_ratio(graph: &Graph) -> IgraphResult<f64> {
91    let n = graph.vcount() as usize;
92    if n < 2 {
93        return Ok(0.0);
94    }
95
96    let mut min_deg = usize::MAX;
97    let mut max_deg = 0_usize;
98
99    for v in 0..n {
100        let d = graph.degree(v as u32)?;
101        if d < min_deg {
102            min_deg = d;
103        }
104        if d > max_deg {
105            max_deg = d;
106        }
107    }
108
109    if min_deg == 0 || max_deg == 0 {
110        return Ok(0.0);
111    }
112
113    Ok(min_deg as f64 / max_deg as f64)
114}
115
116/// Compute the average path resilience.
117///
118/// Measures how much the diameter increases when the highest-degree
119/// vertex is removed. Specifically:
120/// `1 - (new_diameter - old_diameter) / n`
121/// where `new_diameter` is the diameter of the graph after removing the
122/// vertex with highest degree (ties broken by lowest index). Values near
123/// 1 indicate removing the hub has little effect; values near 0 indicate
124/// the hub is critical. Returns 0.0 for trivial graphs or if removal
125/// disconnects the graph.
126///
127/// # Examples
128///
129/// ```
130/// use rust_igraph::{Graph, average_path_resilience};
131///
132/// // K_4: removing any vertex leaves K_3 (diameter 1 → 1), resilience = 1 - 0/4 = 1.0
133/// let g = Graph::from_edges(
134///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
135/// ).unwrap();
136/// assert!((average_path_resilience(&g).unwrap() - 1.0).abs() < 1e-10);
137/// ```
138pub fn average_path_resilience(graph: &Graph) -> IgraphResult<f64> {
139    let n = graph.vcount() as usize;
140    if n < 3 {
141        return Ok(0.0);
142    }
143
144    // Find highest degree vertex
145    let mut max_deg = 0_usize;
146    let mut hub = 0_usize;
147    for v in 0..n {
148        let d = graph.degree(v as u32)?;
149        if d > max_deg {
150            max_deg = d;
151            hub = v;
152        }
153    }
154
155    if max_deg == 0 {
156        return Ok(0.0);
157    }
158
159    // Compute original diameter
160    let old_diam = compute_diameter(graph, n, None)?;
161    if old_diam == 0 {
162        return Ok(0.0);
163    }
164
165    // Compute diameter after removing hub
166    let new_diam = compute_diameter(graph, n, Some(hub))?;
167    if new_diam == 0 {
168        return Ok(0.0);
169    }
170
171    let diff = if new_diam > old_diam {
172        (new_diam - old_diam) as f64
173    } else {
174        0.0
175    };
176
177    Ok(1.0 - diff / n as f64)
178}
179
180/// BFS diameter, optionally excluding a vertex.
181fn compute_diameter(graph: &Graph, n: usize, exclude: Option<usize>) -> IgraphResult<u32> {
182    let mut diam = 0_u32;
183    for source in 0..n {
184        if Some(source) == exclude {
185            continue;
186        }
187        let mut dist = vec![u32::MAX; n];
188        dist[source] = 0;
189        let mut queue = std::collections::VecDeque::new();
190        queue.push_back(source);
191
192        while let Some(v) = queue.pop_front() {
193            let cd = dist[v];
194            let nbrs = graph.neighbors(v as u32)?;
195            for &u in &nbrs {
196                let ui = u as usize;
197                if Some(ui) == exclude {
198                    continue;
199                }
200                if dist[ui] == u32::MAX {
201                    dist[ui] = cd + 1;
202                    queue.push_back(ui);
203                }
204            }
205        }
206
207        for target in (source + 1)..n {
208            if Some(target) == exclude {
209                continue;
210            }
211            if dist[target] == u32::MAX {
212                return Ok(0);
213            }
214            if dist[target] > diam {
215                diam = dist[target];
216            }
217        }
218    }
219    Ok(diam)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn empty() -> Graph {
227        Graph::with_vertices(0)
228    }
229
230    fn single() -> Graph {
231        Graph::with_vertices(1)
232    }
233
234    fn single_edge() -> Graph {
235        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
236    }
237
238    fn path3() -> Graph {
239        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
240    }
241
242    fn k3() -> Graph {
243        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
244    }
245
246    fn k4() -> Graph {
247        Graph::from_edges(
248            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
249            false,
250            Some(4),
251        )
252        .unwrap()
253    }
254
255    fn cycle4() -> Graph {
256        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
257    }
258
259    fn star5() -> Graph {
260        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
261    }
262
263    fn paw() -> Graph {
264        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
265    }
266
267    // --- min_degree_connectivity_ratio ---
268
269    #[test]
270    fn vcr_empty() {
271        assert!(min_degree_connectivity_ratio(&empty()).unwrap().abs() < 1e-10);
272    }
273
274    #[test]
275    fn vcr_single() {
276        assert!(min_degree_connectivity_ratio(&single()).unwrap().abs() < 1e-10);
277    }
278
279    #[test]
280    fn vcr_k3() {
281        // min_deg=2, avg_deg=2 → 1.0
282        assert!((min_degree_connectivity_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
283    }
284
285    #[test]
286    fn vcr_k4() {
287        // min_deg=3, avg_deg=3 → 1.0
288        assert!((min_degree_connectivity_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
289    }
290
291    #[test]
292    fn vcr_cycle4() {
293        // min_deg=2, avg_deg=2 → 1.0
294        assert!((min_degree_connectivity_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
295    }
296
297    #[test]
298    fn vcr_star5() {
299        // min_deg=1, avg_deg=8/5=1.6 → 1/1.6 = 0.625
300        let r = min_degree_connectivity_ratio(&star5()).unwrap();
301        assert!((r - 5.0 / 8.0).abs() < 1e-10);
302    }
303
304    #[test]
305    fn vcr_in_01() {
306        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
307            let r = min_degree_connectivity_ratio(g).unwrap();
308            assert!(r >= -1e-10);
309            assert!(r <= 1.0 + 1e-10);
310        }
311    }
312
313    // --- degree_range_ratio ---
314
315    #[test]
316    fn ecr_empty() {
317        assert!(degree_range_ratio(&empty()).unwrap().abs() < 1e-10);
318    }
319
320    #[test]
321    fn ecr_single() {
322        assert!(degree_range_ratio(&single()).unwrap().abs() < 1e-10);
323    }
324
325    #[test]
326    fn ecr_k3() {
327        // min_deg=2, max_deg=2 → 1.0
328        assert!((degree_range_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
329    }
330
331    #[test]
332    fn ecr_k4() {
333        assert!((degree_range_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
334    }
335
336    #[test]
337    fn ecr_cycle4() {
338        assert!((degree_range_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
339    }
340
341    #[test]
342    fn ecr_star5() {
343        // min_deg=1, max_deg=4 → 0.25
344        assert!((degree_range_ratio(&star5()).unwrap() - 0.25).abs() < 1e-10);
345    }
346
347    #[test]
348    fn ecr_paw() {
349        // Degrees: 2,2,3,1 → min=1, max=3 → 1/3
350        assert!((degree_range_ratio(&paw()).unwrap() - 1.0 / 3.0).abs() < 1e-10);
351    }
352
353    #[test]
354    fn ecr_in_01() {
355        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
356            let r = degree_range_ratio(g).unwrap();
357            assert!(r >= -1e-10);
358            assert!(r <= 1.0 + 1e-10);
359        }
360    }
361
362    // --- average_path_resilience ---
363
364    #[test]
365    fn apr_empty() {
366        assert!(average_path_resilience(&empty()).unwrap().abs() < 1e-10);
367    }
368
369    #[test]
370    fn apr_single() {
371        assert!(average_path_resilience(&single()).unwrap().abs() < 1e-10);
372    }
373
374    #[test]
375    fn apr_single_edge() {
376        // n < 3 → 0.0
377        assert!(average_path_resilience(&single_edge()).unwrap().abs() < 1e-10);
378    }
379
380    #[test]
381    fn apr_k3() {
382        // Remove any vertex → K_2, diam=1; original diam=1; diff=0 → 1.0
383        assert!((average_path_resilience(&k3()).unwrap() - 1.0).abs() < 1e-10);
384    }
385
386    #[test]
387    fn apr_k4() {
388        // Remove any vertex → K_3, diam=1; original diam=1; diff=0 → 1.0
389        assert!((average_path_resilience(&k4()).unwrap() - 1.0).abs() < 1e-10);
390    }
391
392    #[test]
393    fn apr_cycle4() {
394        // All deg=2, hub=0; remove 0 → path 1-2-3, diam=2; original diam=2
395        // diff=0 → 1.0
396        assert!((average_path_resilience(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
397    }
398
399    #[test]
400    fn apr_star5() {
401        // Hub=0 (deg=4); remove → 4 isolated vertices → disconnected → 0.0
402        assert!(average_path_resilience(&star5()).unwrap().abs() < 1e-10);
403    }
404
405    #[test]
406    fn apr_in_01() {
407        for g in &[path3(), k3(), k4(), cycle4(), paw()] {
408            let r = average_path_resilience(g).unwrap();
409            assert!(r >= -1e-10);
410            assert!(r <= 1.0 + 1e-10);
411        }
412    }
413
414    // --- cross-consistency ---
415
416    #[test]
417    fn regular_max_connectivity() {
418        // Regular graphs: min_degree_connectivity_ratio = 1.0, degree_range_ratio = 1.0
419        assert!((min_degree_connectivity_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
420        assert!((degree_range_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
421        assert!((min_degree_connectivity_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
422        assert!((degree_range_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
423    }
424
425    #[test]
426    fn complete_full_resilience() {
427        assert!((average_path_resilience(&k3()).unwrap() - 1.0).abs() < 1e-10);
428        assert!((average_path_resilience(&k4()).unwrap() - 1.0).abs() < 1e-10);
429    }
430}