Skip to main content

rust_igraph/algorithms/properties/
assortativity.rs

1//! Degree assortativity coefficient (ALGO-PR-006).
2//!
3//! Counterpart of `igraph_assortativity_degree()` from
4//! `references/igraph/src/misc/mixing.c:443` and the underlying
5//! `igraph_assortativity()` (`mixing.c:273`). The metric is the Pearson
6//! correlation of endpoint degrees over the edge list — high positive
7//! values mean high-degree vertices tend to connect to each other
8//! (assortative mixing); negative values mean opposite.
9//!
10//! Phase-1 covers undirected unweighted ([`assortativity_degree`] —
11//! ALGO-PR-006), undirected weighted (`assortativity_degree_weighted`
12//! in [`super::assortativity_weighted`] — ALGO-PR-006b), and now
13//! [`assortativity_degree_directed`] (ALGO-PR-006c — directed Pearson
14//! correlation of source out-degree against target in-degree).
15//! Weighted-directed ships when needed.
16//!
17//! Formula (matches upstream's float ordering at `mixing.c:306-349`):
18//! - For each edge `e = (u, v)` over the m edges of the graph:
19//!   - `num1 += deg(u) * deg(v)`
20//!   - `num2 += deg(u) + deg(v)`
21//!   - `den1 += deg(u)^2 + deg(v)^2`
22//! - `num1 /= m`
23//! - `den1 /= 2 * m`
24//! - `num2 /= 2 * m`; `num2 = num2 * num2`
25//! - `r = (num1 - num2) / (den1 - num2)`
26//! - When `den1 == num2` (regular graphs — every vertex has the same
27//!   degree, so the variance is zero), `r` is undefined → `None`.
28
29use crate::core::graph::EdgeId;
30use crate::core::{Graph, IgraphResult};
31
32/// Degree assortativity coefficient of `graph` (undirected, unweighted).
33/// Returns `None` for graphs with no edges or for regular graphs
34/// (all vertices same degree — the variance denominator vanishes,
35/// matching upstream's `IGRAPH_NAN`).
36///
37/// Counterpart of `igraph_assortativity_degree(_, _, /*directed=*/false)`.
38/// Directed graphs return [`crate::IgraphError::Unsupported`].
39///
40/// # Examples
41///
42/// ```
43/// use rust_igraph::{Graph, assortativity_degree};
44///
45/// // 4-cycle: all vertices have degree 2 → regular graph → None.
46/// let mut g = Graph::with_vertices(4);
47/// for i in 0..4u32 { g.add_edge(i, (i + 1) % 4).unwrap(); }
48/// assert_eq!(assortativity_degree(&g).unwrap(), None);
49///
50/// // Path 0-1-2: deg=[1,2,1]. Two edges, both connect a deg-1 vertex
51/// // to the deg-2 centre. The metric is well-defined here:
52/// // num1 = (1*2 + 2*1) / 2 = 2
53/// // num2 = ((1+2 + 2+1) / 4)^2 = (6/4)^2 = 2.25
54/// // den1 = (1+4 + 4+1) / 4 = 2.5
55/// // r = (2 - 2.25) / (2.5 - 2.25) = -1.0  (perfectly disassortative)
56/// let mut g = Graph::with_vertices(3);
57/// g.add_edge(0, 1).unwrap();
58/// g.add_edge(1, 2).unwrap();
59/// assert_eq!(assortativity_degree(&g).unwrap(), Some(-1.0));
60/// ```
61pub fn assortativity_degree(graph: &Graph) -> IgraphResult<Option<f64>> {
62    if graph.is_directed() {
63        // Directed graphs route through the directed Pearson formula
64        // (PR-006c) — `assortativity_degree_directed` is the canonical
65        // entry but `assortativity_degree(g)` doing the natural thing
66        // matches python-igraph's `Graph.assortativity_degree()` default.
67        return assortativity_degree_directed(graph);
68    }
69    let m = graph.ecount();
70    if m == 0 {
71        return Ok(None);
72    }
73
74    // Per-vertex degree vector. `Graph::degree` already counts self-loops
75    // twice for undirected graphs (LOOPS_TWICE), matching upstream's
76    // `igraph_strength(_, _, _, IGRAPH_ALL, IGRAPH_LOOPS, NULL)`.
77    let n = graph.vcount();
78    let mut deg = Vec::with_capacity(n as usize);
79    for v in 0..n {
80        let d = graph.degree(v)?;
81        // d is bounded by ecount * 2 in the undirected LOOPS_TWICE case;
82        // fits in u32 for any practical graph that survives our u32
83        // edge-id encoding.
84        #[allow(clippy::cast_precision_loss)]
85        deg.push(d as f64);
86    }
87
88    let mut num1 = 0.0_f64;
89    let mut num2 = 0.0_f64;
90    let mut den1 = 0.0_f64;
91
92    let m_u32 = u32::try_from(m).map_err(|_| {
93        crate::core::IgraphError::Internal("ecount overflows u32 for assortativity")
94    })?;
95    for e in 0..m_u32 {
96        let (u, v) = graph.edge(e as EdgeId)?;
97        let du = deg[u as usize];
98        let dv = deg[v as usize];
99        num1 += du * dv;
100        num2 += du + dv;
101        den1 += du * du + dv * dv;
102    }
103
104    #[allow(clippy::cast_precision_loss)]
105    let total = m as f64;
106    num1 /= total;
107    den1 /= total * 2.0;
108    num2 /= total * 2.0;
109    num2 *= num2;
110
111    let denom = den1 - num2;
112    if denom == 0.0 {
113        // Regular graph → upstream returns NaN; we encode as None.
114        return Ok(None);
115    }
116    Ok(Some((num1 - num2) / denom))
117}
118
119/// Directed degree assortativity coefficient (ALGO-PR-006c).
120///
121/// Counterpart of `igraph_assortativity_degree(_, _, /*directed=*/true)`
122/// (the directed branch of `mixing.c:443`). For each directed edge
123/// `e = (u → v)` Pearson-correlates the source's out-degree against
124/// the target's in-degree:
125///
126/// ```text
127/// num1 = Σ out_deg(u) * in_deg(v)
128/// num2 = Σ out_deg(u)
129/// num3 = Σ in_deg(v)
130/// den1 = Σ out_deg(u)²
131/// den2 = Σ in_deg(v)²
132///
133/// num = num1 − num2 * num3 / m
134/// den = sqrt(den1 − num2² / m) * sqrt(den2 − num3² / m)
135/// r   = num / den       (None if den == 0)
136/// ```
137///
138/// Returns `None` for graphs with no edges or where either variance
139/// term collapses (regular in-degrees and/or regular out-degrees —
140/// matches upstream NaN). Undirected graphs are accepted and route
141/// to the undirected formula via [`assortativity_degree`].
142///
143/// # Examples
144///
145/// ```
146/// use rust_igraph::{Graph, assortativity_degree_directed};
147///
148/// // Directed 3-cycle 0→1→2→0: every vertex has out-degree 1 and
149/// // in-degree 1. Both variance terms vanish → None.
150/// let mut g = Graph::new(3, true).unwrap();
151/// g.add_edge(0, 1).unwrap();
152/// g.add_edge(1, 2).unwrap();
153/// g.add_edge(2, 0).unwrap();
154/// assert_eq!(assortativity_degree_directed(&g).unwrap(), None);
155/// ```
156pub fn assortativity_degree_directed(graph: &Graph) -> IgraphResult<Option<f64>> {
157    if !graph.is_directed() {
158        // Undirected graphs use the symmetric formula; defer to the
159        // canonical undirected entry.
160        return assortativity_degree(graph);
161    }
162    let m = graph.ecount();
163    if m == 0 {
164        return Ok(None);
165    }
166
167    let n = graph.vcount();
168    let n_us = n as usize;
169    let mut out_deg = vec![0.0_f64; n_us];
170    let mut in_deg = vec![0.0_f64; n_us];
171
172    let m_u32 = u32::try_from(m).map_err(|_| {
173        crate::core::IgraphError::Internal("ecount overflows u32 for assortativity")
174    })?;
175    for e in 0..m_u32 {
176        let (src, tgt) = graph.edge(e as EdgeId)?;
177        out_deg[src as usize] += 1.0;
178        in_deg[tgt as usize] += 1.0;
179    }
180
181    let mut num1 = 0.0_f64;
182    let mut num2 = 0.0_f64;
183    let mut num3 = 0.0_f64;
184    let mut den1 = 0.0_f64;
185    let mut den2 = 0.0_f64;
186
187    for e in 0..m_u32 {
188        let (src, tgt) = graph.edge(e as EdgeId)?;
189        let from_value = out_deg[src as usize];
190        let to_value = in_deg[tgt as usize];
191        num1 += from_value * to_value;
192        num2 += from_value;
193        num3 += to_value;
194        den1 += from_value * from_value;
195        den2 += to_value * to_value;
196    }
197
198    #[allow(clippy::cast_precision_loss)]
199    let total = m as f64;
200    let num = num1 - num2 * num3 / total;
201    let var_from = den1 - num2 * num2 / total;
202    let var_to = den2 - num3 * num3 / total;
203    if var_from <= 0.0 || var_to <= 0.0 {
204        return Ok(None);
205    }
206    let den = var_from.sqrt() * var_to.sqrt();
207    if den == 0.0 {
208        return Ok(None);
209    }
210    Ok(Some(num / den))
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn assert_close(a: f64, b: f64, tol: f64) {
218        assert!(
219            (a - b).abs() < tol,
220            "expected {b} ± {tol}, got {a} (diff {})",
221            (a - b).abs()
222        );
223    }
224
225    #[test]
226    fn empty_graph_is_none() {
227        let g = Graph::with_vertices(0);
228        assert_eq!(assortativity_degree(&g).unwrap(), None);
229    }
230
231    #[test]
232    fn isolated_vertices_no_edges_is_none() {
233        let g = Graph::with_vertices(5);
234        assert_eq!(assortativity_degree(&g).unwrap(), None);
235    }
236
237    #[test]
238    fn regular_graph_returns_none() {
239        // 4-cycle: every vertex has degree 2.
240        let mut g = Graph::with_vertices(4);
241        for i in 0..4u32 {
242            g.add_edge(i, (i + 1) % 4).unwrap();
243        }
244        assert_eq!(assortativity_degree(&g).unwrap(), None);
245    }
246
247    #[test]
248    fn k4_is_regular_returns_none() {
249        // K4: every vertex has degree 3.
250        let mut g = Graph::with_vertices(4);
251        for u in 0..4u32 {
252            for v in (u + 1)..4 {
253                g.add_edge(u, v).unwrap();
254            }
255        }
256        assert_eq!(assortativity_degree(&g).unwrap(), None);
257    }
258
259    #[test]
260    fn path_3_is_perfectly_disassortative() {
261        // 0-1-2: deg [1, 2, 1]. By the formula, r = -1.0.
262        let mut g = Graph::with_vertices(3);
263        g.add_edge(0, 1).unwrap();
264        g.add_edge(1, 2).unwrap();
265        let r = assortativity_degree(&g).unwrap().unwrap();
266        assert_close(r, -1.0, 1e-12);
267    }
268
269    #[test]
270    fn star_is_perfectly_disassortative() {
271        // Star centre deg=3, 3 leaves with deg=1 each.
272        // All 3 edges: deg(centre)=3, deg(leaf)=1.
273        // num1 = 3*1 + 3*1 + 3*1 = 9, /m=9/3 = 3
274        // num2 = (3+1)*3 / (2*3) = 12/6 = 2; squared = 4
275        // den1 = (9+1)*3 / (2*3) = 30/6 = 5
276        // r = (3 - 4) / (5 - 4) = -1.0
277        let mut g = Graph::with_vertices(4);
278        for v in 1..4 {
279            g.add_edge(0, v).unwrap();
280        }
281        let r = assortativity_degree(&g).unwrap().unwrap();
282        assert_close(r, -1.0, 1e-12);
283    }
284
285    #[test]
286    fn two_disjoint_edges_is_assortative_or_regular() {
287        // Two parallel edges 0-1 and 2-3: every vertex has degree 1
288        // (regular; r is undefined / None).
289        let mut g = Graph::with_vertices(4);
290        g.add_edge(0, 1).unwrap();
291        g.add_edge(2, 3).unwrap();
292        assert_eq!(assortativity_degree(&g).unwrap(), None);
293    }
294
295    #[test]
296    fn directed_graph_routes_to_directed_formula() {
297        // PR-006c extension: `assortativity_degree(directed_g)` no
298        // longer returns `Unsupported` — it routes to
299        // `assortativity_degree_directed`. A single edge has too few
300        // samples to compute Pearson (variance == 0 on both sides),
301        // so result is None.
302        let mut g = Graph::new(3, true).unwrap();
303        g.add_edge(0, 1).unwrap();
304        assert_eq!(assortativity_degree(&g).unwrap(), None);
305    }
306
307    // ----- ALGO-PR-006c: directed assortativity -----
308
309    #[test]
310    fn directed_3_cycle_is_regular_returns_none() {
311        let mut g = Graph::new(3, true).unwrap();
312        g.add_edge(0, 1).unwrap();
313        g.add_edge(1, 2).unwrap();
314        g.add_edge(2, 0).unwrap();
315        assert_eq!(assortativity_degree_directed(&g).unwrap(), None);
316    }
317
318    #[test]
319    fn directed_path_three_disassortative() {
320        // 0 → 1 → 2:
321        //   out_deg = [1, 1, 0], in_deg = [0, 1, 1]
322        //   Edge (0,1): from=1, to=1. Edge (1,2): from=1, to=1.
323        //   num1 = 1*1 + 1*1 = 2
324        //   num2 = 1 + 1 = 2 (sum of out_degs over edges)
325        //   num3 = 1 + 1 = 2 (sum of in_degs over edges)
326        //   den1 = 1 + 1 = 2; den2 = 1 + 1 = 2; m = 2
327        //   num = 2 - 2*2/2 = 2 - 2 = 0
328        //   var_from = 2 - 4/2 = 0; var_to = 2 - 4/2 = 0
329        //   → den is 0 → None
330        let mut g = Graph::new(3, true).unwrap();
331        g.add_edge(0, 1).unwrap();
332        g.add_edge(1, 2).unwrap();
333        assert_eq!(assortativity_degree_directed(&g).unwrap(), None);
334    }
335
336    #[test]
337    fn directed_chain_with_branch_is_well_defined() {
338        // 0→1, 1→2, 0→2: out_deg=[2, 1, 0], in_deg=[0, 1, 2].
339        //   Edges (0,1): out(0)=2, in(1)=1
340        //         (0,2): out(0)=2, in(2)=2
341        //         (1,2): out(1)=1, in(2)=2
342        //   num1 = 2*1 + 2*2 + 1*2 = 8
343        //   num2 = 2 + 2 + 1 = 5
344        //   num3 = 1 + 2 + 2 = 5
345        //   den1 = 4 + 4 + 1 = 9
346        //   den2 = 1 + 4 + 4 = 9
347        //   m = 3
348        //   num = 8 - 5*5/3 = 8 - 25/3 ≈ -0.333
349        //   var_from = 9 - 25/3 ≈ 0.667; var_to = same ≈ 0.667
350        //   den = sqrt(0.667)² ≈ 0.667
351        //   r ≈ -0.5
352        let mut g = Graph::new(3, true).unwrap();
353        g.add_edge(0, 1).unwrap();
354        g.add_edge(1, 2).unwrap();
355        g.add_edge(0, 2).unwrap();
356        let r = assortativity_degree_directed(&g).unwrap().unwrap();
357        assert_close(r, -0.5, 1e-12);
358    }
359
360    #[test]
361    fn directed_empty_graph_returns_none() {
362        let g = Graph::new(0, true).unwrap();
363        assert_eq!(assortativity_degree_directed(&g).unwrap(), None);
364    }
365
366    #[test]
367    fn directed_undirected_graph_routes_to_undirected_formula() {
368        // assortativity_degree_directed on an undirected graph should
369        // delegate to assortativity_degree (matches python-igraph's
370        // behaviour where the `directed` arg is ignored on undirected).
371        let mut g = Graph::with_vertices(3);
372        g.add_edge(0, 1).unwrap();
373        g.add_edge(1, 2).unwrap();
374        let a = assortativity_degree(&g).unwrap();
375        let b = assortativity_degree_directed(&g).unwrap();
376        assert_eq!(a, b);
377    }
378
379    #[test]
380    fn diamond_k4_minus_edge() {
381        // Edges (0,1)(0,2)(1,2)(1,3)(2,3): deg=[2, 3, 3, 2].
382        // m = 5
383        // num1 = 2*3 + 2*3 + 3*3 + 3*2 + 3*2 = 6+6+9+6+6 = 33; /5 = 6.6
384        // num2 = (2+3)+(2+3)+(3+3)+(3+2)+(3+2) = 5+5+6+5+5 = 26;  / (2*5) = 2.6; ^2 = 6.76
385        // den1 = 4+9 + 4+9 + 9+9 + 9+4 + 9+4 = 13+13+18+13+13 = 70; /(2*5) = 7.0
386        // r = (6.6 - 6.76) / (7.0 - 6.76) = -0.16 / 0.24 = -0.66666...
387        let mut g = Graph::with_vertices(4);
388        for &(u, v) in &[(0u32, 1), (0, 2), (1, 2), (1, 3), (2, 3)] {
389            g.add_edge(u, v).unwrap();
390        }
391        let r = assortativity_degree(&g).unwrap().unwrap();
392        assert_close(r, -2.0 / 3.0, 1e-12);
393    }
394
395    #[test]
396    fn two_triangles_joined_by_bridge_matches_python_igraph() {
397        // {0,1,2} triangle, {3,4,5} triangle, plus edge 2-3.
398        // deg = [2, 2, 3, 3, 2, 2]. python-igraph 0.11.9 reports
399        // assortativity_degree() = -0.16666666666666424 (slightly
400        // disassortative — the 6 inner triangle edges connect deg-2 to
401        // deg-3 vertices, and the lone bridge connects deg-3 to deg-3).
402        let mut g = Graph::with_vertices(6);
403        for &(u, v) in &[(0u32, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (2, 3)] {
404            g.add_edge(u, v).unwrap();
405        }
406        let r = assortativity_degree(&g).unwrap().unwrap();
407        assert_close(r, -0.166_666_666_666_664_24, 1e-12);
408    }
409}