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