Skip to main content

rust_igraph/algorithms/properties/
subgraph_ratios.rs

1//! Subgraph ratio indices (ALGO-TR-097).
2//!
3//! Simple substructure density measures:
4//!
5//! - **Pendant edge ratio** — fraction of edges incident to degree-1 vertices
6//! - **Bridge ratio** — fraction of edges that are bridges (via Tarjan)
7//! - **Triangle participation** — fraction of vertices in at least one triangle
8//! - **Isolated vertex ratio** — fraction of vertices with degree 0
9
10#![allow(
11    clippy::cast_possible_truncation,
12    clippy::cast_precision_loss,
13    clippy::cast_sign_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 pendant edge ratio.
23///
24/// Fraction of edges that are incident to at least one degree-1
25/// vertex. A pendant edge connects a leaf to the rest of the graph.
26/// Returns 0.0 for graphs with no edges.
27///
28/// # Examples
29///
30/// ```
31/// use rust_igraph::{Graph, pendant_edge_ratio};
32///
33/// // Star S_5: all 4 edges are pendant → 1.0
34/// let g = Graph::from_edges(&[(0,1),(0,2),(0,3),(0,4)], false, Some(5)).unwrap();
35/// assert!((pendant_edge_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
36/// ```
37pub fn pendant_edge_ratio(graph: &Graph) -> IgraphResult<f64> {
38    let m = graph.ecount();
39    if m == 0 {
40        return Ok(0.0);
41    }
42
43    let n = graph.vcount() as usize;
44    let mut degrees = Vec::with_capacity(n);
45    for v in 0..n {
46        degrees.push(graph.degree(v as u32)?);
47    }
48
49    let mut pendant_count = 0_usize;
50    for (u, v) in graph.edges() {
51        if degrees[u as usize] == 1 || degrees[v as usize] == 1 {
52            pendant_count += 1;
53        }
54    }
55
56    Ok(pendant_count as f64 / m as f64)
57}
58
59/// Compute the bridge ratio of the graph.
60///
61/// Fraction of edges that are bridges (whose removal disconnects
62/// the graph). Uses Tarjan's bridge-finding algorithm with DFS.
63/// Returns 0.0 for graphs with no edges.
64///
65/// # Examples
66///
67/// ```
68/// use rust_igraph::{Graph, bridge_ratio};
69///
70/// // Path 0-1-2: both edges are bridges → 1.0
71/// let g = Graph::from_edges(&[(0,1),(1,2)], false, Some(3)).unwrap();
72/// assert!((bridge_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
73/// ```
74pub fn bridge_ratio(graph: &Graph) -> IgraphResult<f64> {
75    let m = graph.ecount();
76    if m == 0 {
77        return Ok(0.0);
78    }
79
80    let bridge_count = count_bridges(graph)?;
81    Ok(bridge_count as f64 / m as f64)
82}
83
84/// Compute the triangle participation ratio.
85///
86/// Fraction of vertices that participate in at least one triangle.
87/// Returns 0.0 for graphs with fewer than 3 vertices or no edges.
88///
89/// # Examples
90///
91/// ```
92/// use rust_igraph::{Graph, triangle_participation};
93///
94/// // K_3: all 3 vertices in a triangle → 1.0
95/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
96/// assert!((triangle_participation(&g).unwrap() - 1.0).abs() < 1e-10);
97/// ```
98pub fn triangle_participation(graph: &Graph) -> IgraphResult<f64> {
99    let n = graph.vcount() as usize;
100    if n < 3 {
101        return Ok(0.0);
102    }
103
104    let mut in_triangle = vec![false; n];
105
106    for v in 0..n {
107        if in_triangle[v] {
108            continue;
109        }
110        let vid = v as u32;
111        let neighbors = graph.neighbors(vid)?;
112        for i in 0..neighbors.len() {
113            for j in (i + 1)..neighbors.len() {
114                let a = neighbors[i];
115                let b = neighbors[j];
116                if graph.has_edge(a, b) {
117                    in_triangle[v] = true;
118                    in_triangle[a as usize] = true;
119                    in_triangle[b as usize] = true;
120                }
121            }
122        }
123    }
124
125    let count = in_triangle.iter().filter(|&&x| x).count();
126    Ok(count as f64 / n as f64)
127}
128
129/// Compute the isolated vertex ratio.
130///
131/// Fraction of vertices with degree 0. Returns 0.0 for empty
132/// graphs (no vertices).
133///
134/// # Examples
135///
136/// ```
137/// use rust_igraph::{Graph, isolated_vertex_ratio};
138///
139/// // 5 isolated vertices → 1.0
140/// let g = Graph::with_vertices(5);
141/// assert!((isolated_vertex_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
142/// ```
143pub fn isolated_vertex_ratio(graph: &Graph) -> IgraphResult<f64> {
144    let n = graph.vcount() as usize;
145    if n == 0 {
146        return Ok(0.0);
147    }
148
149    let mut isolated = 0_usize;
150    for v in 0..n {
151        if graph.degree(v as u32)? == 0 {
152            isolated += 1;
153        }
154    }
155
156    Ok(isolated as f64 / n as f64)
157}
158
159fn count_bridges(graph: &Graph) -> IgraphResult<usize> {
160    let n = graph.vcount() as usize;
161    if n == 0 {
162        return Ok(0);
163    }
164
165    let mut disc = vec![0_u32; n];
166    let mut low = vec![0_u32; n];
167    let mut visited = vec![false; n];
168    let mut timer = 1_u32;
169    let mut bridge_count = 0_usize;
170
171    for start in 0..n {
172        if visited[start] {
173            continue;
174        }
175        let mut stack: Vec<(u32, i64, usize)> = vec![(start as u32, -1, 0)];
176        visited[start] = true;
177        disc[start] = timer;
178        low[start] = timer;
179        timer += 1;
180
181        while let Some(&mut (v, parent, ref mut ni)) = stack.last_mut() {
182            let neighbors = graph.neighbors(v)?;
183            if *ni < neighbors.len() {
184                let u = neighbors[*ni];
185                *ni += 1;
186                if i64::from(u) == parent {
187                    continue;
188                }
189                let ui = u as usize;
190                if visited[ui] {
191                    if disc[ui] < low[v as usize] {
192                        low[v as usize] = disc[ui];
193                    }
194                } else {
195                    visited[ui] = true;
196                    disc[ui] = timer;
197                    low[ui] = timer;
198                    timer += 1;
199                    stack.push((u, i64::from(v), 0));
200                }
201            } else {
202                let vi = v as usize;
203                if parent >= 0 {
204                    let pi = parent as usize;
205                    if low[vi] < low[pi] {
206                        low[pi] = low[vi];
207                    }
208                    if low[vi] > disc[pi] {
209                        bridge_count += 1;
210                    }
211                }
212                stack.pop();
213            }
214        }
215    }
216
217    Ok(bridge_count)
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn single_edge() -> Graph {
225        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
226    }
227
228    fn path3() -> Graph {
229        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
230    }
231
232    fn k3() -> Graph {
233        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
234    }
235
236    fn k4() -> Graph {
237        Graph::from_edges(
238            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
239            false,
240            Some(4),
241        )
242        .unwrap()
243    }
244
245    fn cycle4() -> Graph {
246        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
247    }
248
249    fn star5() -> Graph {
250        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
251    }
252
253    fn paw() -> Graph {
254        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
255    }
256
257    // --- pendant_edge_ratio ---
258
259    #[test]
260    fn per_empty() {
261        let g = Graph::with_vertices(0);
262        assert!(pendant_edge_ratio(&g).unwrap().abs() < 1e-10);
263    }
264
265    #[test]
266    fn per_isolated() {
267        let g = Graph::with_vertices(5);
268        assert!(pendant_edge_ratio(&g).unwrap().abs() < 1e-10);
269    }
270
271    #[test]
272    fn per_single_edge() {
273        // Both endpoints have d=1 → 1 pendant edge / 1 edge = 1.0
274        assert!((pendant_edge_ratio(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
275    }
276
277    #[test]
278    fn per_path3() {
279        // Edges: (0,1) d(0)=1→pendant, (1,2) d(2)=1→pendant → 2/2 = 1.0
280        assert!((pendant_edge_ratio(&path3()).unwrap() - 1.0).abs() < 1e-10);
281    }
282
283    #[test]
284    fn per_k3() {
285        // All d=2, no pendants → 0
286        assert!(pendant_edge_ratio(&k3()).unwrap().abs() < 1e-10);
287    }
288
289    #[test]
290    fn per_k4() {
291        // All d=3, no pendants → 0
292        assert!(pendant_edge_ratio(&k4()).unwrap().abs() < 1e-10);
293    }
294
295    #[test]
296    fn per_cycle4() {
297        // All d=2, no pendants → 0
298        assert!(pendant_edge_ratio(&cycle4()).unwrap().abs() < 1e-10);
299    }
300
301    #[test]
302    fn per_star5() {
303        // All edges are pendant (leaves have d=1) → 4/4 = 1.0
304        assert!((pendant_edge_ratio(&star5()).unwrap() - 1.0).abs() < 1e-10);
305    }
306
307    #[test]
308    fn per_paw() {
309        // Edge (2,3): d(3)=1 → pendant. Others: no d=1 endpoints
310        // 1 pendant / 4 edges = 0.25
311        assert!((pendant_edge_ratio(&paw()).unwrap() - 0.25).abs() < 1e-10);
312    }
313
314    // --- bridge_ratio ---
315
316    #[test]
317    fn br_empty() {
318        let g = Graph::with_vertices(0);
319        assert!(bridge_ratio(&g).unwrap().abs() < 1e-10);
320    }
321
322    #[test]
323    fn br_isolated() {
324        let g = Graph::with_vertices(5);
325        assert!(bridge_ratio(&g).unwrap().abs() < 1e-10);
326    }
327
328    #[test]
329    fn br_single_edge() {
330        // 1 bridge / 1 edge = 1.0
331        assert!((bridge_ratio(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
332    }
333
334    #[test]
335    fn br_path3() {
336        // Both edges are bridges → 2/2 = 1.0
337        assert!((bridge_ratio(&path3()).unwrap() - 1.0).abs() < 1e-10);
338    }
339
340    #[test]
341    fn br_k3() {
342        // No bridges (biconnected) → 0
343        assert!(bridge_ratio(&k3()).unwrap().abs() < 1e-10);
344    }
345
346    #[test]
347    fn br_k4() {
348        // No bridges → 0
349        assert!(bridge_ratio(&k4()).unwrap().abs() < 1e-10);
350    }
351
352    #[test]
353    fn br_cycle4() {
354        // No bridges → 0
355        assert!(bridge_ratio(&cycle4()).unwrap().abs() < 1e-10);
356    }
357
358    #[test]
359    fn br_star5() {
360        // All 4 edges are bridges → 4/4 = 1.0
361        assert!((bridge_ratio(&star5()).unwrap() - 1.0).abs() < 1e-10);
362    }
363
364    #[test]
365    fn br_paw() {
366        // Edge (2,3) is a bridge, triangle edges are not → 1/4 = 0.25
367        assert!((bridge_ratio(&paw()).unwrap() - 0.25).abs() < 1e-10);
368    }
369
370    // --- triangle_participation ---
371
372    #[test]
373    fn tp_empty() {
374        let g = Graph::with_vertices(0);
375        assert!(triangle_participation(&g).unwrap().abs() < 1e-10);
376    }
377
378    #[test]
379    fn tp_two() {
380        let g = Graph::with_vertices(2);
381        assert!(triangle_participation(&g).unwrap().abs() < 1e-10);
382    }
383
384    #[test]
385    fn tp_path3() {
386        // No triangles → 0
387        assert!(triangle_participation(&path3()).unwrap().abs() < 1e-10);
388    }
389
390    #[test]
391    fn tp_k3() {
392        // All in triangle → 3/3 = 1.0
393        assert!((triangle_participation(&k3()).unwrap() - 1.0).abs() < 1e-10);
394    }
395
396    #[test]
397    fn tp_k4() {
398        // All in triangles → 4/4 = 1.0
399        assert!((triangle_participation(&k4()).unwrap() - 1.0).abs() < 1e-10);
400    }
401
402    #[test]
403    fn tp_cycle4() {
404        // No triangles → 0
405        assert!(triangle_participation(&cycle4()).unwrap().abs() < 1e-10);
406    }
407
408    #[test]
409    fn tp_star5() {
410        // No triangles → 0
411        assert!(triangle_participation(&star5()).unwrap().abs() < 1e-10);
412    }
413
414    #[test]
415    fn tp_paw() {
416        // Triangle: {0,1,2} → 3 vertices in triangle, v3 not
417        // 3/4 = 0.75
418        assert!((triangle_participation(&paw()).unwrap() - 0.75).abs() < 1e-10);
419    }
420
421    #[test]
422    fn tp_single_edge() {
423        // n < 3 → 0
424        assert!(triangle_participation(&single_edge()).unwrap().abs() < 1e-10);
425    }
426
427    // --- isolated_vertex_ratio ---
428
429    #[test]
430    fn ivr_empty() {
431        let g = Graph::with_vertices(0);
432        assert!(isolated_vertex_ratio(&g).unwrap().abs() < 1e-10);
433    }
434
435    #[test]
436    fn ivr_all_isolated() {
437        let g = Graph::with_vertices(5);
438        assert!((isolated_vertex_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
439    }
440
441    #[test]
442    fn ivr_single_edge() {
443        // 0 isolated / 2 = 0
444        assert!(isolated_vertex_ratio(&single_edge()).unwrap().abs() < 1e-10);
445    }
446
447    #[test]
448    fn ivr_k3() {
449        assert!(isolated_vertex_ratio(&k3()).unwrap().abs() < 1e-10);
450    }
451
452    #[test]
453    fn ivr_with_isolates() {
454        // 5 vertices, 1 edge (0-1), 3 isolated → 3/5 = 0.6
455        let g = Graph::from_edges(&[(0, 1)], false, Some(5)).unwrap();
456        assert!((isolated_vertex_ratio(&g).unwrap() - 0.6).abs() < 1e-10);
457    }
458
459    #[test]
460    fn ivr_star5() {
461        assert!(isolated_vertex_ratio(&star5()).unwrap().abs() < 1e-10);
462    }
463
464    #[test]
465    fn ivr_paw() {
466        assert!(isolated_vertex_ratio(&paw()).unwrap().abs() < 1e-10);
467    }
468
469    // --- cross-consistency ---
470
471    #[test]
472    fn per_in_01() {
473        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
474            let r = pendant_edge_ratio(g).unwrap();
475            assert!(r >= -1e-10);
476            assert!(r <= 1.0 + 1e-10);
477        }
478    }
479
480    #[test]
481    fn br_in_01() {
482        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
483            let r = bridge_ratio(g).unwrap();
484            assert!(r >= -1e-10);
485            assert!(r <= 1.0 + 1e-10);
486        }
487    }
488
489    #[test]
490    fn tp_in_01() {
491        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
492            let r = triangle_participation(g).unwrap();
493            assert!(r >= -1e-10);
494            assert!(r <= 1.0 + 1e-10);
495        }
496    }
497
498    #[test]
499    fn ivr_in_01() {
500        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
501            let r = isolated_vertex_ratio(g).unwrap();
502            assert!(r >= -1e-10);
503            assert!(r <= 1.0 + 1e-10);
504        }
505    }
506
507    #[test]
508    fn trees_all_bridges() {
509        // In a tree, every edge is a bridge
510        assert!((bridge_ratio(&path3()).unwrap() - 1.0).abs() < 1e-10);
511        assert!((bridge_ratio(&star5()).unwrap() - 1.0).abs() < 1e-10);
512    }
513
514    #[test]
515    fn pendant_implies_bridge_for_trees() {
516        // In a tree, pendant ratio = bridge ratio = 1.0 only if all edges are pendant
517        // path3: both pendant and bridge
518        // star5: all pendant and all bridge
519        let path_p = pendant_edge_ratio(&path3()).unwrap();
520        let path_b = bridge_ratio(&path3()).unwrap();
521        assert!((path_p - path_b).abs() < 1e-10);
522
523        let star_p = pendant_edge_ratio(&star5()).unwrap();
524        let star_b = bridge_ratio(&star5()).unwrap();
525        assert!((star_p - star_b).abs() < 1e-10);
526    }
527}