Skip to main content

rust_igraph/algorithms/properties/
clustering_ratios.rs

1//! Clustering-based ratio indices (ALGO-TR-104).
2//!
3//! Ratios capturing clustering and triangle structure:
4//!
5//! - **Clustering-degree correlation** — Pearson r(degree, local clustering)
6//! - **Transitivity gap** — global transitivity - avg local transitivity
7//! - **Closed triplet ratio** — closed triplets / total triplets (== global transitivity)
8//! - **Square clustering ratio** — fraction of connected 4-paths that close into `C_4`
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 clustering-degree correlation.
22///
23/// Pearson correlation between vertex degree and local clustering
24/// coefficient. Returns 0.0 for graphs where the correlation is
25/// undefined (constant degree or constant clustering, or fewer
26/// than 2 vertices with degree >= 2).
27///
28/// # Examples
29///
30/// ```
31/// use rust_igraph::{Graph, clustering_degree_correlation};
32///
33/// // K_4: all same degree and clustering → 0.0
34/// let g = Graph::from_edges(
35///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
36/// ).unwrap();
37/// assert!(clustering_degree_correlation(&g).unwrap().abs() < 1e-10);
38/// ```
39pub fn clustering_degree_correlation(graph: &Graph) -> IgraphResult<f64> {
40    let n = graph.vcount() as usize;
41    if n < 3 {
42        return Ok(0.0);
43    }
44
45    let mut degrees = Vec::with_capacity(n);
46    let mut clusterings = Vec::with_capacity(n);
47
48    for v in 0..n {
49        let d = graph.degree(v as u32)?;
50        degrees.push(d);
51
52        if d < 2 {
53            clusterings.push(0.0_f64);
54            continue;
55        }
56
57        let neighbors = graph.neighbors(v as u32)?;
58        let mut triangles = 0_u64;
59        for i in 0..neighbors.len() {
60            for j in (i + 1)..neighbors.len() {
61                if graph.has_edge(neighbors[i], neighbors[j]) {
62                    triangles += 1;
63                }
64            }
65        }
66
67        let possible = (d * (d - 1)) / 2;
68        clusterings.push(triangles as f64 / possible as f64);
69    }
70
71    let eligible: Vec<usize> = (0..n).filter(|&v| degrees[v] >= 2).collect();
72    if eligible.len() < 2 {
73        return Ok(0.0);
74    }
75
76    let mean_deg = eligible.iter().map(|&v| degrees[v] as f64).sum::<f64>() / eligible.len() as f64;
77    let mean_cc = eligible.iter().map(|&v| clusterings[v]).sum::<f64>() / eligible.len() as f64;
78
79    let mut cov = 0.0_f64;
80    let mut var_deg = 0.0_f64;
81    let mut var_cc = 0.0_f64;
82
83    for &v in &eligible {
84        let dd = degrees[v] as f64 - mean_deg;
85        let dc = clusterings[v] - mean_cc;
86        cov += dd * dc;
87        var_deg += dd * dd;
88        var_cc += dc * dc;
89    }
90
91    if var_deg < 1e-30 || var_cc < 1e-30 {
92        return Ok(0.0);
93    }
94
95    Ok(cov / (var_deg.sqrt() * var_cc.sqrt()))
96}
97
98/// Compute the transitivity gap.
99///
100/// `global_transitivity - avg_local_transitivity` — the difference
101/// between the global clustering coefficient (fraction of closed
102/// triplets) and the mean local clustering coefficient. Can be
103/// positive or negative. Returns 0.0 for graphs with no triplets.
104///
105/// # Examples
106///
107/// ```
108/// use rust_igraph::{Graph, transitivity_gap};
109///
110/// // K_4: global=1.0, all local=1.0 → gap=0.0
111/// let g = Graph::from_edges(
112///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
113/// ).unwrap();
114/// assert!(transitivity_gap(&g).unwrap().abs() < 1e-10);
115/// ```
116pub fn transitivity_gap(graph: &Graph) -> IgraphResult<f64> {
117    let n = graph.vcount() as usize;
118    if n < 3 {
119        return Ok(0.0);
120    }
121
122    let mut total_triplets = 0_u64;
123    let mut closed_triplets = 0_u64;
124    let mut local_cc_sum = 0.0_f64;
125    let mut local_cc_count = 0_usize;
126
127    for v in 0..n {
128        let d = graph.degree(v as u32)?;
129        if d < 2 {
130            continue;
131        }
132
133        let neighbors = graph.neighbors(v as u32)?;
134        let mut triangles = 0_u64;
135        for i in 0..neighbors.len() {
136            for j in (i + 1)..neighbors.len() {
137                if graph.has_edge(neighbors[i], neighbors[j]) {
138                    triangles += 1;
139                }
140            }
141        }
142
143        let possible = (d * (d - 1)) / 2;
144        total_triplets += possible as u64;
145        closed_triplets += triangles;
146
147        local_cc_sum += triangles as f64 / possible as f64;
148        local_cc_count += 1;
149    }
150
151    if total_triplets == 0 || local_cc_count == 0 {
152        return Ok(0.0);
153    }
154
155    let global_cc = closed_triplets as f64 / total_triplets as f64;
156    let avg_local_cc = local_cc_sum / local_cc_count as f64;
157
158    Ok(global_cc - avg_local_cc)
159}
160
161/// Compute the closed triplet ratio (global transitivity).
162///
163/// `3 * triangles / connected_triples` — the fraction of connected
164/// triples that are closed into triangles. This is the standard
165/// global clustering coefficient. Returns 0.0 for graphs with no
166/// connected triples.
167///
168/// # Examples
169///
170/// ```
171/// use rust_igraph::{Graph, closed_triplet_ratio};
172///
173/// // K_3: 1 triangle, 3 connected triples → 3*1/3 = 1.0
174/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
175/// assert!((closed_triplet_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
176/// ```
177pub fn closed_triplet_ratio(graph: &Graph) -> IgraphResult<f64> {
178    let n = graph.vcount() as usize;
179    if n < 3 {
180        return Ok(0.0);
181    }
182
183    let mut total_triplets = 0_u64;
184    let mut closed_triplets = 0_u64;
185
186    for v in 0..n {
187        let d = graph.degree(v as u32)?;
188        if d < 2 {
189            continue;
190        }
191
192        let neighbors = graph.neighbors(v as u32)?;
193        let mut triangles = 0_u64;
194        for i in 0..neighbors.len() {
195            for j in (i + 1)..neighbors.len() {
196                if graph.has_edge(neighbors[i], neighbors[j]) {
197                    triangles += 1;
198                }
199            }
200        }
201
202        let possible = (d * (d - 1)) / 2;
203        total_triplets += possible as u64;
204        closed_triplets += triangles;
205    }
206
207    if total_triplets == 0 {
208        return Ok(0.0);
209    }
210
211    Ok(closed_triplets as f64 / total_triplets as f64)
212}
213
214/// Compute the square clustering ratio.
215///
216/// Average over all vertices of the fraction of pairs of neighbors
217/// that share a second common neighbor (forming a 4-cycle through
218/// v). For vertex v with neighbors N(v), counts pairs (a,b) in N(v)
219/// that have a common neighbor w != v, divided by total pairs.
220/// Returns 0.0 for graphs where no vertex has degree >= 2.
221///
222/// # Examples
223///
224/// ```
225/// use rust_igraph::{Graph, square_clustering_ratio};
226///
227/// // K_4: every pair of neighbors shares 1 other common neighbor → 1.0
228/// let g = Graph::from_edges(
229///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
230/// ).unwrap();
231/// assert!((square_clustering_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
232/// ```
233pub fn square_clustering_ratio(graph: &Graph) -> IgraphResult<f64> {
234    let n = graph.vcount() as usize;
235    if n < 3 {
236        return Ok(0.0);
237    }
238
239    let mut sum = 0.0_f64;
240    let mut count = 0_usize;
241
242    for v in 0..n {
243        let neighbors = graph.neighbors(v as u32)?;
244        let nn = neighbors.len();
245        if nn < 2 {
246            continue;
247        }
248
249        let mut closed = 0_u64;
250        let total = (nn * (nn - 1)) / 2;
251
252        for i in 0..nn {
253            let ni = neighbors[i];
254            let ni_neighbors = graph.neighbors(ni)?;
255            for j in (i + 1)..nn {
256                let nj = neighbors[j];
257                let has_common = ni_neighbors
258                    .iter()
259                    .any(|&w| w != v as u32 && graph.has_edge(w, nj));
260                if has_common {
261                    closed += 1;
262                }
263            }
264        }
265
266        sum += closed as f64 / total as f64;
267        count += 1;
268    }
269
270    if count == 0 {
271        return Ok(0.0);
272    }
273
274    Ok(sum / count as f64)
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn single_edge() -> Graph {
282        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
283    }
284
285    fn path3() -> Graph {
286        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
287    }
288
289    fn k3() -> Graph {
290        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
291    }
292
293    fn k4() -> Graph {
294        Graph::from_edges(
295            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
296            false,
297            Some(4),
298        )
299        .unwrap()
300    }
301
302    fn cycle4() -> Graph {
303        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
304    }
305
306    fn star5() -> Graph {
307        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
308    }
309
310    fn paw() -> Graph {
311        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
312    }
313
314    // --- clustering_degree_correlation ---
315
316    #[test]
317    fn cdc_empty() {
318        let g = Graph::with_vertices(0);
319        assert!(clustering_degree_correlation(&g).unwrap().abs() < 1e-10);
320    }
321
322    #[test]
323    fn cdc_single() {
324        let g = Graph::with_vertices(1);
325        assert!(clustering_degree_correlation(&g).unwrap().abs() < 1e-10);
326    }
327
328    #[test]
329    fn cdc_k3() {
330        // All deg=2, all cc=1 → constant → 0
331        assert!(clustering_degree_correlation(&k3()).unwrap().abs() < 1e-10);
332    }
333
334    #[test]
335    fn cdc_k4() {
336        assert!(clustering_degree_correlation(&k4()).unwrap().abs() < 1e-10);
337    }
338
339    #[test]
340    fn cdc_cycle4() {
341        // All deg=2, all cc=0 → constant → 0
342        assert!(clustering_degree_correlation(&cycle4()).unwrap().abs() < 1e-10);
343    }
344
345    #[test]
346    fn cdc_path3() {
347        // Only v1 has deg>=2, so only 1 eligible → 0
348        assert!(clustering_degree_correlation(&path3()).unwrap().abs() < 1e-10);
349    }
350
351    #[test]
352    fn cdc_in_range() {
353        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
354            let r = clustering_degree_correlation(g).unwrap();
355            assert!(r >= -1.0 - 1e-10);
356            assert!(r <= 1.0 + 1e-10);
357        }
358    }
359
360    // --- transitivity_gap ---
361
362    #[test]
363    fn tg_empty() {
364        let g = Graph::with_vertices(0);
365        assert!(transitivity_gap(&g).unwrap().abs() < 1e-10);
366    }
367
368    #[test]
369    fn tg_single() {
370        let g = Graph::with_vertices(1);
371        assert!(transitivity_gap(&g).unwrap().abs() < 1e-10);
372    }
373
374    #[test]
375    fn tg_k3() {
376        // Global=1, avg_local=1 → gap=0
377        assert!(transitivity_gap(&k3()).unwrap().abs() < 1e-10);
378    }
379
380    #[test]
381    fn tg_k4() {
382        // Global=1, avg_local=1 → gap=0
383        assert!(transitivity_gap(&k4()).unwrap().abs() < 1e-10);
384    }
385
386    #[test]
387    fn tg_cycle4() {
388        // Global=0 (no triangles), avg_local=0 → gap=0
389        assert!(transitivity_gap(&cycle4()).unwrap().abs() < 1e-10);
390    }
391
392    #[test]
393    fn tg_star5() {
394        // Global=0, avg_local: only center has deg>=2, cc=0 → gap=0
395        assert!(transitivity_gap(&star5()).unwrap().abs() < 1e-10);
396    }
397
398    #[test]
399    fn tg_paw() {
400        // v0: d=2, neighbors={1,2}, has_edge(1,2)=yes → cc=1.0
401        // v1: d=2, neighbors={0,2}, has_edge(0,2)=yes → cc=1.0
402        // v2: d=3, neighbors={0,1,3}, edges: (0,1)=yes, (0,3)=no, (1,3)=no → cc=1/3
403        // v3: d=1, skip
404        // total_triplets: 1+1+3=5, closed: 1+1+1=3
405        // global=3/5=0.6, avg_local=(1+1+1/3)/3=7/9≈0.778
406        // gap = 0.6 - 7/9 ≈ -0.178
407        let r = transitivity_gap(&paw()).unwrap();
408        assert!(r < 0.0);
409    }
410
411    // --- closed_triplet_ratio ---
412
413    #[test]
414    fn ctr_empty() {
415        let g = Graph::with_vertices(0);
416        assert!(closed_triplet_ratio(&g).unwrap().abs() < 1e-10);
417    }
418
419    #[test]
420    fn ctr_single() {
421        let g = Graph::with_vertices(1);
422        assert!(closed_triplet_ratio(&g).unwrap().abs() < 1e-10);
423    }
424
425    #[test]
426    fn ctr_k3() {
427        assert!((closed_triplet_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
428    }
429
430    #[test]
431    fn ctr_k4() {
432        assert!((closed_triplet_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
433    }
434
435    #[test]
436    fn ctr_cycle4() {
437        assert!(closed_triplet_ratio(&cycle4()).unwrap().abs() < 1e-10);
438    }
439
440    #[test]
441    fn ctr_star5() {
442        assert!(closed_triplet_ratio(&star5()).unwrap().abs() < 1e-10);
443    }
444
445    #[test]
446    fn ctr_paw() {
447        // total_triplets=5, closed=3 → 3/5=0.6
448        assert!((closed_triplet_ratio(&paw()).unwrap() - 0.6).abs() < 1e-10);
449    }
450
451    #[test]
452    fn ctr_path3() {
453        // v1: d=2, neighbors={0,2}, no edge → 0/1
454        // total_triplets=1, closed=0 → 0
455        assert!(closed_triplet_ratio(&path3()).unwrap().abs() < 1e-10);
456    }
457
458    #[test]
459    fn ctr_in_01() {
460        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
461            let r = closed_triplet_ratio(g).unwrap();
462            assert!(r >= -1e-10);
463            assert!(r <= 1.0 + 1e-10);
464        }
465    }
466
467    // --- square_clustering_ratio ---
468
469    #[test]
470    fn scr_empty() {
471        let g = Graph::with_vertices(0);
472        assert!(square_clustering_ratio(&g).unwrap().abs() < 1e-10);
473    }
474
475    #[test]
476    fn scr_single() {
477        let g = Graph::with_vertices(1);
478        assert!(square_clustering_ratio(&g).unwrap().abs() < 1e-10);
479    }
480
481    #[test]
482    fn scr_k4() {
483        // Every pair of neighbors shares another common neighbor → 1.0
484        assert!((square_clustering_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
485    }
486
487    #[test]
488    fn scr_cycle4() {
489        // v0: neighbors={1,3}, common neighbor w!=0 between 1 and 3?
490        // 1's neighbors: {0,2}, 3's neighbors: {0,2}
491        // w=2: has_edge(2,3)=yes → closed=1, total=1 → 1.0
492        // Same for all vertices → avg=1.0
493        assert!((square_clustering_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
494    }
495
496    #[test]
497    fn scr_star5() {
498        // Center: d=4, pairs of leaves (a,b): their neighbors are just {center}
499        // So w != center that has edge to both a and b? No such w exists → 0
500        assert!(square_clustering_ratio(&star5()).unwrap().abs() < 1e-10);
501    }
502
503    #[test]
504    fn scr_path3() {
505        // v1: d=2, neighbors={0,2}, need common neighbor w!=1 between 0 and 2
506        // 0's neighbors={1}, 2's neighbors={1}, only w=1 but w==v → no → 0
507        assert!(square_clustering_ratio(&path3()).unwrap().abs() < 1e-10);
508    }
509
510    #[test]
511    fn scr_single_edge() {
512        // No vertex with deg>=2 → 0
513        assert!(square_clustering_ratio(&single_edge()).unwrap().abs() < 1e-10);
514    }
515
516    #[test]
517    fn scr_in_01() {
518        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
519            let r = square_clustering_ratio(g).unwrap();
520            assert!(r >= -1e-10);
521            assert!(r <= 1.0 + 1e-10);
522        }
523    }
524
525    // --- cross-consistency ---
526
527    #[test]
528    fn complete_graphs_full_transitivity() {
529        assert!((closed_triplet_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
530        assert!((closed_triplet_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
531    }
532
533    #[test]
534    fn complete_graphs_zero_gap() {
535        assert!(transitivity_gap(&k3()).unwrap().abs() < 1e-10);
536        assert!(transitivity_gap(&k4()).unwrap().abs() < 1e-10);
537    }
538
539    #[test]
540    fn triangle_free_zero_ctr() {
541        assert!(closed_triplet_ratio(&cycle4()).unwrap().abs() < 1e-10);
542        assert!(closed_triplet_ratio(&star5()).unwrap().abs() < 1e-10);
543        assert!(closed_triplet_ratio(&path3()).unwrap().abs() < 1e-10);
544    }
545}