Skip to main content

rust_igraph/algorithms/community/
edge_betweenness_community.rs

1//! Edge-betweenness community detection (ALGO-CO-006).
2//!
3//! Counterpart of `igraph_community_edge_betweenness()` from
4//! `references/igraph/src/community/edge_betweenness.c`.
5//!
6//! Girvan-Newman (2002): iteratively remove the edge with the highest
7//! current betweenness; the order in which removals split the graph is
8//! a binary dendrogram whose best cut (by modularity) yields a
9//! community partition.
10//!
11//! References:
12//! - M. Girvan, M. E. J. Newman. *Community Structure in Social and
13//!   Biological Networks*, PNAS 99, 7821 (2002).
14//!   <https://doi.org/10.1073/pnas.122653799>
15//! - M. E. J. Newman. *Analysis of Weighted Networks*,
16//!   Phys. Rev. E 70 (2004). <https://doi.org/10.1103/PhysRevE.70.056131>
17//!
18//! Handles **unweighted, undirected + directed** graphs. The weighted
19//! sibling is in `edge_betweenness_community_weighted.rs` (CO-006b for
20//! undirected, CO-006c for directed). Length-aware (separate length
21//! vector) remains a future AWU.
22//!
23//! Directed handling (CO-006c) follows the C reference:
24//! - traversal uses the OUT-incidence list (`Graph::incident`), back-edge
25//!   dependency accumulation uses the IN-incidence list (`Graph::incident_in`);
26//! - `edge_betweenness[i]` is **not** halved for directed (per the upstream
27//!   centrality convention — `if (!directed) eb /= 2.0;`);
28//! - per-level modularity uses `modularity_directed` (Leicht-Newman 2008)
29//!   so the best-Q cut reflects the directed adjacency.
30//!
31//! Pipeline mirrors the upstream loop:
32//! 1. Build a private mutable incidence list (one `Vec<EdgeId>` per
33//!    vertex). Edge IDs from the original graph stay valid — we only
34//!    mask removed edges instead of mutating the graph.
35//! 2. Repeat `m` times: run the Brandes unweighted edge-betweenness
36//!    pass over the **active** edges only, pick the edge with the
37//!    largest betweenness (ties broken by smallest id, matching the
38//!    upstream `igraph_i_which_max_active_ratio` tie behaviour), record
39//!    it in `removed_edges`, mark it passive, and pop it from both
40//!    endpoints' incidence lists.
41//! 3. Replay the removals in reverse to build the merge dendrogram.
42//!    Each removal that re-joins two distinct components is a *merge*;
43//!    cumulative modularity is evaluated after every merge and the
44//!    best-Q membership is reported.
45//!
46//! Complexity: `O(|V| * |E|^2)` — the Brandes pass per removal is the
47//! dominant cost. Matches the C reference.
48
49#![allow(
50    clippy::cast_possible_truncation,
51    clippy::cast_possible_wrap,
52    clippy::cast_precision_loss,
53    clippy::cast_sign_loss,
54    clippy::float_cmp,
55    clippy::items_after_statements,
56    clippy::many_single_char_names,
57    clippy::needless_range_loop,
58    clippy::too_many_lines
59)]
60
61use std::collections::VecDeque;
62
63use crate::algorithms::community::modularity::{modularity, modularity_directed};
64use crate::core::graph::EdgeId;
65use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
66
67/// Result of [`edge_betweenness_community`].
68#[derive(Debug, Clone)]
69pub struct EdgeBetweennessResult {
70    /// Per-vertex community label of the **best** partition along the
71    /// dendrogram (the one maximising modularity). Labels are densely
72    /// numbered in `0..nb_clusters`.
73    pub membership: Vec<u32>,
74    /// Number of distinct communities in `membership`.
75    pub nb_clusters: u32,
76    /// Edge IDs in the order they were removed (length = `ecount`).
77    /// Suitable as input to a separate dendrogram replay if a caller
78    /// wants to recompute partitions at other cut points.
79    pub removed_edges: Vec<EdgeId>,
80    /// Betweenness of each removed edge **at the moment of removal**
81    /// (same length and order as [`removed_edges`](Self::removed_edges)).
82    /// Halved for undirected graphs to match the centrality convention.
83    /// For directed graphs this is left un-halved, matching the upstream
84    /// `if (!directed) eb /= 2.0;` rule.
85    pub edge_betweenness: Vec<f64>,
86    /// Merges in dendrogram order. Each row `[c1, c2]` means clusters
87    /// `c1` and `c2` are merged into the new cluster `n + i` (where
88    /// `i` is the merge index and `n` is `vcount`). Same encoding as
89    /// `igraph_community_eb_get_merges()` / Walktrap.
90    pub merges: Vec<[u32; 2]>,
91    /// `bridges[i]` is the index into [`removed_edges`](Self::removed_edges)
92    /// of the edge whose removal triggered the *i*-th merge (i.e. the
93    /// edge that disconnected the network into one more component).
94    /// Reverse-order count: equal to the number of merges.
95    pub bridges: Vec<u32>,
96    /// Modularity at each level of the dendrogram. `modularity[0]` is
97    /// the modularity of the all-singletons partition, then one entry
98    /// per merge. Length = `merges.len() + 1`.
99    pub modularity: Vec<f64>,
100}
101
102/// Run edge-betweenness community detection on `graph`.
103///
104/// Returns the partition with the highest modularity along the
105/// Girvan-Newman dendrogram, plus the full removal history and merges
106/// so callers can replay alternative cuts. Accepts both undirected and
107/// directed graphs: the directed branch uses directed shortest paths
108/// (OUT-incidence forward, IN-incidence backward) and directed
109/// modularity (Leicht-Newman 2008) for the per-level Q.
110///
111/// # Errors
112/// Returns `IgraphError` only for internal-consistency failures (edge
113/// id overflow in the dendrogram replay, dangling adjacency entries).
114/// Both graph orientations are supported.
115///
116/// # Examples
117///
118/// ```
119/// use rust_igraph::{Graph, edge_betweenness_community};
120///
121/// // Two K3 triangles bridged by a single edge (0-1-2)-(3-4-5).
122/// // Girvan-Newman removes the bridge first, splitting into the two
123/// // expected communities.
124/// let mut g = Graph::with_vertices(6);
125/// for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
126///     g.add_edge(u, v).unwrap();
127/// }
128/// let r = edge_betweenness_community(&g).unwrap();
129/// assert_eq!(r.nb_clusters, 2);
130/// assert_eq!(r.membership[0], r.membership[1]);
131/// assert_eq!(r.membership[3], r.membership[5]);
132/// assert_ne!(r.membership[0], r.membership[3]);
133/// ```
134pub fn edge_betweenness_community(graph: &Graph) -> IgraphResult<EdgeBetweennessResult> {
135    let directed = graph.is_directed();
136    let n = graph.vcount();
137    let m_us = graph.ecount();
138    let n_us = n as usize;
139
140    // Null and edgeless graphs are well-defined: no merges, every vertex its
141    // own community, no removal history.
142    if n == 0 {
143        return Ok(EdgeBetweennessResult {
144            membership: Vec::new(),
145            nb_clusters: 0,
146            removed_edges: Vec::new(),
147            edge_betweenness: Vec::new(),
148            merges: Vec::new(),
149            bridges: Vec::new(),
150            modularity: Vec::new(),
151        });
152    }
153    if m_us == 0 {
154        // All singletons. Modularity is undefined (no edges); upstream
155        // returns NaN — we surface 0.0 for the single trivial level so
156        // downstream code can read `.modularity[0]` without flinching.
157        return Ok(EdgeBetweennessResult {
158            membership: (0..n).collect(),
159            nb_clusters: n,
160            removed_edges: Vec::new(),
161            edge_betweenness: Vec::new(),
162            merges: Vec::new(),
163            bridges: Vec::new(),
164            modularity: vec![0.0],
165        });
166    }
167
168    // --- Stage 1: Girvan-Newman removal order ---
169    //
170    // Build private mutable incidence lists keyed on the original edge
171    // ids. We never call `graph.delete_edges()`, so the original edge
172    // ids stay valid throughout.
173    //
174    // Directed graphs need two lists: `inc_out` for the BFS forward pass
175    // (`elist_out_p` in the C source) and `inc_in` for the backward
176    // dependency-accumulation pass (`elist_in_p`). Undirected graphs use
177    // a single list for both roles, exactly as the C aliases
178    // `elist_out_p = elist_in_p = &elist_out`.
179    let mut inc_out: Vec<Vec<EdgeId>> = (0..n)
180        .map(|v| graph.incident(v))
181        .collect::<IgraphResult<Vec<_>>>()?;
182    let mut inc_in: Vec<Vec<EdgeId>> = if directed {
183        (0..n)
184            .map(|v| graph.incident_in(v))
185            .collect::<IgraphResult<Vec<_>>>()?
186    } else {
187        Vec::new()
188    };
189    let mut passive: Vec<bool> = vec![false; m_us];
190
191    let mut removed_edges: Vec<EdgeId> = Vec::with_capacity(m_us);
192    let mut edge_betweenness_history: Vec<f64> = Vec::with_capacity(m_us);
193
194    // Scratch buffers for the Brandes pass (reused across iterations).
195    let mut sigma = vec![0.0_f64; n_us];
196    let mut dist = vec![-1_i64; n_us];
197    let mut pred: Vec<Vec<(VertexId, EdgeId)>> = vec![Vec::new(); n_us];
198    let mut stack: Vec<VertexId> = Vec::with_capacity(n_us);
199    let mut delta_v = vec![0.0_f64; n_us];
200    let mut eb_now = vec![0.0_f64; m_us];
201    let mut queue: VecDeque<VertexId> = VecDeque::with_capacity(n_us);
202
203    for _ in 0..m_us {
204        // Reset per-iteration edge-betweenness scores. Passive edges
205        // will simply never be incremented; the selection step skips
206        // them explicitly.
207        eb_now.fill(0.0);
208
209        // Brandes over active edges.
210        for s in 0..n {
211            sigma.fill(0.0);
212            dist.fill(-1);
213            for slot in &mut pred {
214                slot.clear();
215            }
216            stack.clear();
217            delta_v.fill(0.0);
218            queue.clear();
219
220            sigma[s as usize] = 1.0;
221            dist[s as usize] = 0;
222            queue.push_back(s);
223
224            while let Some(v) = queue.pop_front() {
225                stack.push(v);
226                // OUT-incidence forward (directed) or full incidence
227                // (undirected). For directed edges, the neighbour is the
228                // target `to`, not `edge_other`; for undirected, both
229                // endpoints appear and `edge_other` returns the correct
230                // one — see the C reference (`elist_out_p`) which uses
231                // OUT-incidence with `IGRAPH_LOOPS_ONCE` for directed and
232                // ALL-incidence with `IGRAPH_LOOPS_TWICE` for undirected.
233                for &e in &inc_out[v as usize] {
234                    let w = if directed {
235                        let (_from, to) = graph.edge(e)?;
236                        to
237                    } else {
238                        graph.edge_other(e, v)?
239                    };
240                    if dist[w as usize] < 0 {
241                        queue.push_back(w);
242                        dist[w as usize] = dist[v as usize] + 1;
243                    }
244                    if dist[w as usize] == dist[v as usize] + 1 {
245                        sigma[w as usize] += sigma[v as usize];
246                        pred[w as usize].push((v, e));
247                    }
248                }
249            }
250
251            while let Some(w) = stack.pop() {
252                for &(v, e) in &pred[w as usize] {
253                    let c = (sigma[v as usize] / sigma[w as usize]) * (1.0 + delta_v[w as usize]);
254                    delta_v[v as usize] += c;
255                    eb_now[e as usize] += c;
256                }
257            }
258        }
259
260        // Find the largest active betweenness. Ties broken by lowest
261        // edge id (matches the upstream linear scan from the first
262        // active index).
263        let mut max_eid: Option<EdgeId> = None;
264        let mut max_val = f64::NEG_INFINITY;
265        for e in 0..m_us {
266            if passive[e] {
267                continue;
268            }
269            let val = eb_now[e];
270            if val > max_val {
271                max_val = val;
272                max_eid = Some(e as EdgeId);
273            }
274        }
275        let eid = max_eid.ok_or(IgraphError::Internal(
276            "edge_betweenness_community: no active edge to remove",
277        ))?;
278        removed_edges.push(eid);
279        // Undirected: every unordered pair was counted from both ends —
280        // halve to match the centrality convention. Directed: keep the
281        // raw count (matches the C `if (!directed) eb /= 2.0;` rule).
282        edge_betweenness_history.push(if directed { max_val } else { max_val / 2.0 });
283        passive[eid as usize] = true;
284
285        // Detach the chosen edge from the incidence lists. Directed: pop
286        // it from `inc_out[from]` and `inc_in[to]`. Undirected: pop from
287        // both endpoints' `inc_out` (the only list). Self-loops appear
288        // twice in undirected incidence; we strip every occurrence.
289        let (from, to) = graph.edge(eid)?;
290        if directed {
291            inc_out[from as usize].retain(|&e| e != eid);
292            inc_in[to as usize].retain(|&e| e != eid);
293        } else {
294            for endpoint in [from, to] {
295                inc_out[endpoint as usize].retain(|&e| e != eid);
296            }
297        }
298    }
299
300    // --- Stage 2: replay merges + compute modularity per level ---
301
302    // `parent[i]` (1-indexed slot) is the union-find pointer used by
303    // the C `igraph_community_eb_get_merges` to walk to the cluster
304    // root; we also track the canonical "current cluster id" per
305    // vertex for the modularity-tracking variant.
306    //
307    // We need both `membership_now` (per-vertex current cluster id,
308    // used to compute modularity at each step) and the dendrogram
309    // pointer table.
310    let mut membership_now: Vec<u32> = (0..n).collect();
311    let mut merges: Vec<[u32; 2]> = Vec::new();
312    let mut bridges: Vec<u32> = Vec::new();
313    let mut modularity_levels: Vec<f64> = Vec::new();
314
315    // Initial all-singletons modularity (always defined for m > 0).
316    // Directed graphs use `modularity_directed` (Leicht-Newman 2008);
317    // undirected fall through `modularity_directed` to `modularity`, but
318    // we dispatch explicitly so the unweighted/undirected fast path
319    // stays a single hop.
320    let level_q = |mem: &[u32]| -> IgraphResult<f64> {
321        let opt = if directed {
322            modularity_directed(graph, mem, 1.0)?
323        } else {
324            modularity(graph, mem, 1.0)?
325        };
326        Ok(opt.unwrap_or(0.0))
327    };
328    let q0 = level_q(&membership_now)?;
329    modularity_levels.push(q0);
330    let mut max_mod = q0;
331    let mut best_membership: Vec<u32> = membership_now.clone();
332
333    // We need the cluster-id pointer too: vertex `v` currently sits in
334    // cluster `find(v)`. The C code holds this implicitly via the
335    // `mymembership` vector and rewrites it on each merge.
336    for (step, &eid) in removed_edges.iter().enumerate().rev() {
337        let (from, to) = graph.edge(eid)?;
338        let c1 = membership_now[from as usize];
339        let c2 = membership_now[to as usize];
340        if c1 == c2 {
341            continue;
342        }
343
344        // Record the merge: the new cluster gets id n + merge_index.
345        let merge_index = merges.len();
346        let new_cluster = n
347            .checked_add(merge_index as u32)
348            .ok_or(IgraphError::Internal(
349                "edge_betweenness_community: merge index overflow",
350            ))?;
351        merges.push([c1, c2]);
352        bridges.push(step as u32);
353
354        // Re-label everyone in c1 or c2 to the new cluster id.
355        for slot in &mut membership_now {
356            if *slot == c1 || *slot == c2 {
357                *slot = new_cluster;
358            }
359        }
360
361        let q = level_q(&membership_now)?;
362        modularity_levels.push(q);
363        if q > max_mod {
364            max_mod = q;
365            best_membership.clone_from(&membership_now);
366        }
367    }
368
369    // Densify the chosen partition's labels onto 0..nb_clusters.
370    let (membership_dense, nb_clusters) = densify_labels(&best_membership);
371
372    Ok(EdgeBetweennessResult {
373        membership: membership_dense,
374        nb_clusters,
375        removed_edges,
376        edge_betweenness: edge_betweenness_history,
377        merges,
378        bridges,
379        modularity: modularity_levels,
380    })
381}
382
383/// Reindex `labels` so the distinct values become `0..nb_clusters`,
384/// preserving first-appearance order. Returns the dense membership and
385/// the cluster count.
386fn densify_labels(labels: &[u32]) -> (Vec<u32>, u32) {
387    let mut remap: Vec<(u32, u32)> = Vec::new();
388    let mut out: Vec<u32> = Vec::with_capacity(labels.len());
389    for &lbl in labels {
390        let dense = if let Some(&(_, d)) = remap.iter().find(|(orig, _)| *orig == lbl) {
391            d
392        } else {
393            let d = remap.len() as u32;
394            remap.push((lbl, d));
395            d
396        };
397        out.push(dense);
398    }
399    let n_clusters = remap.len() as u32;
400    (out, n_clusters)
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    fn well_formed(r: &EdgeBetweennessResult, n: u32, m: usize) {
408        assert_eq!(r.membership.len() as u32, n, "membership length");
409        assert_eq!(r.removed_edges.len(), m, "removed_edges length");
410        assert_eq!(r.edge_betweenness.len(), m, "history length");
411        assert_eq!(r.merges.len(), r.bridges.len(), "merges/bridges");
412        assert_eq!(
413            r.modularity.len(),
414            r.merges.len() + 1,
415            "modularity = merges + 1"
416        );
417        for &lbl in &r.membership {
418            assert!(lbl < r.nb_clusters, "dense label in range");
419        }
420    }
421
422    #[test]
423    fn empty_graph_returns_empty_result() {
424        let g = Graph::with_vertices(0);
425        let r = edge_betweenness_community(&g).unwrap();
426        assert_eq!(r.nb_clusters, 0);
427        assert!(r.removed_edges.is_empty());
428        assert!(r.modularity.is_empty());
429    }
430
431    #[test]
432    fn edgeless_graph_returns_singletons() {
433        let g = Graph::with_vertices(4);
434        let r = edge_betweenness_community(&g).unwrap();
435        assert_eq!(r.nb_clusters, 4);
436        for v in 0..4 {
437            assert_eq!(r.membership[v as usize], v);
438        }
439        assert!(r.removed_edges.is_empty());
440        assert_eq!(r.modularity, vec![0.0]);
441    }
442
443    #[test]
444    fn two_triangles_bridge_splits_into_two() {
445        let mut g = Graph::with_vertices(6);
446        for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
447            g.add_edge(u, v).unwrap();
448        }
449        let r = edge_betweenness_community(&g).unwrap();
450        well_formed(&r, 6, 7);
451        assert_eq!(r.nb_clusters, 2);
452        assert_eq!(r.membership[0], r.membership[1]);
453        assert_eq!(r.membership[1], r.membership[2]);
454        assert_eq!(r.membership[3], r.membership[4]);
455        assert_eq!(r.membership[4], r.membership[5]);
456        assert_ne!(r.membership[0], r.membership[3]);
457        // Bridge edge (2,3) carries the largest betweenness on iter 1,
458        // so it is the first removal.
459        let (from0, to0) = g.edge(r.removed_edges[0]).unwrap();
460        assert!(
461            (from0, to0) == (2, 3) || (from0, to0) == (3, 2),
462            "first removed must be the bridge, got ({from0}, {to0})"
463        );
464    }
465
466    #[test]
467    fn path_4_splits_at_middle_edge() {
468        // 0-1-2-3: edge (1,2) has the highest betweenness → first removal,
469        // splitting into {0,1} and {2,3}. Best modularity should match.
470        let mut g = Graph::with_vertices(4);
471        for i in 0..3u32 {
472            g.add_edge(i, i + 1).unwrap();
473        }
474        let r = edge_betweenness_community(&g).unwrap();
475        well_formed(&r, 4, 3);
476        let (from0, to0) = g.edge(r.removed_edges[0]).unwrap();
477        assert!((from0, to0) == (1, 2) || (from0, to0) == (2, 1));
478        assert_eq!(r.membership[0], r.membership[1]);
479        assert_eq!(r.membership[2], r.membership[3]);
480        assert_ne!(r.membership[0], r.membership[2]);
481    }
482
483    #[test]
484    fn triangle_modularity_levels_are_in_range() {
485        // K3 has no good partition; modularity is bounded in [-1/2, 0].
486        let mut g = Graph::with_vertices(3);
487        g.add_edge(0, 1).unwrap();
488        g.add_edge(1, 2).unwrap();
489        g.add_edge(2, 0).unwrap();
490        let r = edge_betweenness_community(&g).unwrap();
491        well_formed(&r, 3, 3);
492        for &q in &r.modularity {
493            assert!((-0.501..=0.001).contains(&q), "modularity {q} out of range");
494        }
495    }
496
497    #[test]
498    fn two_components_already_split_no_bridges_between_them() {
499        // 0-1 and 2-3-4 → already two components; the algorithm still
500        // strips every edge, but the best partition matches the natural
501        // split into {0,1} and {2,3,4}.
502        let mut g = Graph::with_vertices(5);
503        g.add_edge(0, 1).unwrap();
504        g.add_edge(2, 3).unwrap();
505        g.add_edge(3, 4).unwrap();
506        let r = edge_betweenness_community(&g).unwrap();
507        well_formed(&r, 5, 3);
508        assert!(r.nb_clusters >= 2);
509        assert_eq!(r.membership[0], r.membership[1]);
510        assert_eq!(r.membership[2], r.membership[3]);
511        assert_eq!(r.membership[3], r.membership[4]);
512        assert_ne!(r.membership[0], r.membership[2]);
513    }
514
515    #[test]
516    fn dendrogram_merges_at_most_n_minus_components() {
517        // 4-cycle: 0-1-2-3-0. One component, so at most n-1 = 3 merges.
518        let mut g = Graph::with_vertices(4);
519        for i in 0..4u32 {
520            g.add_edge(i, (i + 1) % 4).unwrap();
521        }
522        let r = edge_betweenness_community(&g).unwrap();
523        assert!(r.merges.len() <= 3);
524        well_formed(&r, 4, 4);
525    }
526
527    #[test]
528    fn directed_path_6_middle_edge_first_split() {
529        // Directed 6-path 0→1→2→3→4→5: edge (2,3) is uniquely the
530        // highest-betweenness edge (it lies on 9 of the 15 reachable
531        // (s,t) pairs vs. 8 for (1,2)/(3,4)). It is removed first and
532        // splits into {0,1,2}|{3,4,5}. Per-level directed modularity
533        // peaks at 8/25 = 0.32 there.
534        let mut g = Graph::new(6, true).unwrap();
535        for i in 0..5u32 {
536            g.add_edge(i, i + 1).unwrap();
537        }
538        let r = edge_betweenness_community(&g).unwrap();
539        well_formed(&r, 6, 5);
540        let (from0, to0) = g.edge(r.removed_edges[0]).unwrap();
541        assert_eq!((from0, to0), (2, 3), "bridge edge must be removed first");
542        assert_eq!(r.nb_clusters, 2);
543        assert_eq!(r.membership[0], r.membership[1]);
544        assert_eq!(r.membership[1], r.membership[2]);
545        assert_eq!(r.membership[3], r.membership[4]);
546        assert_eq!(r.membership[4], r.membership[5]);
547        assert_ne!(r.membership[0], r.membership[3]);
548        // Hand-checked best directed Q.
549        let best = r
550            .modularity
551            .iter()
552            .copied()
553            .fold(f64::NEG_INFINITY, f64::max);
554        assert!(
555            (best - 8.0 / 25.0).abs() < 1e-9,
556            "expected best directed Q ≈ 8/25, got {best}"
557        );
558    }
559
560    #[test]
561    fn directed_two_triangles_bridge_runs_cleanly() {
562        // Directed analogue of two-K3-bridge: 0→1→2→0 + 3→4→5→3 + 2→3.
563        // The cycle structure produces a 3-way Brandes tie at the
564        // central edges (1,2)/(2,3)/(3,4), so tie-breaking by lowest
565        // edge id removes (1,2) first rather than the bridge — every
566        // intermediate dendrogram level then has negative directed Q,
567        // so the algorithm correctly picks the trivial all-one cluster.
568        // Documented here so a future change in tie-breaking is caught.
569        let mut g = Graph::new(6, true).unwrap();
570        for &(u, v) in &[(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (2, 3)] {
571            g.add_edge(u, v).unwrap();
572        }
573        let r = edge_betweenness_community(&g).unwrap();
574        well_formed(&r, 6, 7);
575        for &q in &r.modularity {
576            assert!(q.is_finite(), "directed modularity is finite");
577            assert!((-1.0..=1.0).contains(&q), "directed Q in plausible range");
578        }
579    }
580
581    #[test]
582    fn directed_betweenness_is_not_halved() {
583        // Directed path 0→1→2→3: edge (1,2) lies on the directed shortest
584        // paths 0→{2,3} and 1→{2,3} → unhalved count is 4.0 (vs. 2.0 for
585        // an undirected path, which would be halved).
586        let mut g = Graph::new(4, true).unwrap();
587        g.add_edge(0, 1).unwrap();
588        g.add_edge(1, 2).unwrap();
589        g.add_edge(2, 3).unwrap();
590        let r = edge_betweenness_community(&g).unwrap();
591        well_formed(&r, 4, 3);
592        // First removal is the middle edge with max betweenness.
593        let (from0, to0) = g.edge(r.removed_edges[0]).unwrap();
594        assert_eq!((from0, to0), (1, 2));
595        // Unhalved Brandes count for (1,2) on this 4-path is 4
596        // (4 source-target pairs route through it: 0→2, 0→3, 1→2, 1→3).
597        assert!(
598            (r.edge_betweenness[0] - 4.0).abs() < 1e-9,
599            "expected unhalved eb=4.0, got {}",
600            r.edge_betweenness[0]
601        );
602    }
603
604    #[test]
605    fn directed_disconnected_components_yield_singletons_or_components() {
606        // 0→1 and 2→3→4 — two weakly connected components, no bridges
607        // between them. The natural directed cut keeps each component.
608        let mut g = Graph::new(5, true).unwrap();
609        g.add_edge(0, 1).unwrap();
610        g.add_edge(2, 3).unwrap();
611        g.add_edge(3, 4).unwrap();
612        let r = edge_betweenness_community(&g).unwrap();
613        well_formed(&r, 5, 3);
614        assert!(r.nb_clusters >= 2);
615        assert_ne!(r.membership[0], r.membership[2]);
616    }
617
618    #[test]
619    fn densify_labels_preserves_first_appearance_order() {
620        let (dense, n) = densify_labels(&[7, 7, 4, 4, 7, 9, 4, 9]);
621        assert_eq!(n, 3);
622        assert_eq!(dense, vec![0, 0, 1, 1, 0, 2, 1, 2]);
623    }
624}