Skip to main content

rust_igraph/algorithms/properties/
degree_vertex_class.rs

1//! Degree-based vertex class ratios (ALGO-TR-088).
2//!
3//! Fractions of the vertex set that fall into degree-based classes:
4//!
5//! - **Leaf ratio** — fraction of vertices with degree 1 (pendants)
6//! - **Isolated ratio** — fraction of vertices with degree 0
7//! - **Core ratio** — fraction of vertices with degree ≥ d̄ (mean)
8//! - **Tail ratio** — fraction of vertices with degree ≥ 2·d̄ (heavy tail)
9
10#![allow(
11    clippy::cast_possible_truncation,
12    clippy::cast_precision_loss,
13    clippy::many_single_char_names,
14    clippy::needless_range_loop,
15    clippy::too_many_lines
16)]
17
18use crate::core::{Graph, IgraphResult};
19
20/// Compute the leaf ratio (fraction of degree-1 vertices).
21///
22/// Returns the fraction of vertices with degree exactly 1 (pendants).
23/// Returns 0.0 for empty graphs.
24///
25/// # Examples
26///
27/// ```
28/// use rust_igraph::{Graph, degree_leaf_ratio};
29///
30/// // Star S_5: 4 leaves out of 5 → 0.8
31/// let g = Graph::from_edges(&[(0,1),(0,2),(0,3),(0,4)], false, Some(5)).unwrap();
32/// assert!((degree_leaf_ratio(&g).unwrap() - 0.8).abs() < 1e-10);
33/// ```
34pub fn degree_leaf_ratio(graph: &Graph) -> IgraphResult<f64> {
35    let n = graph.vcount() as usize;
36    if n == 0 {
37        return Ok(0.0);
38    }
39
40    let mut count = 0_usize;
41    for v in 0..n {
42        if graph.degree(v as u32)? == 1 {
43            count += 1;
44        }
45    }
46
47    Ok(count as f64 / n as f64)
48}
49
50/// Compute the isolated ratio (fraction of degree-0 vertices).
51///
52/// Returns the fraction of vertices with degree 0.
53/// Returns 0.0 for empty graphs. Returns 1.0 for edgeless
54/// non-empty graphs.
55///
56/// # Examples
57///
58/// ```
59/// use rust_igraph::{Graph, degree_isolated_ratio};
60///
61/// // 3 isolated + 2 connected → 3/5 = 0.6
62/// let mut g = Graph::with_vertices(5);
63/// g.add_edge(3, 4).unwrap();
64/// assert!((degree_isolated_ratio(&g).unwrap() - 0.6).abs() < 1e-10);
65/// ```
66pub fn degree_isolated_ratio(graph: &Graph) -> IgraphResult<f64> {
67    let n = graph.vcount() as usize;
68    if n == 0 {
69        return Ok(0.0);
70    }
71
72    let mut count = 0_usize;
73    for v in 0..n {
74        if graph.degree(v as u32)? == 0 {
75            count += 1;
76        }
77    }
78
79    Ok(count as f64 / n as f64)
80}
81
82/// Compute the core ratio (fraction of vertices with degree ≥ d̄).
83///
84/// Returns the fraction of vertices whose degree is at least the
85/// mean degree. Returns 0.0 for empty graphs. For regular graphs
86/// this is always 1.0.
87///
88/// # Examples
89///
90/// ```
91/// use rust_igraph::{Graph, degree_core_ratio};
92///
93/// // K_3: all degree 2, mean = 2 → all qualify → 1.0
94/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
95/// assert!((degree_core_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
96/// ```
97pub fn degree_core_ratio(graph: &Graph) -> IgraphResult<f64> {
98    let n = graph.vcount() as usize;
99    if n == 0 {
100        return Ok(0.0);
101    }
102
103    let degrees = collect_degrees(graph)?;
104    let mean = degrees.iter().sum::<usize>() as f64 / n as f64;
105
106    let count = degrees
107        .iter()
108        .filter(|&&d| d as f64 >= mean - 1e-12)
109        .count();
110
111    Ok(count as f64 / n as f64)
112}
113
114/// Compute the tail ratio (fraction of vertices with degree ≥ 2·d̄).
115///
116/// Returns the fraction of vertices whose degree is at least twice
117/// the mean degree. A heavy-tail indicator: sparse graphs with hubs
118/// have higher tail ratios. Returns 0.0 for empty or edgeless graphs.
119///
120/// # Examples
121///
122/// ```
123/// use rust_igraph::{Graph, degree_tail_ratio};
124///
125/// // Star S_5: mean = 8/5 = 1.6, threshold = 3.2, only vertex 0 (d=4) qualifies → 1/5 = 0.2
126/// let g = Graph::from_edges(&[(0,1),(0,2),(0,3),(0,4)], false, Some(5)).unwrap();
127/// assert!((degree_tail_ratio(&g).unwrap() - 0.2).abs() < 1e-10);
128/// ```
129pub fn degree_tail_ratio(graph: &Graph) -> IgraphResult<f64> {
130    let n = graph.vcount() as usize;
131    if n == 0 {
132        return Ok(0.0);
133    }
134
135    let degrees = collect_degrees(graph)?;
136    let mean = degrees.iter().sum::<usize>() as f64 / n as f64;
137    let threshold = 2.0 * mean;
138
139    let count = degrees
140        .iter()
141        .filter(|&&d| d as f64 >= threshold - 1e-12)
142        .count();
143
144    Ok(count as f64 / n as f64)
145}
146
147fn collect_degrees(graph: &Graph) -> IgraphResult<Vec<usize>> {
148    let n = graph.vcount() as usize;
149    let mut degrees = Vec::with_capacity(n);
150    for v in 0..n {
151        degrees.push(graph.degree(v as u32)?);
152    }
153    Ok(degrees)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn single_edge() -> Graph {
161        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
162    }
163
164    fn path3() -> Graph {
165        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
166    }
167
168    fn k3() -> Graph {
169        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
170    }
171
172    fn k4() -> Graph {
173        Graph::from_edges(
174            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
175            false,
176            Some(4),
177        )
178        .unwrap()
179    }
180
181    fn cycle4() -> Graph {
182        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
183    }
184
185    fn star5() -> Graph {
186        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
187    }
188
189    fn paw() -> Graph {
190        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
191    }
192
193    // --- degree_leaf_ratio ---
194
195    #[test]
196    fn leaf_empty() {
197        let g = Graph::with_vertices(0);
198        assert!(degree_leaf_ratio(&g).unwrap().abs() < 1e-10);
199    }
200
201    #[test]
202    fn leaf_isolated() {
203        let g = Graph::with_vertices(5);
204        assert!(degree_leaf_ratio(&g).unwrap().abs() < 1e-10);
205    }
206
207    #[test]
208    fn leaf_single_edge() {
209        // Both vertices have degree 1 → 1.0
210        assert!((degree_leaf_ratio(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
211    }
212
213    #[test]
214    fn leaf_star5() {
215        // 4 leaves / 5 = 0.8
216        assert!((degree_leaf_ratio(&star5()).unwrap() - 0.8).abs() < 1e-10);
217    }
218
219    #[test]
220    fn leaf_path3() {
221        // [1,2,1] → 2 leaves / 3
222        let expected = 2.0 / 3.0;
223        assert!((degree_leaf_ratio(&path3()).unwrap() - expected).abs() < 1e-10);
224    }
225
226    #[test]
227    fn leaf_k3() {
228        // All degree 2 → 0 leaves
229        assert!(degree_leaf_ratio(&k3()).unwrap().abs() < 1e-10);
230    }
231
232    #[test]
233    fn leaf_paw() {
234        // [2,2,3,1] → 1 leaf / 4 = 0.25
235        assert!((degree_leaf_ratio(&paw()).unwrap() - 0.25).abs() < 1e-10);
236    }
237
238    // --- degree_isolated_ratio ---
239
240    #[test]
241    fn iso_empty() {
242        let g = Graph::with_vertices(0);
243        assert!(degree_isolated_ratio(&g).unwrap().abs() < 1e-10);
244    }
245
246    #[test]
247    fn iso_all_isolated() {
248        let g = Graph::with_vertices(5);
249        assert!((degree_isolated_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
250    }
251
252    #[test]
253    fn iso_single_edge() {
254        assert!(degree_isolated_ratio(&single_edge()).unwrap().abs() < 1e-10);
255    }
256
257    #[test]
258    fn iso_k3() {
259        assert!(degree_isolated_ratio(&k3()).unwrap().abs() < 1e-10);
260    }
261
262    #[test]
263    fn iso_with_isolates() {
264        // 5 vertices, only edge (0,1) → 3 isolated / 5 = 0.6
265        let mut g = Graph::with_vertices(5);
266        g.add_edge(0, 1).unwrap();
267        assert!((degree_isolated_ratio(&g).unwrap() - 0.6).abs() < 1e-10);
268    }
269
270    #[test]
271    fn iso_star5() {
272        assert!(degree_isolated_ratio(&star5()).unwrap().abs() < 1e-10);
273    }
274
275    // --- degree_core_ratio ---
276
277    #[test]
278    fn core_empty() {
279        let g = Graph::with_vertices(0);
280        assert!(degree_core_ratio(&g).unwrap().abs() < 1e-10);
281    }
282
283    #[test]
284    fn core_isolated() {
285        // All degree 0, mean = 0 → all qualify → 1.0
286        let g = Graph::with_vertices(5);
287        assert!((degree_core_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
288    }
289
290    #[test]
291    fn core_regular() {
292        // Regular: all degrees = mean → all qualify → 1.0
293        assert!((degree_core_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
294        assert!((degree_core_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
295        assert!((degree_core_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
296    }
297
298    #[test]
299    fn core_single_edge() {
300        // [1,1] mean=1 → both qualify → 1.0
301        assert!((degree_core_ratio(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
302    }
303
304    #[test]
305    fn core_star5() {
306        // [4,1,1,1,1] mean=8/5=1.6, only vertex 0 (d=4) qualifies → 1/5 = 0.2
307        assert!((degree_core_ratio(&star5()).unwrap() - 0.2).abs() < 1e-10);
308    }
309
310    #[test]
311    fn core_path3() {
312        // [1,2,1] mean=4/3≈1.33, vertex 1 (d=2) qualifies → 1/3
313        let expected = 1.0 / 3.0;
314        assert!((degree_core_ratio(&path3()).unwrap() - expected).abs() < 1e-10);
315    }
316
317    #[test]
318    fn core_paw() {
319        // [2,2,3,1] mean=8/4=2, vertices 0,1,2 qualify (d≥2) → 3/4 = 0.75
320        assert!((degree_core_ratio(&paw()).unwrap() - 0.75).abs() < 1e-10);
321    }
322
323    // --- degree_tail_ratio ---
324
325    #[test]
326    fn tail_empty() {
327        let g = Graph::with_vertices(0);
328        assert!(degree_tail_ratio(&g).unwrap().abs() < 1e-10);
329    }
330
331    #[test]
332    fn tail_isolated() {
333        // mean=0, threshold=0, all qualify → 1.0
334        let g = Graph::with_vertices(5);
335        assert!((degree_tail_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
336    }
337
338    #[test]
339    fn tail_regular() {
340        // Regular degree r: threshold=2r, no vertex reaches 2r → 0.0
341        assert!(degree_tail_ratio(&k3()).unwrap().abs() < 1e-10);
342        assert!(degree_tail_ratio(&k4()).unwrap().abs() < 1e-10);
343        assert!(degree_tail_ratio(&cycle4()).unwrap().abs() < 1e-10);
344    }
345
346    #[test]
347    fn tail_single_edge() {
348        // [1,1] mean=1, threshold=2, none qualify → 0.0
349        assert!(degree_tail_ratio(&single_edge()).unwrap().abs() < 1e-10);
350    }
351
352    #[test]
353    fn tail_star5() {
354        // [4,1,1,1,1] mean=1.6, threshold=3.2, only d=4 → 1/5 = 0.2
355        assert!((degree_tail_ratio(&star5()).unwrap() - 0.2).abs() < 1e-10);
356    }
357
358    #[test]
359    fn tail_path3() {
360        // [1,2,1] mean=4/3, threshold=8/3≈2.67, none qualify → 0.0
361        assert!(degree_tail_ratio(&path3()).unwrap().abs() < 1e-10);
362    }
363
364    #[test]
365    fn tail_paw() {
366        // [2,2,3,1] mean=2, threshold=4, none qualify → 0.0
367        assert!(degree_tail_ratio(&paw()).unwrap().abs() < 1e-10);
368    }
369
370    // --- cross-consistency ---
371
372    #[test]
373    fn all_ratios_in_01() {
374        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
375            for f in &[
376                degree_leaf_ratio as fn(&Graph) -> IgraphResult<f64>,
377                degree_isolated_ratio,
378                degree_core_ratio,
379                degree_tail_ratio,
380            ] {
381                let val = f(g).unwrap();
382                assert!(val >= -1e-10, "ratio below 0: {val}");
383                assert!(val <= 1.0 + 1e-10, "ratio above 1: {val}");
384            }
385        }
386    }
387
388    #[test]
389    fn tail_le_core() {
390        // d ≥ 2·mean implies d ≥ mean, so tail ⊆ core
391        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
392            let tail = degree_tail_ratio(g).unwrap();
393            let core = degree_core_ratio(g).unwrap();
394            assert!(tail <= core + 1e-10);
395        }
396    }
397
398    #[test]
399    fn leaf_plus_iso_le_one() {
400        // leaf (d=1) and isolated (d=0) are disjoint classes
401        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
402            let leaf = degree_leaf_ratio(g).unwrap();
403            let iso = degree_isolated_ratio(g).unwrap();
404            assert!(leaf + iso <= 1.0 + 1e-10);
405        }
406    }
407}