Skip to main content

rust_igraph/algorithms/properties/
edge_degree_correlation.rs

1//! Edge degree correlation indices (ALGO-TR-093).
2//!
3//! Correlation-style measures between degrees at edge endpoints:
4//!
5//! - **Degree covariance (edges)** — Cov(d(u), d(v)) over edges
6//! - **Degree Pearson (edges)** — Pearson r of (d(u), d(v)) pairs
7//! - **Degree cosine similarity** — cosine of degree vectors over edges
8//! - **Degree discrepancy** — Σ (d(u) - d(v))² / (4m) normalized squared diff
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::similar_names,
16    clippy::too_many_lines
17)]
18
19use crate::core::{Graph, IgraphResult};
20
21/// Compute the degree covariance across edges.
22///
23/// `Cov = (1/m) Σ_{(u,v)∈E} d(u)·d(v) - [(1/m) Σ d(u)]·[(1/m) Σ d(v)]`
24///
25/// where each edge contributes a pair (d(u), d(v)). Positive covariance
26/// means high-degree vertices tend to connect to high-degree vertices
27/// (assortative). Returns 0.0 for the empty or single-edge graph.
28/// Self-loops are skipped.
29///
30/// # Examples
31///
32/// ```
33/// use rust_igraph::{Graph, edge_degree_covariance};
34///
35/// // K_3: 3 edges, all (2,2) → Cov = 4 - 2·2 = 0
36/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
37/// assert!(edge_degree_covariance(&g).unwrap().abs() < 1e-10);
38/// ```
39pub fn edge_degree_covariance(graph: &Graph) -> IgraphResult<f64> {
40    let mut m = 0_u64;
41    let mut sum_u = 0.0_f64;
42    let mut sum_v = 0.0_f64;
43    let mut sum_uv = 0.0_f64;
44
45    for (u, v) in graph.edges() {
46        if u == v {
47            continue;
48        }
49        let du = graph.degree(u)? as f64;
50        let dv = graph.degree(v)? as f64;
51        sum_u += du;
52        sum_v += dv;
53        sum_uv += du * dv;
54        m += 1;
55    }
56
57    if m == 0 {
58        return Ok(0.0);
59    }
60
61    let mf = m as f64;
62    let mean_u = sum_u / mf;
63    let mean_v = sum_v / mf;
64    let cov = sum_uv / mf - mean_u * mean_v;
65
66    Ok(cov)
67}
68
69/// Compute the Pearson correlation of endpoint degrees across edges.
70///
71/// The standard Pearson r between the two degree sequences formed by
72/// edge endpoints. Related to but not identical to `degree_assortativity`
73/// (Newman's definition uses a different normalization).
74///
75/// Returns 0.0 for the empty graph or when variance is zero (regular
76/// graphs). Self-loops are skipped.
77///
78/// # Examples
79///
80/// ```
81/// use rust_igraph::{Graph, edge_degree_pearson};
82///
83/// // K_3: all (2,2) → Var=0 → r=0.0
84/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
85/// assert!(edge_degree_pearson(&g).unwrap().abs() < 1e-10);
86/// ```
87pub fn edge_degree_pearson(graph: &Graph) -> IgraphResult<f64> {
88    let mut m = 0_u64;
89    let mut sum_u = 0.0_f64;
90    let mut sum_v = 0.0_f64;
91    let mut sum_uu = 0.0_f64;
92    let mut sum_vv = 0.0_f64;
93    let mut sum_uv = 0.0_f64;
94
95    for (u, v) in graph.edges() {
96        if u == v {
97            continue;
98        }
99        let du = graph.degree(u)? as f64;
100        let dv = graph.degree(v)? as f64;
101        sum_u += du;
102        sum_v += dv;
103        sum_uu += du * du;
104        sum_vv += dv * dv;
105        sum_uv += du * dv;
106        m += 1;
107    }
108
109    if m == 0 {
110        return Ok(0.0);
111    }
112
113    let mf = m as f64;
114    let var_u = sum_uu / mf - (sum_u / mf) * (sum_u / mf);
115    let var_v = sum_vv / mf - (sum_v / mf) * (sum_v / mf);
116    let cov = sum_uv / mf - (sum_u / mf) * (sum_v / mf);
117
118    let denom = (var_u * var_v).sqrt();
119    if denom < 1e-15 {
120        return Ok(0.0);
121    }
122
123    Ok(cov / denom)
124}
125
126/// Compute the cosine similarity of degree vectors across edges.
127///
128/// `cos = Σ d(u)·d(v) / √(Σ d(u)² · Σ d(v)²)`
129///
130/// where the sums run over all edges. Treats each edge endpoint as
131/// a component of two vectors. Returns 0.0 for the empty graph.
132/// Self-loops are skipped.
133///
134/// # Examples
135///
136/// ```
137/// use rust_igraph::{Graph, edge_degree_cosine};
138///
139/// // K_3: all (2,2) → cos = 3·4 / √(3·4 · 3·4) = 12/12 = 1.0
140/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
141/// assert!((edge_degree_cosine(&g).unwrap() - 1.0).abs() < 1e-10);
142/// ```
143pub fn edge_degree_cosine(graph: &Graph) -> IgraphResult<f64> {
144    let mut sum_uv = 0.0_f64;
145    let mut sum_uu = 0.0_f64;
146    let mut sum_vv = 0.0_f64;
147
148    for (u, v) in graph.edges() {
149        if u == v {
150            continue;
151        }
152        let du = graph.degree(u)? as f64;
153        let dv = graph.degree(v)? as f64;
154        sum_uv += du * dv;
155        sum_uu += du * du;
156        sum_vv += dv * dv;
157    }
158
159    let denom = (sum_uu * sum_vv).sqrt();
160    if denom < 1e-15 {
161        return Ok(0.0);
162    }
163
164    Ok(sum_uv / denom)
165}
166
167/// Compute the normalized degree discrepancy across edges.
168///
169/// `D = Σ_{(u,v)∈E} (d(u) - d(v))² / (4m)`
170///
171/// Measures how differently-connected each edge's endpoints are,
172/// normalized by edge count. Zero for regular graphs. Self-loops
173/// are skipped. Returns 0.0 for the empty graph.
174///
175/// # Examples
176///
177/// ```
178/// use rust_igraph::{Graph, edge_degree_discrepancy};
179///
180/// // K_3: all (2,2) → (0)²/12 = 0
181/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
182/// assert!(edge_degree_discrepancy(&g).unwrap().abs() < 1e-10);
183/// ```
184pub fn edge_degree_discrepancy(graph: &Graph) -> IgraphResult<f64> {
185    let mut m = 0_u64;
186    let mut sum_sq = 0.0_f64;
187
188    for (u, v) in graph.edges() {
189        if u == v {
190            continue;
191        }
192        let du = graph.degree(u)? as f64;
193        let dv = graph.degree(v)? as f64;
194        let diff = du - dv;
195        sum_sq += diff * diff;
196        m += 1;
197    }
198
199    if m == 0 {
200        return Ok(0.0);
201    }
202
203    Ok(sum_sq / (4.0 * m as f64))
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn single_edge() -> Graph {
211        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
212    }
213
214    fn path3() -> Graph {
215        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
216    }
217
218    fn k3() -> Graph {
219        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
220    }
221
222    fn k4() -> Graph {
223        Graph::from_edges(
224            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
225            false,
226            Some(4),
227        )
228        .unwrap()
229    }
230
231    fn cycle4() -> Graph {
232        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
233    }
234
235    fn star5() -> Graph {
236        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
237    }
238
239    fn paw() -> Graph {
240        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
241    }
242
243    // --- edge_degree_covariance ---
244
245    #[test]
246    fn cov_empty() {
247        let g = Graph::with_vertices(0);
248        assert!(edge_degree_covariance(&g).unwrap().abs() < 1e-10);
249    }
250
251    #[test]
252    fn cov_isolated() {
253        let g = Graph::with_vertices(5);
254        assert!(edge_degree_covariance(&g).unwrap().abs() < 1e-10);
255    }
256
257    #[test]
258    fn cov_regular() {
259        // Regular: all (r,r), Cov = r² - r·r = 0
260        assert!(edge_degree_covariance(&k3()).unwrap().abs() < 1e-10);
261        assert!(edge_degree_covariance(&k4()).unwrap().abs() < 1e-10);
262        assert!(edge_degree_covariance(&cycle4()).unwrap().abs() < 1e-10);
263    }
264
265    #[test]
266    fn cov_single_edge() {
267        // (1,1) → Cov = 1 - 1·1 = 0
268        assert!(edge_degree_covariance(&single_edge()).unwrap().abs() < 1e-10);
269    }
270
271    #[test]
272    fn cov_star5() {
273        // 4 edges all (4,1): sum_uv=4·4=16, sum_u=4·4=16, sum_v=4·1=4
274        // Cov = 16/4 - (16/4)·(4/4) = 4 - 4·1 = 0
275        assert!(edge_degree_covariance(&star5()).unwrap().abs() < 1e-10);
276    }
277
278    #[test]
279    fn cov_path3() {
280        // 2 edges: (0,1)→(1,2), (1,2)→(2,1) [stored u<v]
281        // Edge (0,1): d(0)=1, d(1)=2
282        // Edge (1,2): d(1)=2, d(2)=1
283        // sum_u = 1+2=3, sum_v = 2+1=3
284        // sum_uv = 1·2 + 2·1 = 4
285        // Cov = 4/2 - (3/2)·(3/2) = 2 - 2.25 = -0.25
286        assert!((edge_degree_covariance(&path3()).unwrap() - (-0.25)).abs() < 1e-10);
287    }
288
289    #[test]
290    fn cov_paw() {
291        // Edges (u<v): (0,1),(0,2),(1,2),(2,3)
292        // d: 0→2, 1→2, 2→3, 3→1
293        // (0,1): (2,2), (0,2): (2,3), (1,2): (2,3), (2,3): (3,1)
294        // sum_u = 2+2+2+3=9, sum_v = 2+3+3+1=9
295        // sum_uv = 4+6+6+3=19
296        // Cov = 19/4 - (9/4)² = 4.75 - 5.0625 = -0.3125
297        assert!((edge_degree_covariance(&paw()).unwrap() - (-0.3125)).abs() < 1e-10);
298    }
299
300    // --- edge_degree_pearson ---
301
302    #[test]
303    fn pearson_empty() {
304        let g = Graph::with_vertices(0);
305        assert!(edge_degree_pearson(&g).unwrap().abs() < 1e-10);
306    }
307
308    #[test]
309    fn pearson_regular() {
310        // Var=0 → r=0
311        assert!(edge_degree_pearson(&k3()).unwrap().abs() < 1e-10);
312        assert!(edge_degree_pearson(&k4()).unwrap().abs() < 1e-10);
313        assert!(edge_degree_pearson(&cycle4()).unwrap().abs() < 1e-10);
314    }
315
316    #[test]
317    fn pearson_single_edge() {
318        // Var=0 → r=0
319        assert!(edge_degree_pearson(&single_edge()).unwrap().abs() < 1e-10);
320    }
321
322    #[test]
323    fn pearson_star5() {
324        // Edges (4,1),(4,1),(4,1),(4,1): u always 4, Var_u=0 → r=0
325        assert!(edge_degree_pearson(&star5()).unwrap().abs() < 1e-10);
326    }
327
328    #[test]
329    fn pearson_path3() {
330        // u=[1,2], v=[2,1]: Cov=-0.25, Var_u=0.25, Var_v=0.25
331        // r = -0.25/sqrt(0.25·0.25) = -0.25/0.25 = -1.0
332        assert!((edge_degree_pearson(&path3()).unwrap() - (-1.0)).abs() < 1e-10);
333    }
334
335    #[test]
336    fn pearson_in_range() {
337        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
338            let r = edge_degree_pearson(g).unwrap();
339            assert!(r >= -1.0 - 1e-10);
340            assert!(r <= 1.0 + 1e-10);
341        }
342    }
343
344    // --- edge_degree_cosine ---
345
346    #[test]
347    fn cos_empty() {
348        let g = Graph::with_vertices(0);
349        assert!(edge_degree_cosine(&g).unwrap().abs() < 1e-10);
350    }
351
352    #[test]
353    fn cos_regular() {
354        // All identical → cos = 1.0
355        assert!((edge_degree_cosine(&k3()).unwrap() - 1.0).abs() < 1e-10);
356        assert!((edge_degree_cosine(&k4()).unwrap() - 1.0).abs() < 1e-10);
357        assert!((edge_degree_cosine(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
358    }
359
360    #[test]
361    fn cos_single_edge() {
362        // (1,1): cos = 1/(1·1) = 1.0
363        assert!((edge_degree_cosine(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
364    }
365
366    #[test]
367    fn cos_star5() {
368        // u=[4,4,4,4], v=[1,1,1,1]
369        // sum_uv=16, sum_uu=64, sum_vv=4
370        // cos = 16/sqrt(64·4) = 16/16 = 1.0
371        assert!((edge_degree_cosine(&star5()).unwrap() - 1.0).abs() < 1e-10);
372    }
373
374    #[test]
375    fn cos_path3() {
376        // u=[1,2], v=[2,1]
377        // sum_uv=1·2+2·1=4, sum_uu=1+4=5, sum_vv=4+1=5
378        // cos = 4/sqrt(25) = 4/5 = 0.8
379        assert!((edge_degree_cosine(&path3()).unwrap() - 0.8).abs() < 1e-10);
380    }
381
382    #[test]
383    fn cos_paw() {
384        // u=[2,2,2,3], v=[2,3,3,1]
385        // sum_uv=4+6+6+3=19, sum_uu=4+4+4+9=21, sum_vv=4+9+9+1=23
386        // cos = 19/sqrt(21·23) = 19/sqrt(483)
387        let expected = 19.0 / (21.0_f64 * 23.0).sqrt();
388        assert!((edge_degree_cosine(&paw()).unwrap() - expected).abs() < 1e-10);
389    }
390
391    #[test]
392    fn cos_in_01() {
393        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
394            let c = edge_degree_cosine(g).unwrap();
395            assert!(c >= -1e-10);
396            assert!(c <= 1.0 + 1e-10);
397        }
398    }
399
400    // --- edge_degree_discrepancy ---
401
402    #[test]
403    fn disc_empty() {
404        let g = Graph::with_vertices(0);
405        assert!(edge_degree_discrepancy(&g).unwrap().abs() < 1e-10);
406    }
407
408    #[test]
409    fn disc_regular() {
410        // All diffs=0 → 0
411        assert!(edge_degree_discrepancy(&k3()).unwrap().abs() < 1e-10);
412        assert!(edge_degree_discrepancy(&k4()).unwrap().abs() < 1e-10);
413        assert!(edge_degree_discrepancy(&cycle4()).unwrap().abs() < 1e-10);
414    }
415
416    #[test]
417    fn disc_single_edge() {
418        // (1,1) → 0²/(4·1) = 0
419        assert!(edge_degree_discrepancy(&single_edge()).unwrap().abs() < 1e-10);
420    }
421
422    #[test]
423    fn disc_star5() {
424        // 4 edges, each (4-1)²=9
425        // D = 4·9 / (4·4) = 36/16 = 2.25
426        assert!((edge_degree_discrepancy(&star5()).unwrap() - 2.25).abs() < 1e-10);
427    }
428
429    #[test]
430    fn disc_path3() {
431        // 2 edges, each (1-2)²=1
432        // D = 2·1 / (4·2) = 2/8 = 0.25
433        assert!((edge_degree_discrepancy(&path3()).unwrap() - 0.25).abs() < 1e-10);
434    }
435
436    #[test]
437    fn disc_paw() {
438        // (0,1):(2-2)²=0, (0,2):(2-3)²=1, (1,2):(2-3)²=1, (2,3):(3-1)²=4
439        // D = (0+1+1+4) / (4·4) = 6/16 = 3/8
440        assert!((edge_degree_discrepancy(&paw()).unwrap() - 3.0 / 8.0).abs() < 1e-10);
441    }
442
443    // --- cross-consistency ---
444
445    #[test]
446    fn disc_nonnegative() {
447        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
448            assert!(edge_degree_discrepancy(g).unwrap() >= -1e-10);
449        }
450    }
451
452    #[test]
453    fn cov_zero_implies_disc_related() {
454        // When cov=0 (regular), discrepancy=0 too
455        for g in &[k3(), k4(), cycle4()] {
456            assert!(edge_degree_covariance(g).unwrap().abs() < 1e-10);
457            assert!(edge_degree_discrepancy(g).unwrap().abs() < 1e-10);
458        }
459    }
460
461    #[test]
462    fn cos_ge_pearson_squared() {
463        // cosine ≥ 0 always (degrees are positive)
464        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
465            assert!(edge_degree_cosine(g).unwrap() >= -1e-10);
466        }
467    }
468}