Skip to main content

rust_igraph/algorithms/properties/
edge_degree_norm.rs

1//! Edge degree normalized indices (ALGO-TR-090).
2//!
3//! Edge-level indices that normalize endpoint degrees:
4//!
5//! - **Inverse sum index** `Σ 1/(d(u)+d(v))` — reciprocal of degree sum
6//! - **Difference ratio** `Σ |d(u)-d(v)|/(d(u)+d(v))` — normalized asymmetry
7//! - **Sørensen index** `Σ 2·min(d(u),d(v))/(d(u)+d(v))` — per-edge similarity
8//! - **Product-sum ratio** `Σ d(u)·d(v)/(d(u)+d(v))²` — product/sum² ratio
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 inverse degree-sum index over edges.
21///
22/// `Σ_{(u,v)∈E} 1 / (d(u) + d(v))`
23///
24/// Self-loops and edges with a zero-degree endpoint are skipped.
25/// Related to the inverse sum indeg index (ISD) but sums over
26/// edges rather than vertex neighborhoods.
27///
28/// # Examples
29///
30/// ```
31/// use rust_igraph::{Graph, edge_inverse_degree_sum};
32///
33/// // K_3: 3 edges, each (2,2) → 3·(1/4) = 0.75
34/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
35/// assert!((edge_inverse_degree_sum(&g).unwrap() - 0.75).abs() < 1e-10);
36/// ```
37pub fn edge_inverse_degree_sum(graph: &Graph) -> IgraphResult<f64> {
38    let mut result = 0.0_f64;
39
40    for (u, v) in graph.edges() {
41        if u == v {
42            continue;
43        }
44        let du = graph.degree(u)? as f64;
45        let dv = graph.degree(v)? as f64;
46        let s = du + dv;
47        if s == 0.0 {
48            continue;
49        }
50        result += 1.0 / s;
51    }
52
53    Ok(result)
54}
55
56/// Compute the degree difference ratio over edges.
57///
58/// `Σ_{(u,v)∈E} |d(u) - d(v)| / (d(u) + d(v))`
59///
60/// Each edge contributes a value in [0, 1). Returns 0.0 for regular
61/// or edgeless graphs. Self-loops and zero-degree endpoints are skipped.
62///
63/// # Examples
64///
65/// ```
66/// use rust_igraph::{Graph, edge_degree_diff_ratio};
67///
68/// // K_3: all (2,2) → |0|/4 = 0 per edge → 0.0
69/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
70/// assert!(edge_degree_diff_ratio(&g).unwrap().abs() < 1e-10);
71/// ```
72pub fn edge_degree_diff_ratio(graph: &Graph) -> IgraphResult<f64> {
73    let mut result = 0.0_f64;
74
75    for (u, v) in graph.edges() {
76        if u == v {
77            continue;
78        }
79        let du = graph.degree(u)?;
80        let dv = graph.degree(v)?;
81        let s = du + dv;
82        if s == 0 {
83            continue;
84        }
85        result += du.abs_diff(dv) as f64 / s as f64;
86    }
87
88    Ok(result)
89}
90
91/// Compute the Sørensen edge degree index.
92///
93/// `Σ_{(u,v)∈E} 2·min(d(u),d(v)) / (d(u) + d(v))`
94///
95/// Each edge contributes a value in (0, 1]. Equals m for regular
96/// graphs. Self-loops and zero-degree endpoints are skipped.
97/// Note: `sorensen + diff_ratio = m` (for non-loop edges with
98/// non-zero degree sum).
99///
100/// # Examples
101///
102/// ```
103/// use rust_igraph::{Graph, edge_degree_sorensen};
104///
105/// // K_3: all (2,2) → 2·2/4 = 1 per edge → 3.0
106/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
107/// assert!((edge_degree_sorensen(&g).unwrap() - 3.0).abs() < 1e-10);
108/// ```
109pub fn edge_degree_sorensen(graph: &Graph) -> IgraphResult<f64> {
110    let mut result = 0.0_f64;
111
112    for (u, v) in graph.edges() {
113        if u == v {
114            continue;
115        }
116        let du = graph.degree(u)?;
117        let dv = graph.degree(v)?;
118        let s = du + dv;
119        if s == 0 {
120            continue;
121        }
122        let min_d = du.min(dv);
123        result += 2.0 * min_d as f64 / s as f64;
124    }
125
126    Ok(result)
127}
128
129/// Compute the product-sum ratio index over edges.
130///
131/// `Σ_{(u,v)∈E} d(u)·d(v) / (d(u) + d(v))²`
132///
133/// Each edge contributes a value in (0, 0.25]. Equals `m/4` for
134/// regular graphs. Self-loops and zero-degree endpoints are skipped.
135///
136/// # Examples
137///
138/// ```
139/// use rust_igraph::{Graph, edge_degree_product_ratio};
140///
141/// // K_3: all (2,2) → 4/16 = 0.25 per edge → 0.75
142/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
143/// assert!((edge_degree_product_ratio(&g).unwrap() - 0.75).abs() < 1e-10);
144/// ```
145pub fn edge_degree_product_ratio(graph: &Graph) -> IgraphResult<f64> {
146    let mut result = 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        let s = du + dv;
155        if s == 0.0 {
156            continue;
157        }
158        result += du * dv / (s * s);
159    }
160
161    Ok(result)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn single_edge() -> Graph {
169        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
170    }
171
172    fn path3() -> Graph {
173        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
174    }
175
176    fn k3() -> Graph {
177        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
178    }
179
180    fn k4() -> Graph {
181        Graph::from_edges(
182            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
183            false,
184            Some(4),
185        )
186        .unwrap()
187    }
188
189    fn cycle4() -> Graph {
190        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
191    }
192
193    fn star5() -> Graph {
194        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
195    }
196
197    fn paw() -> Graph {
198        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
199    }
200
201    // --- edge_inverse_degree_sum ---
202
203    #[test]
204    fn inv_empty() {
205        let g = Graph::with_vertices(0);
206        assert!(edge_inverse_degree_sum(&g).unwrap().abs() < 1e-10);
207    }
208
209    #[test]
210    fn inv_isolated() {
211        let g = Graph::with_vertices(5);
212        assert!(edge_inverse_degree_sum(&g).unwrap().abs() < 1e-10);
213    }
214
215    #[test]
216    fn inv_k3() {
217        // 3·(1/4) = 0.75
218        assert!((edge_inverse_degree_sum(&k3()).unwrap() - 0.75).abs() < 1e-10);
219    }
220
221    #[test]
222    fn inv_k4() {
223        // 6·(1/6) = 1.0
224        assert!((edge_inverse_degree_sum(&k4()).unwrap() - 1.0).abs() < 1e-10);
225    }
226
227    #[test]
228    fn inv_single_edge() {
229        // 1/(1+1) = 0.5
230        assert!((edge_inverse_degree_sum(&single_edge()).unwrap() - 0.5).abs() < 1e-10);
231    }
232
233    #[test]
234    fn inv_star5() {
235        // 4·(1/(4+1)) = 4/5 = 0.8
236        assert!((edge_inverse_degree_sum(&star5()).unwrap() - 0.8).abs() < 1e-10);
237    }
238
239    #[test]
240    fn inv_path3() {
241        // 2·(1/(1+2)) = 2/3
242        let expected = 2.0 / 3.0;
243        assert!((edge_inverse_degree_sum(&path3()).unwrap() - expected).abs() < 1e-10);
244    }
245
246    #[test]
247    fn inv_paw() {
248        // (0,1):1/4  (0,2):1/5  (1,2):1/5  (2,3):1/4
249        let expected = 0.25 + 0.2 + 0.2 + 0.25;
250        assert!((edge_inverse_degree_sum(&paw()).unwrap() - expected).abs() < 1e-10);
251    }
252
253    // --- edge_degree_diff_ratio ---
254
255    #[test]
256    fn diff_empty() {
257        let g = Graph::with_vertices(0);
258        assert!(edge_degree_diff_ratio(&g).unwrap().abs() < 1e-10);
259    }
260
261    #[test]
262    fn diff_regular() {
263        // Regular: all diffs = 0
264        assert!(edge_degree_diff_ratio(&k3()).unwrap().abs() < 1e-10);
265        assert!(edge_degree_diff_ratio(&k4()).unwrap().abs() < 1e-10);
266        assert!(edge_degree_diff_ratio(&cycle4()).unwrap().abs() < 1e-10);
267    }
268
269    #[test]
270    fn diff_single_edge() {
271        // (1,1) → |0|/2 = 0
272        assert!(edge_degree_diff_ratio(&single_edge()).unwrap().abs() < 1e-10);
273    }
274
275    #[test]
276    fn diff_star5() {
277        // 4 edges (4,1): |3|/5 = 3/5 each → 12/5 = 2.4
278        assert!((edge_degree_diff_ratio(&star5()).unwrap() - 2.4).abs() < 1e-10);
279    }
280
281    #[test]
282    fn diff_path3() {
283        // 2 edges (1,2): |1|/3 each → 2/3
284        let expected = 2.0 / 3.0;
285        assert!((edge_degree_diff_ratio(&path3()).unwrap() - expected).abs() < 1e-10);
286    }
287
288    #[test]
289    fn diff_paw() {
290        // (0,1):|0|/4=0  (0,2):|1|/5=0.2  (1,2):|1|/5=0.2  (2,3):|2|/4=0.5
291        let expected = 0.0 + 0.2 + 0.2 + 0.5;
292        assert!((edge_degree_diff_ratio(&paw()).unwrap() - expected).abs() < 1e-10);
293    }
294
295    // --- edge_degree_sorensen ---
296
297    #[test]
298    fn sor_empty() {
299        let g = Graph::with_vertices(0);
300        assert!(edge_degree_sorensen(&g).unwrap().abs() < 1e-10);
301    }
302
303    #[test]
304    fn sor_regular() {
305        // Regular: 2·r/(2r) = 1 per edge → m
306        assert!((edge_degree_sorensen(&k3()).unwrap() - 3.0).abs() < 1e-10);
307        assert!((edge_degree_sorensen(&k4()).unwrap() - 6.0).abs() < 1e-10);
308        assert!((edge_degree_sorensen(&cycle4()).unwrap() - 4.0).abs() < 1e-10);
309    }
310
311    #[test]
312    fn sor_single_edge() {
313        // 2·1/2 = 1
314        assert!((edge_degree_sorensen(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
315    }
316
317    #[test]
318    fn sor_star5() {
319        // 4·(2·1/5) = 8/5 = 1.6
320        assert!((edge_degree_sorensen(&star5()).unwrap() - 1.6).abs() < 1e-10);
321    }
322
323    #[test]
324    fn sor_path3() {
325        // 2·(2·1/3) = 4/3
326        let expected = 4.0 / 3.0;
327        assert!((edge_degree_sorensen(&path3()).unwrap() - expected).abs() < 1e-10);
328    }
329
330    #[test]
331    fn sor_paw() {
332        // (0,1):4/4=1  (0,2):4/5=0.8  (1,2):4/5=0.8  (2,3):2/4=0.5
333        let expected = 1.0 + 0.8 + 0.8 + 0.5;
334        assert!((edge_degree_sorensen(&paw()).unwrap() - expected).abs() < 1e-10);
335    }
336
337    // --- edge_degree_product_ratio ---
338
339    #[test]
340    fn pr_empty() {
341        let g = Graph::with_vertices(0);
342        assert!(edge_degree_product_ratio(&g).unwrap().abs() < 1e-10);
343    }
344
345    #[test]
346    fn pr_regular() {
347        // r²/(2r)² = 1/4 per edge → m/4
348        assert!((edge_degree_product_ratio(&k3()).unwrap() - 0.75).abs() < 1e-10);
349        assert!((edge_degree_product_ratio(&k4()).unwrap() - 1.5).abs() < 1e-10);
350        assert!((edge_degree_product_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
351    }
352
353    #[test]
354    fn pr_single_edge() {
355        // 1·1/(2)² = 1/4 = 0.25
356        assert!((edge_degree_product_ratio(&single_edge()).unwrap() - 0.25).abs() < 1e-10);
357    }
358
359    #[test]
360    fn pr_star5() {
361        // 4·(4·1/25) = 16/25 = 0.64
362        assert!((edge_degree_product_ratio(&star5()).unwrap() - 0.64).abs() < 1e-10);
363    }
364
365    #[test]
366    fn pr_path3() {
367        // 2·(1·2/9) = 4/9
368        let expected = 4.0 / 9.0;
369        assert!((edge_degree_product_ratio(&path3()).unwrap() - expected).abs() < 1e-10);
370    }
371
372    #[test]
373    fn pr_paw() {
374        // (0,1):4/16=0.25  (0,2):6/25=0.24  (1,2):6/25=0.24  (2,3):3/16=0.1875
375        let expected = 0.25 + 6.0 / 25.0 + 6.0 / 25.0 + 3.0 / 16.0;
376        assert!((edge_degree_product_ratio(&paw()).unwrap() - expected).abs() < 1e-10);
377    }
378
379    // --- cross-consistency ---
380
381    #[test]
382    fn sorensen_plus_diff_equals_m() {
383        // 2min/(d(u)+d(v)) + |d(u)-d(v)|/(d(u)+d(v)) = (2min + |diff|)/(sum)
384        // = (2min + max - min)/(sum) = (min + max)/(sum) = 1
385        // So sorensen + diff_ratio = m (non-loop edges with nonzero sum)
386        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
387            let sor = edge_degree_sorensen(g).unwrap();
388            let diff = edge_degree_diff_ratio(g).unwrap();
389            let m = g.edges().filter(|&(u, v)| u != v).count() as f64;
390            assert!(
391                (sor + diff - m).abs() < 1e-10,
392                "sorensen({sor}) + diff({diff}) != m({m})"
393            );
394        }
395    }
396
397    #[test]
398    fn product_ratio_le_quarter_m() {
399        // d(u)·d(v)/(d(u)+d(v))² ≤ 1/4 by AM-GM
400        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
401            let pr = edge_degree_product_ratio(g).unwrap();
402            let m = g.edges().filter(|&(u, v)| u != v).count() as f64;
403            assert!(pr <= m / 4.0 + 1e-10);
404        }
405    }
406
407    #[test]
408    fn diff_nonnegative() {
409        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
410            assert!(edge_degree_diff_ratio(g).unwrap() >= -1e-10);
411        }
412    }
413
414    #[test]
415    fn inv_positive_for_nonempty() {
416        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
417            assert!(edge_inverse_degree_sum(g).unwrap() > 0.0);
418        }
419    }
420}