1use crate::core::graph::EdgeId;
31use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
32
33#[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 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
126fn 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 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#[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#[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)]
167fn ecc4_pair(adj: &[Vec<VertexId>], deg: &[u32], v1: VertexId, v2: VertexId) -> (f64, f64) {
168 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 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
195fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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}