Skip to main content

rust_igraph/algorithms/properties/
degree_inequality.rs

1//! Degree inequality indices (ALGO-TR-091).
2//!
3//! Inequality and concentration measures over the degree sequence:
4//!
5//! - **Herfindahl index** `Σ (d(v)/Σd)²` — concentration/monopoly measure
6//! - **Theil index** `(1/n) Σ (d/d̄)·ln(d/d̄)` — generalized entropy inequality
7//! - **Palma ratio** top 10% share / bottom 40% share of total degree
8//! - **Hoover index** `Σ|d(v) - d̄| / (2·Σd)` — Robin Hood index
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 Herfindahl–Hirschman index of the degree sequence.
21///
22/// `H = Σ_v (d(v) / Σ_u d(u))²`
23///
24/// Measures concentration: 1/n for a regular graph (perfect equality),
25/// 1.0 for a star (one vertex holds all degree mass). Returns 0.0
26/// for the empty or edgeless graph.
27///
28/// # Examples
29///
30/// ```
31/// use rust_igraph::{Graph, degree_herfindahl};
32///
33/// // K_3: all degrees 2, total 6 → 3·(2/6)² = 3·(1/9) = 1/3
34/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
35/// assert!((degree_herfindahl(&g).unwrap() - 1.0/3.0).abs() < 1e-10);
36/// ```
37pub fn degree_herfindahl(graph: &Graph) -> IgraphResult<f64> {
38    let n = graph.vcount() as usize;
39    if n == 0 {
40        return Ok(0.0);
41    }
42
43    let mut total = 0_u64;
44    let mut degrees = Vec::with_capacity(n);
45    for v in 0..n {
46        let d = graph.degree(v as u32)?;
47        degrees.push(d);
48        total = total.saturating_add(d as u64);
49    }
50
51    if total == 0 {
52        return Ok(0.0);
53    }
54
55    let total_f = total as f64;
56    let mut hhi = 0.0_f64;
57    for &d in &degrees {
58        let share = d as f64 / total_f;
59        hhi += share * share;
60    }
61
62    Ok(hhi)
63}
64
65/// Compute the Theil index (GE(1)) of the degree sequence.
66///
67/// `T = (1/n) Σ_{v: d(v)>0} (d(v)/d̄) · ln(d(v)/d̄)`
68///
69/// A generalized entropy inequality measure. Zero for regular graphs,
70/// higher values indicate more inequality. Vertices with d=0 contribute
71/// 0 (by L'Hôpital: x·ln(x) → 0 as x → 0+). Returns 0.0 for the
72/// empty or edgeless graph.
73///
74/// # Examples
75///
76/// ```
77/// use rust_igraph::{Graph, degree_theil};
78///
79/// // K_3: all degrees equal → Theil = 0
80/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
81/// assert!(degree_theil(&g).unwrap().abs() < 1e-10);
82/// ```
83pub fn degree_theil(graph: &Graph) -> IgraphResult<f64> {
84    let n = graph.vcount() as usize;
85    if n == 0 {
86        return Ok(0.0);
87    }
88
89    let mut total = 0_u64;
90    let mut degrees = Vec::with_capacity(n);
91    for v in 0..n {
92        let d = graph.degree(v as u32)?;
93        degrees.push(d);
94        total = total.saturating_add(d as u64);
95    }
96
97    if total == 0 {
98        return Ok(0.0);
99    }
100
101    let mean = total as f64 / n as f64;
102    let mut theil = 0.0_f64;
103    for &d in &degrees {
104        if d == 0 {
105            continue;
106        }
107        let ratio = d as f64 / mean;
108        theil += ratio * ratio.ln();
109    }
110
111    Ok(theil / n as f64)
112}
113
114/// Compute the Palma ratio of the degree sequence.
115///
116/// Ratio of total degree held by the top 10% of vertices to the
117/// total degree held by the bottom 40%, where vertices are ranked
118/// by degree in ascending order.
119///
120/// Returns `f64::INFINITY` if the bottom 40% hold zero degree.
121/// Returns 0.0 for the empty graph or graphs with fewer than 2
122/// vertices (undefined).
123///
124/// # Examples
125///
126/// ```
127/// use rust_igraph::{Graph, degree_palma};
128///
129/// // K_3: all degrees equal → top 10% ≈ bottom 40% ratio → 1/n ratios
130/// // With 3 vertices: bottom 40% = 1 vertex (d=2), top 10% = 0 vertices → 0/2 = 0
131/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
132/// let p = degree_palma(&g).unwrap();
133/// assert!(p.abs() < 1e-10); // top 10% of 3 = 0 vertices → 0
134/// ```
135pub fn degree_palma(graph: &Graph) -> IgraphResult<f64> {
136    let n = graph.vcount() as usize;
137    if n < 2 {
138        return Ok(0.0);
139    }
140
141    let mut degrees = Vec::with_capacity(n);
142    for v in 0..n {
143        degrees.push(graph.degree(v as u32)?);
144    }
145    degrees.sort_unstable();
146
147    let bottom_count = n * 40 / 100;
148    let top_start = n.saturating_sub(n * 10 / 100);
149
150    let bottom_sum: u64 = degrees[..bottom_count].iter().map(|&d| d as u64).sum();
151    let top_sum: u64 = degrees[top_start..].iter().map(|&d| d as u64).sum();
152
153    if bottom_sum == 0 {
154        if top_sum == 0 {
155            return Ok(0.0);
156        }
157        return Ok(f64::INFINITY);
158    }
159
160    Ok(top_sum as f64 / bottom_sum as f64)
161}
162
163/// Compute the Hoover index (Robin Hood index) of the degree sequence.
164///
165/// `H = Σ|d(v) - d̄| / (2 · Σd)`
166///
167/// The maximum fraction of total degree that would need to be
168/// redistributed to achieve perfect equality. Ranges from 0
169/// (regular graph) to (n-1)/n (star). Returns 0.0 for the empty
170/// or edgeless graph.
171///
172/// # Examples
173///
174/// ```
175/// use rust_igraph::{Graph, degree_hoover};
176///
177/// // K_3: all degrees 2, mean=2 → all |d-d̄|=0 → Hoover=0
178/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
179/// assert!(degree_hoover(&g).unwrap().abs() < 1e-10);
180/// ```
181pub fn degree_hoover(graph: &Graph) -> IgraphResult<f64> {
182    let n = graph.vcount() as usize;
183    if n == 0 {
184        return Ok(0.0);
185    }
186
187    let mut total = 0_u64;
188    let mut degrees = Vec::with_capacity(n);
189    for v in 0..n {
190        let d = graph.degree(v as u32)?;
191        degrees.push(d);
192        total = total.saturating_add(d as u64);
193    }
194
195    if total == 0 {
196        return Ok(0.0);
197    }
198
199    let mean = total as f64 / n as f64;
200    let mut abs_dev_sum = 0.0_f64;
201    for &d in &degrees {
202        abs_dev_sum += (d as f64 - mean).abs();
203    }
204
205    Ok(abs_dev_sum / (2.0 * total as f64))
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn single_edge() -> Graph {
213        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
214    }
215
216    fn path3() -> Graph {
217        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
218    }
219
220    fn k3() -> Graph {
221        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
222    }
223
224    fn k4() -> Graph {
225        Graph::from_edges(
226            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
227            false,
228            Some(4),
229        )
230        .unwrap()
231    }
232
233    fn cycle4() -> Graph {
234        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
235    }
236
237    fn star5() -> Graph {
238        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
239    }
240
241    fn paw() -> Graph {
242        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
243    }
244
245    // --- degree_herfindahl ---
246
247    #[test]
248    fn hhi_empty() {
249        let g = Graph::with_vertices(0);
250        assert!(degree_herfindahl(&g).unwrap().abs() < 1e-10);
251    }
252
253    #[test]
254    fn hhi_isolated() {
255        let g = Graph::with_vertices(5);
256        assert!(degree_herfindahl(&g).unwrap().abs() < 1e-10);
257    }
258
259    #[test]
260    fn hhi_regular() {
261        // Regular graph: H = n·(1/n)² = 1/n
262        assert!((degree_herfindahl(&k3()).unwrap() - 1.0 / 3.0).abs() < 1e-10);
263        assert!((degree_herfindahl(&k4()).unwrap() - 1.0 / 4.0).abs() < 1e-10);
264        assert!((degree_herfindahl(&cycle4()).unwrap() - 1.0 / 4.0).abs() < 1e-10);
265    }
266
267    #[test]
268    fn hhi_single_edge() {
269        // (1,1): total=2, each share=0.5 → 2·0.25 = 0.5
270        assert!((degree_herfindahl(&single_edge()).unwrap() - 0.5).abs() < 1e-10);
271    }
272
273    #[test]
274    fn hhi_star5() {
275        // degrees: [4,1,1,1,1], total=8
276        // (4/8)² + 4·(1/8)² = 0.25 + 4·(1/64) = 0.25 + 0.0625 = 0.3125
277        assert!((degree_herfindahl(&star5()).unwrap() - 0.3125).abs() < 1e-10);
278    }
279
280    #[test]
281    fn hhi_path3() {
282        // degrees: [1,2,1], total=4
283        // (1/4)² + (2/4)² + (1/4)² = 1/16 + 4/16 + 1/16 = 6/16 = 3/8
284        assert!((degree_herfindahl(&path3()).unwrap() - 3.0 / 8.0).abs() < 1e-10);
285    }
286
287    #[test]
288    fn hhi_paw() {
289        // degrees: [2,2,3,1], total=8
290        // (2/8)² + (2/8)² + (3/8)² + (1/8)² = 4/64+4/64+9/64+1/64 = 18/64 = 9/32
291        assert!((degree_herfindahl(&paw()).unwrap() - 9.0 / 32.0).abs() < 1e-10);
292    }
293
294    // --- degree_theil ---
295
296    #[test]
297    fn theil_empty() {
298        let g = Graph::with_vertices(0);
299        assert!(degree_theil(&g).unwrap().abs() < 1e-10);
300    }
301
302    #[test]
303    fn theil_isolated() {
304        let g = Graph::with_vertices(5);
305        assert!(degree_theil(&g).unwrap().abs() < 1e-10);
306    }
307
308    #[test]
309    fn theil_regular() {
310        // All degrees equal → ratio=1 → ln(1)=0 → T=0
311        assert!(degree_theil(&k3()).unwrap().abs() < 1e-10);
312        assert!(degree_theil(&k4()).unwrap().abs() < 1e-10);
313        assert!(degree_theil(&cycle4()).unwrap().abs() < 1e-10);
314    }
315
316    #[test]
317    fn theil_single_edge() {
318        // Degrees [1,1] → regular → 0
319        assert!(degree_theil(&single_edge()).unwrap().abs() < 1e-10);
320    }
321
322    #[test]
323    fn theil_star5() {
324        // degrees [4,1,1,1,1], mean=8/5=1.6
325        // v0: (4/1.6)·ln(4/1.6) = 2.5·ln(2.5)
326        // 4 leaves: (1/1.6)·ln(1/1.6) = 0.625·ln(0.625)
327        // T = (2.5·ln(2.5) + 4·0.625·ln(0.625)) / 5
328        let mean = 1.6_f64;
329        let r0 = 4.0 / mean;
330        let r1 = 1.0 / mean;
331        let expected = (r0 * r0.ln() + 4.0 * r1 * r1.ln()) / 5.0;
332        assert!((degree_theil(&star5()).unwrap() - expected).abs() < 1e-10);
333    }
334
335    #[test]
336    fn theil_path3() {
337        // degrees [1,2,1], mean=4/3
338        // v0: (1/(4/3))·ln(1/(4/3)) = 0.75·ln(0.75)
339        // v1: (2/(4/3))·ln(2/(4/3)) = 1.5·ln(1.5)
340        // v2: same as v0
341        // T = (2·0.75·ln(0.75) + 1.5·ln(1.5)) / 3
342        let mean: f64 = 4.0 / 3.0;
343        let r_end: f64 = 1.0 / mean;
344        let r_mid: f64 = 2.0 / mean;
345        let expected = (2.0 * r_end * r_end.ln() + r_mid * r_mid.ln()) / 3.0;
346        assert!((degree_theil(&path3()).unwrap() - expected).abs() < 1e-10);
347    }
348
349    #[test]
350    fn theil_paw() {
351        // degrees [2,2,3,1], mean=8/4=2
352        let mean = 2.0_f64;
353        let vals: [f64; 4] = [2.0, 2.0, 3.0, 1.0];
354        let mut sum = 0.0_f64;
355        for &d in &vals {
356            let r: f64 = d / mean;
357            sum += r * r.ln();
358        }
359        let expected = sum / 4.0;
360        assert!((degree_theil(&paw()).unwrap() - expected).abs() < 1e-10);
361    }
362
363    // --- degree_palma ---
364
365    #[test]
366    fn palma_empty() {
367        let g = Graph::with_vertices(0);
368        assert!(degree_palma(&g).unwrap().abs() < 1e-10);
369    }
370
371    #[test]
372    fn palma_single() {
373        let g = Graph::with_vertices(1);
374        assert!(degree_palma(&g).unwrap().abs() < 1e-10);
375    }
376
377    #[test]
378    fn palma_single_edge() {
379        // 2 vertices, bottom 40% = 0 vertices, top 10% = 0 vertices → 0/0 → 0.0
380        let p = degree_palma(&single_edge()).unwrap();
381        assert!(p.abs() < 1e-10);
382    }
383
384    #[test]
385    fn palma_k3() {
386        // 3 vertices: bottom_count=3*40/100=1, top_start=3-3*10/100=3-0=3
387        // bottom=[2], top=[] → 0/2 = 0
388        assert!(degree_palma(&k3()).unwrap().abs() < 1e-10);
389    }
390
391    #[test]
392    fn palma_star5() {
393        // 5 vertices sorted: [1,1,1,1,4]
394        // bottom_count = 5*40/100 = 2 → bottom=[1,1], sum=2
395        // top_start = 5 - 5*10/100 = 5-0 = 5 → top=[], sum=0
396        // 0/2 = 0
397        assert!(degree_palma(&star5()).unwrap().abs() < 1e-10);
398    }
399
400    #[test]
401    fn palma_10vertices() {
402        // 10 vertices: bottom 40% = 4, top 10% = 1
403        // Star S_10 (center+9 leaves): degrees sorted [1,1,1,1,1,1,1,1,1,9]
404        // bottom 4: [1,1,1,1]=4, top 1: [9]=9
405        // Palma = 9/4 = 2.25
406        let mut edges = Vec::new();
407        for i in 1..10_u32 {
408            edges.push((0, i));
409        }
410        let g = Graph::from_edges(&edges, false, Some(10)).unwrap();
411        assert!((degree_palma(&g).unwrap() - 2.25).abs() < 1e-10);
412    }
413
414    #[test]
415    fn palma_20vertices_regular() {
416        // K_4 extended: 20-vertex cycle — all degrees 2
417        // bottom 40% = 8 verts each d=2 → sum=16
418        // top 10% = 2 verts each d=2 → sum=4
419        // Palma = 4/16 = 0.25
420        let mut edges = Vec::new();
421        for i in 0..20_u32 {
422            edges.push((i, (i + 1) % 20));
423        }
424        let g = Graph::from_edges(&edges, false, Some(20)).unwrap();
425        assert!((degree_palma(&g).unwrap() - 0.25).abs() < 1e-10);
426    }
427
428    // --- degree_hoover ---
429
430    #[test]
431    fn hoover_empty() {
432        let g = Graph::with_vertices(0);
433        assert!(degree_hoover(&g).unwrap().abs() < 1e-10);
434    }
435
436    #[test]
437    fn hoover_isolated() {
438        let g = Graph::with_vertices(5);
439        assert!(degree_hoover(&g).unwrap().abs() < 1e-10);
440    }
441
442    #[test]
443    fn hoover_regular() {
444        // Regular: all d=d̄ → all |d-d̄|=0 → 0
445        assert!(degree_hoover(&k3()).unwrap().abs() < 1e-10);
446        assert!(degree_hoover(&k4()).unwrap().abs() < 1e-10);
447        assert!(degree_hoover(&cycle4()).unwrap().abs() < 1e-10);
448    }
449
450    #[test]
451    fn hoover_single_edge() {
452        // [1,1] mean=1 → all |d-mean|=0 → 0
453        assert!(degree_hoover(&single_edge()).unwrap().abs() < 1e-10);
454    }
455
456    #[test]
457    fn hoover_star5() {
458        // degrees [4,1,1,1,1], mean=8/5=1.6, total=8
459        // |4-1.6|=2.4, 4·|1-1.6|=4·0.6=2.4
460        // sum_abs_dev = 2.4 + 2.4 = 4.8
461        // H = 4.8 / (2·8) = 4.8/16 = 0.3
462        assert!((degree_hoover(&star5()).unwrap() - 0.3).abs() < 1e-10);
463    }
464
465    #[test]
466    fn hoover_path3() {
467        // degrees [1,2,1], mean=4/3, total=4
468        // |1-4/3|=1/3, |2-4/3|=2/3, |1-4/3|=1/3
469        // sum = 1/3 + 2/3 + 1/3 = 4/3
470        // H = (4/3) / (2·4) = (4/3)/8 = 1/6
471        assert!((degree_hoover(&path3()).unwrap() - 1.0 / 6.0).abs() < 1e-10);
472    }
473
474    #[test]
475    fn hoover_paw() {
476        // degrees [2,2,3,1], mean=2, total=8
477        // |2-2|=0, |2-2|=0, |3-2|=1, |1-2|=1
478        // sum=2, H = 2/(2·8) = 2/16 = 1/8
479        assert!((degree_hoover(&paw()).unwrap() - 0.125).abs() < 1e-10);
480    }
481
482    // --- cross-consistency ---
483
484    #[test]
485    fn herfindahl_bounds() {
486        // H ∈ [1/n, 1] for graphs with edges
487        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
488            let n = f64::from(g.vcount());
489            let h = degree_herfindahl(g).unwrap();
490            assert!(h >= 1.0 / n - 1e-10);
491            assert!(h <= 1.0 + 1e-10);
492        }
493    }
494
495    #[test]
496    fn theil_nonnegative() {
497        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
498            assert!(degree_theil(g).unwrap() >= -1e-10);
499        }
500    }
501
502    #[test]
503    fn hoover_in_01() {
504        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
505            let h = degree_hoover(g).unwrap();
506            assert!(h >= -1e-10);
507            assert!(h <= 1.0 + 1e-10);
508        }
509    }
510
511    #[test]
512    fn palma_nonnegative() {
513        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
514            assert!(degree_palma(g).unwrap() >= -1e-10);
515        }
516    }
517
518    #[test]
519    fn regular_all_zero_inequality() {
520        for g in &[k3(), k4(), cycle4()] {
521            assert!(degree_theil(g).unwrap().abs() < 1e-10);
522            assert!(degree_hoover(g).unwrap().abs() < 1e-10);
523        }
524    }
525}