Skip to main content

rust_igraph/algorithms/properties/
edge_density_ratios.rs

1//! Edge density ratio indices (ALGO-TR-098).
2//!
3//! Normalized edge-level density measures:
4//!
5//! - **Self-loop ratio** — fraction of edges that are self-loops
6//! - **Multi-edge ratio** — fraction of edges that are duplicated
7//! - **Reciprocity ratio** — fraction of directed edges with a reciprocal
8//! - **Average local clustering** — mean local clustering coefficient
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 self-loop ratio of the graph.
22///
23/// Fraction of edges that are self-loops (edges where both endpoints
24/// are the same vertex). Returns 0.0 for graphs with no edges.
25///
26/// # Examples
27///
28/// ```
29/// use rust_igraph::{Graph, self_loop_ratio};
30///
31/// // No self-loops in a simple triangle
32/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
33/// assert!(self_loop_ratio(&g).unwrap().abs() < 1e-10);
34/// ```
35pub fn self_loop_ratio(graph: &Graph) -> IgraphResult<f64> {
36    let m = graph.ecount();
37    if m == 0 {
38        return Ok(0.0);
39    }
40
41    let mut loop_count = 0_usize;
42    for (u, v) in graph.edges() {
43        if u == v {
44            loop_count += 1;
45        }
46    }
47
48    Ok(loop_count as f64 / m as f64)
49}
50
51/// Compute the multi-edge ratio of the graph.
52///
53/// Fraction of edges that are duplicates (i.e., share the same pair
54/// of endpoints with at least one other edge). For undirected graphs,
55/// edges (u,v) and (v,u) are the same. Returns 0.0 for graphs with
56/// no edges.
57///
58/// # Examples
59///
60/// ```
61/// use rust_igraph::{Graph, multi_edge_ratio};
62///
63/// // Simple triangle — no multi-edges → 0.0
64/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
65/// assert!(multi_edge_ratio(&g).unwrap().abs() < 1e-10);
66/// ```
67pub fn multi_edge_ratio(graph: &Graph) -> IgraphResult<f64> {
68    let m = graph.ecount();
69    if m == 0 {
70        return Ok(0.0);
71    }
72
73    let directed = graph.is_directed();
74    let mut edge_set = std::collections::HashSet::new();
75    let mut multi_count = 0_usize;
76
77    for (u, v) in graph.edges() {
78        let key = if directed {
79            (u, v)
80        } else {
81            (u.min(v), u.max(v))
82        };
83        if !edge_set.insert(key) {
84            multi_count += 1;
85        }
86    }
87
88    Ok(multi_count as f64 / m as f64)
89}
90
91/// Compute the reciprocity ratio of the graph.
92///
93/// For directed graphs, the fraction of directed edges `(u,v)` for which
94/// the reverse edge `(v,u)` also exists. For undirected graphs, returns
95/// 1.0 (every edge is trivially reciprocal). Returns 0.0 for graphs
96/// with no edges.
97///
98/// # Examples
99///
100/// ```
101/// use rust_igraph::{Graph, reciprocity_ratio};
102///
103/// // Undirected triangle → all reciprocal → 1.0
104/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
105/// assert!((reciprocity_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
106/// ```
107pub fn reciprocity_ratio(graph: &Graph) -> IgraphResult<f64> {
108    let m = graph.ecount();
109    if m == 0 {
110        return Ok(0.0);
111    }
112
113    if !graph.is_directed() {
114        return Ok(1.0);
115    }
116
117    let mut edge_set = std::collections::HashSet::new();
118    for (u, v) in graph.edges() {
119        edge_set.insert((u, v));
120    }
121
122    let mut reciprocal_count = 0_usize;
123    for &(u, v) in &edge_set {
124        if edge_set.contains(&(v, u)) {
125            reciprocal_count += 1;
126        }
127    }
128
129    Ok(reciprocal_count as f64 / m as f64)
130}
131
132/// Compute the average local clustering coefficient.
133///
134/// The mean of the local clustering coefficients over all vertices
135/// with degree >= 2. The local clustering coefficient of vertex `v`
136/// is the fraction of pairs of neighbors of `v` that are themselves
137/// connected. Returns 0.0 for graphs where no vertex has degree >= 2.
138///
139/// # Examples
140///
141/// ```
142/// use rust_igraph::{Graph, avg_local_clustering};
143///
144/// // K_3: each vertex has 2 neighbors, 1 pair, 1 edge → C(v)=1 → avg=1.0
145/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
146/// assert!((avg_local_clustering(&g).unwrap() - 1.0).abs() < 1e-10);
147/// ```
148pub fn avg_local_clustering(graph: &Graph) -> IgraphResult<f64> {
149    let n = graph.vcount() as usize;
150    if n == 0 {
151        return Ok(0.0);
152    }
153
154    let mut sum = 0.0_f64;
155    let mut count = 0_usize;
156
157    for v in 0..n {
158        let vid = v as u32;
159        let deg = graph.degree(vid)?;
160        if deg < 2 {
161            continue;
162        }
163
164        let neighbors = graph.neighbors(vid)?;
165        let mut triangles = 0_usize;
166        for i in 0..neighbors.len() {
167            for j in (i + 1)..neighbors.len() {
168                if graph.has_edge(neighbors[i], neighbors[j]) {
169                    triangles += 1;
170                }
171            }
172        }
173
174        let pairs = deg * (deg - 1) / 2;
175        sum += triangles as f64 / pairs as f64;
176        count += 1;
177    }
178
179    if count == 0 {
180        return Ok(0.0);
181    }
182
183    Ok(sum / count as f64)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn single_edge() -> Graph {
191        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
192    }
193
194    fn path3() -> Graph {
195        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
196    }
197
198    fn k3() -> Graph {
199        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
200    }
201
202    fn k4() -> Graph {
203        Graph::from_edges(
204            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
205            false,
206            Some(4),
207        )
208        .unwrap()
209    }
210
211    fn cycle4() -> Graph {
212        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
213    }
214
215    fn star5() -> Graph {
216        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
217    }
218
219    fn paw() -> Graph {
220        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
221    }
222
223    // --- self_loop_ratio ---
224
225    #[test]
226    fn slr_empty() {
227        let g = Graph::with_vertices(0);
228        assert!(self_loop_ratio(&g).unwrap().abs() < 1e-10);
229    }
230
231    #[test]
232    fn slr_isolated() {
233        let g = Graph::with_vertices(5);
234        assert!(self_loop_ratio(&g).unwrap().abs() < 1e-10);
235    }
236
237    #[test]
238    fn slr_single_edge() {
239        assert!(self_loop_ratio(&single_edge()).unwrap().abs() < 1e-10);
240    }
241
242    #[test]
243    fn slr_k3() {
244        assert!(self_loop_ratio(&k3()).unwrap().abs() < 1e-10);
245    }
246
247    #[test]
248    fn slr_k4() {
249        assert!(self_loop_ratio(&k4()).unwrap().abs() < 1e-10);
250    }
251
252    #[test]
253    fn slr_cycle4() {
254        assert!(self_loop_ratio(&cycle4()).unwrap().abs() < 1e-10);
255    }
256
257    #[test]
258    fn slr_star5() {
259        assert!(self_loop_ratio(&star5()).unwrap().abs() < 1e-10);
260    }
261
262    #[test]
263    fn slr_with_loops() {
264        // Graph with 1 self-loop and 2 normal edges → 1/3
265        let mut g = Graph::with_vertices(3);
266        g.add_edge(0, 1).unwrap();
267        g.add_edge(1, 2).unwrap();
268        g.add_edge(0, 0).unwrap();
269        assert!((self_loop_ratio(&g).unwrap() - 1.0 / 3.0).abs() < 1e-10);
270    }
271
272    #[test]
273    fn slr_all_loops() {
274        // Graph with 2 self-loops only → 1.0
275        let mut g = Graph::with_vertices(2);
276        g.add_edge(0, 0).unwrap();
277        g.add_edge(1, 1).unwrap();
278        assert!((self_loop_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
279    }
280
281    // --- multi_edge_ratio ---
282
283    #[test]
284    fn mer_empty() {
285        let g = Graph::with_vertices(0);
286        assert!(multi_edge_ratio(&g).unwrap().abs() < 1e-10);
287    }
288
289    #[test]
290    fn mer_isolated() {
291        let g = Graph::with_vertices(5);
292        assert!(multi_edge_ratio(&g).unwrap().abs() < 1e-10);
293    }
294
295    #[test]
296    fn mer_single_edge() {
297        assert!(multi_edge_ratio(&single_edge()).unwrap().abs() < 1e-10);
298    }
299
300    #[test]
301    fn mer_k3() {
302        assert!(multi_edge_ratio(&k3()).unwrap().abs() < 1e-10);
303    }
304
305    #[test]
306    fn mer_k4() {
307        assert!(multi_edge_ratio(&k4()).unwrap().abs() < 1e-10);
308    }
309
310    #[test]
311    fn mer_cycle4() {
312        assert!(multi_edge_ratio(&cycle4()).unwrap().abs() < 1e-10);
313    }
314
315    #[test]
316    fn mer_with_multi() {
317        // Graph: 0-1, 0-1 (dup), 1-2 → 1 multi / 3 edges = 1/3
318        let mut g = Graph::with_vertices(3);
319        g.add_edge(0, 1).unwrap();
320        g.add_edge(0, 1).unwrap();
321        g.add_edge(1, 2).unwrap();
322        assert!((multi_edge_ratio(&g).unwrap() - 1.0 / 3.0).abs() < 1e-10);
323    }
324
325    #[test]
326    fn mer_all_multi() {
327        // 2 copies of the same edge → 1 multi / 2 edges = 0.5
328        let mut g = Graph::with_vertices(2);
329        g.add_edge(0, 1).unwrap();
330        g.add_edge(0, 1).unwrap();
331        assert!((multi_edge_ratio(&g).unwrap() - 0.5).abs() < 1e-10);
332    }
333
334    // --- reciprocity_ratio ---
335
336    #[test]
337    fn rr_empty() {
338        let g = Graph::with_vertices(0);
339        assert!(reciprocity_ratio(&g).unwrap().abs() < 1e-10);
340    }
341
342    #[test]
343    fn rr_isolated() {
344        let g = Graph::with_vertices(5);
345        assert!(reciprocity_ratio(&g).unwrap().abs() < 1e-10);
346    }
347
348    #[test]
349    fn rr_undirected_always_one() {
350        // Undirected → trivially reciprocal
351        assert!((reciprocity_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
352        assert!((reciprocity_ratio(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
353    }
354
355    #[test]
356    fn rr_directed_full_reciprocal() {
357        // Directed triangle with both directions → all reciprocal
358        let g = Graph::from_edges(
359            &[(0, 1), (1, 0), (1, 2), (2, 1), (0, 2), (2, 0)],
360            true,
361            Some(3),
362        )
363        .unwrap();
364        assert!((reciprocity_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
365    }
366
367    #[test]
368    fn rr_directed_no_reciprocal() {
369        // Directed cycle 0→1→2→0, no back edges
370        let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 0)], true, Some(3)).unwrap();
371        assert!(reciprocity_ratio(&g).unwrap().abs() < 1e-10);
372    }
373
374    #[test]
375    fn rr_directed_partial() {
376        // 0→1, 1→0, 0→2 → 2 reciprocal / 3 edges = 2/3
377        let g = Graph::from_edges(&[(0, 1), (1, 0), (0, 2)], true, Some(3)).unwrap();
378        assert!((reciprocity_ratio(&g).unwrap() - 2.0 / 3.0).abs() < 1e-10);
379    }
380
381    // --- avg_local_clustering ---
382
383    #[test]
384    fn alc_empty() {
385        let g = Graph::with_vertices(0);
386        assert!(avg_local_clustering(&g).unwrap().abs() < 1e-10);
387    }
388
389    #[test]
390    fn alc_isolated() {
391        let g = Graph::with_vertices(5);
392        assert!(avg_local_clustering(&g).unwrap().abs() < 1e-10);
393    }
394
395    #[test]
396    fn alc_single_edge() {
397        // Both vertices have d=1 < 2 → no eligible vertex → 0.0
398        assert!(avg_local_clustering(&single_edge()).unwrap().abs() < 1e-10);
399    }
400
401    #[test]
402    fn alc_path3() {
403        // v0: d=1 skip, v1: d=2, neighbors {0,2}, edge(0,2)? no → C=0, v2: d=1 skip
404        // avg = 0/1 = 0
405        assert!(avg_local_clustering(&path3()).unwrap().abs() < 1e-10);
406    }
407
408    #[test]
409    fn alc_k3() {
410        // All d=2, each pair connected → C(v)=1 → avg=1.0
411        assert!((avg_local_clustering(&k3()).unwrap() - 1.0).abs() < 1e-10);
412    }
413
414    #[test]
415    fn alc_k4() {
416        // All d=3, C(3,2)=3 pairs, all connected → C(v)=1 → avg=1.0
417        assert!((avg_local_clustering(&k4()).unwrap() - 1.0).abs() < 1e-10);
418    }
419
420    #[test]
421    fn alc_cycle4() {
422        // Each vertex has d=2, 1 pair, no triangle → C(v)=0 → avg=0.0
423        assert!(avg_local_clustering(&cycle4()).unwrap().abs() < 1e-10);
424    }
425
426    #[test]
427    fn alc_star5() {
428        // Center d=4: C(4,2)=6 pairs, 0 triangles → C=0
429        // Leaves d=1: skip
430        // avg = 0/1 = 0
431        assert!(avg_local_clustering(&star5()).unwrap().abs() < 1e-10);
432    }
433
434    #[test]
435    fn alc_paw() {
436        // v0: d=2, neighbors {1,2}, edge(1,2)? yes → C=1/1=1
437        // v1: d=2, neighbors {0,2}, edge(0,2)? yes → C=1/1=1
438        // v2: d=3, neighbors {0,1,3}, pairs: (0,1),(0,3),(1,3)
439        //   edge(0,1)? yes, edge(0,3)? no, edge(1,3)? no → C=1/3
440        // v3: d=1, skip
441        // avg = (1 + 1 + 1/3) / 3 = (7/3)/3 = 7/9
442        assert!((avg_local_clustering(&paw()).unwrap() - 7.0 / 9.0).abs() < 1e-10);
443    }
444
445    // --- cross-consistency ---
446
447    #[test]
448    fn slr_in_01() {
449        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
450            let r = self_loop_ratio(g).unwrap();
451            assert!(r >= -1e-10);
452            assert!(r <= 1.0 + 1e-10);
453        }
454    }
455
456    #[test]
457    fn mer_in_01() {
458        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
459            let r = multi_edge_ratio(g).unwrap();
460            assert!(r >= -1e-10);
461            assert!(r <= 1.0 + 1e-10);
462        }
463    }
464
465    #[test]
466    fn rr_in_01() {
467        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
468            let r = reciprocity_ratio(g).unwrap();
469            assert!(r >= -1e-10);
470            assert!(r <= 1.0 + 1e-10);
471        }
472    }
473
474    #[test]
475    fn alc_in_01() {
476        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
477            let r = avg_local_clustering(g).unwrap();
478            assert!(r >= -1e-10);
479            assert!(r <= 1.0 + 1e-10);
480        }
481    }
482
483    #[test]
484    fn simple_graphs_no_loops_no_multi() {
485        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
486            assert!(self_loop_ratio(g).unwrap().abs() < 1e-10);
487            assert!(multi_edge_ratio(g).unwrap().abs() < 1e-10);
488        }
489    }
490
491    #[test]
492    fn complete_graphs_full_clustering() {
493        assert!((avg_local_clustering(&k3()).unwrap() - 1.0).abs() < 1e-10);
494        assert!((avg_local_clustering(&k4()).unwrap() - 1.0).abs() < 1e-10);
495    }
496}