Skip to main content

rust_igraph/algorithms/properties/
degree_spread.rs

1//! Degree spread measures (ALGO-TR-083).
2//!
3//! Descriptive statistics of the degree sequence that capture how
4//! spread-out or concentrated the vertex degrees are:
5//!
6//! - **Degree range** `R(G) = d_max − d_min`
7//! - **Degree span ratio** `DSR(G) = (d_max − d_min) / d̄` (0 for edgeless)
8//! - **Degree median** — the median of the degree sequence
9//! - **Degree IQR** `IQR(G) = Q₃ − Q₁` (interquartile range)
10
11#![allow(
12    clippy::cast_possible_truncation,
13    clippy::cast_precision_loss,
14    clippy::cast_sign_loss,
15    clippy::many_single_char_names,
16    clippy::needless_range_loop,
17    clippy::too_many_lines
18)]
19
20use crate::core::{Graph, IgraphResult};
21
22/// Compute the range of the degree sequence.
23///
24/// `R(G) = d_max - d_min`
25///
26/// Returns 0 for empty or single-vertex graphs. For regular graphs,
27/// the range is 0.
28///
29/// # Examples
30///
31/// ```
32/// use rust_igraph::{Graph, degree_range};
33///
34/// // Star S_5: d_max=4, d_min=1 → range=3
35/// let g = Graph::from_edges(&[(0,1),(0,2),(0,3),(0,4)], false, Some(5)).unwrap();
36/// assert_eq!(degree_range(&g).unwrap(), 3);
37/// ```
38pub fn degree_range(graph: &Graph) -> IgraphResult<usize> {
39    let n = graph.vcount() as usize;
40    if n == 0 {
41        return Ok(0);
42    }
43
44    let mut d_min = usize::MAX;
45    let mut d_max = 0_usize;
46    for v in 0..n {
47        let d = graph.degree(v as u32)?;
48        if d < d_min {
49            d_min = d;
50        }
51        if d > d_max {
52            d_max = d;
53        }
54    }
55
56    Ok(d_max - d_min)
57}
58
59/// Compute the degree span ratio.
60///
61/// `DSR(G) = (d_max - d_min) / d̄`
62///
63/// Normalizes the degree range by the mean degree. Returns 0.0 for
64/// edgeless or empty graphs.
65///
66/// # Examples
67///
68/// ```
69/// use rust_igraph::{Graph, degree_span_ratio};
70///
71/// // K_3: all degrees equal → span ratio = 0
72/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
73/// assert!(degree_span_ratio(&g).unwrap().abs() < 1e-10);
74/// ```
75pub fn degree_span_ratio(graph: &Graph) -> IgraphResult<f64> {
76    let n = graph.vcount() as usize;
77    if n == 0 {
78        return Ok(0.0);
79    }
80
81    let mut d_min = usize::MAX;
82    let mut d_max = 0_usize;
83    let mut sum = 0_usize;
84    for v in 0..n {
85        let d = graph.degree(v as u32)?;
86        if d < d_min {
87            d_min = d;
88        }
89        if d > d_max {
90            d_max = d;
91        }
92        sum += d;
93    }
94
95    let mean = sum as f64 / n as f64;
96    if mean <= 0.0 {
97        return Ok(0.0);
98    }
99
100    Ok((d_max - d_min) as f64 / mean)
101}
102
103/// Compute the median of the degree sequence.
104///
105/// Returns the median degree. For even-sized sequences, returns the
106/// average of the two middle values. Returns 0.0 for empty graphs.
107///
108/// # Examples
109///
110/// ```
111/// use rust_igraph::{Graph, degree_median};
112///
113/// // Path P_3: degrees [1,2,1] sorted → [1,1,2], median = 1
114/// let g = Graph::from_edges(&[(0,1),(1,2)], false, Some(3)).unwrap();
115/// assert!((degree_median(&g).unwrap() - 1.0).abs() < 1e-10);
116/// ```
117pub fn degree_median(graph: &Graph) -> IgraphResult<f64> {
118    let n = graph.vcount() as usize;
119    if n == 0 {
120        return Ok(0.0);
121    }
122
123    let mut degrees = Vec::with_capacity(n);
124    for v in 0..n {
125        degrees.push(graph.degree(v as u32)?);
126    }
127    degrees.sort_unstable();
128
129    if n % 2 == 1 {
130        Ok(degrees[n / 2] as f64)
131    } else {
132        Ok(f64::midpoint(
133            degrees[n / 2 - 1] as f64,
134            degrees[n / 2] as f64,
135        ))
136    }
137}
138
139/// Compute the interquartile range (IQR) of the degree sequence.
140///
141/// `IQR(G) = Q₃ - Q₁`
142///
143/// Uses linear interpolation for quartiles. Returns 0.0 for graphs
144/// with fewer than 2 vertices.
145///
146/// # Examples
147///
148/// ```
149/// use rust_igraph::{Graph, degree_iqr};
150///
151/// // K_3: all degrees = 2 → IQR = 0
152/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
153/// assert!(degree_iqr(&g).unwrap().abs() < 1e-10);
154/// ```
155pub fn degree_iqr(graph: &Graph) -> IgraphResult<f64> {
156    let n = graph.vcount() as usize;
157    if n < 2 {
158        return Ok(0.0);
159    }
160
161    let mut degrees = Vec::with_capacity(n);
162    for v in 0..n {
163        degrees.push(graph.degree(v as u32)? as f64);
164    }
165    degrees.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
166
167    let q1 = percentile(&degrees, 0.25);
168    let q3 = percentile(&degrees, 0.75);
169
170    Ok(q3 - q1)
171}
172
173fn percentile(sorted: &[f64], p: f64) -> f64 {
174    let n = sorted.len();
175    if n == 0 {
176        return 0.0;
177    }
178    if n == 1 {
179        return sorted[0];
180    }
181
182    let idx = p * (n - 1) as f64;
183    let lo = idx.floor() as usize;
184    let hi = idx.ceil() as usize;
185
186    if lo == hi {
187        sorted[lo]
188    } else {
189        let frac = idx - lo as f64;
190        sorted[lo] * (1.0 - frac) + sorted[hi] * frac
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn single_edge() -> Graph {
199        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
200    }
201
202    fn path3() -> Graph {
203        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
204    }
205
206    fn k3() -> Graph {
207        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
208    }
209
210    fn k4() -> Graph {
211        Graph::from_edges(
212            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
213            false,
214            Some(4),
215        )
216        .unwrap()
217    }
218
219    fn cycle4() -> Graph {
220        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
221    }
222
223    fn star5() -> Graph {
224        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
225    }
226
227    fn paw() -> Graph {
228        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
229    }
230
231    // --- degree_range ---
232
233    #[test]
234    fn range_empty() {
235        let g = Graph::with_vertices(0);
236        assert_eq!(degree_range(&g).unwrap(), 0);
237    }
238
239    #[test]
240    fn range_isolated() {
241        let g = Graph::with_vertices(5);
242        assert_eq!(degree_range(&g).unwrap(), 0);
243    }
244
245    #[test]
246    fn range_regular_zero() {
247        assert_eq!(degree_range(&k3()).unwrap(), 0);
248        assert_eq!(degree_range(&k4()).unwrap(), 0);
249        assert_eq!(degree_range(&cycle4()).unwrap(), 0);
250    }
251
252    #[test]
253    fn range_single_edge() {
254        assert_eq!(degree_range(&single_edge()).unwrap(), 0);
255    }
256
257    #[test]
258    fn range_star5() {
259        assert_eq!(degree_range(&star5()).unwrap(), 3);
260    }
261
262    #[test]
263    fn range_path3() {
264        assert_eq!(degree_range(&path3()).unwrap(), 1);
265    }
266
267    #[test]
268    fn range_paw() {
269        // degrees: 2,2,3,1 → range=3-1=2
270        assert_eq!(degree_range(&paw()).unwrap(), 2);
271    }
272
273    // --- degree_span_ratio ---
274
275    #[test]
276    fn span_empty() {
277        let g = Graph::with_vertices(0);
278        assert!(degree_span_ratio(&g).unwrap().abs() < 1e-10);
279    }
280
281    #[test]
282    fn span_isolated() {
283        let g = Graph::with_vertices(5);
284        assert!(degree_span_ratio(&g).unwrap().abs() < 1e-10);
285    }
286
287    #[test]
288    fn span_regular_zero() {
289        assert!(degree_span_ratio(&k3()).unwrap().abs() < 1e-10);
290        assert!(degree_span_ratio(&k4()).unwrap().abs() < 1e-10);
291        assert!(degree_span_ratio(&cycle4()).unwrap().abs() < 1e-10);
292    }
293
294    #[test]
295    fn span_single_edge() {
296        assert!(degree_span_ratio(&single_edge()).unwrap().abs() < 1e-10);
297    }
298
299    #[test]
300    fn span_star5() {
301        // range=3, mean=1.6 → 3/1.6 = 1.875
302        assert!((degree_span_ratio(&star5()).unwrap() - 1.875).abs() < 1e-10);
303    }
304
305    #[test]
306    fn span_path3() {
307        // range=1, mean=4/3 → 3/4 = 0.75
308        assert!((degree_span_ratio(&path3()).unwrap() - 0.75).abs() < 1e-10);
309    }
310
311    #[test]
312    fn span_paw() {
313        // range=2, mean=2 → 1.0
314        assert!((degree_span_ratio(&paw()).unwrap() - 1.0).abs() < 1e-10);
315    }
316
317    #[test]
318    fn span_nonneg() {
319        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
320            assert!(degree_span_ratio(g).unwrap() >= -1e-10);
321        }
322    }
323
324    // --- degree_median ---
325
326    #[test]
327    fn median_empty() {
328        let g = Graph::with_vertices(0);
329        assert!(degree_median(&g).unwrap().abs() < 1e-10);
330    }
331
332    #[test]
333    fn median_isolated() {
334        let g = Graph::with_vertices(5);
335        assert!(degree_median(&g).unwrap().abs() < 1e-10);
336    }
337
338    #[test]
339    fn median_k3() {
340        // all degree 2
341        assert!((degree_median(&k3()).unwrap() - 2.0).abs() < 1e-10);
342    }
343
344    #[test]
345    fn median_k4() {
346        // all degree 3
347        assert!((degree_median(&k4()).unwrap() - 3.0).abs() < 1e-10);
348    }
349
350    #[test]
351    fn median_cycle4() {
352        // all degree 2
353        assert!((degree_median(&cycle4()).unwrap() - 2.0).abs() < 1e-10);
354    }
355
356    #[test]
357    fn median_single_edge() {
358        // [1,1] → 1
359        assert!((degree_median(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
360    }
361
362    #[test]
363    fn median_star5() {
364        // sorted: [1,1,1,1,4] → median=1
365        assert!((degree_median(&star5()).unwrap() - 1.0).abs() < 1e-10);
366    }
367
368    #[test]
369    fn median_path3() {
370        // sorted: [1,1,2] → median=1
371        assert!((degree_median(&path3()).unwrap() - 1.0).abs() < 1e-10);
372    }
373
374    #[test]
375    fn median_paw() {
376        // sorted: [1,2,2,3] → median=(2+2)/2=2
377        assert!((degree_median(&paw()).unwrap() - 2.0).abs() < 1e-10);
378    }
379
380    // --- degree_iqr ---
381
382    #[test]
383    fn iqr_empty() {
384        let g = Graph::with_vertices(0);
385        assert!(degree_iqr(&g).unwrap().abs() < 1e-10);
386    }
387
388    #[test]
389    fn iqr_isolated() {
390        let g = Graph::with_vertices(5);
391        assert!(degree_iqr(&g).unwrap().abs() < 1e-10);
392    }
393
394    #[test]
395    fn iqr_regular_zero() {
396        assert!(degree_iqr(&k3()).unwrap().abs() < 1e-10);
397        assert!(degree_iqr(&k4()).unwrap().abs() < 1e-10);
398        assert!(degree_iqr(&cycle4()).unwrap().abs() < 1e-10);
399    }
400
401    #[test]
402    fn iqr_single_edge() {
403        assert!(degree_iqr(&single_edge()).unwrap().abs() < 1e-10);
404    }
405
406    #[test]
407    fn iqr_star5() {
408        // sorted: [1,1,1,1,4], n=5
409        // Q1 at 0.25*(5-1)=1.0 → sorted[1]=1
410        // Q3 at 0.75*(5-1)=3.0 → sorted[3]=1
411        // IQR = 1-1 = 0
412        assert!(degree_iqr(&star5()).unwrap().abs() < 1e-10);
413    }
414
415    #[test]
416    fn iqr_paw() {
417        // sorted: [1,2,2,3], n=4
418        // Q1 at 0.25*3=0.75 → 1*0.25 + 2*0.75 = 1.75
419        // Q3 at 0.75*3=2.25 → 2*0.75 + 3*0.25 = 2.25
420        // IQR = 2.25 - 1.75 = 0.5
421        assert!((degree_iqr(&paw()).unwrap() - 0.5).abs() < 1e-10);
422    }
423
424    #[test]
425    fn iqr_path3() {
426        // sorted: [1,1,2], n=3
427        // Q1 at 0.25*2=0.5 → 1*0.5 + 1*0.5 = 1
428        // Q3 at 0.75*2=1.5 → 1*0.5 + 2*0.5 = 1.5
429        // IQR = 1.5 - 1.0 = 0.5
430        assert!((degree_iqr(&path3()).unwrap() - 0.5).abs() < 1e-10);
431    }
432
433    #[test]
434    fn iqr_nonneg() {
435        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
436            assert!(degree_iqr(g).unwrap() >= -1e-10);
437        }
438    }
439
440    // --- cross-consistency ---
441
442    #[test]
443    fn range_le_max_degree() {
444        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
445            let n = g.vcount() as usize;
446            let mut d_max = 0_usize;
447            for v in 0..n {
448                let d = g.degree(v as u32).unwrap();
449                if d > d_max {
450                    d_max = d;
451                }
452            }
453            assert!(degree_range(g).unwrap() <= d_max);
454        }
455    }
456
457    #[test]
458    fn median_between_min_max() {
459        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
460            let n = g.vcount() as usize;
461            let mut d_min = usize::MAX;
462            let mut d_max = 0_usize;
463            for v in 0..n {
464                let d = g.degree(v as u32).unwrap();
465                if d < d_min {
466                    d_min = d;
467                }
468                if d > d_max {
469                    d_max = d;
470                }
471            }
472            let med = degree_median(g).unwrap();
473            assert!(med >= d_min as f64 - 1e-10);
474            assert!(med <= d_max as f64 + 1e-10);
475        }
476    }
477}