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