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}