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