Skip to main content

rust_igraph/algorithms/properties/
ecc.rs

1//! Edge clustering coefficient (ALGO-PR-031).
2//!
3//! Counterpart of `igraph_ecc` from
4//! `references/igraph/src/properties/ecc.c` (lines 33-385). Given an
5//! edge `(i, j)`, the **edge clustering coefficient** (Radicchi 2004)
6//! counts the `k`-cycles the edge participates in and normalises by the
7//! maximum number of such cycles allowed by the endpoint degrees:
8//!
9//! - `k = 3`: `s = min(d_i, d_j) - 1`
10//! - `k = 4`: `s = (d_i - 1) · (d_j - 1)`
11//!
12//! With the canonical Radicchi definition,
13//! `C^(k)_ij = (z^(k)_ij + 1) / s^(k)_ij`. The `offset` / `normalize`
14//! flags toggle the `+1` and the `/s` respectively; passing `(offset=
15//! true, normalize=true)` reproduces the paper's definition exactly.
16//!
17//! Reference: F. Radicchi, C. Castellano, F. Cecconi, V. Loreto,
18//! D. Parisi (2004) "Defining and identifying communities in
19//! networks", PNAS 101 (9) 2658-2663.
20//!
21//! Multi-edges and self-loops:
22//! - cycle counts `z` ignore multi-edges (the simple adjacency is used);
23//! - the normaliser `s` uses the **loop-aware** degree, matching the C
24//!   reference's `IGRAPH_LOOPS` mode (each self-loop contributes 2 to
25//!   the undirected degree);
26//! - a self-loop edge yields `NaN`;
27//! - any edge with `s <= 0` (e.g. a degree-1 endpoint) yields
28//!   `NaN` when normalising.
29
30use crate::core::graph::EdgeId;
31use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
32
33/// Compute the edge clustering coefficient for `eids` in `graph`.
34///
35/// - `eids = None` → every edge (in id order, `0..graph.ecount()`).
36/// - `eids = Some(&[...])` → just those edges, in the order given.
37///
38/// `k` must be `3` or `4`. `offset = true` adds the canonical Radicchi
39/// `+1` to the cycle count; `normalize = true` divides by the
40/// degree-derived maximum.
41///
42/// Edge directions are ignored — the function treats the input as the
43/// underlying undirected graph, the same way `igraph_ecc` does.
44///
45/// # Errors
46///
47/// - [`IgraphError::InvalidArgument`] when `k < 3`.
48/// - [`IgraphError::Unsupported`] when `k > 4` (Radicchi only defines
49///   `k = 3` and `k = 4`).
50/// - [`IgraphError::EdgeOutOfRange`] when any `eid` is `>= ecount()`.
51///
52/// # Examples
53///
54/// ```
55/// use rust_igraph::{Graph, ecc};
56///
57/// // K_4: every edge sits in 2 triangles, max is 2 → normalised value
58/// // (without offset) is 1.0.
59/// let mut g = Graph::with_vertices(4);
60/// for u in 0..4u32 {
61///     for v in (u + 1)..4 {
62///         g.add_edge(u, v).unwrap();
63///     }
64/// }
65/// let c = ecc(&g, None, 3, false, true).unwrap();
66/// assert_eq!(c, vec![1.0; 6]);
67/// ```
68#[allow(clippy::many_single_char_names)]
69pub fn ecc(
70    graph: &Graph,
71    eids: Option<&[EdgeId]>,
72    k: u32,
73    offset: bool,
74    normalize: bool,
75) -> IgraphResult<Vec<f64>> {
76    if k < 3 {
77        return Err(IgraphError::InvalidArgument(format!(
78            "cycle size for edge clustering coefficient must be at least 3, got {k}"
79        )));
80    }
81    if k > 4 {
82        return Err(IgraphError::Unsupported(
83            "edge clustering coefficient is only implemented for k = 3 and k = 4",
84        ));
85    }
86
87    let m = graph.ecount();
88    let owned: Vec<EdgeId>;
89    let eids: &[EdgeId] = if let Some(slice) = eids {
90        slice
91    } else {
92        owned = (0..u32::try_from(m)
93            .map_err(|_| IgraphError::Internal("ecc: ecount() exceeds u32::MAX"))?)
94            .collect();
95        &owned
96    };
97
98    let (simple_adj, loop_degree) = build_simple_adjacency(graph)?;
99    let c = if offset { 1.0 } else { 0.0 };
100    let mut result = Vec::with_capacity(eids.len());
101    let m_u32 = u32::try_from(m).unwrap_or(u32::MAX);
102
103    for &eid in eids {
104        if eid as usize >= m {
105            return Err(IgraphError::EdgeOutOfRange { id: eid, m: m_u32 });
106        }
107        let (v1, v2) = graph.edge(eid)?;
108        let value = if v1 == v2 {
109            // Self-loops sit in no cycles and have no meaningful denominator.
110            if normalize { f64::NAN } else { c }
111        } else {
112            let (z, s) = match k {
113                3 => ecc3_pair(&simple_adj, &loop_degree, v1, v2),
114                4 => ecc4_pair(&simple_adj, &loop_degree, v1, v2),
115                _ => unreachable!("k validated above"),
116            };
117            let zc = z + c;
118            if normalize { zc / s } else { zc }
119        };
120        result.push(value);
121    }
122
123    Ok(result)
124}
125
126/// Build the simple-adjacency view (no self-loops, no parallel edges,
127/// neighbours sorted ascending) plus the loop-aware degree for every
128/// vertex. The latter counts each self-loop **twice** to mirror the C
129/// reference's `IGRAPH_LOOPS` mode.
130fn build_simple_adjacency(graph: &Graph) -> IgraphResult<(Vec<Vec<VertexId>>, Vec<u32>)> {
131    let n = graph.vcount();
132    let n_us = n as usize;
133    let mut adj: Vec<Vec<VertexId>> = Vec::with_capacity(n_us);
134    let mut deg: Vec<u32> = vec![0; n_us];
135    for v in 0..n {
136        // `Graph::degree` already implements IGRAPH_LOOPS_TWICE semantics
137        // (each self-loop counts 2 in an undirected graph), so reuse it
138        // verbatim instead of re-deriving from `neighbors()` (which
139        // returns the loop endpoint twice and would double-count).
140        let d = u32::try_from(graph.degree(v)?)
141            .map_err(|_| IgraphError::Internal("ecc: degree exceeds u32::MAX"))?;
142        deg[v as usize] = d;
143
144        let raw = graph.neighbors(v)?;
145        let mut simple: Vec<VertexId> = raw.into_iter().filter(|&u| u != v).collect();
146        simple.sort_unstable();
147        simple.dedup();
148        adj.push(simple);
149    }
150    Ok((adj, deg))
151}
152
153/// k=3 inner kernel: returns `(z, s)` for an edge `(v1, v2)`.
154#[allow(clippy::cast_precision_loss)]
155fn ecc3_pair(adj: &[Vec<VertexId>], deg: &[u32], v1: VertexId, v2: VertexId) -> (f64, f64) {
156    let a1 = &adj[v1 as usize];
157    let a2 = &adj[v2 as usize];
158    let z = intersection_size_sorted(a1, a2) as f64;
159    let d1 = f64::from(deg[v1 as usize]);
160    let d2 = f64::from(deg[v2 as usize]);
161    let s = d1.min(d2) - 1.0;
162    (z, s)
163}
164
165/// k=4 inner kernel: returns `(z, s)` for an edge `(v1, v2)`.
166#[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)]
167fn ecc4_pair(adj: &[Vec<VertexId>], deg: &[u32], v1: VertexId, v2: VertexId) -> (f64, f64) {
168    // Iterate from the smaller-degree endpoint so we visit fewer
169    // intermediates. Matches the swap in `igraph_i_ecc4_*`.
170    let (lo, hi) = if deg[v1 as usize] <= deg[v2 as usize] {
171        (v1, v2)
172    } else {
173        (v2, v1)
174    };
175    let a_lo = &adj[lo as usize];
176    let a_hi = &adj[hi as usize];
177    let mut z = 0.0_f64;
178    for &v3 in a_lo {
179        if v3 == hi {
180            continue;
181        }
182        let a3 = &adj[v3 as usize];
183        // |N(hi) ∩ N(v3)| - 1 — the -1 strips the (hi, v3) edge itself
184        // when it exists; for non-neighbours the term is still
185        // intersection-size minus one, matching the C reference.
186        let inter = intersection_size_sorted(a_hi, a3) as i64;
187        z += (inter - 1) as f64;
188    }
189    let d_lo = f64::from(deg[lo as usize]);
190    let d_hi = f64::from(deg[hi as usize]);
191    let s = (d_lo - 1.0) * (d_hi - 1.0);
192    (z, s)
193}
194
195/// Linear-merge intersection size for two sorted, deduplicated lists.
196fn intersection_size_sorted(a: &[VertexId], b: &[VertexId]) -> usize {
197    let mut i = 0usize;
198    let mut j = 0usize;
199    let mut count = 0usize;
200    while i < a.len() && j < b.len() {
201        match a[i].cmp(&b[j]) {
202            std::cmp::Ordering::Less => i += 1,
203            std::cmp::Ordering::Greater => j += 1,
204            std::cmp::Ordering::Equal => {
205                count += 1;
206                i += 1;
207                j += 1;
208            }
209        }
210    }
211    count
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn approx_eq(a: f64, b: f64, eps: f64) -> bool {
219        if a.is_nan() && b.is_nan() {
220            return true;
221        }
222        (a - b).abs() <= eps
223    }
224
225    fn vec_approx_eq(got: &[f64], want: &[f64]) {
226        assert_eq!(
227            got.len(),
228            want.len(),
229            "length mismatch: {got:?} vs {want:?}"
230        );
231        for (i, (&g, &w)) in got.iter().zip(want.iter()).enumerate() {
232            assert!(
233                approx_eq(g, w, 1e-6),
234                "index {i}: got {g}, want {w}\nfull: {got:?}\nwant: {want:?}"
235            );
236        }
237    }
238
239    fn k5() -> Graph {
240        let mut g = Graph::with_vertices(5);
241        for u in 0..5u32 {
242            for v in (u + 1)..5 {
243                g.add_edge(u, v).unwrap();
244            }
245        }
246        g
247    }
248
249    fn p2() -> Graph {
250        let mut g = Graph::with_vertices(2);
251        g.add_edge(0, 1).unwrap();
252        g
253    }
254
255    #[test]
256    fn null_graph_returns_empty_for_all_edges() {
257        let g = Graph::with_vertices(0);
258        for k in [3u32, 4] {
259            let r = ecc(&g, None, k, false, true).unwrap();
260            assert!(r.is_empty());
261        }
262    }
263
264    #[test]
265    fn singleton_returns_empty_for_all_edges() {
266        let g = Graph::with_vertices(1);
267        for k in [3u32, 4] {
268            let r = ecc(&g, None, k, false, true).unwrap();
269            assert!(r.is_empty());
270        }
271    }
272
273    #[test]
274    fn p2_normalises_to_nan() {
275        // P_2: single edge, both endpoints have degree 1 → s = 0 → NaN.
276        let g = p2();
277        for k in [3u32, 4] {
278            let r = ecc(&g, None, k, false, true).unwrap();
279            assert_eq!(r.len(), 1);
280            assert!(r[0].is_nan(), "k = {k}: got {r:?}");
281        }
282        // Without normalisation we just get z (= 0).
283        for k in [3u32, 4] {
284            let r = ecc(&g, None, k, false, false).unwrap();
285            assert_eq!(r, vec![0.0]);
286        }
287    }
288
289    #[test]
290    fn k5_k3_offset_false_normalize_true() {
291        let g = k5();
292        let r = ecc(&g, None, 3, false, true).unwrap();
293        // Matches igraph_ecc.out for K_5: every edge yields 1.
294        vec_approx_eq(&r, &[1.0; 10]);
295    }
296
297    #[test]
298    fn k5_k4_offset_false_normalize_true() {
299        let g = k5();
300        let r = ecc(&g, None, 4, false, true).unwrap();
301        // Matches igraph_ecc.out for K_5: every edge yields 2/3.
302        vec_approx_eq(&r, &[2.0 / 3.0; 10]);
303    }
304
305    #[test]
306    fn k5_offset_true_normalize_false_returns_z_plus_one() {
307        let g = k5();
308        let r = ecc(&g, None, 3, true, false).unwrap();
309        // z = 3 (each edge sits in 3 triangles in K_5) → 3 + 1 = 4.
310        vec_approx_eq(&r, &[4.0; 10]);
311    }
312
313    #[test]
314    fn k5_offset_true_normalize_true_is_radicchi_canonical() {
315        let g = k5();
316        let r = ecc(&g, None, 3, true, true).unwrap();
317        // In K_5 each non-loop edge sits in 3 triangles; loop-aware
318        // endpoint degree is 4 → s = min(4,4) - 1 = 3.
319        // Radicchi canonical = (z + 1) / s = (3 + 1) / 3 = 4/3.
320        vec_approx_eq(&r, &[4.0 / 3.0; 10]);
321    }
322
323    #[test]
324    #[allow(clippy::many_single_char_names)]
325    fn k5_with_self_loops_k3_yields_nan_for_loops_and_0_6_for_others() {
326        // K_5 plus a self-loop on every vertex (5 + 5 = 10 edges? No,
327        // 5 self-loops + C(5,2) = 5 + 10 = 15 edges). The C .out
328        // confirms 15 entries, with NaN at the self-loop positions.
329        let mut g = Graph::with_vertices(5);
330        for u in 0..5u32 {
331            g.add_edge(u, u).unwrap();
332            for v in (u + 1)..5 {
333                g.add_edge(u, v).unwrap();
334            }
335        }
336        let r = ecc(&g, None, 3, false, true).unwrap();
337        assert_eq!(r.len(), 15);
338        // The five self-loops are at indices where edge_source == edge_target.
339        let m = u32::try_from(g.ecount()).unwrap();
340        for eid in 0..m {
341            let (a, b) = g.edge(eid).unwrap();
342            if a == b {
343                assert!(
344                    r[eid as usize].is_nan(),
345                    "edge {eid} (self-loop): got {}",
346                    r[eid as usize]
347                );
348            } else {
349                // z = 3 (common neighbours, excluding self-loops); loop-aware
350                // degree = 4 + 2 = 6; s = 5 → 3/5 = 0.6.
351                assert!(
352                    approx_eq(r[eid as usize], 0.6, 1e-9),
353                    "edge {eid}: got {}",
354                    r[eid as usize]
355                );
356            }
357        }
358    }
359
360    #[test]
361    fn k5_subset_eids_matches_all_eids_order() {
362        let g = k5();
363        let all = ecc(&g, None, 3, false, true).unwrap();
364        let subset_ids = vec![0u32, 3, 7];
365        let sub = ecc(&g, Some(&subset_ids), 3, false, true).unwrap();
366        assert_eq!(sub.len(), 3);
367        for (i, &eid) in subset_ids.iter().enumerate() {
368            assert!(
369                approx_eq(sub[i], all[eid as usize], 1e-9),
370                "subset[{i}] = {} vs all[{eid}] = {}",
371                sub[i],
372                all[eid as usize]
373            );
374        }
375    }
376
377    #[test]
378    fn out_of_range_edge_errors() {
379        let g = k5();
380        let r = ecc(&g, Some(&[99u32]), 3, false, true);
381        assert!(matches!(r, Err(IgraphError::EdgeOutOfRange { .. })));
382    }
383
384    #[test]
385    fn k_below_three_errors() {
386        let g = k5();
387        let r = ecc(&g, None, 2, false, true);
388        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
389    }
390
391    #[test]
392    fn k_above_four_errors() {
393        let g = k5();
394        let r = ecc(&g, None, 5, false, true);
395        assert!(matches!(r, Err(IgraphError::Unsupported(_))));
396    }
397
398    #[test]
399    fn empty_eids_slice_returns_empty() {
400        let g = k5();
401        let r = ecc(&g, Some(&[]), 3, false, true).unwrap();
402        assert!(r.is_empty());
403    }
404
405    #[test]
406    fn cycle_c4_k4_normalised_is_1() {
407        // 4-cycle: each edge sits in exactly one 4-cycle. Endpoints have
408        // degree 2, so s = (2-1)*(2-1) = 1 → C^(4) = 1.
409        let mut g = Graph::with_vertices(4);
410        for i in 0..4u32 {
411            g.add_edge(i, (i + 1) % 4).unwrap();
412        }
413        let r = ecc(&g, None, 4, false, true).unwrap();
414        vec_approx_eq(&r, &[1.0, 1.0, 1.0, 1.0]);
415    }
416
417    #[test]
418    fn star_k3_yields_nan_then_normalised_zero_without_normalize() {
419        // 4-star: center has degree 3, leaves have degree 1. s = min(d)-1
420        // = 0 → NaN for every edge. z is always 0 (no triangles).
421        let mut g = Graph::with_vertices(4);
422        for v in 1..4u32 {
423            g.add_edge(0, v).unwrap();
424        }
425        let r = ecc(&g, None, 3, false, true).unwrap();
426        assert_eq!(r.len(), 3);
427        for x in &r {
428            assert!(x.is_nan(), "got {x}");
429        }
430        // Without normalisation z is just 0.
431        let r = ecc(&g, None, 3, false, false).unwrap();
432        vec_approx_eq(&r, &[0.0, 0.0, 0.0]);
433    }
434
435    #[test]
436    fn karate_first_few_match_c_reference() {
437        // First 4 edges of Zachary's karate (k=3, offset=false, normalize=true)
438        // from `references/igraph/tests/unit/igraph_ecc.out` line 73:
439        //   0.875 0.555556 1 1 ...
440        use std::fs::File;
441        use std::path::PathBuf;
442        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
443        path.push("fixtures/karate.edges");
444        let g = crate::algorithms::io::read_edgelist(File::open(path).unwrap()).unwrap();
445        let r = ecc(&g, None, 3, false, true).unwrap();
446        let want_first = [0.875, 0.555_555_555_555_555_6, 1.0, 1.0];
447        for (i, &w) in want_first.iter().enumerate() {
448            assert!(
449                approx_eq(r[i], w, 1e-6),
450                "karate[{i}]: got {} want {w}",
451                r[i]
452            );
453        }
454    }
455
456    #[test]
457    fn ess_subset_order_preserved() {
458        let g = k5();
459        let r = ecc(&g, Some(&[7u32, 0, 4]), 3, false, true).unwrap();
460        let all = ecc(&g, None, 3, false, true).unwrap();
461        assert_eq!(r.len(), 3);
462        assert!(approx_eq(r[0], all[7], 1e-9));
463        assert!(approx_eq(r[1], all[0], 1e-9));
464        assert!(approx_eq(r[2], all[4], 1e-9));
465    }
466}
467
468#[cfg(all(test, feature = "proptest-harness"))]
469mod proptests {
470    use super::*;
471    use proptest::prelude::*;
472
473    fn approx_eq(a: f64, b: f64, eps: f64) -> bool {
474        if a.is_nan() && b.is_nan() {
475            return true;
476        }
477        (a - b).abs() <= eps
478    }
479
480    prop_compose! {
481        fn small_undirected_graph()(n in 2u32..=8u32, edges_seed in any::<u64>()) -> Graph {
482            let mut g = Graph::with_vertices(n);
483            let mut rng = edges_seed;
484            let target_m = ((n * (n - 1)) / 2).min(n + 4) as usize;
485            let mut added = 0usize;
486            let mut guard = 0usize;
487            while added < target_m && guard < target_m * 8 + 4 {
488                rng = rng
489                    .wrapping_mul(6_364_136_223_846_793_005)
490                    .wrapping_add(1_442_695_040_888_963_407);
491                let u = ((rng >> 33) % u64::from(n)) as u32;
492                rng = rng
493                    .wrapping_mul(6_364_136_223_846_793_005)
494                    .wrapping_add(1_442_695_040_888_963_407);
495                let v = ((rng >> 33) % u64::from(n)) as u32;
496                guard += 1;
497                if u == v {
498                    continue;
499                }
500                if g.add_edge(u, v).is_ok() {
501                    added += 1;
502                }
503            }
504            g
505        }
506    }
507
508    proptest! {
509        #[test]
510        fn unnormalised_z_is_non_negative_integer(g in small_undirected_graph()) {
511            for k in [3u32, 4] {
512                let r = ecc(&g, None, k, false, false).unwrap();
513                for (i, &x) in r.iter().enumerate() {
514                    prop_assert!(x >= 0.0, "edge {i} k={k}: got {x}");
515                    prop_assert!(x.fract() == 0.0, "edge {i} k={k}: expected integer, got {x}");
516                }
517            }
518        }
519
520        #[test]
521        fn offset_shifts_by_one(g in small_undirected_graph()) {
522            for k in [3u32, 4] {
523                let without = ecc(&g, None, k, false, false).unwrap();
524                let with = ecc(&g, None, k, true, false).unwrap();
525                prop_assert_eq!(without.len(), with.len());
526                for (i, (&a, &b)) in without.iter().zip(with.iter()).enumerate() {
527                    prop_assert!(approx_eq(b - a, 1.0, 1e-9),
528                        "edge {} k={}: with - without = {}", i, k, b - a);
529                }
530            }
531        }
532
533        #[test]
534        fn subset_matches_full_sweep(g in small_undirected_graph()) {
535            let m = g.ecount();
536            if m == 0 { return Ok(()); }
537            let all = ecc(&g, None, 3, false, true).unwrap();
538            let sub_ids: Vec<u32> = (0..m as u32).rev().collect();
539            let sub = ecc(&g, Some(&sub_ids), 3, false, true).unwrap();
540            for (i, &eid) in sub_ids.iter().enumerate() {
541                prop_assert!(approx_eq(sub[i], all[eid as usize], 1e-9),
542                    "sub[{}] = {} all[{}] = {}", i, sub[i], eid, all[eid as usize]);
543            }
544        }
545    }
546}