Skip to main content

rust_igraph/algorithms/properties/
triangles.rs

1//! Triangles and global transitivity (ALGO-PR-002 / 002b / 002c / 002d).
2//!
3//! Counterparts of:
4//! - `igraph_count_triangles()`             from `references/igraph/src/properties/triangles.c:501`
5//! - `igraph_count_adjacent_triangles()`    from `references/igraph/src/properties/triangles.c:522`
6//! - `igraph_transitivity_undirected()`     from `references/igraph/src/properties/triangles.c:615`
7//! - `igraph_transitivity_local_undirected()` from `references/igraph/src/properties/triangles.c:369`
8//! - `igraph_transitivity_barrat()`         from `references/igraph/src/properties/triangles.c:874`
9//!
10//! The first three share a private helper that computes triangles AND
11//! connected triples in a single sweep. We port that helper directly:
12//!
13//! 1. Build simple adjacency lists (no self-loops, no parallel edges).
14//! 2. For each vertex `v1`, scan its neighbours `v2 < v1` (acyclic
15//!    orientation trick — counts each undirected triangle exactly
16//!    once). Mark each such `v2` with the sentinel `v1 + 1`.
17//! 3. For each `v2 < v1`, scan `v2`'s neighbours `v3 < v2`. If
18//!    `mark[v3] == v1 + 1`, then `(v1, v2, v3)` is a triangle.
19//! 4. Connected triples per vertex `v` = `C(deg, 2) = deg*(deg-1)/2`.
20//! 5. Global transitivity = `3 * triangles / triples` (or `None` if no
21//!    triples).
22//!
23//! Barrat's weighted local variant uses a different inner loop driven
24//! by the `transitivity_barrat1` helper at `triangles.c:632`.
25
26use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
27
28/// Count the number of triangles in `graph`. Edge directions, parallel
29/// edges, and self-loops are ignored.
30///
31/// Counterpart of `igraph_count_triangles()`. Returns the number of
32/// triangles as a `u64` (fits comfortably for graphs up to about
33/// `n = 600 000` cliques).
34pub fn count_triangles(graph: &Graph) -> IgraphResult<u64> {
35    let (triangles, _) = triangles_and_triples(graph)?;
36    Ok(triangles)
37}
38
39/// Per-vertex adjacent-triangle count. Entry `i` is the number of
40/// triangles vertex `i` participates in. Parallel edges and self-loops
41/// are ignored — the simple graph induced by the OUT-neighbour view is
42/// used (consistent with [`count_triangles`]). For directed graphs that
43/// is not yet the underlying-undirected projection that upstream uses;
44/// see ALGO-PR-002 for the deferred fix.
45///
46/// Counterpart of `igraph_count_adjacent_triangles()` from
47/// `references/igraph/src/properties/triangles.c:522`. Always runs the
48/// "all vertices" path (`adjacent_triangles4`); the C version's
49/// per-vertex `adjacent_triangles1` short-circuit is moot when querying
50/// every vertex.
51///
52/// Invariants: `result.iter().sum::<u64>() == 3 * count_triangles(g)`,
53/// and each entry is bounded by `C(deg, 2)`.
54///
55/// # Examples
56///
57/// ```
58/// use rust_igraph::{Graph, count_adjacent_triangles};
59///
60/// // K4: every vertex sees the 3 triangles meeting at it.
61/// let mut g = Graph::with_vertices(4);
62/// for u in 0..4u32 {
63///     for v in (u + 1)..4 {
64///         g.add_edge(u, v).unwrap();
65///     }
66/// }
67/// assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![3, 3, 3, 3]);
68///
69/// // Star centre meets 0 triangles; leaves all 0 as well.
70/// let mut g = Graph::with_vertices(4);
71/// for v in 1..4 { g.add_edge(0, v).unwrap(); }
72/// assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![0, 0, 0, 0]);
73/// ```
74pub fn count_adjacent_triangles(graph: &Graph) -> IgraphResult<Vec<u64>> {
75    let (per_vertex_triangles, _) = per_vertex_triangle_stats(graph)?;
76    Ok(per_vertex_triangles)
77}
78
79/// Global transitivity (clustering coefficient) of `graph` —
80/// `3 * triangles / connected_triples`. Returns `None` when there are
81/// no connected triples (matches upstream's `IGRAPH_TRANSITIVITY_NAN`
82/// mode); use `.unwrap_or(0.0)` for the `IGRAPH_TRANSITIVITY_ZERO`
83/// behaviour.
84///
85/// Edge directions, parallel edges, and self-loops are ignored.
86///
87/// # Examples
88///
89/// ```
90/// use rust_igraph::{Graph, transitivity_undirected};
91///
92/// // K4: every triple is a triangle → transitivity 1.0.
93/// let mut g = Graph::with_vertices(4);
94/// for u in 0..4u32 {
95///     for v in (u + 1)..4 {
96///         g.add_edge(u, v).unwrap();
97///     }
98/// }
99/// let t = transitivity_undirected(&g).unwrap();
100/// assert_eq!(t, Some(1.0));
101///
102/// // 4-cycle: 4 connected triples (one per vertex) but no triangles.
103/// let mut g = Graph::with_vertices(4);
104/// for i in 0..4u32 { g.add_edge(i, (i + 1) % 4).unwrap(); }
105/// let t = transitivity_undirected(&g).unwrap();
106/// assert_eq!(t, Some(0.0));
107/// ```
108pub fn transitivity_undirected(graph: &Graph) -> IgraphResult<Option<f64>> {
109    let (triangles, triples) = triangles_and_triples(graph)?;
110    if triples == 0 {
111        return Ok(None);
112    }
113    // f64 mantissa is 52 bits. triangles + triples both fit exactly for any
114    // graph that survives the u32 vertex-id encoding (n ≤ 2^32 → triples
115    // ≤ n^2/2 ≤ 2^63, but in practice n ≤ ~2^25 keeps the cast lossless).
116    #[allow(clippy::cast_precision_loss)]
117    let t = (triangles as f64) * 3.0 / (triples as f64);
118    Ok(Some(t))
119}
120
121/// Local transitivity (clustering coefficient) per vertex.
122///
123/// For vertex `v` with simple-degree `d` and `t` adjacent triangles,
124/// returns `2t / (d * (d - 1))`. `None` when `d < 2` (no closed triple
125/// possible — upstream's `IGRAPH_TRANSITIVITY_NAN` mode); use
126/// `result.iter().map(|o| o.unwrap_or(0.0))` for `IGRAPH_TRANSITIVITY_ZERO`
127/// behaviour.
128///
129/// Counterpart of `igraph_transitivity_local_undirected()` from
130/// `references/igraph/src/properties/triangles.c:369`.
131///
132/// # Examples
133///
134/// ```
135/// use rust_igraph::{Graph, transitivity_local_undirected};
136///
137/// // Triangle: every vertex has clustering 1.0.
138/// let mut g = Graph::with_vertices(3);
139/// g.add_edge(0, 1).unwrap();
140/// g.add_edge(1, 2).unwrap();
141/// g.add_edge(2, 0).unwrap();
142/// assert_eq!(
143///     transitivity_local_undirected(&g).unwrap(),
144///     vec![Some(1.0), Some(1.0), Some(1.0)],
145/// );
146///
147/// // Star centre: 3 neighbours but 0 triangles → 0.0.
148/// // Leaves: degree 1 → None (no closed triple possible).
149/// let mut g = Graph::with_vertices(4);
150/// for v in 1..4 { g.add_edge(0, v).unwrap(); }
151/// let r = transitivity_local_undirected(&g).unwrap();
152/// assert_eq!(r, vec![Some(0.0), None, None, None]);
153/// ```
154pub fn transitivity_local_undirected(graph: &Graph) -> IgraphResult<Vec<Option<f64>>> {
155    let n = graph.vcount();
156    let n_us = n as usize;
157    let (per_vertex_triangles, simple_degrees) = per_vertex_triangle_stats(graph)?;
158    let mut out: Vec<Option<f64>> = Vec::with_capacity(n_us);
159    for v in 0..n_us {
160        let d = simple_degrees[v];
161        if d < 2 {
162            out.push(None);
163            continue;
164        }
165        let t = per_vertex_triangles[v];
166        // d ≤ n ≤ 2^32; t ≤ n^2; both fit in f64 exactly for any
167        // graph that survives u32 vertex ids in practice.
168        #[allow(clippy::cast_precision_loss)]
169        let val = 2.0 * (t as f64) / ((d as f64) * ((d - 1) as f64));
170        out.push(Some(val));
171    }
172    Ok(out)
173}
174
175/// Adjacent-triangle count per vertex (length `vcount`) plus the simple
176/// degree (no loops, no multi) of each vertex.
177fn per_vertex_triangle_stats(graph: &Graph) -> IgraphResult<(Vec<u64>, Vec<u64>)> {
178    let n = graph.vcount();
179    let n_us = n as usize;
180
181    let mut adj: Vec<Vec<VertexId>> = Vec::with_capacity(n_us);
182    for v in 0..n {
183        let raw = graph.neighbors(v)?;
184        let mut simple: Vec<VertexId> = raw.into_iter().filter(|&u| u != v).collect();
185        simple.sort_unstable();
186        simple.dedup();
187        adj.push(simple);
188    }
189
190    let degrees: Vec<u64> = adj.iter().map(|nei| nei.len() as u64).collect();
191    let mut tri_counts: Vec<u64> = vec![0; n_us];
192    let mut mark: Vec<u32> = vec![0; n_us];
193
194    for v1 in 0..n {
195        let nei1 = &adj[v1 as usize];
196        if nei1.len() < 2 {
197            continue;
198        }
199        let v1_mark = v1
200            .checked_add(1)
201            .ok_or(IgraphError::Internal("vertex id overflow"))?;
202        for &v2 in nei1 {
203            if v2 >= v1 {
204                break;
205            }
206            mark[v2 as usize] = v1_mark;
207        }
208        for &v2 in nei1 {
209            if v2 >= v1 {
210                break;
211            }
212            let nei2 = &adj[v2 as usize];
213            for &v3 in nei2 {
214                if v3 >= v2 {
215                    break;
216                }
217                if mark[v3 as usize] == v1_mark {
218                    tri_counts[v1 as usize] += 1;
219                    tri_counts[v2 as usize] += 1;
220                    tri_counts[v3 as usize] += 1;
221                }
222            }
223        }
224    }
225
226    Ok((tri_counts, degrees))
227}
228
229/// Barrat's weighted local transitivity, per vertex.
230///
231/// For each vertex `v`, returns
232/// `(Σ over triangles (v, u, w) (w(v,u) + w(v,w))) /
233///  (s_v * (deg_v - 1))`,
234/// where `s_v = Σ_{u ∼ v} w(v, u)` is `v`'s strength and `deg_v` is
235/// `v`'s simple degree. Returns `None` for vertices with `triples = 0`
236/// (degree < 2 or strength 0) — matches upstream's
237/// `IGRAPH_TRANSITIVITY_NAN` mode.
238///
239/// Counterpart of `igraph_transitivity_barrat()` from
240/// `references/igraph/src/properties/triangles.c:874`. The graph must
241/// be simple (no multi-edges, no self-loops); otherwise this returns
242/// `IgraphError::Internal`. Edge directions are ignored — directed
243/// graphs are currently rejected (Phase-1 minimal slice).
244///
245/// `weights` must have length `graph.ecount()` and contain only finite,
246/// non-negative values.
247///
248/// Reference: Barrat, Barthélemy, Pastor-Satorras, Vespignani (2004),
249/// "The architecture of complex weighted networks", PNAS 101 3747,
250/// equation (5).
251///
252/// # Examples
253///
254/// ```
255/// use rust_igraph::{Graph, transitivity_barrat};
256///
257/// // Triangle 0-1-2 with all unit weights — every vertex sees the
258/// // single triangle, so weighted transitivity equals the unweighted
259/// // value of 1.0.
260/// let mut g = Graph::with_vertices(3);
261/// g.add_edge(0, 1).unwrap();
262/// g.add_edge(1, 2).unwrap();
263/// g.add_edge(2, 0).unwrap();
264/// let r = transitivity_barrat(&g, &[1.0, 1.0, 1.0]).unwrap();
265/// assert_eq!(r, vec![Some(1.0), Some(1.0), Some(1.0)]);
266/// ```
267pub fn transitivity_barrat(graph: &Graph, weights: &[f64]) -> IgraphResult<Vec<Option<f64>>> {
268    if graph.is_directed() {
269        return Err(IgraphError::Internal(
270            "transitivity_barrat: directed graphs not supported in Phase-1 minimal slice",
271        ));
272    }
273    let n = graph.vcount();
274    let n_us = n as usize;
275    let m = graph.ecount();
276    if weights.len() != m {
277        return Err(IgraphError::Internal(
278            "weights length does not match ecount in transitivity_barrat",
279        ));
280    }
281    for &w in weights {
282        if !w.is_finite() || w < 0.0 {
283            return Err(IgraphError::Internal(
284                "weights must be finite and non-negative in transitivity_barrat",
285            ));
286        }
287    }
288    // Upstream rejects multi-edges; loops yield undefined output, so
289    // require simple graphs here — matches the documented contract
290    // ("the function does not work for non-simple graphs").
291    if !crate::algorithms::properties::is_simple::is_simple(graph)? {
292        return Err(IgraphError::Internal(
293            "transitivity_barrat requires a simple graph (no multi-edges, no self-loops)",
294        ));
295    }
296
297    if n_us == 0 {
298        return Ok(Vec::new());
299    }
300
301    // Sentinel-based neighbour marker: `nei_mark[u] == i+1` means u is
302    // a neighbour of the i-th vertex being processed. Avoids reset cost
303    // between iterations. `actw[u]` records the weight of the edge
304    // from the current vertex to u.
305    let mut nei_mark: Vec<u64> = vec![0; n_us];
306    let mut actw: Vec<f64> = vec![0.0; n_us];
307    let mut out: Vec<Option<f64>> = Vec::with_capacity(n_us);
308
309    for v in 0..n {
310        let i_mark = u64::from(v) + 1;
311        let inc_v = graph.incident(v)?;
312        let mut strength = 0.0_f64;
313        for &e in &inc_v {
314            let u = graph.edge_other(e, v)?;
315            nei_mark[u as usize] = i_mark;
316            actw[u as usize] = weights[e as usize];
317            strength += weights[e as usize];
318        }
319        let deg_v = inc_v.len();
320        if deg_v < 2 {
321            out.push(None);
322            continue;
323        }
324        // triples = strength_v * (deg_v - 1). For a simple graph, deg_v
325        // is the simple degree, matching upstream.
326        #[allow(clippy::cast_precision_loss)]
327        let triples = strength * (deg_v as f64 - 1.0);
328        let mut triangles_w = 0.0_f64;
329
330        for &e1 in &inc_v {
331            let u = graph.edge_other(e1, v)?;
332            let w1 = weights[e1 as usize];
333            let inc_u = graph.incident(u)?;
334            for &e2 in &inc_u {
335                let u2 = graph.edge_other(e2, u)?;
336                if nei_mark[u2 as usize] == i_mark {
337                    triangles_w += f64::midpoint(actw[u2 as usize], w1);
338                }
339            }
340        }
341
342        if triples > 0.0 {
343            out.push(Some(triangles_w / triples));
344        } else {
345            out.push(None);
346        }
347    }
348
349    Ok(out)
350}
351
352fn triangles_and_triples(graph: &Graph) -> IgraphResult<(u64, u64)> {
353    let n = graph.vcount();
354    let n_us = n as usize;
355
356    // Build simple adjacency lists (no loops, no multi). We sort and
357    // dedupe each list so the `if v2 >= v1: break` early-out works.
358    let mut adj: Vec<Vec<VertexId>> = Vec::with_capacity(n_us);
359    for v in 0..n {
360        let raw = graph.neighbors(v)?;
361        let mut simple: Vec<VertexId> = raw.into_iter().filter(|&u| u != v).collect();
362        simple.sort_unstable();
363        simple.dedup();
364        adj.push(simple);
365    }
366
367    // mark[v3] == v1+1 means "v3 is a neighbour of v1 that we've seen
368    // in the current outer iteration". Sentinel 0 = unmarked.
369    let mut mark: Vec<u32> = vec![0; n_us];
370    let mut triangles: u64 = 0;
371    let mut triples: u64 = 0;
372
373    for v1 in 0..n {
374        let nei1 = &adj[v1 as usize];
375        let d1 = nei1.len();
376        if d1 < 2 {
377            continue;
378        }
379        // Each pair of neighbours of v1 forms one connected triple.
380        triples = triples.saturating_add((d1 as u64) * ((d1 - 1) as u64) / 2);
381
382        // Mark v1's lower-id neighbours.
383        let v1_mark = v1
384            .checked_add(1)
385            .ok_or(IgraphError::Internal("vertex id overflow"))?;
386        for &v2 in nei1 {
387            if v2 >= v1 {
388                break;
389            }
390            mark[v2 as usize] = v1_mark;
391        }
392
393        // For each lower-id neighbour v2, scan v2's lower-id neighbours.
394        for &v2 in nei1 {
395            if v2 >= v1 {
396                break;
397            }
398            let nei2 = &adj[v2 as usize];
399            for &v3 in nei2 {
400                if v3 >= v2 {
401                    break;
402                }
403                if mark[v3 as usize] == v1_mark {
404                    triangles = triangles.saturating_add(1);
405                }
406            }
407        }
408    }
409
410    Ok((triangles, triples))
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn empty_graph_has_no_triangles_or_transitivity() {
419        let g = Graph::with_vertices(0);
420        assert_eq!(count_triangles(&g).unwrap(), 0);
421        assert_eq!(transitivity_undirected(&g).unwrap(), None);
422    }
423
424    #[test]
425    fn isolated_vertices_give_no_triples() {
426        let g = Graph::with_vertices(5);
427        assert_eq!(count_triangles(&g).unwrap(), 0);
428        assert_eq!(transitivity_undirected(&g).unwrap(), None);
429    }
430
431    #[test]
432    fn triangle_count_is_one_transitivity_is_one() {
433        let mut g = Graph::with_vertices(3);
434        g.add_edge(0, 1).unwrap();
435        g.add_edge(1, 2).unwrap();
436        g.add_edge(2, 0).unwrap();
437        assert_eq!(count_triangles(&g).unwrap(), 1);
438        assert_eq!(transitivity_undirected(&g).unwrap(), Some(1.0));
439    }
440
441    #[test]
442    fn k4_has_4_triangles_transitivity_1() {
443        let mut g = Graph::with_vertices(4);
444        for u in 0..4u32 {
445            for v in (u + 1)..4 {
446                g.add_edge(u, v).unwrap();
447            }
448        }
449        assert_eq!(count_triangles(&g).unwrap(), 4);
450        assert_eq!(transitivity_undirected(&g).unwrap(), Some(1.0));
451    }
452
453    #[test]
454    fn cycle_4_has_no_triangles_transitivity_zero() {
455        let mut g = Graph::with_vertices(4);
456        for i in 0..4u32 {
457            g.add_edge(i, (i + 1) % 4).unwrap();
458        }
459        assert_eq!(count_triangles(&g).unwrap(), 0);
460        assert_eq!(transitivity_undirected(&g).unwrap(), Some(0.0));
461    }
462
463    #[test]
464    fn star_has_no_triangles_transitivity_zero() {
465        let mut g = Graph::with_vertices(4);
466        for v in 1..4 {
467            g.add_edge(0, v).unwrap();
468        }
469        assert_eq!(count_triangles(&g).unwrap(), 0);
470        // Centre has C(3,2) = 3 connected triples, no triangles → 0.0.
471        assert_eq!(transitivity_undirected(&g).unwrap(), Some(0.0));
472    }
473
474    #[test]
475    fn path_has_one_triple_no_triangle() {
476        // Path 0-1-2: vertex 1 has 2 neighbours → 1 triple, 0 triangles.
477        let mut g = Graph::with_vertices(3);
478        g.add_edge(0, 1).unwrap();
479        g.add_edge(1, 2).unwrap();
480        assert_eq!(count_triangles(&g).unwrap(), 0);
481        assert_eq!(transitivity_undirected(&g).unwrap(), Some(0.0));
482    }
483
484    #[test]
485    fn self_loop_is_ignored() {
486        let mut g = Graph::with_vertices(3);
487        g.add_edge(0, 0).unwrap(); // self-loop
488        g.add_edge(0, 1).unwrap();
489        g.add_edge(1, 2).unwrap();
490        g.add_edge(2, 0).unwrap();
491        // The self-loop must not change the triangle/triple count.
492        assert_eq!(count_triangles(&g).unwrap(), 1);
493        assert_eq!(transitivity_undirected(&g).unwrap(), Some(1.0));
494    }
495
496    #[test]
497    fn parallel_edges_are_ignored() {
498        // Triangle with one duplicated edge.
499        let mut g = Graph::with_vertices(3);
500        g.add_edge(0, 1).unwrap();
501        g.add_edge(0, 1).unwrap(); // duplicate
502        g.add_edge(1, 2).unwrap();
503        g.add_edge(2, 0).unwrap();
504        assert_eq!(count_triangles(&g).unwrap(), 1);
505        assert_eq!(transitivity_undirected(&g).unwrap(), Some(1.0));
506    }
507
508    #[test]
509    fn two_disjoint_triangles_count_as_two() {
510        let mut g = Graph::with_vertices(6);
511        g.add_edge(0, 1).unwrap();
512        g.add_edge(1, 2).unwrap();
513        g.add_edge(2, 0).unwrap();
514        g.add_edge(3, 4).unwrap();
515        g.add_edge(4, 5).unwrap();
516        g.add_edge(5, 3).unwrap();
517        assert_eq!(count_triangles(&g).unwrap(), 2);
518        // Each component contributes 3 triples → 6 triples, 2 triangles → 1.0.
519        assert_eq!(transitivity_undirected(&g).unwrap(), Some(1.0));
520    }
521
522    #[test]
523    fn local_transitivity_triangle_is_all_ones() {
524        let mut g = Graph::with_vertices(3);
525        g.add_edge(0, 1).unwrap();
526        g.add_edge(1, 2).unwrap();
527        g.add_edge(2, 0).unwrap();
528        assert_eq!(
529            transitivity_local_undirected(&g).unwrap(),
530            vec![Some(1.0), Some(1.0), Some(1.0)]
531        );
532    }
533
534    #[test]
535    fn local_transitivity_star_centre_zero_leaves_none() {
536        let mut g = Graph::with_vertices(4);
537        for v in 1..4 {
538            g.add_edge(0, v).unwrap();
539        }
540        assert_eq!(
541            transitivity_local_undirected(&g).unwrap(),
542            vec![Some(0.0), None, None, None]
543        );
544    }
545
546    #[test]
547    fn local_transitivity_isolated_vertices_all_none() {
548        let g = Graph::with_vertices(3);
549        assert_eq!(
550            transitivity_local_undirected(&g).unwrap(),
551            vec![None, None, None]
552        );
553    }
554
555    #[test]
556    fn local_transitivity_diamond_per_vertex() {
557        // K4 minus edge (0,3). Adjacent triangles per vertex: 0→1, 1→2, 2→2, 3→1.
558        // Simple degrees: 0→2, 1→3, 2→3, 3→2.
559        // Expected: 2*1/(2*1)=1.0; 2*2/(3*2)=2/3; 2*2/(3*2)=2/3; 2*1/(2*1)=1.0.
560        let mut g = Graph::with_vertices(4);
561        g.add_edge(0, 1).unwrap();
562        g.add_edge(0, 2).unwrap();
563        g.add_edge(1, 2).unwrap();
564        g.add_edge(1, 3).unwrap();
565        g.add_edge(2, 3).unwrap();
566        let r = transitivity_local_undirected(&g).unwrap();
567        assert_eq!(r[0], Some(1.0));
568        assert_eq!(r[3], Some(1.0));
569        // 2/3 isn't exactly representable; compare via approximate equality
570        // with f64 epsilon (matches python-igraph's exact computation).
571        let two_thirds = 2.0_f64 / 3.0;
572        assert_eq!(r[1], Some(two_thirds));
573        assert_eq!(r[2], Some(two_thirds));
574    }
575
576    #[test]
577    fn barrat_unit_weights_match_unweighted_on_k4() {
578        // K4: every triple is a triangle → local clustering = 1.0 per
579        // vertex. Barrat with unit weights must reduce to that value.
580        let mut g = Graph::with_vertices(4);
581        for u in 0..4u32 {
582            for v in (u + 1)..4 {
583                g.add_edge(u, v).unwrap();
584            }
585        }
586        let r = transitivity_barrat(&g, &[1.0; 6]).unwrap();
587        assert_eq!(r, vec![Some(1.0); 4]);
588    }
589
590    #[test]
591    fn barrat_triangle_unit_weights_all_ones() {
592        let mut g = Graph::with_vertices(3);
593        g.add_edge(0, 1).unwrap();
594        g.add_edge(1, 2).unwrap();
595        g.add_edge(2, 0).unwrap();
596        let r = transitivity_barrat(&g, &[1.0; 3]).unwrap();
597        assert_eq!(r, vec![Some(1.0), Some(1.0), Some(1.0)]);
598    }
599
600    #[test]
601    fn barrat_triangle_unequal_weights_hand_check() {
602        // Triangle 0-1-2 with edges (0,1)=1, (1,2)=2, (2,0)=4.
603        // strength: s_0=1+4=5, s_1=1+2=3, s_2=2+4=6. degrees=2 each.
604        // Vertex 0: triples = 5*1 = 5;
605        //   triangle (0,1,2): w(0,1)=1, w(0,2)=4. Sum = 1+4 = 5.
606        //   Barrat = 5/5 = 1.0.
607        // Vertex 1: triples = 3; triangle sum = w(1,0)+w(1,2)=1+2=3 → 1.0.
608        // Vertex 2: triples = 6; sum = w(2,0)+w(2,1)=4+2=6 → 1.0.
609        let mut g = Graph::with_vertices(3);
610        g.add_edge(0, 1).unwrap();
611        g.add_edge(1, 2).unwrap();
612        g.add_edge(2, 0).unwrap();
613        let r = transitivity_barrat(&g, &[1.0, 2.0, 4.0]).unwrap();
614        assert_eq!(r, vec![Some(1.0), Some(1.0), Some(1.0)]);
615    }
616
617    #[test]
618    fn barrat_path_no_triangles_yields_zero_inner_none_endpoints() {
619        // Path 0-1-2: vertex 1 has 2 neighbours, no triangle → 0.0.
620        // Vertices 0, 2 have degree 1 → triples = 0 → None.
621        let mut g = Graph::with_vertices(3);
622        g.add_edge(0, 1).unwrap();
623        g.add_edge(1, 2).unwrap();
624        let r = transitivity_barrat(&g, &[1.0, 1.0]).unwrap();
625        assert_eq!(r, vec![None, Some(0.0), None]);
626    }
627
628    #[test]
629    fn barrat_isolated_vertex_yields_none() {
630        let mut g = Graph::with_vertices(3);
631        g.add_edge(0, 1).unwrap();
632        let r = transitivity_barrat(&g, &[1.0]).unwrap();
633        assert_eq!(r, vec![None, None, None]);
634    }
635
636    #[test]
637    fn barrat_empty_graph_empty_result() {
638        let g = Graph::with_vertices(0);
639        let r = transitivity_barrat(&g, &[]).unwrap();
640        assert!(r.is_empty());
641    }
642
643    #[test]
644    fn barrat_diamond_unit_weights() {
645        // K4 minus edge (0,3): same as unweighted local with unit weights.
646        // Expected: 1.0, 2/3, 2/3, 1.0.
647        let mut g = Graph::with_vertices(4);
648        g.add_edge(0, 1).unwrap();
649        g.add_edge(0, 2).unwrap();
650        g.add_edge(1, 2).unwrap();
651        g.add_edge(1, 3).unwrap();
652        g.add_edge(2, 3).unwrap();
653        let r = transitivity_barrat(&g, &[1.0; 5]).unwrap();
654        let two_thirds = 2.0_f64 / 3.0;
655        assert_eq!(r[0], Some(1.0));
656        assert_eq!(r[3], Some(1.0));
657        match (r[1], r[2]) {
658            (Some(a), Some(b)) => {
659                assert!((a - two_thirds).abs() < 1e-12);
660                assert!((b - two_thirds).abs() < 1e-12);
661            }
662            _ => panic!("middle vertices must be Some"),
663        }
664    }
665
666    #[test]
667    fn barrat_invalid_weight_length_errors() {
668        let mut g = Graph::with_vertices(3);
669        g.add_edge(0, 1).unwrap();
670        g.add_edge(1, 2).unwrap();
671        assert!(transitivity_barrat(&g, &[1.0]).is_err());
672    }
673
674    #[test]
675    fn barrat_negative_weight_errors() {
676        let mut g = Graph::with_vertices(3);
677        g.add_edge(0, 1).unwrap();
678        g.add_edge(1, 2).unwrap();
679        assert!(transitivity_barrat(&g, &[1.0, -1.0]).is_err());
680    }
681
682    #[test]
683    fn barrat_self_loop_rejected() {
684        let mut g = Graph::with_vertices(2);
685        g.add_edge(0, 0).unwrap();
686        g.add_edge(0, 1).unwrap();
687        assert!(transitivity_barrat(&g, &[1.0, 1.0]).is_err());
688    }
689
690    #[test]
691    fn barrat_multi_edge_rejected() {
692        let mut g = Graph::with_vertices(2);
693        g.add_edge(0, 1).unwrap();
694        g.add_edge(0, 1).unwrap();
695        assert!(transitivity_barrat(&g, &[1.0, 1.0]).is_err());
696    }
697
698    #[test]
699    fn barrat_directed_rejected() {
700        let mut g = Graph::new(2, true).unwrap();
701        g.add_edge(0, 1).unwrap();
702        assert!(transitivity_barrat(&g, &[1.0]).is_err());
703    }
704
705    // ---------- count_adjacent_triangles (PR-002d) ----------
706
707    #[test]
708    fn adjacent_triangles_empty_graph() {
709        let g = Graph::with_vertices(0);
710        assert_eq!(count_adjacent_triangles(&g).unwrap(), Vec::<u64>::new());
711    }
712
713    #[test]
714    fn adjacent_triangles_isolated_vertices() {
715        let g = Graph::with_vertices(5);
716        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![0; 5]);
717    }
718
719    #[test]
720    fn adjacent_triangles_single_triangle() {
721        let mut g = Graph::with_vertices(3);
722        g.add_edge(0, 1).unwrap();
723        g.add_edge(1, 2).unwrap();
724        g.add_edge(2, 0).unwrap();
725        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![1, 1, 1]);
726    }
727
728    #[test]
729    fn adjacent_triangles_k4_each_vertex_sees_3() {
730        let mut g = Graph::with_vertices(4);
731        for u in 0..4u32 {
732            for v in (u + 1)..4 {
733                g.add_edge(u, v).unwrap();
734            }
735        }
736        // K4 has 4 triangles; each vertex is in 3 of them.
737        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![3, 3, 3, 3]);
738    }
739
740    #[test]
741    fn adjacent_triangles_diamond_k4_minus_edge() {
742        // Triangles (0,1,2) and (1,2,3); deg-by-vertex {0,3} see one
743        // triangle, deg-by-vertex {1,2} see both.
744        let mut g = Graph::with_vertices(4);
745        g.add_edge(0, 1).unwrap();
746        g.add_edge(0, 2).unwrap();
747        g.add_edge(1, 2).unwrap();
748        g.add_edge(1, 3).unwrap();
749        g.add_edge(2, 3).unwrap();
750        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![1, 2, 2, 1]);
751    }
752
753    #[test]
754    fn adjacent_triangles_star_all_zero() {
755        let mut g = Graph::with_vertices(5);
756        for v in 1..5u32 {
757            g.add_edge(0, v).unwrap();
758        }
759        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![0; 5]);
760    }
761
762    #[test]
763    fn adjacent_triangles_self_loops_ignored() {
764        let mut g = Graph::with_vertices(3);
765        g.add_edge(0, 0).unwrap();
766        g.add_edge(1, 1).unwrap();
767        g.add_edge(0, 1).unwrap();
768        g.add_edge(1, 2).unwrap();
769        g.add_edge(2, 0).unwrap();
770        // Self-loops don't contribute; the (0,1,2) triangle remains.
771        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![1, 1, 1]);
772    }
773
774    #[test]
775    fn adjacent_triangles_parallel_edges_ignored() {
776        let mut g = Graph::with_vertices(3);
777        g.add_edge(0, 1).unwrap();
778        g.add_edge(0, 1).unwrap();
779        g.add_edge(1, 2).unwrap();
780        g.add_edge(1, 2).unwrap();
781        g.add_edge(2, 0).unwrap();
782        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![1, 1, 1]);
783    }
784
785    #[test]
786    fn adjacent_triangles_two_disjoint_triangles() {
787        let mut g = Graph::with_vertices(6);
788        g.add_edge(0, 1).unwrap();
789        g.add_edge(1, 2).unwrap();
790        g.add_edge(2, 0).unwrap();
791        g.add_edge(3, 4).unwrap();
792        g.add_edge(4, 5).unwrap();
793        g.add_edge(5, 3).unwrap();
794        assert_eq!(
795            count_adjacent_triangles(&g).unwrap(),
796            vec![1, 1, 1, 1, 1, 1]
797        );
798    }
799
800    #[test]
801    fn adjacent_triangles_sum_equals_three_times_count_triangles() {
802        // K4: count_triangles=4 → sum should equal 12.
803        let mut g = Graph::with_vertices(4);
804        for u in 0..4u32 {
805            for v in (u + 1)..4 {
806                g.add_edge(u, v).unwrap();
807            }
808        }
809        let per_vertex = count_adjacent_triangles(&g).unwrap();
810        let total = count_triangles(&g).unwrap();
811        assert_eq!(per_vertex.iter().sum::<u64>(), 3 * total);
812    }
813
814    #[test]
815    fn adjacent_triangles_consistent_with_local_transitivity() {
816        // For a triangle, each vertex has one adjacent triangle and
817        // simple-degree 2 → local transitivity 2*1/(2*1) = 1.
818        let mut g = Graph::with_vertices(3);
819        g.add_edge(0, 1).unwrap();
820        g.add_edge(1, 2).unwrap();
821        g.add_edge(2, 0).unwrap();
822        let adj = count_adjacent_triangles(&g).unwrap();
823        let local = transitivity_local_undirected(&g).unwrap();
824        for v in 0..3 {
825            assert_eq!(adj[v], 1);
826            assert_eq!(local[v], Some(1.0));
827        }
828    }
829
830    #[test]
831    fn diamond_k4_minus_edge_transitivity_below_one() {
832        // K4 minus the edge (0, 3). Triangles: (0,1,2), (1,2,3) → 2.
833        // Triples: deg(0)=2 → 1; deg(1)=3 → 3; deg(2)=3 → 3; deg(3)=2 → 1.
834        // Total triples = 8, transitivity = 6/8 = 0.75.
835        let mut g = Graph::with_vertices(4);
836        g.add_edge(0, 1).unwrap();
837        g.add_edge(0, 2).unwrap();
838        g.add_edge(1, 2).unwrap();
839        g.add_edge(1, 3).unwrap();
840        g.add_edge(2, 3).unwrap();
841        assert_eq!(count_triangles(&g).unwrap(), 2);
842        assert_eq!(transitivity_undirected(&g).unwrap(), Some(0.75));
843    }
844}