Skip to main content

rust_igraph/algorithms/properties/
mixing_ratios.rs

1//! Mixing-pattern ratio indices (ALGO-TR-107).
2//!
3//! Ratios capturing degree mixing and correlation patterns:
4//!
5//! - **Degree assortativity proxy** — Pearson r of degree at edge endpoints
6//! - **Rich club density** — density among top-k% degree vertices
7//! - **Degree mixing entropy** — Shannon entropy of the degree-pair
8//!   distribution over edges
9//! - **Hub dominance ratio** — fraction of edges incident to the
10//!   highest-degree vertex
11
12#![allow(
13    clippy::cast_possible_truncation,
14    clippy::cast_precision_loss,
15    clippy::many_single_char_names,
16    clippy::needless_range_loop,
17    clippy::similar_names,
18    clippy::too_many_lines
19)]
20
21use crate::core::{Graph, IgraphResult};
22
23/// Compute the degree assortativity proxy.
24///
25/// Pearson correlation coefficient of degrees at edge endpoints.
26/// Positive values indicate assortative mixing (high-degree nodes
27/// connect to high-degree nodes). Returns 0.0 for graphs with
28/// fewer than 2 edges or constant degree.
29///
30/// # Examples
31///
32/// ```
33/// use rust_igraph::{Graph, degree_assortativity_proxy};
34///
35/// // K_3: all degrees equal → 0.0
36/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
37/// assert!(degree_assortativity_proxy(&g).unwrap().abs() < 1e-10);
38/// ```
39pub fn degree_assortativity_proxy(graph: &Graph) -> IgraphResult<f64> {
40    let n = graph.vcount() as usize;
41    let m = graph.ecount();
42    if m < 2 {
43        return Ok(0.0);
44    }
45
46    let mut degrees = Vec::with_capacity(n);
47    for v in 0..n {
48        degrees.push(graph.degree(v as u32)?);
49    }
50
51    let mut sum_x = 0.0_f64;
52    let mut sum_y = 0.0_f64;
53    let mut sum_xx = 0.0_f64;
54    let mut sum_yy = 0.0_f64;
55    let mut sum_xy = 0.0_f64;
56    let mut edge_count = 0_u64;
57
58    for v in 0..n {
59        let neighbors = graph.neighbors(v as u32)?;
60        for &u in &neighbors {
61            let ui = u as usize;
62            if !graph.is_directed() && ui <= v {
63                continue;
64            }
65            let dx = degrees[v] as f64;
66            let dy = degrees[ui] as f64;
67            sum_x += dx;
68            sum_y += dy;
69            sum_xx += dx * dx;
70            sum_yy += dy * dy;
71            sum_xy += dx * dy;
72            edge_count += 1;
73        }
74    }
75
76    if edge_count < 2 {
77        return Ok(0.0);
78    }
79
80    let mf = edge_count as f64;
81    let mean_x = sum_x / mf;
82    let mean_y = sum_y / mf;
83    let var_x = sum_xx / mf - mean_x * mean_x;
84    let var_y = sum_yy / mf - mean_y * mean_y;
85
86    if var_x < 1e-30 || var_y < 1e-30 {
87        return Ok(0.0);
88    }
89
90    let cov = sum_xy / mf - mean_x * mean_y;
91    Ok(cov / (var_x.sqrt() * var_y.sqrt()))
92}
93
94/// Compute the rich club density.
95///
96/// Density of the subgraph induced by the top-25% highest-degree
97/// vertices (at least 2 vertices). This measures how interconnected
98/// the hubs are. Returns 0.0 for trivial graphs or when fewer than
99/// 2 vertices qualify.
100///
101/// # Examples
102///
103/// ```
104/// use rust_igraph::{Graph, rich_club_density};
105///
106/// // K_4: all vertices in top 25% → density = 1.0
107/// let g = Graph::from_edges(
108///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
109/// ).unwrap();
110/// assert!((rich_club_density(&g).unwrap() - 1.0).abs() < 1e-10);
111/// ```
112pub fn rich_club_density(graph: &Graph) -> IgraphResult<f64> {
113    let n = graph.vcount() as usize;
114    if n < 2 {
115        return Ok(0.0);
116    }
117
118    let mut degrees = Vec::with_capacity(n);
119    for v in 0..n {
120        degrees.push(graph.degree(v as u32)?);
121    }
122
123    let mut sorted_degs: Vec<(usize, usize)> = degrees.iter().copied().enumerate().collect();
124    sorted_degs.sort_unstable_by(|a, b| b.1.cmp(&a.1));
125
126    let k = (n / 4).max(2).min(n);
127    let rich_set: Vec<usize> = sorted_degs[..k].iter().map(|&(v, _)| v).collect();
128
129    if rich_set.len() < 2 {
130        return Ok(0.0);
131    }
132
133    let mut in_rich = vec![false; n];
134    for &v in &rich_set {
135        in_rich[v] = true;
136    }
137
138    let mut edges_in_rich = 0_u64;
139    for &v in &rich_set {
140        let neighbors = graph.neighbors(v as u32)?;
141        for &u in &neighbors {
142            let ui = u as usize;
143            if in_rich[ui] && (graph.is_directed() || ui > v) {
144                edges_in_rich += 1;
145            }
146        }
147    }
148
149    let rs = rich_set.len();
150    let max_edges = if graph.is_directed() {
151        rs * (rs - 1)
152    } else {
153        rs * (rs - 1) / 2
154    };
155
156    if max_edges == 0 {
157        return Ok(0.0);
158    }
159
160    Ok(edges_in_rich as f64 / max_edges as f64)
161}
162
163/// Compute the degree mixing entropy.
164///
165/// Shannon entropy of the distribution of degree pairs `(d_u, d_v)`
166/// over all edges, normalized by `log2(m)` where m is the edge count.
167/// Higher values indicate more diverse degree mixing. Returns 0.0
168/// for graphs with fewer than 2 edges.
169///
170/// # Examples
171///
172/// ```
173/// use rust_igraph::{Graph, degree_mixing_entropy};
174///
175/// // K_3: all edges have same degree pair (2,2) → entropy = 0
176/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
177/// assert!(degree_mixing_entropy(&g).unwrap().abs() < 1e-10);
178/// ```
179pub fn degree_mixing_entropy(graph: &Graph) -> IgraphResult<f64> {
180    let n = graph.vcount() as usize;
181    let m = graph.ecount();
182    if m < 2 {
183        return Ok(0.0);
184    }
185
186    let mut degrees = Vec::with_capacity(n);
187    for v in 0..n {
188        degrees.push(graph.degree(v as u32)?);
189    }
190
191    let mut pair_counts = std::collections::HashMap::new();
192    let mut edge_count = 0_u64;
193
194    for v in 0..n {
195        let neighbors = graph.neighbors(v as u32)?;
196        for &u in &neighbors {
197            let ui = u as usize;
198            if !graph.is_directed() && ui <= v {
199                continue;
200            }
201            let (da, db) = if degrees[v] <= degrees[ui] {
202                (degrees[v], degrees[ui])
203            } else {
204                (degrees[ui], degrees[v])
205            };
206            *pair_counts.entry((da, db)).or_insert(0_u64) += 1;
207            edge_count += 1;
208        }
209    }
210
211    if edge_count < 2 {
212        return Ok(0.0);
213    }
214
215    let mf = edge_count as f64;
216    let log_m = mf.log2();
217    if log_m < 1e-30 {
218        return Ok(0.0);
219    }
220
221    let mut entropy = 0.0_f64;
222    for &count in pair_counts.values() {
223        let p = count as f64 / mf;
224        if p > 0.0 {
225            entropy -= p * p.log2();
226        }
227    }
228
229    Ok(entropy / log_m)
230}
231
232/// Compute the hub dominance ratio.
233///
234/// Fraction of edges that are incident to the highest-degree vertex.
235/// Measures how much the network depends on a single hub. Returns
236/// 0.0 for empty graphs.
237///
238/// # Examples
239///
240/// ```
241/// use rust_igraph::{Graph, hub_dominance_ratio};
242///
243/// // Star_5: center has 4 edges out of 4 total → 1.0
244/// let g = Graph::from_edges(
245///     &[(0,1),(0,2),(0,3),(0,4)], false, Some(5)
246/// ).unwrap();
247/// assert!((hub_dominance_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
248/// ```
249pub fn hub_dominance_ratio(graph: &Graph) -> IgraphResult<f64> {
250    let n = graph.vcount() as usize;
251    let m = graph.ecount();
252    if m == 0 || n == 0 {
253        return Ok(0.0);
254    }
255
256    let mut max_deg = 0_usize;
257    for v in 0..n {
258        let d = graph.degree(v as u32)?;
259        if d > max_deg {
260            max_deg = d;
261        }
262    }
263
264    Ok(max_deg as f64 / m as f64)
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn single_edge() -> Graph {
272        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
273    }
274
275    fn path3() -> Graph {
276        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
277    }
278
279    fn k3() -> Graph {
280        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
281    }
282
283    fn k4() -> Graph {
284        Graph::from_edges(
285            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
286            false,
287            Some(4),
288        )
289        .unwrap()
290    }
291
292    fn cycle4() -> Graph {
293        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
294    }
295
296    fn star5() -> Graph {
297        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
298    }
299
300    fn paw() -> Graph {
301        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
302    }
303
304    // --- degree_assortativity_proxy ---
305
306    #[test]
307    fn dap_empty() {
308        let g = Graph::with_vertices(0);
309        assert!(degree_assortativity_proxy(&g).unwrap().abs() < 1e-10);
310    }
311
312    #[test]
313    fn dap_single() {
314        let g = Graph::with_vertices(1);
315        assert!(degree_assortativity_proxy(&g).unwrap().abs() < 1e-10);
316    }
317
318    #[test]
319    fn dap_single_edge() {
320        // Only 1 edge → 0
321        assert!(degree_assortativity_proxy(&single_edge()).unwrap().abs() < 1e-10);
322    }
323
324    #[test]
325    fn dap_k3() {
326        // All same degree → 0
327        assert!(degree_assortativity_proxy(&k3()).unwrap().abs() < 1e-10);
328    }
329
330    #[test]
331    fn dap_k4() {
332        assert!(degree_assortativity_proxy(&k4()).unwrap().abs() < 1e-10);
333    }
334
335    #[test]
336    fn dap_cycle4() {
337        assert!(degree_assortativity_proxy(&cycle4()).unwrap().abs() < 1e-10);
338    }
339
340    #[test]
341    fn dap_star5() {
342        // Star: all edges have same pattern (4,1) → zero variance → 0.0
343        let r = degree_assortativity_proxy(&star5()).unwrap();
344        assert!(r.abs() < 1e-10);
345    }
346
347    #[test]
348    fn dap_in_range() {
349        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
350            let r = degree_assortativity_proxy(g).unwrap();
351            assert!(r >= -1.0 - 1e-10);
352            assert!(r <= 1.0 + 1e-10);
353        }
354    }
355
356    // --- rich_club_density ---
357
358    #[test]
359    fn rcd_empty() {
360        let g = Graph::with_vertices(0);
361        assert!(rich_club_density(&g).unwrap().abs() < 1e-10);
362    }
363
364    #[test]
365    fn rcd_single() {
366        let g = Graph::with_vertices(1);
367        assert!(rich_club_density(&g).unwrap().abs() < 1e-10);
368    }
369
370    #[test]
371    fn rcd_k4() {
372        // n=4, top 25% = max(4/4, 2) = 2 vertices (top 2 by degree, all deg=3)
373        // Those 2 are connected → density = 1/(1) = 1.0
374        assert!((rich_club_density(&k4()).unwrap() - 1.0).abs() < 1e-10);
375    }
376
377    #[test]
378    fn rcd_star5() {
379        // n=5, top 25% = max(5/4=1, 2) = 2 vertices
380        // Top 2 by degree: center(4) + one leaf(1)
381        // They're connected → density = 1/1 = 1.0
382        assert!((rich_club_density(&star5()).unwrap() - 1.0).abs() < 1e-10);
383    }
384
385    #[test]
386    fn rcd_in_01() {
387        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
388            let r = rich_club_density(g).unwrap();
389            assert!(r >= -1e-10);
390            assert!(r <= 1.0 + 1e-10);
391        }
392    }
393
394    // --- degree_mixing_entropy ---
395
396    #[test]
397    fn dme_empty() {
398        let g = Graph::with_vertices(0);
399        assert!(degree_mixing_entropy(&g).unwrap().abs() < 1e-10);
400    }
401
402    #[test]
403    fn dme_single() {
404        let g = Graph::with_vertices(1);
405        assert!(degree_mixing_entropy(&g).unwrap().abs() < 1e-10);
406    }
407
408    #[test]
409    fn dme_single_edge() {
410        assert!(degree_mixing_entropy(&single_edge()).unwrap().abs() < 1e-10);
411    }
412
413    #[test]
414    fn dme_k3() {
415        // All edges (2,2) → one bin → entropy=0
416        assert!(degree_mixing_entropy(&k3()).unwrap().abs() < 1e-10);
417    }
418
419    #[test]
420    fn dme_k4() {
421        // All edges (3,3) → one bin → entropy=0
422        assert!(degree_mixing_entropy(&k4()).unwrap().abs() < 1e-10);
423    }
424
425    #[test]
426    fn dme_cycle4() {
427        // All edges (2,2) → one bin → entropy=0
428        assert!(degree_mixing_entropy(&cycle4()).unwrap().abs() < 1e-10);
429    }
430
431    #[test]
432    fn dme_star5() {
433        // All edges (1,4) → one bin → entropy=0
434        assert!(degree_mixing_entropy(&star5()).unwrap().abs() < 1e-10);
435    }
436
437    #[test]
438    fn dme_paw() {
439        // Edges: (0,1)→(2,2), (0,2)→(2,3), (1,2)→(2,3), (2,3)→(3,1)→(1,3)
440        // Degree pairs sorted: (2,2):1, (2,3):2, (1,3):1
441        // 3 distinct bins, 4 edges, log2(4)=2
442        // entropy = -(1/4*log2(1/4) + 2/4*log2(2/4) + 1/4*log2(1/4))/2
443        //         = -(2*(-2/4) + (-1/2))/2 = (1 + 0.5)/2 = 1.5/2 = 0.75
444        let r = degree_mixing_entropy(&paw()).unwrap();
445        assert!(r > 0.0);
446        assert!(r <= 1.0);
447    }
448
449    #[test]
450    fn dme_in_01() {
451        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
452            let r = degree_mixing_entropy(g).unwrap();
453            assert!(r >= -1e-10);
454            assert!(r <= 1.0 + 1e-10);
455        }
456    }
457
458    // --- hub_dominance_ratio ---
459
460    #[test]
461    fn hdr_empty() {
462        let g = Graph::with_vertices(0);
463        assert!(hub_dominance_ratio(&g).unwrap().abs() < 1e-10);
464    }
465
466    #[test]
467    fn hdr_single() {
468        let g = Graph::with_vertices(1);
469        assert!(hub_dominance_ratio(&g).unwrap().abs() < 1e-10);
470    }
471
472    #[test]
473    fn hdr_single_edge() {
474        // max_deg=1, m=1 → 1.0
475        assert!((hub_dominance_ratio(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
476    }
477
478    #[test]
479    fn hdr_k3() {
480        // max_deg=2, m=3 → 2/3
481        assert!((hub_dominance_ratio(&k3()).unwrap() - 2.0 / 3.0).abs() < 1e-10);
482    }
483
484    #[test]
485    fn hdr_k4() {
486        // max_deg=3, m=6 → 3/6 = 0.5
487        assert!((hub_dominance_ratio(&k4()).unwrap() - 0.5).abs() < 1e-10);
488    }
489
490    #[test]
491    fn hdr_star5() {
492        // max_deg=4, m=4 → 1.0
493        assert!((hub_dominance_ratio(&star5()).unwrap() - 1.0).abs() < 1e-10);
494    }
495
496    #[test]
497    fn hdr_cycle4() {
498        // max_deg=2, m=4 → 0.5
499        assert!((hub_dominance_ratio(&cycle4()).unwrap() - 0.5).abs() < 1e-10);
500    }
501
502    #[test]
503    fn hdr_path3() {
504        // max_deg=2, m=2 → 1.0
505        assert!((hub_dominance_ratio(&path3()).unwrap() - 1.0).abs() < 1e-10);
506    }
507
508    #[test]
509    fn hdr_positive() {
510        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
511            assert!(hub_dominance_ratio(g).unwrap() > 0.0);
512        }
513    }
514
515    // --- cross-consistency ---
516
517    #[test]
518    fn regular_zero_assortativity() {
519        assert!(degree_assortativity_proxy(&k3()).unwrap().abs() < 1e-10);
520        assert!(degree_assortativity_proxy(&k4()).unwrap().abs() < 1e-10);
521        assert!(degree_assortativity_proxy(&cycle4()).unwrap().abs() < 1e-10);
522    }
523
524    #[test]
525    fn regular_zero_entropy() {
526        assert!(degree_mixing_entropy(&k3()).unwrap().abs() < 1e-10);
527        assert!(degree_mixing_entropy(&k4()).unwrap().abs() < 1e-10);
528        assert!(degree_mixing_entropy(&cycle4()).unwrap().abs() < 1e-10);
529    }
530
531    #[test]
532    fn star_max_hub_dominance() {
533        assert!((hub_dominance_ratio(&star5()).unwrap() - 1.0).abs() < 1e-10);
534    }
535}