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}