Skip to main content

rust_igraph/algorithms/community/
modularity.rs

1//! Modularity (ALGO-CO-001).
2//!
3//! Counterpart of `igraph_modularity()` from
4//! `references/igraph/src/community/modularity.c`.
5//!
6//! Newman-Girvan modularity of a partition `c`:
7//!
8//! ```text
9//! Q = (1/2m) Σ_{ij} (A_ij − γ k_i k_j / 2m) δ(c_i, c_j)
10//! ```
11//!
12//! where `m = ecount`, `A_ij` is the adjacency matrix (each undirected
13//! edge counts 2 — diagonal counts twice the number of self-loops),
14//! `k_i` is the degree of vertex `i`, and `γ` is the resolution
15//! parameter (1.0 recovers the classical definition).
16//!
17//! Phase-1 minimal slice: **undirected, unweighted**. Directed and
18//! weighted variants land later (ALGO-CO-001b/c) — directed needs the
19//! `(k_out_i k_in_j) / m` denominator (Leicht-Newman 2008) and
20//! weighted needs the strength sum to replace `m`. Both stem from the
21//! same loop body so the extension is mechanical once `Graph` exposes
22//! in/out degree separately.
23
24use crate::core::graph::EdgeId;
25use crate::core::{Graph, IgraphError, IgraphResult};
26
27/// Modularity of `graph` with respect to community assignment `membership`.
28///
29/// `membership[v]` is the integer community label of vertex `v`; labels
30/// need not be consecutive (we reindex internally). Returns `None` for
31/// `ecount == 0` (modularity is undefined — matches upstream's NaN).
32///
33/// # Errors
34/// - [`IgraphError::InvalidArgument`] if `membership.len() != vcount()`
35///   or if `resolution < 0`.
36/// - [`IgraphError::Unsupported`] for directed graphs (Phase-1 ships
37///   the undirected slice; directed mode lands in ALGO-CO-001b).
38///
39/// # Examples
40///
41/// ```
42/// use rust_igraph::{Graph, modularity};
43///
44/// // Two K3 triangles plus a single bridge edge: communities [0,0,0,1,1,1]
45/// // give a high (positive) modularity.
46/// let mut g = Graph::with_vertices(6);
47/// for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
48///     g.add_edge(u, v).unwrap();
49/// }
50/// let q = modularity(&g, &[0, 0, 0, 1, 1, 1], 1.0).unwrap();
51/// assert!(q.is_some());
52/// assert!(q.unwrap() > 0.3);
53/// ```
54pub fn modularity(graph: &Graph, membership: &[u32], resolution: f64) -> IgraphResult<Option<f64>> {
55    if graph.is_directed() {
56        return Err(IgraphError::Unsupported(
57            "directed modularity is ALGO-CO-001b; not yet ported",
58        ));
59    }
60    let n = graph.vcount() as usize;
61    if membership.len() != n {
62        return Err(IgraphError::InvalidArgument(
63            "membership vector size differs from number of vertices".to_string(),
64        ));
65    }
66    if !resolution.is_finite() || resolution < 0.0 {
67        return Err(IgraphError::InvalidArgument(
68            "resolution parameter must be non-negative and finite".to_string(),
69        ));
70    }
71
72    let ecount = graph.ecount();
73    if ecount == 0 {
74        return Ok(None);
75    }
76
77    // Reindex labels onto [0, no_of_partitions).
78    let max_label = membership.iter().copied().max().unwrap_or(0);
79    let mut remap: Vec<Option<u32>> = vec![None; max_label as usize + 1];
80    let mut next_id: u32 = 0;
81    let mut reindexed: Vec<u32> = Vec::with_capacity(n);
82    for &lbl in membership {
83        let slot = lbl as usize;
84        if remap[slot].is_none() {
85            remap[slot] = Some(next_id);
86            next_id += 1;
87        }
88        let Some(id) = remap[slot] else {
89            return Err(IgraphError::Internal(
90                "membership reindex failed: missing label",
91            ));
92        };
93        reindexed.push(id);
94    }
95    let no_of_partitions = next_id as usize;
96
97    // Sum of degrees per partition (k_out + k_in for undirected graphs;
98    // we accumulate into a single `k` since the contributions are equal).
99    let mut k_part = vec![0.0_f64; no_of_partitions];
100    let mut e_internal = 0.0_f64; // edges with both endpoints in same partition
101
102    let m_u32 =
103        u32::try_from(ecount).map_err(|_| IgraphError::Internal("ecount exceeds u32::MAX"))?;
104    for eid in 0..m_u32 {
105        let (src, dst) = graph.edge(eid as EdgeId)?;
106        let cu = reindexed[src as usize];
107        let cv = reindexed[dst as usize];
108        if cu == cv {
109            // Undirected: each internal edge contributes `directed_multiplier=2`
110            // to e (matches C reference at modularity.c:198).
111            e_internal += 2.0;
112        }
113        // For undirected, k_out and k_in collapse into the same vector
114        // after the C code's `igraph_vector_add(&k_out, &k_in)`. We
115        // anticipate that by adding 1 for each endpoint here.
116        k_part[cu as usize] += 1.0;
117        k_part[cv as usize] += 1.0;
118    }
119
120    #[allow(clippy::cast_precision_loss)]
121    let m_f = ecount as f64;
122    let two_m = 2.0 * m_f;
123    // Normalise by 2m.
124    for slot in &mut k_part {
125        *slot /= two_m;
126    }
127    let e_norm = e_internal / two_m;
128
129    let mut q = e_norm;
130    for &kc in &k_part {
131        q -= resolution * kc * kc;
132    }
133    Ok(Some(q))
134}
135
136/// Weighted modularity (ALGO-CO-001c).
137///
138/// Counterpart of `igraph_modularity(_, _, &weights, resolution,
139/// /*directed=*/false, _)`. Same Newman-Girvan formula as
140/// [`modularity`] but with edge weights replacing the unit
141/// adjacency: `s_v = Σ w_e` over incident edges (self-loops
142/// contribute `2w` per upstream `IGRAPH_LOOPS`), `W = Σ w_e`, and
143///
144/// ```text
145/// Q_w = (1 / 2W) Σ_{ij} (w_ij − γ s_i s_j / 2W) δ(c_i, c_j)
146/// ```
147///
148/// `weights.len()` must equal `graph.ecount()`. Returns `None` for
149/// graphs with `ecount == 0` or `W == 0` (both modularity-undefined,
150/// matches upstream NaN). Weights must be non-negative and finite —
151/// igraph rejects negatives outright because the bound `Q ∈ [-1, 1]`
152/// stops holding.
153///
154/// # Examples
155///
156/// ```
157/// use rust_igraph::{Graph, modularity_weighted};
158///
159/// // Unit weights collapse to unweighted modularity (CO-001).
160/// let mut g = Graph::with_vertices(6);
161/// for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
162///     g.add_edge(u, v).unwrap();
163/// }
164/// let weights = vec![1.0_f64; 7];
165/// let q = modularity_weighted(&g, &[0, 0, 0, 1, 1, 1], 1.0, &weights).unwrap();
166/// assert!(q.is_some());
167/// assert!(q.unwrap() > 0.3);
168/// ```
169pub fn modularity_weighted(
170    graph: &Graph,
171    membership: &[u32],
172    resolution: f64,
173    weights: &[f64],
174) -> IgraphResult<Option<f64>> {
175    if graph.is_directed() {
176        return Err(IgraphError::Unsupported(
177            "directed weighted modularity is ALGO-CO-001b/c follow-up; not yet ported",
178        ));
179    }
180    let n = graph.vcount() as usize;
181    if membership.len() != n {
182        return Err(IgraphError::InvalidArgument(
183            "membership vector size differs from number of vertices".to_string(),
184        ));
185    }
186    if !resolution.is_finite() || resolution < 0.0 {
187        return Err(IgraphError::InvalidArgument(
188            "resolution parameter must be non-negative and finite".to_string(),
189        ));
190    }
191    let ecount = graph.ecount();
192    if weights.len() != ecount {
193        return Err(IgraphError::InvalidArgument(format!(
194            "weights vector size ({}) differs from edge count ({})",
195            weights.len(),
196            ecount
197        )));
198    }
199    for (e, &w) in weights.iter().enumerate() {
200        if w.is_nan() {
201            return Err(IgraphError::InvalidArgument(format!(
202                "weight at edge {e} is NaN"
203            )));
204        }
205        if w < 0.0 {
206            return Err(IgraphError::InvalidArgument(format!(
207                "weight at edge {e} is negative ({w}); modularity is undefined"
208            )));
209        }
210        if !w.is_finite() {
211            return Err(IgraphError::InvalidArgument(format!(
212                "weight at edge {e} is not finite ({w})"
213            )));
214        }
215    }
216    if ecount == 0 {
217        return Ok(None);
218    }
219
220    // Reindex labels (same as the unweighted entry above).
221    let max_label = membership.iter().copied().max().unwrap_or(0);
222    let mut remap: Vec<Option<u32>> = vec![None; max_label as usize + 1];
223    let mut next_id: u32 = 0;
224    let mut reindexed: Vec<u32> = Vec::with_capacity(n);
225    for &lbl in membership {
226        let slot = lbl as usize;
227        if remap[slot].is_none() {
228            remap[slot] = Some(next_id);
229            next_id += 1;
230        }
231        let Some(id) = remap[slot] else {
232            return Err(IgraphError::Internal(
233                "membership reindex failed: missing label",
234            ));
235        };
236        reindexed.push(id);
237    }
238    let no_of_partitions = next_id as usize;
239
240    let mut s_part = vec![0.0_f64; no_of_partitions];
241    let mut w_internal = 0.0_f64;
242    let mut total_w = 0.0_f64;
243
244    let m_u32 =
245        u32::try_from(ecount).map_err(|_| IgraphError::Internal("ecount exceeds u32::MAX"))?;
246    for eid in 0..m_u32 {
247        let (src, tgt) = graph.edge(eid as EdgeId)?;
248        let w = weights[eid as usize];
249        let cu = reindexed[src as usize];
250        let cv = reindexed[tgt as usize];
251        if cu == cv {
252            // Each internal undirected edge contributes 2w to e (two
253            // ordered (i,j) pairs).
254            w_internal += 2.0 * w;
255        }
256        // Strength: each endpoint accumulates `w`. Self-loops have
257        // src == tgt so this naturally contributes 2w to that vertex's
258        // strength (matches IGRAPH_LOOPS).
259        s_part[cu as usize] += w;
260        s_part[cv as usize] += w;
261        total_w += w;
262    }
263
264    if total_w == 0.0 {
265        return Ok(None);
266    }
267
268    let two_w = 2.0 * total_w;
269    for slot in &mut s_part {
270        *slot /= two_w;
271    }
272    let e_norm = w_internal / two_w;
273
274    let mut q = e_norm;
275    for &sc in &s_part {
276        q -= resolution * sc * sc;
277    }
278    Ok(Some(q))
279}
280
281/// Directed modularity (Leicht-Newman, ALGO-CO-001b).
282///
283/// Counterpart of `igraph_modularity(_, _, NULL_weights, resolution,
284/// /*directed=*/true, _)`. The directed analogue of [`modularity`]:
285///
286/// ```text
287/// Q = (1 / m) Σ_{ij} (A_ij − γ k_out_i * k_in_j / m) δ(c_i, c_j)
288/// ```
289///
290/// where `m = ecount`, `k_out_i = out_degree(i)`, `k_in_j = in_degree(j)`,
291/// and `A_ij` is the directed adjacency matrix (each directed edge
292/// contributes 1 to one entry, not symmetric). Reference: Leicht &
293/// Newman (2008).
294///
295/// Undirected graphs route to [`modularity`] with the same membership
296/// and resolution (matches python-igraph's "ignored on undirected"
297/// semantics).
298///
299/// # Examples
300///
301/// ```
302/// use rust_igraph::{Graph, modularity_directed};
303///
304/// // Two directed triangles connected by a single bridge:
305/// // 0→1→2→0, 3→4→5→3, plus 2→3.
306/// // Partition {0,1,2} vs {3,4,5} gives a positive Q (hand-checked
307/// // value: 18/49 ≈ 0.367).
308/// let mut g = Graph::new(6, true).unwrap();
309/// for &(u, v) in &[(0u32, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (2, 3)] {
310///     g.add_edge(u, v).unwrap();
311/// }
312/// let q = modularity_directed(&g, &[0, 0, 0, 1, 1, 1], 1.0).unwrap();
313/// assert!(q.is_some());
314/// assert!(q.unwrap() > 0.3);
315/// ```
316pub fn modularity_directed(
317    graph: &Graph,
318    membership: &[u32],
319    resolution: f64,
320) -> IgraphResult<Option<f64>> {
321    if !graph.is_directed() {
322        // Match python-igraph: directed flag is ignored on undirected.
323        return modularity(graph, membership, resolution);
324    }
325    let n = graph.vcount() as usize;
326    if membership.len() != n {
327        return Err(IgraphError::InvalidArgument(
328            "membership vector size differs from number of vertices".to_string(),
329        ));
330    }
331    if !resolution.is_finite() || resolution < 0.0 {
332        return Err(IgraphError::InvalidArgument(
333            "resolution parameter must be non-negative and finite".to_string(),
334        ));
335    }
336    let ecount = graph.ecount();
337    if ecount == 0 {
338        return Ok(None);
339    }
340
341    // Reindex labels.
342    let max_label = membership.iter().copied().max().unwrap_or(0);
343    let mut remap: Vec<Option<u32>> = vec![None; max_label as usize + 1];
344    let mut next_id: u32 = 0;
345    let mut reindexed: Vec<u32> = Vec::with_capacity(n);
346    for &lbl in membership {
347        let slot = lbl as usize;
348        if remap[slot].is_none() {
349            remap[slot] = Some(next_id);
350            next_id += 1;
351        }
352        let Some(id) = remap[slot] else {
353            return Err(IgraphError::Internal(
354                "membership reindex failed: missing label",
355            ));
356        };
357        reindexed.push(id);
358    }
359    let no_of_partitions = next_id as usize;
360
361    // Per-partition out- and in-degree sums; e = count of edges with
362    // both endpoints in the same partition (directed_multiplier = 1).
363    let mut k_out = vec![0.0_f64; no_of_partitions];
364    let mut k_in = vec![0.0_f64; no_of_partitions];
365    let mut e_internal = 0.0_f64;
366
367    let m_u32 =
368        u32::try_from(ecount).map_err(|_| IgraphError::Internal("ecount exceeds u32::MAX"))?;
369    for eid in 0..m_u32 {
370        let (src, tgt) = graph.edge(eid as EdgeId)?;
371        let cu = reindexed[src as usize];
372        let cv = reindexed[tgt as usize];
373        if cu == cv {
374            e_internal += 1.0;
375        }
376        k_out[cu as usize] += 1.0;
377        k_in[cv as usize] += 1.0;
378    }
379
380    #[allow(clippy::cast_precision_loss)]
381    let m_f = ecount as f64;
382    for slot in &mut k_out {
383        *slot /= m_f;
384    }
385    for slot in &mut k_in {
386        *slot /= m_f;
387    }
388    let e_norm = e_internal / m_f;
389
390    let mut q = e_norm;
391    for c in 0..no_of_partitions {
392        q -= resolution * k_out[c] * k_in[c];
393    }
394    Ok(Some(q))
395}
396
397/// Directed *weighted* modularity (Leicht-Newman, ALGO-CO-006c
398/// follow-up).
399///
400/// Combines the directed normalisation of [`modularity_directed`]
401/// (split out-strength / in-strength) with the per-edge weighting of
402/// [`modularity_weighted`]:
403///
404/// ```text
405/// Q = (1 / W) Σ_{ij} (W_ij − γ s_out_i * s_in_j / W) δ(c_i, c_j)
406/// ```
407///
408/// where `W = Σ_e w_e`, `s_out_i = Σ_{j: i→j} w_{ij}`, `s_in_j = Σ_{i: i→j} w_{ij}`,
409/// and `W_ij` is the weighted adjacency. Undirected graphs route to
410/// [`modularity_weighted`] (matching python-igraph's "directed flag
411/// ignored on undirected" semantics).
412pub fn modularity_weighted_directed(
413    graph: &Graph,
414    membership: &[u32],
415    resolution: f64,
416    weights: &[f64],
417) -> IgraphResult<Option<f64>> {
418    if !graph.is_directed() {
419        return modularity_weighted(graph, membership, resolution, weights);
420    }
421    let n = graph.vcount() as usize;
422    if membership.len() != n {
423        return Err(IgraphError::InvalidArgument(
424            "membership vector size differs from number of vertices".to_string(),
425        ));
426    }
427    if !resolution.is_finite() || resolution < 0.0 {
428        return Err(IgraphError::InvalidArgument(
429            "resolution parameter must be non-negative and finite".to_string(),
430        ));
431    }
432    let ecount = graph.ecount();
433    if weights.len() != ecount {
434        return Err(IgraphError::InvalidArgument(format!(
435            "weights vector size ({}) differs from edge count ({})",
436            weights.len(),
437            ecount
438        )));
439    }
440    for (e, &w) in weights.iter().enumerate() {
441        if w.is_nan() {
442            return Err(IgraphError::InvalidArgument(format!(
443                "weight at edge {e} is NaN"
444            )));
445        }
446        if w < 0.0 {
447            return Err(IgraphError::InvalidArgument(format!(
448                "weight at edge {e} is negative ({w}); modularity is undefined"
449            )));
450        }
451        if !w.is_finite() {
452            return Err(IgraphError::InvalidArgument(format!(
453                "weight at edge {e} is not finite ({w})"
454            )));
455        }
456    }
457    if ecount == 0 {
458        return Ok(None);
459    }
460
461    let max_label = membership.iter().copied().max().unwrap_or(0);
462    let mut remap: Vec<Option<u32>> = vec![None; max_label as usize + 1];
463    let mut next_id: u32 = 0;
464    let mut reindexed: Vec<u32> = Vec::with_capacity(n);
465    for &lbl in membership {
466        let slot = lbl as usize;
467        if remap[slot].is_none() {
468            remap[slot] = Some(next_id);
469            next_id += 1;
470        }
471        let Some(id) = remap[slot] else {
472            return Err(IgraphError::Internal(
473                "membership reindex failed: missing label",
474            ));
475        };
476        reindexed.push(id);
477    }
478    let no_of_partitions = next_id as usize;
479
480    let mut s_out = vec![0.0_f64; no_of_partitions];
481    let mut s_in = vec![0.0_f64; no_of_partitions];
482    let mut w_internal = 0.0_f64;
483    let mut total_w = 0.0_f64;
484
485    let m_u32 =
486        u32::try_from(ecount).map_err(|_| IgraphError::Internal("ecount exceeds u32::MAX"))?;
487    for eid in 0..m_u32 {
488        let (src, tgt) = graph.edge(eid as EdgeId)?;
489        let w = weights[eid as usize];
490        let cu = reindexed[src as usize];
491        let cv = reindexed[tgt as usize];
492        if cu == cv {
493            w_internal += w;
494        }
495        s_out[cu as usize] += w;
496        s_in[cv as usize] += w;
497        total_w += w;
498    }
499
500    if total_w == 0.0 {
501        return Ok(None);
502    }
503
504    for slot in &mut s_out {
505        *slot /= total_w;
506    }
507    for slot in &mut s_in {
508        *slot /= total_w;
509    }
510    let e_norm = w_internal / total_w;
511
512    let mut q = e_norm;
513    for c in 0..no_of_partitions {
514        q -= resolution * s_out[c] * s_in[c];
515    }
516    Ok(Some(q))
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    fn close(actual: f64, expected: f64, tol: f64) {
524        assert!(
525            (actual - expected).abs() < tol,
526            "actual={actual} expected={expected}"
527        );
528    }
529
530    #[test]
531    fn empty_graph_yields_none() {
532        let g = Graph::with_vertices(0);
533        let q = modularity(&g, &[], 1.0).unwrap();
534        assert!(q.is_none());
535    }
536
537    #[test]
538    fn no_edges_yields_none() {
539        let g = Graph::with_vertices(3);
540        let q = modularity(&g, &[0, 0, 0], 1.0).unwrap();
541        assert!(q.is_none());
542    }
543
544    #[test]
545    fn single_partition_zero_for_well_separated_clusters() {
546        // K3 ∪ K3 + bridge edge:
547        // Putting all 6 vertices in one community means all edges are
548        // "internal" → e/2m = 1.0; sum of k_c² = (2m)² / (2m)² = 1.0.
549        // Q = 1.0 - 1.0 = 0.0 exactly.
550        let mut g = Graph::with_vertices(6);
551        for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
552            g.add_edge(u, v).unwrap();
553        }
554        let q = modularity(&g, &[0; 6], 1.0).unwrap().unwrap();
555        close(q, 0.0, 1e-12);
556    }
557
558    #[test]
559    fn k3_union_k3_with_bridge_two_communities_high_q() {
560        // Two K3 triangles connected by a single bridge edge, partition
561        // {0,1,2} vs {3,4,5} → Q ≈ 0.408 (matches python-igraph).
562        let mut g = Graph::with_vertices(6);
563        for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
564            g.add_edge(u, v).unwrap();
565        }
566        let q = modularity(&g, &[0, 0, 0, 1, 1, 1], 1.0).unwrap().unwrap();
567        // Hand calculation:
568        //   m = 7, 2m = 14
569        //   internal edges = 6 (3 per triangle), bridge crosses → e = 12/14
570        //   k_c0 = (2+2+3)*2/2m = 14/14 = 1? Wait: vertex degrees in {0..5}:
571        //     deg(0)=2, deg(1)=2, deg(2)=3, deg(3)=3, deg(4)=2, deg(5)=2.
572        //     sum(c0)=7, sum(c1)=7. k_c0 = 7*2/14 = 1.0 — no wait,
573        //     k_c[c] = (sum endpoints in c across edges) / 2m.
574        //     Each edge contributes 1 to k of each endpoint's community.
575        //     For c0 = {0,1,2}: edges 01,02,12 contribute 2 each = 6,
576        //     bridge 23 contributes 1 (to c0 from vertex 2) → total 7.
577        //     k[c0] = 7/14 = 0.5. Same for c1.
578        //   Q = 12/14 - 1.0*(0.25 + 0.25) = 6/7 - 0.5 ≈ 0.857 - 0.5 = 0.357.
579        // Sanity: positive but not maximal. python-igraph confirms.
580        close(q, 6.0_f64 / 7.0 - 0.5, 1e-12);
581    }
582
583    #[test]
584    fn negative_q_for_singleton_cluster_in_dense_graph() {
585        // K4 with each vertex in its own community → Q < 0.
586        let mut g = Graph::with_vertices(4);
587        for u in 0..4u32 {
588            for v in (u + 1)..4 {
589                g.add_edge(u, v).unwrap();
590            }
591        }
592        let q = modularity(&g, &[0, 1, 2, 3], 1.0).unwrap().unwrap();
593        // K4: m = 6, 2m = 12; e = 0 (no internal edges); each k_c = 3/12 = 0.25.
594        // Q = 0 - 4*(1/4)*(1/4)*1 = -0.25.
595        close(q, -0.25, 1e-12);
596    }
597
598    #[test]
599    fn membership_size_mismatch_errors() {
600        let mut g = Graph::with_vertices(3);
601        g.add_edge(0, 1).unwrap();
602        assert!(modularity(&g, &[0, 0], 1.0).is_err());
603    }
604
605    #[test]
606    fn negative_resolution_errors() {
607        let mut g = Graph::with_vertices(3);
608        g.add_edge(0, 1).unwrap();
609        assert!(modularity(&g, &[0, 0, 0], -0.1).is_err());
610    }
611
612    #[test]
613    fn directed_returns_unsupported() {
614        let mut g = Graph::new(2, true).unwrap();
615        g.add_edge(0, 1).unwrap();
616        assert!(modularity(&g, &[0, 0], 1.0).is_err());
617    }
618
619    #[test]
620    fn non_consecutive_labels_reindex_correctly() {
621        // Sparse non-consecutive label set should reindex internally.
622        // Same K3 ∪ K3 + bridge graph and same partition, just with
623        // labels {7, 42}.
624        let mut g = Graph::with_vertices(6);
625        for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
626            g.add_edge(u, v).unwrap();
627        }
628        let q1 = modularity(&g, &[0, 0, 0, 1, 1, 1], 1.0).unwrap().unwrap();
629        let q2 = modularity(&g, &[7, 7, 7, 42, 42, 42], 1.0)
630            .unwrap()
631            .unwrap();
632        close(q1, q2, 1e-12);
633    }
634
635    #[test]
636    fn resolution_zero_yields_density_term_only() {
637        // γ = 0 turns Q into e/2m alone.
638        let mut g = Graph::with_vertices(4);
639        for u in 0..4u32 {
640            for v in (u + 1)..4 {
641                g.add_edge(u, v).unwrap();
642            }
643        }
644        // K4 with [0,0,1,1]: 2 internal edges (01, 23), 4 cross.
645        // Q(γ=0) = 2*2/(2*6) = 4/12 = 1/3.
646        let q = modularity(&g, &[0, 0, 1, 1], 0.0).unwrap().unwrap();
647        close(q, 4.0 / 12.0, 1e-12);
648    }
649
650    // ----- ALGO-CO-001c: weighted modularity -----
651
652    #[test]
653    fn weighted_unit_weights_match_unweighted() {
654        // Unit weights → modularity_weighted == modularity.
655        let mut g = Graph::with_vertices(6);
656        for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
657            g.add_edge(u, v).unwrap();
658        }
659        let mem = [0, 0, 0, 1, 1, 1];
660        let weights = vec![1.0_f64; 7];
661        let qw = modularity_weighted(&g, &mem, 1.0, &weights)
662            .unwrap()
663            .unwrap();
664        let q = modularity(&g, &mem, 1.0).unwrap().unwrap();
665        close(qw, q, 1e-12);
666    }
667
668    #[test]
669    fn weighted_balanced_heavy_internal_edges_increase_q() {
670        // Same K3 ∪ K3 + bridge graph. SYMMETRICALLY boost every
671        // internal edge to 10× and shrink the bridge to 0.1×: both
672        // communities keep equal strength so the s² penalty stays
673        // balanced and the heavier concentration of internal weight
674        // makes Q go up vs. the unit-weight baseline.
675        let mut g = Graph::with_vertices(6);
676        for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
677            g.add_edge(u, v).unwrap();
678        }
679        let mem = [0, 0, 0, 1, 1, 1];
680        let weights = vec![10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 0.1];
681        let qw = modularity_weighted(&g, &mem, 1.0, &weights)
682            .unwrap()
683            .unwrap();
684        let q_unit = modularity(&g, &mem, 1.0).unwrap().unwrap();
685        assert!(
686            qw > q_unit,
687            "balanced-heavy Q ({qw}) should exceed unit-weight Q ({q_unit})"
688        );
689    }
690
691    #[test]
692    fn weighted_zero_total_weight_yields_none() {
693        let mut g = Graph::with_vertices(2);
694        g.add_edge(0, 1).unwrap();
695        let q = modularity_weighted(&g, &[0, 0], 1.0, &[0.0]).unwrap();
696        assert!(q.is_none());
697    }
698
699    #[test]
700    fn weighted_negative_weight_errors() {
701        let mut g = Graph::with_vertices(2);
702        g.add_edge(0, 1).unwrap();
703        assert!(modularity_weighted(&g, &[0, 0], 1.0, &[-1.0]).is_err());
704    }
705
706    #[test]
707    fn weighted_size_mismatch_errors() {
708        let mut g = Graph::with_vertices(2);
709        g.add_edge(0, 1).unwrap();
710        assert!(modularity_weighted(&g, &[0, 0], 1.0, &[1.0, 2.0]).is_err());
711    }
712
713    #[test]
714    fn weighted_directed_returns_unsupported() {
715        let mut g = Graph::new(2, true).unwrap();
716        g.add_edge(0, 1).unwrap();
717        assert!(modularity_weighted(&g, &[0, 0], 1.0, &[1.0]).is_err());
718    }
719
720    // ----- ALGO-CO-001b: directed modularity (Leicht-Newman) -----
721
722    #[test]
723    fn directed_two_triangles_with_bridge_high_q() {
724        // 0→1→2→0, 3→4→5→3, 2→3. Partition {0,1,2}/{3,4,5}.
725        // m = 7.
726        // Internal edges in c0: (0→1), (1→2), (2→0) → e_c0 = 3
727        // Internal edges in c1: (3→4), (4→5), (5→3) → e_c1 = 3
728        // Cross: (2→3) → 1.
729        // e_internal = 6. e_norm = 6/7.
730        // k_out[c0] = (1+1+1)/7 = 3/7 (vertices 0,1,2 each have out-deg 1)
731        // Actually vertex 2 has out-deg 2 (2→0 and 2→3) so k_out[c0] = 4/7.
732        // k_out[c1] = 3/7 (each of 3,4,5 has out-deg 1).
733        // k_in[c0] = 3/7 (each of 0,1,2 has in-deg 1).
734        // k_in[c1] = 4/7 (3 has in-deg 2, 4 and 5 have in-deg 1).
735        // Q = 6/7 - (k_out[c0]*k_in[c0] + k_out[c1]*k_in[c1])
736        //   = 6/7 - (4/7 * 3/7 + 3/7 * 4/7)
737        //   = 6/7 - 24/49 = 42/49 - 24/49 = 18/49
738        //   ≈ 0.3673
739        let mut g = Graph::new(6, true).unwrap();
740        for &(u, v) in &[(0u32, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (2, 3)] {
741            g.add_edge(u, v).unwrap();
742        }
743        let q = modularity_directed(&g, &[0, 0, 0, 1, 1, 1], 1.0)
744            .unwrap()
745            .unwrap();
746        close(q, 18.0 / 49.0, 1e-12);
747    }
748
749    #[test]
750    fn directed_undirected_graph_routes_to_undirected_formula() {
751        let mut g = Graph::with_vertices(6);
752        for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
753            g.add_edge(u, v).unwrap();
754        }
755        let mem = [0u32, 0, 0, 1, 1, 1];
756        let a = modularity(&g, &mem, 1.0).unwrap();
757        let b = modularity_directed(&g, &mem, 1.0).unwrap();
758        assert_eq!(a, b);
759    }
760
761    #[test]
762    fn directed_no_edges_yields_none() {
763        let g = Graph::new(3, true).unwrap();
764        let q = modularity_directed(&g, &[0, 0, 0], 1.0).unwrap();
765        assert!(q.is_none());
766    }
767
768    #[test]
769    fn directed_membership_size_mismatch_errors() {
770        let mut g = Graph::new(3, true).unwrap();
771        g.add_edge(0, 1).unwrap();
772        assert!(modularity_directed(&g, &[0, 0], 1.0).is_err());
773    }
774
775    #[test]
776    fn directed_negative_resolution_errors() {
777        let mut g = Graph::new(3, true).unwrap();
778        g.add_edge(0, 1).unwrap();
779        assert!(modularity_directed(&g, &[0, 0, 0], -0.1).is_err());
780    }
781
782    #[test]
783    fn directed_3_cycle_single_partition_zero() {
784        // 0→1→2→0, partition [0,0,0]: all 3 edges internal.
785        // m = 3, e = 3, e_norm = 1.0.
786        // k_out[0] = 3/3 = 1.0; k_in[0] = 3/3 = 1.0.
787        // Q = 1.0 - 1.0*1.0 = 0.0.
788        let mut g = Graph::new(3, true).unwrap();
789        g.add_edge(0, 1).unwrap();
790        g.add_edge(1, 2).unwrap();
791        g.add_edge(2, 0).unwrap();
792        let q = modularity_directed(&g, &[0, 0, 0], 1.0).unwrap().unwrap();
793        close(q, 0.0, 1e-12);
794    }
795
796    #[test]
797    fn weighted_two_disjoint_edges_q_eq_half() {
798        // Two disjoint edges with equal weights, partitioned
799        // {0,1} vs {2,3}: w_internal = 2*(1+1) = 4 (two undirected
800        // edges), W = 2, 2W = 4. e_norm = 4/4 = 1.0. s[c0] = 2/4 = 0.5,
801        // s[c1] = 2/4 = 0.5. Q = 1.0 - (0.25 + 0.25) = 0.5.
802        let mut g = Graph::with_vertices(4);
803        g.add_edge(0, 1).unwrap();
804        g.add_edge(2, 3).unwrap();
805        let q = modularity_weighted(&g, &[0, 0, 1, 1], 1.0, &[1.0, 1.0])
806            .unwrap()
807            .unwrap();
808        close(q, 0.5, 1e-12);
809    }
810}