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/// How to handle vertices with degree < 2 when averaging local transitivity.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum TransitivityMode {
178    /// Exclude low-degree vertices from the average; return NaN if none qualify.
179    Nan,
180    /// Treat low-degree vertices as having transitivity 0.
181    Zero,
182}
183
184/// Average local transitivity (clustering coefficient).
185///
186/// Computes the local transitivity for each vertex and returns the mean.
187///
188/// - `mode`: how to handle vertices with degree < 2.
189///   - [`TransitivityMode::Nan`]: exclude them (result is NaN if no vertex qualifies).
190///   - [`TransitivityMode::Zero`]: include them with transitivity 0.
191///
192/// # Examples
193///
194/// ```
195/// use rust_igraph::{Graph, transitivity_avglocal_undirected, TransitivityMode};
196///
197/// // Triangle: all vertices have local transitivity 1.0
198/// let mut g = Graph::with_vertices(3);
199/// g.add_edge(0, 1).unwrap();
200/// g.add_edge(1, 2).unwrap();
201/// g.add_edge(0, 2).unwrap();
202/// let avg = transitivity_avglocal_undirected(&g, TransitivityMode::Nan).unwrap();
203/// assert!((avg - 1.0).abs() < 1e-10);
204/// ```
205pub fn transitivity_avglocal_undirected(
206    graph: &Graph,
207    mode: TransitivityMode,
208) -> IgraphResult<f64> {
209    let n = graph.vcount() as usize;
210    if n == 0 {
211        return Ok(if mode == TransitivityMode::Zero {
212            0.0
213        } else {
214            f64::NAN
215        });
216    }
217
218    let local = transitivity_local_undirected(graph)?;
219    let mut sum = 0.0_f64;
220    let mut count = 0_u64;
221
222    for val in &local {
223        match val {
224            Some(t) => {
225                sum += t;
226                count += 1;
227            }
228            None => {
229                if mode == TransitivityMode::Zero {
230                    count += 1;
231                }
232            }
233        }
234    }
235
236    if count == 0 {
237        Ok(f64::NAN)
238    } else {
239        #[allow(clippy::cast_precision_loss)]
240        Ok(sum / count as f64)
241    }
242}
243
244/// Adjacent-triangle count per vertex (length `vcount`) plus the simple
245/// degree (no loops, no multi) of each vertex.
246fn per_vertex_triangle_stats(graph: &Graph) -> IgraphResult<(Vec<u64>, Vec<u64>)> {
247    let n = graph.vcount();
248    let n_us = n as usize;
249
250    let mut adj: Vec<Vec<VertexId>> = Vec::with_capacity(n_us);
251    for v in 0..n {
252        let raw = graph.neighbors(v)?;
253        let mut simple: Vec<VertexId> = raw.into_iter().filter(|&u| u != v).collect();
254        simple.sort_unstable();
255        simple.dedup();
256        adj.push(simple);
257    }
258
259    let degrees: Vec<u64> = adj.iter().map(|nei| nei.len() as u64).collect();
260    let mut tri_counts: Vec<u64> = vec![0; n_us];
261    let mut mark: Vec<u32> = vec![0; n_us];
262
263    for v1 in 0..n {
264        let nei1 = &adj[v1 as usize];
265        if nei1.len() < 2 {
266            continue;
267        }
268        let v1_mark = v1
269            .checked_add(1)
270            .ok_or(IgraphError::Internal("vertex id overflow"))?;
271        for &v2 in nei1 {
272            if v2 >= v1 {
273                break;
274            }
275            mark[v2 as usize] = v1_mark;
276        }
277        for &v2 in nei1 {
278            if v2 >= v1 {
279                break;
280            }
281            let nei2 = &adj[v2 as usize];
282            for &v3 in nei2 {
283                if v3 >= v2 {
284                    break;
285                }
286                if mark[v3 as usize] == v1_mark {
287                    tri_counts[v1 as usize] += 1;
288                    tri_counts[v2 as usize] += 1;
289                    tri_counts[v3 as usize] += 1;
290                }
291            }
292        }
293    }
294
295    Ok((tri_counts, degrees))
296}
297
298/// Barrat's weighted local transitivity, per vertex.
299///
300/// For each vertex `v`, returns
301/// `(Σ over triangles (v, u, w) (w(v,u) + w(v,w))) /
302///  (s_v * (deg_v - 1))`,
303/// where `s_v = Σ_{u ∼ v} w(v, u)` is `v`'s strength and `deg_v` is
304/// `v`'s simple degree. Returns `None` for vertices with `triples = 0`
305/// (degree < 2 or strength 0) — matches upstream's
306/// `IGRAPH_TRANSITIVITY_NAN` mode.
307///
308/// Counterpart of `igraph_transitivity_barrat()` from
309/// `references/igraph/src/properties/triangles.c:874`. The graph must
310/// be simple (no multi-edges, no self-loops); otherwise this returns
311/// `IgraphError::Internal`. Edge directions are ignored — directed
312/// graphs are currently rejected (Phase-1 minimal slice).
313///
314/// `weights` must have length `graph.ecount()` and contain only finite,
315/// non-negative values.
316///
317/// Reference: Barrat, Barthélemy, Pastor-Satorras, Vespignani (2004),
318/// "The architecture of complex weighted networks", PNAS 101 3747,
319/// equation (5).
320///
321/// # Examples
322///
323/// ```
324/// use rust_igraph::{Graph, transitivity_barrat};
325///
326/// // Triangle 0-1-2 with all unit weights — every vertex sees the
327/// // single triangle, so weighted transitivity equals the unweighted
328/// // value of 1.0.
329/// let mut g = Graph::with_vertices(3);
330/// g.add_edge(0, 1).unwrap();
331/// g.add_edge(1, 2).unwrap();
332/// g.add_edge(2, 0).unwrap();
333/// let r = transitivity_barrat(&g, &[1.0, 1.0, 1.0]).unwrap();
334/// assert_eq!(r, vec![Some(1.0), Some(1.0), Some(1.0)]);
335/// ```
336pub fn transitivity_barrat(graph: &Graph, weights: &[f64]) -> IgraphResult<Vec<Option<f64>>> {
337    if graph.is_directed() {
338        return Err(IgraphError::Internal(
339            "transitivity_barrat: directed graphs not supported in Phase-1 minimal slice",
340        ));
341    }
342    let n = graph.vcount();
343    let n_us = n as usize;
344    let m = graph.ecount();
345    if weights.len() != m {
346        return Err(IgraphError::Internal(
347            "weights length does not match ecount in transitivity_barrat",
348        ));
349    }
350    for &w in weights {
351        if !w.is_finite() || w < 0.0 {
352            return Err(IgraphError::Internal(
353                "weights must be finite and non-negative in transitivity_barrat",
354            ));
355        }
356    }
357    // Upstream rejects multi-edges; loops yield undefined output, so
358    // require simple graphs here — matches the documented contract
359    // ("the function does not work for non-simple graphs").
360    if !crate::algorithms::properties::is_simple::is_simple(graph)? {
361        return Err(IgraphError::Internal(
362            "transitivity_barrat requires a simple graph (no multi-edges, no self-loops)",
363        ));
364    }
365
366    if n_us == 0 {
367        return Ok(Vec::new());
368    }
369
370    // Sentinel-based neighbour marker: `nei_mark[u] == i+1` means u is
371    // a neighbour of the i-th vertex being processed. Avoids reset cost
372    // between iterations. `actw[u]` records the weight of the edge
373    // from the current vertex to u.
374    let mut nei_mark: Vec<u64> = vec![0; n_us];
375    let mut actw: Vec<f64> = vec![0.0; n_us];
376    let mut out: Vec<Option<f64>> = Vec::with_capacity(n_us);
377
378    for v in 0..n {
379        let i_mark = u64::from(v) + 1;
380        let inc_v = graph.incident(v)?;
381        let mut strength = 0.0_f64;
382        for &e in &inc_v {
383            let u = graph.edge_other(e, v)?;
384            nei_mark[u as usize] = i_mark;
385            actw[u as usize] = weights[e as usize];
386            strength += weights[e as usize];
387        }
388        let deg_v = inc_v.len();
389        if deg_v < 2 {
390            out.push(None);
391            continue;
392        }
393        // triples = strength_v * (deg_v - 1). For a simple graph, deg_v
394        // is the simple degree, matching upstream.
395        #[allow(clippy::cast_precision_loss)]
396        let triples = strength * (deg_v as f64 - 1.0);
397        let mut triangles_w = 0.0_f64;
398
399        for &e1 in &inc_v {
400            let u = graph.edge_other(e1, v)?;
401            let w1 = weights[e1 as usize];
402            let inc_u = graph.incident(u)?;
403            for &e2 in &inc_u {
404                let u2 = graph.edge_other(e2, u)?;
405                if nei_mark[u2 as usize] == i_mark {
406                    triangles_w += f64::midpoint(actw[u2 as usize], w1);
407                }
408            }
409        }
410
411        if triples > 0.0 {
412            out.push(Some(triangles_w / triples));
413        } else {
414            out.push(None);
415        }
416    }
417
418    Ok(out)
419}
420
421fn triangles_and_triples(graph: &Graph) -> IgraphResult<(u64, u64)> {
422    let n = graph.vcount();
423    let n_us = n as usize;
424
425    // Build simple adjacency lists (no loops, no multi). We sort and
426    // dedupe each list so the `if v2 >= v1: break` early-out works.
427    let mut adj: Vec<Vec<VertexId>> = Vec::with_capacity(n_us);
428    for v in 0..n {
429        let raw = graph.neighbors(v)?;
430        let mut simple: Vec<VertexId> = raw.into_iter().filter(|&u| u != v).collect();
431        simple.sort_unstable();
432        simple.dedup();
433        adj.push(simple);
434    }
435
436    // mark[v3] == v1+1 means "v3 is a neighbour of v1 that we've seen
437    // in the current outer iteration". Sentinel 0 = unmarked.
438    let mut mark: Vec<u32> = vec![0; n_us];
439    let mut triangles: u64 = 0;
440    let mut triples: u64 = 0;
441
442    for v1 in 0..n {
443        let nei1 = &adj[v1 as usize];
444        let d1 = nei1.len();
445        if d1 < 2 {
446            continue;
447        }
448        // Each pair of neighbours of v1 forms one connected triple.
449        triples = triples.saturating_add((d1 as u64) * ((d1 - 1) as u64) / 2);
450
451        // Mark v1's lower-id neighbours.
452        let v1_mark = v1
453            .checked_add(1)
454            .ok_or(IgraphError::Internal("vertex id overflow"))?;
455        for &v2 in nei1 {
456            if v2 >= v1 {
457                break;
458            }
459            mark[v2 as usize] = v1_mark;
460        }
461
462        // For each lower-id neighbour v2, scan v2's lower-id neighbours.
463        for &v2 in nei1 {
464            if v2 >= v1 {
465                break;
466            }
467            let nei2 = &adj[v2 as usize];
468            for &v3 in nei2 {
469                if v3 >= v2 {
470                    break;
471                }
472                if mark[v3 as usize] == v1_mark {
473                    triangles = triangles.saturating_add(1);
474                }
475            }
476        }
477    }
478
479    Ok((triangles, triples))
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn empty_graph_has_no_triangles_or_transitivity() {
488        let g = Graph::with_vertices(0);
489        assert_eq!(count_triangles(&g).unwrap(), 0);
490        assert_eq!(transitivity_undirected(&g).unwrap(), None);
491    }
492
493    #[test]
494    fn isolated_vertices_give_no_triples() {
495        let g = Graph::with_vertices(5);
496        assert_eq!(count_triangles(&g).unwrap(), 0);
497        assert_eq!(transitivity_undirected(&g).unwrap(), None);
498    }
499
500    #[test]
501    fn triangle_count_is_one_transitivity_is_one() {
502        let mut g = Graph::with_vertices(3);
503        g.add_edge(0, 1).unwrap();
504        g.add_edge(1, 2).unwrap();
505        g.add_edge(2, 0).unwrap();
506        assert_eq!(count_triangles(&g).unwrap(), 1);
507        assert_eq!(transitivity_undirected(&g).unwrap(), Some(1.0));
508    }
509
510    #[test]
511    fn k4_has_4_triangles_transitivity_1() {
512        let mut g = Graph::with_vertices(4);
513        for u in 0..4u32 {
514            for v in (u + 1)..4 {
515                g.add_edge(u, v).unwrap();
516            }
517        }
518        assert_eq!(count_triangles(&g).unwrap(), 4);
519        assert_eq!(transitivity_undirected(&g).unwrap(), Some(1.0));
520    }
521
522    #[test]
523    fn cycle_4_has_no_triangles_transitivity_zero() {
524        let mut g = Graph::with_vertices(4);
525        for i in 0..4u32 {
526            g.add_edge(i, (i + 1) % 4).unwrap();
527        }
528        assert_eq!(count_triangles(&g).unwrap(), 0);
529        assert_eq!(transitivity_undirected(&g).unwrap(), Some(0.0));
530    }
531
532    #[test]
533    fn star_has_no_triangles_transitivity_zero() {
534        let mut g = Graph::with_vertices(4);
535        for v in 1..4 {
536            g.add_edge(0, v).unwrap();
537        }
538        assert_eq!(count_triangles(&g).unwrap(), 0);
539        // Centre has C(3,2) = 3 connected triples, no triangles → 0.0.
540        assert_eq!(transitivity_undirected(&g).unwrap(), Some(0.0));
541    }
542
543    #[test]
544    fn path_has_one_triple_no_triangle() {
545        // Path 0-1-2: vertex 1 has 2 neighbours → 1 triple, 0 triangles.
546        let mut g = Graph::with_vertices(3);
547        g.add_edge(0, 1).unwrap();
548        g.add_edge(1, 2).unwrap();
549        assert_eq!(count_triangles(&g).unwrap(), 0);
550        assert_eq!(transitivity_undirected(&g).unwrap(), Some(0.0));
551    }
552
553    #[test]
554    fn self_loop_is_ignored() {
555        let mut g = Graph::with_vertices(3);
556        g.add_edge(0, 0).unwrap(); // self-loop
557        g.add_edge(0, 1).unwrap();
558        g.add_edge(1, 2).unwrap();
559        g.add_edge(2, 0).unwrap();
560        // The self-loop must not change the triangle/triple count.
561        assert_eq!(count_triangles(&g).unwrap(), 1);
562        assert_eq!(transitivity_undirected(&g).unwrap(), Some(1.0));
563    }
564
565    #[test]
566    fn parallel_edges_are_ignored() {
567        // Triangle with one duplicated edge.
568        let mut g = Graph::with_vertices(3);
569        g.add_edge(0, 1).unwrap();
570        g.add_edge(0, 1).unwrap(); // duplicate
571        g.add_edge(1, 2).unwrap();
572        g.add_edge(2, 0).unwrap();
573        assert_eq!(count_triangles(&g).unwrap(), 1);
574        assert_eq!(transitivity_undirected(&g).unwrap(), Some(1.0));
575    }
576
577    #[test]
578    fn two_disjoint_triangles_count_as_two() {
579        let mut g = Graph::with_vertices(6);
580        g.add_edge(0, 1).unwrap();
581        g.add_edge(1, 2).unwrap();
582        g.add_edge(2, 0).unwrap();
583        g.add_edge(3, 4).unwrap();
584        g.add_edge(4, 5).unwrap();
585        g.add_edge(5, 3).unwrap();
586        assert_eq!(count_triangles(&g).unwrap(), 2);
587        // Each component contributes 3 triples → 6 triples, 2 triangles → 1.0.
588        assert_eq!(transitivity_undirected(&g).unwrap(), Some(1.0));
589    }
590
591    #[test]
592    fn local_transitivity_triangle_is_all_ones() {
593        let mut g = Graph::with_vertices(3);
594        g.add_edge(0, 1).unwrap();
595        g.add_edge(1, 2).unwrap();
596        g.add_edge(2, 0).unwrap();
597        assert_eq!(
598            transitivity_local_undirected(&g).unwrap(),
599            vec![Some(1.0), Some(1.0), Some(1.0)]
600        );
601    }
602
603    #[test]
604    fn local_transitivity_star_centre_zero_leaves_none() {
605        let mut g = Graph::with_vertices(4);
606        for v in 1..4 {
607            g.add_edge(0, v).unwrap();
608        }
609        assert_eq!(
610            transitivity_local_undirected(&g).unwrap(),
611            vec![Some(0.0), None, None, None]
612        );
613    }
614
615    #[test]
616    fn local_transitivity_isolated_vertices_all_none() {
617        let g = Graph::with_vertices(3);
618        assert_eq!(
619            transitivity_local_undirected(&g).unwrap(),
620            vec![None, None, None]
621        );
622    }
623
624    #[test]
625    fn local_transitivity_diamond_per_vertex() {
626        // K4 minus edge (0,3). Adjacent triangles per vertex: 0→1, 1→2, 2→2, 3→1.
627        // Simple degrees: 0→2, 1→3, 2→3, 3→2.
628        // 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.
629        let mut g = Graph::with_vertices(4);
630        g.add_edge(0, 1).unwrap();
631        g.add_edge(0, 2).unwrap();
632        g.add_edge(1, 2).unwrap();
633        g.add_edge(1, 3).unwrap();
634        g.add_edge(2, 3).unwrap();
635        let r = transitivity_local_undirected(&g).unwrap();
636        assert_eq!(r[0], Some(1.0));
637        assert_eq!(r[3], Some(1.0));
638        // 2/3 isn't exactly representable; compare via approximate equality
639        // with f64 epsilon (matches python-igraph's exact computation).
640        let two_thirds = 2.0_f64 / 3.0;
641        assert_eq!(r[1], Some(two_thirds));
642        assert_eq!(r[2], Some(two_thirds));
643    }
644
645    #[test]
646    fn barrat_unit_weights_match_unweighted_on_k4() {
647        // K4: every triple is a triangle → local clustering = 1.0 per
648        // vertex. Barrat with unit weights must reduce to that value.
649        let mut g = Graph::with_vertices(4);
650        for u in 0..4u32 {
651            for v in (u + 1)..4 {
652                g.add_edge(u, v).unwrap();
653            }
654        }
655        let r = transitivity_barrat(&g, &[1.0; 6]).unwrap();
656        assert_eq!(r, vec![Some(1.0); 4]);
657    }
658
659    #[test]
660    fn barrat_triangle_unit_weights_all_ones() {
661        let mut g = Graph::with_vertices(3);
662        g.add_edge(0, 1).unwrap();
663        g.add_edge(1, 2).unwrap();
664        g.add_edge(2, 0).unwrap();
665        let r = transitivity_barrat(&g, &[1.0; 3]).unwrap();
666        assert_eq!(r, vec![Some(1.0), Some(1.0), Some(1.0)]);
667    }
668
669    #[test]
670    fn barrat_triangle_unequal_weights_hand_check() {
671        // Triangle 0-1-2 with edges (0,1)=1, (1,2)=2, (2,0)=4.
672        // strength: s_0=1+4=5, s_1=1+2=3, s_2=2+4=6. degrees=2 each.
673        // Vertex 0: triples = 5*1 = 5;
674        //   triangle (0,1,2): w(0,1)=1, w(0,2)=4. Sum = 1+4 = 5.
675        //   Barrat = 5/5 = 1.0.
676        // Vertex 1: triples = 3; triangle sum = w(1,0)+w(1,2)=1+2=3 → 1.0.
677        // Vertex 2: triples = 6; sum = w(2,0)+w(2,1)=4+2=6 → 1.0.
678        let mut g = Graph::with_vertices(3);
679        g.add_edge(0, 1).unwrap();
680        g.add_edge(1, 2).unwrap();
681        g.add_edge(2, 0).unwrap();
682        let r = transitivity_barrat(&g, &[1.0, 2.0, 4.0]).unwrap();
683        assert_eq!(r, vec![Some(1.0), Some(1.0), Some(1.0)]);
684    }
685
686    #[test]
687    fn barrat_path_no_triangles_yields_zero_inner_none_endpoints() {
688        // Path 0-1-2: vertex 1 has 2 neighbours, no triangle → 0.0.
689        // Vertices 0, 2 have degree 1 → triples = 0 → None.
690        let mut g = Graph::with_vertices(3);
691        g.add_edge(0, 1).unwrap();
692        g.add_edge(1, 2).unwrap();
693        let r = transitivity_barrat(&g, &[1.0, 1.0]).unwrap();
694        assert_eq!(r, vec![None, Some(0.0), None]);
695    }
696
697    #[test]
698    fn barrat_isolated_vertex_yields_none() {
699        let mut g = Graph::with_vertices(3);
700        g.add_edge(0, 1).unwrap();
701        let r = transitivity_barrat(&g, &[1.0]).unwrap();
702        assert_eq!(r, vec![None, None, None]);
703    }
704
705    #[test]
706    fn barrat_empty_graph_empty_result() {
707        let g = Graph::with_vertices(0);
708        let r = transitivity_barrat(&g, &[]).unwrap();
709        assert!(r.is_empty());
710    }
711
712    #[test]
713    fn barrat_diamond_unit_weights() {
714        // K4 minus edge (0,3): same as unweighted local with unit weights.
715        // Expected: 1.0, 2/3, 2/3, 1.0.
716        let mut g = Graph::with_vertices(4);
717        g.add_edge(0, 1).unwrap();
718        g.add_edge(0, 2).unwrap();
719        g.add_edge(1, 2).unwrap();
720        g.add_edge(1, 3).unwrap();
721        g.add_edge(2, 3).unwrap();
722        let r = transitivity_barrat(&g, &[1.0; 5]).unwrap();
723        let two_thirds = 2.0_f64 / 3.0;
724        assert_eq!(r[0], Some(1.0));
725        assert_eq!(r[3], Some(1.0));
726        match (r[1], r[2]) {
727            (Some(a), Some(b)) => {
728                assert!((a - two_thirds).abs() < 1e-12);
729                assert!((b - two_thirds).abs() < 1e-12);
730            }
731            _ => panic!("middle vertices must be Some"),
732        }
733    }
734
735    #[test]
736    fn barrat_invalid_weight_length_errors() {
737        let mut g = Graph::with_vertices(3);
738        g.add_edge(0, 1).unwrap();
739        g.add_edge(1, 2).unwrap();
740        assert!(transitivity_barrat(&g, &[1.0]).is_err());
741    }
742
743    #[test]
744    fn barrat_negative_weight_errors() {
745        let mut g = Graph::with_vertices(3);
746        g.add_edge(0, 1).unwrap();
747        g.add_edge(1, 2).unwrap();
748        assert!(transitivity_barrat(&g, &[1.0, -1.0]).is_err());
749    }
750
751    #[test]
752    fn barrat_self_loop_rejected() {
753        let mut g = Graph::with_vertices(2);
754        g.add_edge(0, 0).unwrap();
755        g.add_edge(0, 1).unwrap();
756        assert!(transitivity_barrat(&g, &[1.0, 1.0]).is_err());
757    }
758
759    #[test]
760    fn barrat_multi_edge_rejected() {
761        let mut g = Graph::with_vertices(2);
762        g.add_edge(0, 1).unwrap();
763        g.add_edge(0, 1).unwrap();
764        assert!(transitivity_barrat(&g, &[1.0, 1.0]).is_err());
765    }
766
767    #[test]
768    fn barrat_directed_rejected() {
769        let mut g = Graph::new(2, true).unwrap();
770        g.add_edge(0, 1).unwrap();
771        assert!(transitivity_barrat(&g, &[1.0]).is_err());
772    }
773
774    // ---------- count_adjacent_triangles (PR-002d) ----------
775
776    #[test]
777    fn adjacent_triangles_empty_graph() {
778        let g = Graph::with_vertices(0);
779        assert_eq!(count_adjacent_triangles(&g).unwrap(), Vec::<u64>::new());
780    }
781
782    #[test]
783    fn adjacent_triangles_isolated_vertices() {
784        let g = Graph::with_vertices(5);
785        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![0; 5]);
786    }
787
788    #[test]
789    fn adjacent_triangles_single_triangle() {
790        let mut g = Graph::with_vertices(3);
791        g.add_edge(0, 1).unwrap();
792        g.add_edge(1, 2).unwrap();
793        g.add_edge(2, 0).unwrap();
794        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![1, 1, 1]);
795    }
796
797    #[test]
798    fn adjacent_triangles_k4_each_vertex_sees_3() {
799        let mut g = Graph::with_vertices(4);
800        for u in 0..4u32 {
801            for v in (u + 1)..4 {
802                g.add_edge(u, v).unwrap();
803            }
804        }
805        // K4 has 4 triangles; each vertex is in 3 of them.
806        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![3, 3, 3, 3]);
807    }
808
809    #[test]
810    fn adjacent_triangles_diamond_k4_minus_edge() {
811        // Triangles (0,1,2) and (1,2,3); deg-by-vertex {0,3} see one
812        // triangle, deg-by-vertex {1,2} see both.
813        let mut g = Graph::with_vertices(4);
814        g.add_edge(0, 1).unwrap();
815        g.add_edge(0, 2).unwrap();
816        g.add_edge(1, 2).unwrap();
817        g.add_edge(1, 3).unwrap();
818        g.add_edge(2, 3).unwrap();
819        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![1, 2, 2, 1]);
820    }
821
822    #[test]
823    fn adjacent_triangles_star_all_zero() {
824        let mut g = Graph::with_vertices(5);
825        for v in 1..5u32 {
826            g.add_edge(0, v).unwrap();
827        }
828        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![0; 5]);
829    }
830
831    #[test]
832    fn adjacent_triangles_self_loops_ignored() {
833        let mut g = Graph::with_vertices(3);
834        g.add_edge(0, 0).unwrap();
835        g.add_edge(1, 1).unwrap();
836        g.add_edge(0, 1).unwrap();
837        g.add_edge(1, 2).unwrap();
838        g.add_edge(2, 0).unwrap();
839        // Self-loops don't contribute; the (0,1,2) triangle remains.
840        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![1, 1, 1]);
841    }
842
843    #[test]
844    fn adjacent_triangles_parallel_edges_ignored() {
845        let mut g = Graph::with_vertices(3);
846        g.add_edge(0, 1).unwrap();
847        g.add_edge(0, 1).unwrap();
848        g.add_edge(1, 2).unwrap();
849        g.add_edge(1, 2).unwrap();
850        g.add_edge(2, 0).unwrap();
851        assert_eq!(count_adjacent_triangles(&g).unwrap(), vec![1, 1, 1]);
852    }
853
854    #[test]
855    fn adjacent_triangles_two_disjoint_triangles() {
856        let mut g = Graph::with_vertices(6);
857        g.add_edge(0, 1).unwrap();
858        g.add_edge(1, 2).unwrap();
859        g.add_edge(2, 0).unwrap();
860        g.add_edge(3, 4).unwrap();
861        g.add_edge(4, 5).unwrap();
862        g.add_edge(5, 3).unwrap();
863        assert_eq!(
864            count_adjacent_triangles(&g).unwrap(),
865            vec![1, 1, 1, 1, 1, 1]
866        );
867    }
868
869    #[test]
870    fn adjacent_triangles_sum_equals_three_times_count_triangles() {
871        // K4: count_triangles=4 → sum should equal 12.
872        let mut g = Graph::with_vertices(4);
873        for u in 0..4u32 {
874            for v in (u + 1)..4 {
875                g.add_edge(u, v).unwrap();
876            }
877        }
878        let per_vertex = count_adjacent_triangles(&g).unwrap();
879        let total = count_triangles(&g).unwrap();
880        assert_eq!(per_vertex.iter().sum::<u64>(), 3 * total);
881    }
882
883    #[test]
884    fn adjacent_triangles_consistent_with_local_transitivity() {
885        // For a triangle, each vertex has one adjacent triangle and
886        // simple-degree 2 → local transitivity 2*1/(2*1) = 1.
887        let mut g = Graph::with_vertices(3);
888        g.add_edge(0, 1).unwrap();
889        g.add_edge(1, 2).unwrap();
890        g.add_edge(2, 0).unwrap();
891        let adj = count_adjacent_triangles(&g).unwrap();
892        let local = transitivity_local_undirected(&g).unwrap();
893        for v in 0..3 {
894            assert_eq!(adj[v], 1);
895            assert_eq!(local[v], Some(1.0));
896        }
897    }
898
899    #[test]
900    fn diamond_k4_minus_edge_transitivity_below_one() {
901        // K4 minus the edge (0, 3). Triangles: (0,1,2), (1,2,3) → 2.
902        // Triples: deg(0)=2 → 1; deg(1)=3 → 3; deg(2)=3 → 3; deg(3)=2 → 1.
903        // Total triples = 8, transitivity = 6/8 = 0.75.
904        let mut g = Graph::with_vertices(4);
905        g.add_edge(0, 1).unwrap();
906        g.add_edge(0, 2).unwrap();
907        g.add_edge(1, 2).unwrap();
908        g.add_edge(1, 3).unwrap();
909        g.add_edge(2, 3).unwrap();
910        assert_eq!(count_triangles(&g).unwrap(), 2);
911        assert_eq!(transitivity_undirected(&g).unwrap(), Some(0.75));
912    }
913}