Skip to main content

rust_igraph/algorithms/community/
edge_betweenness_community_weighted.rs

1//! Weighted edge-betweenness community detection (ALGO-CO-006b).
2//!
3//! Counterpart of `igraph_community_edge_betweenness(..., weights=&w, ...)`
4//! from `references/igraph/src/community/edge_betweenness.c`.
5//!
6//! Same Girvan-Newman framework as the unweighted CO-006
7//! (`edge_betweenness_community`): iteratively strip the edge with the
8//! highest current betweenness, then replay removals in reverse to
9//! build the binary dendrogram and surface the best-modularity
10//! partition. The only differences against the unweighted slice:
11//!
12//! - Per-removal betweenness is computed via Brandes over Dijkstra
13//!   shortest paths (`edge_betweenness_weighted` style) rather than the
14//!   BFS shortest paths used in CO-006.
15//! - Modularity at every dendrogram level uses [`modularity_weighted`]
16//!   (undirected) or [`modularity_weighted_directed`] (directed) so the
17//!   merge score reflects the weighted edge sums (`m = Σ_e w_e`).
18//! - Weights must be non-negative + finite; weight vector length must
19//!   equal `graph.ecount()`. Both constraints surface as
20//!   `IgraphError::InvalidArgument`.
21//! - Directed graphs (CO-006c) use OUT-incidence for the Dijkstra
22//!   forward pass and IN-incidence for the dependency-accumulation
23//!   back pass; `edge_betweenness[i]` is **not** halved (matches the
24//!   upstream `if (!directed) eb /= 2.0;` rule).
25//!
26//! Complexity: `O(|V| * |E| * (|E| + |V| log |V|))` — the per-removal
27//! Dijkstra-Brandes pass dominates.
28
29#![allow(
30    clippy::cast_possible_truncation,
31    clippy::cast_possible_wrap,
32    clippy::cast_precision_loss,
33    clippy::cast_sign_loss,
34    clippy::float_cmp,
35    clippy::items_after_statements,
36    clippy::many_single_char_names,
37    clippy::needless_range_loop,
38    clippy::too_many_lines
39)]
40
41use std::cmp::Ordering;
42use std::collections::BinaryHeap;
43
44use crate::algorithms::community::edge_betweenness_community::EdgeBetweennessResult;
45use crate::algorithms::community::modularity::{modularity_weighted, modularity_weighted_directed};
46use crate::core::graph::EdgeId;
47use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
48
49/// Min-heap entry. Reversed ordering so `BinaryHeap` (max-heap) pops
50/// the smallest distance first. NaN / negative weights are rejected by
51/// the entry point so `total_cmp` is safe.
52#[derive(Copy, Clone)]
53struct Frontier(f64, VertexId);
54
55impl PartialEq for Frontier {
56    fn eq(&self, other: &Self) -> bool {
57        self.0 == other.0 && self.1 == other.1
58    }
59}
60impl Eq for Frontier {}
61impl Ord for Frontier {
62    fn cmp(&self, other: &Self) -> Ordering {
63        other.0.total_cmp(&self.0).then(other.1.cmp(&self.1))
64    }
65}
66impl PartialOrd for Frontier {
67    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
68        Some(self.cmp(other))
69    }
70}
71
72/// Run weighted edge-betweenness community detection on `graph` with
73/// per-edge `weights`.
74///
75/// Returns the same [`EdgeBetweennessResult`] shape as the unweighted
76/// CO-006 entrypoint. `edge_betweenness[i]` is the **weighted**
77/// betweenness of the *i*-th removed edge at the moment of removal
78/// (halved for undirected to match the centrality convention, left
79/// un-halved for directed). Per-level modularity uses
80/// [`modularity_weighted`] (undirected) or
81/// [`modularity_weighted_directed`] (directed) so the best-Q partition
82/// reflects edge weights, not just edge counts.
83///
84/// Accepts both undirected and directed graphs: the directed branch
85/// uses directed Dijkstra (OUT-incidence forward, IN-incidence
86/// backward) and directed weighted modularity (Leicht-Newman 2008).
87///
88/// # Errors
89/// - [`IgraphError::InvalidArgument`] if `weights.len() != ecount`,
90///   or if any weight is NaN, negative, or non-finite.
91///
92/// # Examples
93///
94/// ```
95/// use rust_igraph::{Graph, edge_betweenness_community_weighted};
96///
97/// // Two K3 triangles bridged by edge (2,3). Weights = 1.0 everywhere
98/// // ⇒ identical result to the unweighted slice (CO-006).
99/// let mut g = Graph::with_vertices(6);
100/// for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
101///     g.add_edge(u, v).unwrap();
102/// }
103/// let w = vec![1.0_f64; g.ecount()];
104/// let r = edge_betweenness_community_weighted(&g, &w).unwrap();
105/// assert_eq!(r.nb_clusters, 2);
106/// assert_eq!(r.membership[0], r.membership[1]);
107/// assert_eq!(r.membership[3], r.membership[5]);
108/// assert_ne!(r.membership[0], r.membership[3]);
109/// ```
110pub fn edge_betweenness_community_weighted(
111    graph: &Graph,
112    weights: &[f64],
113) -> IgraphResult<EdgeBetweennessResult> {
114    let directed = graph.is_directed();
115    let n = graph.vcount();
116    let m_us = graph.ecount();
117    let n_us = n as usize;
118
119    // Null and edgeless graphs follow the same well-defined trivial-result
120    // contract as the unweighted slice.
121    if n == 0 {
122        return Ok(EdgeBetweennessResult {
123            membership: Vec::new(),
124            nb_clusters: 0,
125            removed_edges: Vec::new(),
126            edge_betweenness: Vec::new(),
127            merges: Vec::new(),
128            bridges: Vec::new(),
129            modularity: Vec::new(),
130        });
131    }
132    if m_us == 0 {
133        if !weights.is_empty() {
134            return Err(IgraphError::InvalidArgument(format!(
135                "weights vector size ({}) differs from edge count (0)",
136                weights.len(),
137            )));
138        }
139        return Ok(EdgeBetweennessResult {
140            membership: (0..n).collect(),
141            nb_clusters: n,
142            removed_edges: Vec::new(),
143            edge_betweenness: Vec::new(),
144            merges: Vec::new(),
145            bridges: Vec::new(),
146            modularity: vec![0.0],
147        });
148    }
149
150    // Validate weights up front: same contract as edge_betweenness_weighted
151    // / modularity_weighted.
152    if weights.len() != m_us {
153        return Err(IgraphError::InvalidArgument(format!(
154            "weights vector size ({}) differs from edge count ({})",
155            weights.len(),
156            m_us
157        )));
158    }
159    for (e, &w) in weights.iter().enumerate() {
160        if w.is_nan() || !w.is_finite() || w < 0.0 {
161            return Err(IgraphError::InvalidArgument(format!(
162                "weight at edge {e} must be non-negative and finite (got {w})"
163            )));
164        }
165    }
166
167    // --- Stage 1: weighted Girvan-Newman removal order ---
168    //
169    // Directed graphs need two adjacency lists: `inc_out` for the
170    // Dijkstra forward pass (`elist_out_p` in the C source) and
171    // `inc_in` for the back-pass dependency accumulation
172    // (`elist_in_p`). Undirected uses a single list for both, exactly
173    // as the C aliases `elist_out_p = elist_in_p = &elist_out`.
174    let mut inc_out: Vec<Vec<EdgeId>> = (0..n)
175        .map(|v| graph.incident(v))
176        .collect::<IgraphResult<Vec<_>>>()?;
177    let mut inc_in: Vec<Vec<EdgeId>> = if directed {
178        (0..n)
179            .map(|v| graph.incident_in(v))
180            .collect::<IgraphResult<Vec<_>>>()?
181    } else {
182        Vec::new()
183    };
184    let mut passive: Vec<bool> = vec![false; m_us];
185
186    let mut removed_edges: Vec<EdgeId> = Vec::with_capacity(m_us);
187    let mut edge_betweenness_history: Vec<f64> = Vec::with_capacity(m_us);
188
189    // Brandes-Dijkstra scratch buffers (reused across removals).
190    let mut sigma = vec![0.0_f64; n_us];
191    let mut dist = vec![f64::INFINITY; n_us];
192    let mut visited = vec![false; n_us];
193    let mut pred: Vec<Vec<(VertexId, EdgeId)>> = vec![Vec::new(); n_us];
194    let mut stack: Vec<VertexId> = Vec::with_capacity(n_us);
195    let mut delta_v = vec![0.0_f64; n_us];
196    let mut eb_now = vec![0.0_f64; m_us];
197
198    for _ in 0..m_us {
199        eb_now.fill(0.0);
200
201        // Brandes over Dijkstra shortest paths, active edges only.
202        for s in 0..n {
203            sigma.fill(0.0);
204            dist.fill(f64::INFINITY);
205            visited.fill(false);
206            for slot in &mut pred {
207                slot.clear();
208            }
209            stack.clear();
210            delta_v.fill(0.0);
211
212            sigma[s as usize] = 1.0;
213            dist[s as usize] = 0.0;
214            let mut heap: BinaryHeap<Frontier> = BinaryHeap::new();
215            heap.push(Frontier(0.0, s));
216
217            while let Some(Frontier(d, v)) = heap.pop() {
218                let v_us = v as usize;
219                if visited[v_us] {
220                    continue;
221                }
222                visited[v_us] = true;
223                stack.push(v);
224
225                for &eid in &inc_out[v_us] {
226                    let w_edge = weights[eid as usize];
227                    let other = if directed {
228                        let (_from, to) = graph.edge(eid)?;
229                        to
230                    } else {
231                        graph.edge_other(eid, v)?
232                    };
233                    let other_us = other as usize;
234                    let nd = d + w_edge;
235                    match nd.partial_cmp(&dist[other_us]) {
236                        Some(Ordering::Less) => {
237                            dist[other_us] = nd;
238                            sigma[other_us] = sigma[v_us];
239                            pred[other_us].clear();
240                            pred[other_us].push((v, eid));
241                            heap.push(Frontier(nd, other));
242                        }
243                        Some(Ordering::Equal) => {
244                            sigma[other_us] += sigma[v_us];
245                            pred[other_us].push((v, eid));
246                        }
247                        _ => {}
248                    }
249                }
250            }
251
252            while let Some(w) = stack.pop() {
253                let w_us = w as usize;
254                for &(v, e) in &pred[w_us] {
255                    let c = (sigma[v as usize] / sigma[w_us]) * (1.0 + delta_v[w_us]);
256                    delta_v[v as usize] += c;
257                    eb_now[e as usize] += c;
258                }
259            }
260        }
261
262        // Tie-break: largest weighted betweenness, ties → smallest active
263        // edge id (matches the upstream linear scan).
264        let mut max_eid: Option<EdgeId> = None;
265        let mut max_val = f64::NEG_INFINITY;
266        for e in 0..m_us {
267            if passive[e] {
268                continue;
269            }
270            let val = eb_now[e];
271            if val > max_val {
272                max_val = val;
273                max_eid = Some(e as EdgeId);
274            }
275        }
276        let eid = max_eid.ok_or(IgraphError::Internal(
277            "edge_betweenness_community_weighted: no active edge to remove",
278        ))?;
279        removed_edges.push(eid);
280        // Undirected: halve to match the centrality convention.
281        // Directed: leave raw (matches the C `if (!directed) eb /= 2.0;`).
282        edge_betweenness_history.push(if directed { max_val } else { max_val / 2.0 });
283        passive[eid as usize] = true;
284
285        let (from, to) = graph.edge(eid)?;
286        if directed {
287            inc_out[from as usize].retain(|&e| e != eid);
288            inc_in[to as usize].retain(|&e| e != eid);
289        } else {
290            for endpoint in [from, to] {
291                inc_out[endpoint as usize].retain(|&e| e != eid);
292            }
293        }
294    }
295
296    // --- Stage 2: replay merges + weighted modularity per level ---
297
298    let mut membership_now: Vec<u32> = (0..n).collect();
299    let mut merges: Vec<[u32; 2]> = Vec::new();
300    let mut bridges: Vec<u32> = Vec::new();
301    let mut modularity_levels: Vec<f64> = Vec::new();
302
303    let level_q = |mem: &[u32]| -> IgraphResult<f64> {
304        let opt = if directed {
305            modularity_weighted_directed(graph, mem, 1.0, weights)?
306        } else {
307            modularity_weighted(graph, mem, 1.0, weights)?
308        };
309        Ok(opt.unwrap_or(0.0))
310    };
311    let q0 = level_q(&membership_now)?;
312    modularity_levels.push(q0);
313    let mut max_mod = q0;
314    let mut best_membership: Vec<u32> = membership_now.clone();
315
316    for (step, &eid) in removed_edges.iter().enumerate().rev() {
317        let (from, to) = graph.edge(eid)?;
318        let c1 = membership_now[from as usize];
319        let c2 = membership_now[to as usize];
320        if c1 == c2 {
321            continue;
322        }
323
324        let merge_index = merges.len();
325        let new_cluster = n
326            .checked_add(merge_index as u32)
327            .ok_or(IgraphError::Internal(
328                "edge_betweenness_community_weighted: merge index overflow",
329            ))?;
330        merges.push([c1, c2]);
331        bridges.push(step as u32);
332
333        for slot in &mut membership_now {
334            if *slot == c1 || *slot == c2 {
335                *slot = new_cluster;
336            }
337        }
338
339        let q = level_q(&membership_now)?;
340        modularity_levels.push(q);
341        if q > max_mod {
342            max_mod = q;
343            best_membership.clone_from(&membership_now);
344        }
345    }
346
347    let (membership_dense, nb_clusters) = densify_labels(&best_membership);
348
349    Ok(EdgeBetweennessResult {
350        membership: membership_dense,
351        nb_clusters,
352        removed_edges,
353        edge_betweenness: edge_betweenness_history,
354        merges,
355        bridges,
356        modularity: modularity_levels,
357    })
358}
359
360/// Reindex `labels` so distinct values become `0..nb_clusters`,
361/// preserving first-appearance order. (Mirror of the helper in the
362/// unweighted module — kept private here so the two SOP slices stay
363/// independent.)
364fn densify_labels(labels: &[u32]) -> (Vec<u32>, u32) {
365    let mut remap: Vec<(u32, u32)> = Vec::new();
366    let mut out: Vec<u32> = Vec::with_capacity(labels.len());
367    for &lbl in labels {
368        let dense = if let Some(&(_, d)) = remap.iter().find(|(orig, _)| *orig == lbl) {
369            d
370        } else {
371            let d = remap.len() as u32;
372            remap.push((lbl, d));
373            d
374        };
375        out.push(dense);
376    }
377    let n_clusters = remap.len() as u32;
378    (out, n_clusters)
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crate::algorithms::community::edge_betweenness_community::edge_betweenness_community;
385
386    fn well_formed(r: &EdgeBetweennessResult, n: u32, m: usize) {
387        assert_eq!(r.membership.len() as u32, n, "membership length");
388        assert_eq!(r.removed_edges.len(), m, "removed_edges length");
389        assert_eq!(r.edge_betweenness.len(), m, "history length");
390        assert_eq!(r.merges.len(), r.bridges.len(), "merges/bridges");
391        assert_eq!(
392            r.modularity.len(),
393            r.merges.len() + 1,
394            "modularity = merges + 1"
395        );
396        for &lbl in &r.membership {
397            assert!(lbl < r.nb_clusters, "dense label in range");
398        }
399    }
400
401    #[test]
402    fn empty_graph_returns_empty_result() {
403        let g = Graph::with_vertices(0);
404        let r = edge_betweenness_community_weighted(&g, &[]).unwrap();
405        assert_eq!(r.nb_clusters, 0);
406        assert!(r.removed_edges.is_empty());
407        assert!(r.modularity.is_empty());
408    }
409
410    #[test]
411    fn edgeless_graph_returns_singletons() {
412        let g = Graph::with_vertices(4);
413        let r = edge_betweenness_community_weighted(&g, &[]).unwrap();
414        assert_eq!(r.nb_clusters, 4);
415        for v in 0..4 {
416            assert_eq!(r.membership[v as usize], v);
417        }
418        assert!(r.removed_edges.is_empty());
419        assert_eq!(r.modularity, vec![0.0]);
420    }
421
422    #[test]
423    fn two_triangles_bridge_unit_weights_split_into_two() {
424        let mut g = Graph::with_vertices(6);
425        for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
426            g.add_edge(u, v).unwrap();
427        }
428        let w = vec![1.0_f64; g.ecount()];
429        let r = edge_betweenness_community_weighted(&g, &w).unwrap();
430        well_formed(&r, 6, 7);
431        assert_eq!(r.nb_clusters, 2);
432        let (from0, to0) = g.edge(r.removed_edges[0]).unwrap();
433        assert!(
434            (from0, to0) == (2, 3) || (from0, to0) == (3, 2),
435            "first removed must be the bridge, got ({from0}, {to0})"
436        );
437    }
438
439    #[test]
440    fn unit_weights_match_unweighted_path_5() {
441        let mut g = Graph::with_vertices(5);
442        for i in 0..4u32 {
443            g.add_edge(i, i + 1).unwrap();
444        }
445        let w = vec![1.0_f64; g.ecount()];
446        let rw = edge_betweenness_community_weighted(&g, &w).unwrap();
447        let ru = edge_betweenness_community(&g).unwrap();
448        assert_eq!(rw.nb_clusters, ru.nb_clusters);
449        assert_eq!(rw.merges, ru.merges);
450        assert_eq!(rw.removed_edges, ru.removed_edges);
451        // Per-step modularity is the unweighted-equivalent (m = ecount).
452        for (a, b) in rw.modularity.iter().zip(ru.modularity.iter()) {
453            assert!((a - b).abs() < 1e-9, "modularity mismatch: {a} vs {b}");
454        }
455    }
456
457    #[test]
458    fn cheap_bridge_still_removed_first() {
459        // Two K3 triangles joined by edge (2,3). Bridge weight 0.1 means
460        // it is the cheapest path between any (0..2) and (3..5) vertex,
461        // so it sits on every cross-triangle shortest path and carries
462        // the largest weighted betweenness ⇒ first removal.
463        let mut g = Graph::with_vertices(6);
464        for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
465            g.add_edge(u, v).unwrap();
466        }
467        let weights = vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.1];
468        let r = edge_betweenness_community_weighted(&g, &weights).unwrap();
469        well_formed(&r, 6, 7);
470        let (from0, to0) = g.edge(r.removed_edges[0]).unwrap();
471        assert!(
472            (from0, to0) == (2, 3) || (from0, to0) == (3, 2),
473            "weighted-first-removed must be the bridge, got ({from0}, {to0})"
474        );
475    }
476
477    #[test]
478    fn directed_unit_weights_match_unweighted_path_6() {
479        // Directed 6-path: weighted run with unit weights must produce
480        // the exact same dendrogram as the unweighted entrypoint.
481        let mut g = Graph::new(6, true).unwrap();
482        for i in 0..5u32 {
483            g.add_edge(i, i + 1).unwrap();
484        }
485        let w = vec![1.0_f64; g.ecount()];
486        let rw = edge_betweenness_community_weighted(&g, &w).unwrap();
487        let ru = edge_betweenness_community(&g).unwrap();
488        assert_eq!(rw.nb_clusters, ru.nb_clusters);
489        assert_eq!(rw.removed_edges, ru.removed_edges);
490        assert_eq!(rw.merges, ru.merges);
491        for (a, b) in rw.modularity.iter().zip(ru.modularity.iter()) {
492            assert!((a - b).abs() < 1e-9, "modularity mismatch: {a} vs {b}");
493        }
494    }
495
496    #[test]
497    fn directed_weighted_betweenness_is_not_halved() {
498        // Directed 4-path 0→1→2→3 with unit weights: edge (1,2) lies
499        // on 4 source-target pairs (0→2, 0→3, 1→2, 1→3); the C
500        // reference reports an un-halved 4.0.
501        let mut g = Graph::new(4, true).unwrap();
502        for i in 0..3u32 {
503            g.add_edge(i, i + 1).unwrap();
504        }
505        let r = edge_betweenness_community_weighted(&g, &[1.0; 3]).unwrap();
506        let (from0, to0) = g.edge(r.removed_edges[0]).unwrap();
507        assert_eq!((from0, to0), (1, 2));
508        assert!(
509            (r.edge_betweenness[0] - 4.0).abs() < 1e-9,
510            "expected unhalved eb=4.0, got {}",
511            r.edge_betweenness[0]
512        );
513    }
514
515    #[test]
516    fn weights_size_mismatch_errors() {
517        let mut g = Graph::with_vertices(2);
518        g.add_edge(0, 1).unwrap();
519        assert!(edge_betweenness_community_weighted(&g, &[]).is_err());
520    }
521
522    #[test]
523    fn negative_weight_errors() {
524        let mut g = Graph::with_vertices(2);
525        g.add_edge(0, 1).unwrap();
526        let err = edge_betweenness_community_weighted(&g, &[-1.0]).unwrap_err();
527        assert!(matches!(err, IgraphError::InvalidArgument(_)));
528    }
529
530    #[test]
531    fn nan_weight_errors() {
532        let mut g = Graph::with_vertices(2);
533        g.add_edge(0, 1).unwrap();
534        let err = edge_betweenness_community_weighted(&g, &[f64::NAN]).unwrap_err();
535        assert!(matches!(err, IgraphError::InvalidArgument(_)));
536    }
537
538    #[test]
539    fn dendrogram_at_most_n_minus_components() {
540        let mut g = Graph::with_vertices(4);
541        for i in 0..4u32 {
542            g.add_edge(i, (i + 1) % 4).unwrap();
543        }
544        let r = edge_betweenness_community_weighted(&g, &[1.0; 4]).unwrap();
545        assert!(r.merges.len() <= 3);
546    }
547
548    #[test]
549    fn densify_labels_preserves_first_appearance_order() {
550        let (dense, n) = densify_labels(&[7, 7, 4, 4, 7, 9, 4, 9]);
551        assert_eq!(n, 3);
552        assert_eq!(dense, vec![0, 0, 1, 1, 0, 2, 1, 2]);
553    }
554}