Skip to main content

rust_igraph/algorithms/properties/
extended_irregularity.rs

1//! Extended irregularity indices (ALGO-TR-078).
2//!
3//! Irregularity measures beyond the basic Albertson/sigma set:
4//!
5//! - **Bell index** `B(G) = Σ_v (d(v) - d̄)²/n` (population degree
6//!   variance, i.e. `degree_variance` divided by n — but here as a
7//!   standalone function from the Bell (1992) reference)
8//! - **Collatz–Sinogowitz irregularity** `CS(G) = ρ(A) - 2m/n` where
9//!   `ρ(A)` is the spectral radius and `2m/n` the average degree
10//! - **IRL(G)** (irregularity by logarithm) `= Σ_{(u,v)∈E} |ln d(u) - ln d(v)|`
11//! - **IRLU(G)** (irregularity by logarithm of ratio)
12//!   `= Σ_{(u,v)∈E} |ln(d(u)/d(v))|` — same as IRL for positive degrees
13//! - **Degree coefficient of variation** `CV(G) = σ/d̄` where `σ` is
14//!   the degree standard deviation and `d̄` the average degree
15
16#![allow(
17    clippy::cast_possible_truncation,
18    clippy::cast_precision_loss,
19    clippy::many_single_char_names,
20    clippy::needless_range_loop,
21    clippy::too_many_lines
22)]
23
24use crate::core::{Graph, IgraphResult};
25
26/// Compute the Bell index (degree population variance).
27///
28/// `B(G) = (1/n) Σ_v (d(v) - d̄)²`
29///
30/// where `d̄ = 2m/n` is the average degree. Equals 0 for regular graphs.
31/// Returns 0.0 for graphs with fewer than 1 vertex.
32///
33/// This is the population variance of the degree sequence, as defined
34/// by Bell (1992). It differs from `degree_variance` only if the latter
35/// uses sample variance (n-1 denominator); our `degree_variance` already
36/// uses population variance, so they are equivalent. This function is
37/// provided for nomenclature completeness in chemical graph theory.
38///
39/// # Examples
40///
41/// ```
42/// use rust_igraph::{Graph, bell_index};
43///
44/// // K_3: d=(2,2,2), d̄=2, B=0
45/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
46/// assert!(bell_index(&g).unwrap().abs() < 1e-10);
47/// ```
48pub fn bell_index(graph: &Graph) -> IgraphResult<f64> {
49    let n = graph.vcount() as usize;
50    if n == 0 {
51        return Ok(0.0);
52    }
53
54    let m = graph.ecount();
55    let d_bar = 2.0 * m as f64 / n as f64;
56    let mut sum = 0.0_f64;
57
58    for v in 0..n {
59        let d = graph.degree(v as u32)? as f64;
60        let diff = d - d_bar;
61        sum += diff * diff;
62    }
63
64    Ok(sum / n as f64)
65}
66
67/// Compute the Collatz–Sinogowitz irregularity.
68///
69/// `CS(G) = ρ(A) - 2m/n`
70///
71/// where `ρ(A)` is the spectral radius (largest eigenvalue of the
72/// adjacency matrix) and `2m/n` is the average degree. Always ≥ 0 by
73/// the Perron-Frobenius theorem for connected graphs.
74///
75/// Returns 0.0 for empty graphs (0 vertices or 0 edges).
76///
77/// # Examples
78///
79/// ```
80/// use rust_igraph::{Graph, collatz_sinogowitz};
81///
82/// // K_3: spectral radius = 2, avg degree = 2, CS = 0
83/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
84/// assert!(collatz_sinogowitz(&g).unwrap().abs() < 1e-10);
85/// ```
86pub fn collatz_sinogowitz(graph: &Graph) -> IgraphResult<f64> {
87    let n = graph.vcount() as usize;
88    if n == 0 {
89        return Ok(0.0);
90    }
91    let m = graph.ecount();
92    if m == 0 {
93        return Ok(0.0);
94    }
95
96    let d_bar = 2.0 * m as f64 / n as f64;
97
98    let rho = crate::algorithms::properties::spectral_metrics::spectral_radius(graph)?;
99
100    Ok((rho - d_bar).max(0.0))
101}
102
103/// Compute the IRL irregularity (irregularity by logarithm).
104///
105/// `IRL(G) = Σ_{(u,v)∈E} |ln d(u) - ln d(v)|`
106///
107/// Edges with a degree-0 endpoint are skipped. Self-loops are skipped.
108/// Returns 0.0 for regular graphs and edgeless graphs.
109///
110/// # Examples
111///
112/// ```
113/// use rust_igraph::{Graph, irl_irregularity};
114///
115/// // K_3: all degrees 2, IRL = 0
116/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
117/// assert!(irl_irregularity(&g).unwrap().abs() < 1e-10);
118/// ```
119pub fn irl_irregularity(graph: &Graph) -> IgraphResult<f64> {
120    let mut result = 0.0_f64;
121
122    for (u, v) in graph.edges() {
123        if u == v {
124            continue;
125        }
126        let du = graph.degree(u)? as f64;
127        let dv = graph.degree(v)? as f64;
128        if du <= 0.0 || dv <= 0.0 {
129            continue;
130        }
131        result += (du.ln() - dv.ln()).abs();
132    }
133
134    Ok(result)
135}
136
137/// Compute the IRLU irregularity (irregularity by log-ratio).
138///
139/// `IRLU(G) = Σ_{(u,v)∈E} |ln(d(u)/d(v))|`
140///
141/// Numerically equivalent to `IRL(G)` for all positive degrees (by
142/// logarithm properties), but provided for nomenclature completeness
143/// in the literature. Edges with a degree-0 endpoint are skipped.
144/// Self-loops are skipped.
145///
146/// # Examples
147///
148/// ```
149/// use rust_igraph::{Graph, irlu_irregularity};
150///
151/// // Star S_5: center d=4, leaves d=1
152/// // 4 edges each |ln(4/1)| = ln(4) → 4·ln(4)
153/// let g = Graph::from_edges(&[(0,1),(0,2),(0,3),(0,4)], false, Some(5)).unwrap();
154/// assert!((irlu_irregularity(&g).unwrap() - 4.0 * 4.0_f64.ln()).abs() < 1e-10);
155/// ```
156pub fn irlu_irregularity(graph: &Graph) -> IgraphResult<f64> {
157    let mut result = 0.0_f64;
158
159    for (u, v) in graph.edges() {
160        if u == v {
161            continue;
162        }
163        let du = graph.degree(u)? as f64;
164        let dv = graph.degree(v)? as f64;
165        if du <= 0.0 || dv <= 0.0 {
166            continue;
167        }
168        result += (du / dv).ln().abs();
169    }
170
171    Ok(result)
172}
173
174/// Compute the degree coefficient of variation.
175///
176/// `CV(G) = σ/d̄` where `σ = √(B(G))` is the degree standard
177/// deviation and `d̄ = 2m/n` is the average degree.
178///
179/// Returns 0.0 for regular graphs, edgeless graphs, or empty graphs.
180/// The CV is undefined when `d̄ = 0` (edgeless); we return 0.0 in
181/// that case.
182///
183/// # Examples
184///
185/// ```
186/// use rust_igraph::{Graph, degree_cv};
187///
188/// // K_3: regular, CV = 0
189/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
190/// assert!(degree_cv(&g).unwrap().abs() < 1e-10);
191/// ```
192pub fn degree_cv(graph: &Graph) -> IgraphResult<f64> {
193    let n = graph.vcount() as usize;
194    if n == 0 {
195        return Ok(0.0);
196    }
197    let m = graph.ecount();
198    if m == 0 {
199        return Ok(0.0);
200    }
201
202    let d_bar = 2.0 * m as f64 / n as f64;
203    let bell = bell_index(graph)?;
204    let sigma = bell.sqrt();
205
206    Ok(sigma / d_bar)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn single_edge() -> Graph {
214        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
215    }
216
217    fn path3() -> Graph {
218        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
219    }
220
221    fn k3() -> Graph {
222        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
223    }
224
225    fn k4() -> Graph {
226        Graph::from_edges(
227            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
228            false,
229            Some(4),
230        )
231        .unwrap()
232    }
233
234    fn cycle4() -> Graph {
235        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
236    }
237
238    fn star5() -> Graph {
239        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
240    }
241
242    fn paw() -> Graph {
243        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
244    }
245
246    // --- bell_index ---
247
248    #[test]
249    fn bell_empty() {
250        let g = Graph::with_vertices(0);
251        assert!(bell_index(&g).unwrap().abs() < 1e-10);
252    }
253
254    #[test]
255    fn bell_isolated() {
256        let g = Graph::with_vertices(5);
257        assert!(bell_index(&g).unwrap().abs() < 1e-10);
258    }
259
260    #[test]
261    fn bell_regular_zero() {
262        assert!(bell_index(&k3()).unwrap().abs() < 1e-10);
263        assert!(bell_index(&k4()).unwrap().abs() < 1e-10);
264        assert!(bell_index(&cycle4()).unwrap().abs() < 1e-10);
265    }
266
267    #[test]
268    fn bell_single_edge() {
269        // d=(1,1), d̄=1, B=0
270        assert!(bell_index(&single_edge()).unwrap().abs() < 1e-10);
271    }
272
273    #[test]
274    fn bell_path3() {
275        // d=(1,2,1), d̄=4/3
276        // (1-4/3)²+(2-4/3)²+(1-4/3)² = 2·(1/3)²+(2/3)² = 2/9+4/9 = 6/9 = 2/3
277        // B = (2/3)/3 = 2/9
278        assert!((bell_index(&path3()).unwrap() - 2.0 / 9.0).abs() < 1e-10);
279    }
280
281    #[test]
282    fn bell_star5() {
283        // d=(4,1,1,1,1), d̄=8/5=1.6
284        // (4-1.6)²+4·(1-1.6)² = 5.76+4·0.36 = 5.76+1.44 = 7.2
285        // B = 7.2/5 = 1.44
286        assert!((bell_index(&star5()).unwrap() - 1.44).abs() < 1e-10);
287    }
288
289    #[test]
290    fn bell_paw() {
291        // d=(2,2,3,1), d̄=8/4=2
292        // (2-2)²+(2-2)²+(3-2)²+(1-2)² = 0+0+1+1 = 2
293        // B = 2/4 = 0.5
294        assert!((bell_index(&paw()).unwrap() - 0.5).abs() < 1e-10);
295    }
296
297    // --- collatz_sinogowitz ---
298
299    #[test]
300    fn cs_empty() {
301        let g = Graph::with_vertices(0);
302        assert!(collatz_sinogowitz(&g).unwrap().abs() < 1e-10);
303    }
304
305    #[test]
306    fn cs_isolated() {
307        let g = Graph::with_vertices(5);
308        assert!(collatz_sinogowitz(&g).unwrap().abs() < 1e-10);
309    }
310
311    #[test]
312    fn cs_regular_zero() {
313        // Regular: spectral radius = degree, avg degree = degree → CS = 0
314        assert!(collatz_sinogowitz(&k3()).unwrap().abs() < 1e-10);
315        assert!(collatz_sinogowitz(&k4()).unwrap().abs() < 1e-10);
316        assert!(collatz_sinogowitz(&cycle4()).unwrap().abs() < 1e-10);
317    }
318
319    #[test]
320    fn cs_star5() {
321        // S_5: spectral radius = √4 = 2, avg degree = 8/5 = 1.6
322        // CS = 2 - 1.6 = 0.4
323        assert!((collatz_sinogowitz(&star5()).unwrap() - 0.4).abs() < 1e-10);
324    }
325
326    #[test]
327    fn cs_nonnegative() {
328        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
329            assert!(collatz_sinogowitz(g).unwrap() >= -1e-10);
330        }
331    }
332
333    // --- irl_irregularity ---
334
335    #[test]
336    fn irl_empty() {
337        let g = Graph::with_vertices(0);
338        assert!(irl_irregularity(&g).unwrap().abs() < 1e-10);
339    }
340
341    #[test]
342    fn irl_isolated() {
343        let g = Graph::with_vertices(5);
344        assert!(irl_irregularity(&g).unwrap().abs() < 1e-10);
345    }
346
347    #[test]
348    fn irl_regular_zero() {
349        assert!(irl_irregularity(&k3()).unwrap().abs() < 1e-10);
350        assert!(irl_irregularity(&k4()).unwrap().abs() < 1e-10);
351        assert!(irl_irregularity(&cycle4()).unwrap().abs() < 1e-10);
352    }
353
354    #[test]
355    fn irl_single_edge() {
356        // d=(1,1): |ln1-ln1| = 0
357        assert!(irl_irregularity(&single_edge()).unwrap().abs() < 1e-10);
358    }
359
360    #[test]
361    fn irl_star5() {
362        // 4 edges (4,1): 4·|ln4-ln1| = 4·ln4
363        let expected = 4.0 * 4.0_f64.ln();
364        assert!((irl_irregularity(&star5()).unwrap() - expected).abs() < 1e-10);
365    }
366
367    #[test]
368    fn irl_path3() {
369        // 2 edges: (1,2) and (2,1) → 2·|ln1-ln2| = 2·ln2
370        let expected = 2.0 * 2.0_f64.ln();
371        assert!((irl_irregularity(&path3()).unwrap() - expected).abs() < 1e-10);
372    }
373
374    #[test]
375    fn irl_paw() {
376        // (0,1) d=(2,2): 0
377        // (0,2) d=(2,3): |ln2-ln3|
378        // (1,2) d=(2,3): |ln2-ln3|
379        // (2,3) d=(3,1): |ln3-ln1| = ln3
380        let expected = 2.0 * (3.0_f64.ln() - 2.0_f64.ln()) + 3.0_f64.ln();
381        assert!((irl_irregularity(&paw()).unwrap() - expected).abs() < 1e-10);
382    }
383
384    // --- irlu_irregularity ---
385
386    #[test]
387    fn irlu_empty() {
388        let g = Graph::with_vertices(0);
389        assert!(irlu_irregularity(&g).unwrap().abs() < 1e-10);
390    }
391
392    #[test]
393    fn irlu_regular_zero() {
394        assert!(irlu_irregularity(&k3()).unwrap().abs() < 1e-10);
395        assert!(irlu_irregularity(&k4()).unwrap().abs() < 1e-10);
396    }
397
398    #[test]
399    fn irlu_equals_irl() {
400        // For positive degrees, IRL = IRLU (by ln properties)
401        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
402            let val_irl = irl_irregularity(g).unwrap();
403            let val_irlu = irlu_irregularity(g).unwrap();
404            assert!(
405                (val_irl - val_irlu).abs() < 1e-10,
406                "IRL={val_irl} IRLU={val_irlu}"
407            );
408        }
409    }
410
411    #[test]
412    fn irlu_star5() {
413        let expected = 4.0 * 4.0_f64.ln();
414        assert!((irlu_irregularity(&star5()).unwrap() - expected).abs() < 1e-10);
415    }
416
417    // --- degree_cv ---
418
419    #[test]
420    fn cv_empty() {
421        let g = Graph::with_vertices(0);
422        assert!(degree_cv(&g).unwrap().abs() < 1e-10);
423    }
424
425    #[test]
426    fn cv_isolated() {
427        let g = Graph::with_vertices(5);
428        assert!(degree_cv(&g).unwrap().abs() < 1e-10);
429    }
430
431    #[test]
432    fn cv_regular_zero() {
433        assert!(degree_cv(&k3()).unwrap().abs() < 1e-10);
434        assert!(degree_cv(&k4()).unwrap().abs() < 1e-10);
435        assert!(degree_cv(&cycle4()).unwrap().abs() < 1e-10);
436    }
437
438    #[test]
439    fn cv_single_edge() {
440        // d=(1,1), d̄=1, σ=0, CV=0
441        assert!(degree_cv(&single_edge()).unwrap().abs() < 1e-10);
442    }
443
444    #[test]
445    fn cv_star5() {
446        // B=1.44, σ=√1.44=1.2, d̄=1.6, CV=1.2/1.6=0.75
447        assert!((degree_cv(&star5()).unwrap() - 0.75).abs() < 1e-10);
448    }
449
450    #[test]
451    fn cv_paw() {
452        // B=0.5, σ=√0.5, d̄=2, CV=√0.5/2
453        let expected = 0.5_f64.sqrt() / 2.0;
454        assert!((degree_cv(&paw()).unwrap() - expected).abs() < 1e-10);
455    }
456
457    #[test]
458    fn cv_nonnegative() {
459        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
460            assert!(degree_cv(g).unwrap() >= -1e-10);
461        }
462    }
463
464    // --- cross-consistency ---
465
466    #[test]
467    fn irl_nonnegative() {
468        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
469            assert!(irl_irregularity(g).unwrap() >= -1e-10);
470        }
471    }
472
473    #[test]
474    fn bell_nonnegative() {
475        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
476            assert!(bell_index(g).unwrap() >= -1e-10);
477        }
478    }
479}