Skip to main content

leiden_rs/
infomap.rs

1//! Infomap community detection algorithm — foundational data structures and PageRank flow.
2//!
3//! This module provides the flow-based primitives required by the Infomap algorithm:
4//!
5//! - [`FlowData`] — per-node flow values (PageRank steady-state probabilities).
6//! - [`DeltaFlow`] — flow change when moving a node between modules.
7//! - [`InfomapConfig`] — algorithm configuration with sensible defaults.
8//! - [`InfomapOutput`] — result of running Infomap.
9//! - [`plogp`] — information-theoretic helper `x log₂(x)`.
10//! - [`compute_flow`] — PageRank power-iteration on a [`GraphData`].
11
12use crate::graph::data::GraphData;
13use crate::graph::GraphDataBuilder;
14use crate::partition::Partition;
15use rand::rngs::StdRng;
16use rand::seq::SliceRandom;
17use rand::SeedableRng;
18use rustc_hash::FxHashMap;
19
20// ── Structs ──────────────────────────────────────────────────────────────────
21
22/// Per-node flow values computed by PageRank power iteration.
23///
24/// All flow values are non-negative and the sum of `flow` across all nodes
25/// equals 1.0 after convergence.
26#[derive(Debug, Clone, PartialEq)]
27pub struct FlowData {
28    /// Steady-state PageRank probability for this node.
29    pub flow: f64,
30    /// Flow entering this node from other modules (used by Map Equation).
31    pub enter_flow: f64,
32    /// Flow exiting this node to other modules (used by Map Equation).
33    pub exit_flow: f64,
34    /// Teleportation flow component for this node.
35    pub teleport_flow: f64,
36}
37
38/// Change in flow when moving a node between modules.
39///
40/// Used during the Map Equation optimisation to evaluate whether a move
41/// reduces the description length.
42#[derive(Debug, Clone, PartialEq)]
43pub struct DeltaFlow {
44    /// Target module index.
45    pub module: usize,
46    /// Change in exit flow.
47    pub delta_exit: f64,
48    /// Change in enter flow.
49    pub delta_enter: f64,
50}
51
52/// Per-module aggregated flow data for Map Equation computation.
53///
54/// Each module tracks the total node flow, inter-module enter flow, and
55/// inter-module exit flow. These values are updated incrementally during
56/// the local moving optimisation phase.
57#[derive(Debug, Clone, PartialEq)]
58pub struct ModuleFlowData {
59    /// Total flow of all nodes in this module (sum of PageRank values).
60    pub flow: f64,
61    /// Flow entering this module from other modules (inter-module boundary).
62    pub enter_flow: f64,
63    /// Flow exiting this module to other modules (inter-module boundary).
64    pub exit_flow: f64,
65}
66
67/// Map Equation state for O(1) delta codelength computation.
68///
69/// Stores per-module flow data and cached codelength terms for efficient
70/// incremental updates during local moving optimisation. The `node_flow_log`
71/// term is constant (node PageRank values never change after computation),
72/// so only module-level terms need updating when nodes move.
73///
74/// # Codelength Formula
75///
76/// `L(M) = enterFlow_log - enter_log_enter - exit_log_exit + flow_log_flow - node_flow_log`
77///
78/// Where each term is a sum of `plogp(x) = x · log₂(x)` values over modules.
79#[derive(Debug, Clone)]
80pub struct MapEquation {
81    /// Per-module flow data, indexed by module ID.
82    pub module_data: Vec<ModuleFlowData>,
83
84    // Cached global codelength terms:
85    /// Total enter flow across all modules (including exit network flow).
86    pub enter_flow: f64,
87    /// `plogp(total_enterFlow)` — cached, 1 plogp call.
88    pub enter_flow_log: f64,
89    /// `Σ_modules plogp(module.enter_flow)` — cached sum.
90    pub enter_log_enter: f64,
91    /// `Σ_modules plogp(module.exit_flow)` — cached sum.
92    pub exit_log_exit: f64,
93    /// `Σ_modules plogp(module.exit_flow + module.flow)` — cached sum.
94    pub flow_log_flow: f64,
95    /// `Σ_nodes plogp(node.flow)` — CONSTANT, never changes during optimisation.
96    pub node_flow_log: f64,
97    /// Flow exiting the top-level network (≈ 0 for closed two-level partition).
98    pub exit_network_flow: f64,
99}
100
101/// Configuration for the Infomap algorithm.
102///
103/// Provides default values matching the reference implementation:
104/// teleportation rate α = 0.15, up to 100 power-iteration steps, etc.
105#[derive(Debug, Clone, PartialEq)]
106pub struct InfomapConfig {
107    /// Optional RNG seed for reproducible results.
108    pub seed: Option<u64>,
109    /// Maximum number of optimisation iterations.
110    pub max_iterations: usize,
111    /// Teleportation probability α (PageRank damping = 1 − α).
112    pub teleportation_rate: f64,
113    /// Number of independent trials (best result is kept).
114    pub num_trials: usize,
115    /// Convergence tolerance for PageRank power iteration.
116    pub tolerance: f64,
117}
118
119impl Default for InfomapConfig {
120    fn default() -> Self {
121        Self {
122            seed: None,
123            max_iterations: 100,
124            teleportation_rate: 0.15,
125            num_trials: 10,
126            tolerance: 1e-10,
127        }
128    }
129}
130
131impl InfomapConfig {
132    /// Create a new configuration with the given seed.
133    #[must_use = "constructor returns a new instance"]
134    pub fn new(seed: Option<u64>) -> Self {
135        Self {
136            seed,
137            ..Default::default()
138        }
139    }
140}
141
142/// Result of running the Infomap algorithm.
143#[derive(Debug, Clone, PartialEq)]
144pub struct InfomapOutput {
145    /// The detected community partition.
146    pub partition: Partition,
147    /// Map Equation codelength (description length) of the partition.
148    pub codelength: f64,
149    /// Number of hierarchical levels in the partition.
150    pub num_levels: usize,
151    /// Number of optimisation iterations performed.
152    pub iterations: usize,
153}
154
155// ── Functions ────────────────────────────────────────────────────────────────
156
157/// Compute `x · log₂(x)`, returning 0.0 for `x ≤ 0` instead of NaN.
158///
159/// This is the fundamental building block of the Map Equation's entropy
160/// calculations: `H(p) = −Σ pᵢ log₂ pᵢ = −Σ plogp(pᵢ)`.
161#[must_use]
162pub fn plogp(x: f64) -> f64 {
163    if x > 0.0 {
164        x * x.log2()
165    } else {
166        0.0
167    }
168}
169
170/// Compute PageRank flow via power iteration.
171///
172/// Returns a `Vec<FlowData>` of length `graph.node_count()` where each
173/// element's `flow` field holds the steady-state PageRank probability.
174///
175/// # Arguments
176///
177/// * `graph` — input graph (directed or undirected).
178/// * `teleport_rate` — probability α of teleporting (default 0.15).
179/// * `tolerance` — convergence threshold on L∞ norm of flow change.
180/// * `max_iter` — hard cap on power-iteration steps.
181///
182/// # Algorithm
183///
184/// 1. Initialise flow vector uniformly: `flow[i] = 1/n`.
185/// 2. For directed graphs the transition matrix uses out-edges; for
186///    undirected graphs the symmetric transition is used.
187/// 3. Dangling nodes (out-degree 0) distribute their flow uniformly.
188/// 4. Power iteration: `flow_new[i] = α/n + (1−α) · Σⱼ flow[j]·P[j→i]`.
189/// 5. Converge when `max_i |flow_new[i] − flow[i]| < tolerance`.
190#[must_use]
191pub fn compute_flow(
192    graph: &GraphData,
193    teleport_rate: f64,
194    tolerance: f64,
195    max_iter: usize,
196) -> Vec<FlowData> {
197    let n = graph.node_count();
198    if n == 0 {
199        return Vec::new();
200    }
201
202    // Uniform initial distribution.
203    let mut flow = vec![1.0 / n as f64; n];
204    let teleport_dist = 1.0 / n as f64; // uniform teleportation target
205
206    for _ in 0..max_iter {
207        let mut flow_new = vec![0.0; n];
208
209        // Accumulate flow from neighbours (via transition matrix).
210        // For each node j, distribute flow[j] to its out-neighbours
211        // proportionally to edge weight / out_degree[j].
212        for (j, f) in flow.iter().enumerate() {
213            let out_deg = graph.out_degree_of(j);
214            if out_deg > 0.0 {
215                // Normal node: distribute flow proportionally.
216                for (target, weight) in graph.out_neighbors(j) {
217                    flow_new[target] += f * (weight / out_deg);
218                }
219            } else {
220                // Dangling node: distribute flow uniformly (like teleportation).
221                for item in flow_new.iter_mut() {
222                    *item += f * teleport_dist;
223                }
224            }
225        }
226
227        // For undirected graphs, use neighbors() which returns symmetric edges.
228        // But out_neighbors() already works for undirected because the CSR stores
229        // both directions in out_offsets. So the above handles both cases.
230
231        // Apply teleportation: mix with uniform distribution.
232        let damping = 1.0 - teleport_rate;
233        for item in flow_new.iter_mut() {
234            *item = teleport_rate * teleport_dist + damping * *item;
235        }
236
237        // Check convergence (L∞ norm).
238        let mut max_diff = 0.0;
239        for i in 0..n {
240            let diff = (flow_new[i] - flow[i]).abs();
241            if diff > max_diff {
242                max_diff = diff;
243            }
244        }
245
246        flow = flow_new;
247
248        if max_diff < tolerance {
249            break;
250        }
251    }
252
253    // Build FlowData with flow values; enter/exit/teleport computed later by Map Equation.
254    flow.into_iter()
255        .map(|f| FlowData {
256            flow: f,
257            enter_flow: 0.0,
258            exit_flow: 0.0,
259            teleport_flow: 0.0,
260        })
261        .collect()
262}
263
264// ── MapEquation Implementation ────────────────────────────────────────────────
265
266impl MapEquation {
267    /// Create a new MapEquation where each node starts in its own singleton module.
268    ///
269    /// The `node_flows` slice provides per-node `FlowData` with `enter_flow` and
270    /// `exit_flow` set to total incoming/outgoing edge flow respectively.
271    /// `exit_network_flow` is ≈ 0 for a closed two-level partition.
272    #[must_use]
273    pub fn new(node_flows: &[FlowData], exit_network_flow: f64) -> Self {
274        let num_modules = node_flows.len();
275        let mut module_data = Vec::with_capacity(num_modules);
276
277        let mut enter_flow = 0.0_f64;
278        let mut enter_log_enter = 0.0_f64;
279        let mut exit_log_exit = 0.0_f64;
280        let mut flow_log_flow = 0.0_f64;
281        let mut node_flow_log = 0.0_f64;
282
283        for fd in node_flows {
284            let module = ModuleFlowData {
285                flow: fd.flow,
286                enter_flow: fd.enter_flow,
287                exit_flow: fd.exit_flow,
288            };
289
290            enter_flow += module.enter_flow;
291            enter_log_enter += plogp(module.enter_flow);
292            exit_log_exit += plogp(module.exit_flow);
293            flow_log_flow += plogp(module.exit_flow + module.flow);
294            node_flow_log += plogp(fd.flow);
295
296            module_data.push(module);
297        }
298
299        enter_flow += exit_network_flow;
300        let enter_flow_log = plogp(enter_flow);
301
302        Self {
303            module_data,
304            enter_flow,
305            enter_flow_log,
306            enter_log_enter,
307            exit_log_exit,
308            flow_log_flow,
309            node_flow_log,
310            exit_network_flow,
311        }
312    }
313
314    /// Compute total Map Equation codelength from cached terms.
315    ///
316    /// `L(M) = (enterFlow_log - enter_log_enter - exitNetwork_log)`
317    ///       `+ (-exit_log_exit + flow_log_flow - nodeFlow_log)`
318    #[must_use]
319    pub fn codelength(&self) -> f64 {
320        let index_codelength =
321            self.enter_flow_log - self.enter_log_enter - plogp(self.exit_network_flow);
322        let module_codelength = -self.exit_log_exit + self.flow_log_flow - self.node_flow_log;
323        index_codelength + module_codelength
324    }
325
326    /// O(1) delta codelength when moving a node between modules.
327    ///
328    /// Returns ΔL — **negative** means the move improves (reduces) codelength.
329    /// Uses exactly 13 `plogp` calls: 7 cached values + 6 new evaluations.
330    ///
331    /// # Arguments
332    ///
333    /// * `current` — `FlowData` of the node being moved (`enter_flow`/`exit_flow`
334    ///   are total incoming/outgoing edge flow, NOT inter-module).
335    /// * `old_module` — index of the source module.
336    /// * `new_module` — index of the target module.
337    /// * `old_delta` — `DeltaFlow` for the old module (edges between node and old module).
338    /// * `new_delta` — `DeltaFlow` for the new module (edges between node and new module).
339    #[must_use]
340    pub fn get_delta_codelength_on_moving_node(
341        &self,
342        current: &FlowData,
343        old_module: usize,
344        new_module: usize,
345        old_delta: &DeltaFlow,
346        new_delta: &DeltaFlow,
347    ) -> f64 {
348        let dee_old = old_delta.delta_enter + old_delta.delta_exit;
349        let dee_new = new_delta.delta_enter + new_delta.delta_exit;
350
351        let old_data = &self.module_data[old_module];
352        let new_data = &self.module_data[new_module];
353
354        // Group 1: index codebook enter flow (2 plogp: 1 new + 1 cached)
355        let delta_enter = plogp(self.enter_flow + dee_old - dee_new) - self.enter_flow_log;
356
357        // Group 2: module enter flow (4 plogp: 2 cached + 2 new)
358        let delta_enter_log_enter = -plogp(old_data.enter_flow)
359            - plogp(new_data.enter_flow)
360            + plogp(old_data.enter_flow - current.enter_flow + dee_old)
361            + plogp(new_data.enter_flow + current.enter_flow - dee_new);
362
363        // Group 3: module exit flow (4 plogp: 2 cached + 2 new)
364        let delta_exit_log_exit = -plogp(old_data.exit_flow)
365            - plogp(new_data.exit_flow)
366            + plogp(old_data.exit_flow - current.exit_flow + dee_old)
367            + plogp(new_data.exit_flow + current.exit_flow - dee_new);
368
369        // Group 4: module total flow (4 plogp: 2 cached + 2 new)
370        let old_total = old_data.exit_flow + old_data.flow;
371        let new_total = new_data.exit_flow + new_data.flow;
372        let delta_flow_log_flow = -plogp(old_total)
373            - plogp(new_total)
374            + plogp(old_total - current.exit_flow - current.flow + dee_old)
375            + plogp(new_total + current.exit_flow + current.flow - dee_new);
376
377        // Total: 13 plogp calls (7 cached + 6 new)
378        delta_enter - delta_enter_log_enter - delta_exit_log_exit + delta_flow_log_flow
379    }
380
381    /// Apply a node move: update module data and cached codelength terms.
382    ///
383    /// After this call the `MapEquation` state reflects the partition with the
384    /// node moved from `old_module` to `new_module`.
385    pub fn update_codelength_on_moving_node(
386        &mut self,
387        current: &FlowData,
388        old_module: usize,
389        new_module: usize,
390        old_delta: &DeltaFlow,
391        new_delta: &DeltaFlow,
392    ) {
393        let dee_old = old_delta.delta_enter + old_delta.delta_exit;
394        let dee_new = new_delta.delta_enter + new_delta.delta_exit;
395
396        let old_data = &self.module_data[old_module];
397        let new_data = &self.module_data[new_module];
398        self.enter_log_enter -= plogp(old_data.enter_flow) + plogp(new_data.enter_flow);
399        self.exit_log_exit -= plogp(old_data.exit_flow) + plogp(new_data.exit_flow);
400        self.flow_log_flow -=
401            plogp(old_data.exit_flow + old_data.flow) + plogp(new_data.exit_flow + new_data.flow);
402
403        self.remove_flow_from_module(old_module, current);
404        self.add_flow_to_module(new_module, current);
405
406        // Adjust boundary flows: edges between node and old module become inter-module,
407        // edges between node and new module become intra-module.
408        self.module_data[old_module].enter_flow += dee_old;
409        self.module_data[old_module].exit_flow += dee_old;
410        self.module_data[new_module].enter_flow -= dee_new;
411        self.module_data[new_module].exit_flow -= dee_new;
412
413        self.enter_flow += dee_old - dee_new;
414        self.enter_flow_log = plogp(self.enter_flow);
415
416        let old_data = &self.module_data[old_module];
417        let new_data = &self.module_data[new_module];
418        self.enter_log_enter += plogp(old_data.enter_flow) + plogp(new_data.enter_flow);
419        self.exit_log_exit += plogp(old_data.exit_flow) + plogp(new_data.exit_flow);
420        self.flow_log_flow +=
421            plogp(old_data.exit_flow + old_data.flow) + plogp(new_data.exit_flow + new_data.flow);
422    }
423
424    /// Add a node's flow data to a module (raw aggregation, no boundary correction).
425    pub fn add_flow_to_module(&mut self, module: usize, flow: &FlowData) {
426        self.module_data[module].flow += flow.flow;
427        self.module_data[module].enter_flow += flow.enter_flow;
428        self.module_data[module].exit_flow += flow.exit_flow;
429    }
430
431    /// Remove a node's flow data from a module (raw aggregation, no boundary correction).
432    pub fn remove_flow_from_module(&mut self, module: usize, flow: &FlowData) {
433        self.module_data[module].flow -= flow.flow;
434        self.module_data[module].enter_flow -= flow.enter_flow;
435        self.module_data[module].exit_flow -= flow.exit_flow;
436    }
437
438    /// Recompute all cached codelength terms from scratch (full O(N_modules) scan).
439    pub fn recalc_terms(&mut self) {
440        self.enter_flow_log = plogp(self.enter_flow);
441        self.enter_log_enter = 0.0;
442        self.exit_log_exit = 0.0;
443        self.flow_log_flow = 0.0;
444        for module in &self.module_data {
445            self.enter_log_enter += plogp(module.enter_flow);
446            self.exit_log_exit += plogp(module.exit_flow);
447            self.flow_log_flow += plogp(module.exit_flow + module.flow);
448        }
449    }
450}
451
452/// Compute total Map Equation codelength (standalone convenience function).
453#[must_use]
454pub fn calc_codelength(map_eq: &MapEquation) -> f64 {
455    map_eq.codelength()
456}
457
458// ── Boundary Flow Computation ────────────────────────────────────────────────
459
460/// Populate the `enter_flow` and `exit_flow` fields of `FlowData` from PageRank
461/// values and graph structure.
462///
463/// For each directed edge (u→v, w), the link flow is:
464/// `link_flow(u→v) = (1 − τ) × (w / out_degree(u)) × flow[u]`
465///
466/// * `exit_flow[u]` = Σ link_flow(u→v) for all out-edges of u.
467/// * `enter_flow[u]` = Σ link_flow(v→u) for all in-edges to u.
468///
469/// For undirected graphs the CSR stores both directions in the out-edge array,
470/// so iterating `out_neighbors` for every node visits each undirected edge in
471/// both directions, correctly accumulating enter **and** exit flows.
472fn populate_boundary_flows(
473    graph: &GraphData,
474    flow_data: &mut [FlowData],
475    teleport_rate: f64,
476) {
477    let n = graph.node_count();
478    let damping = 1.0 - teleport_rate;
479
480    let mut enter_flow = vec![0.0; n];
481    let mut exit_flow = vec![0.0; n];
482
483    for u in 0..n {
484        let out_deg = graph.out_degree_of(u);
485        if out_deg > 0.0 {
486            for (v, w) in graph.out_neighbors(u) {
487                let lf = damping * (w / out_deg) * flow_data[u].flow;
488                exit_flow[u] += lf;
489                enter_flow[v] += lf;
490            }
491        }
492    }
493
494    for i in 0..n {
495        flow_data[i].enter_flow = enter_flow[i];
496        flow_data[i].exit_flow = exit_flow[i];
497    }
498}
499
500// ── Local Moving ─────────────────────────────────────────────────────────────
501
502/// Core Louvain-style local moving loop for Infomap.
503///
504/// Iterates over all nodes in random order, moving each to the module that
505/// most decreases the Map Equation codelength ΔL. Repeats full passes until
506/// no node moves.
507///
508/// # Arguments
509///
510/// * `map_eq` — mutable MapEquation state (module data and cached terms updated in place).
511/// * `flow_data` — per-node FlowData (constant, never modified).
512/// * `graph` — the graph on which local moving operates.
513/// * `partition` — mutable community assignment (`partition[node] = module`).
514/// * `rng` — seeded RNG for random node ordering.
515///
516/// # Returns
517///
518/// `true` if at least one node was moved during any pass.
519pub fn run_local_moving(
520    map_eq: &mut MapEquation,
521    flow_data: &[FlowData],
522    graph: &GraphData,
523    partition: &mut [usize],
524    rng: &mut StdRng,
525) -> bool {
526    let n = graph.node_count();
527    if n <= 1 {
528        return false;
529    }
530
531    let mut changed = false;
532
533    loop {
534        let mut improved = false;
535
536        let mut order: Vec<usize> = (0..n).collect();
537        order.shuffle(rng);
538
539        for &node in &order {
540            let old_module = partition[node];
541            let node_exit = flow_data[node].exit_flow;
542            let node_out_deg = graph.out_degree_of(node);
543
544            // ── Collect per-module DeltaFlow via link flows ──
545
546            let mut delta_exit_map: FxHashMap<usize, f64> = FxHashMap::default();
547            let mut delta_enter_map: FxHashMap<usize, f64> = FxHashMap::default();
548
549            if graph.is_directed() {
550                // Out-edges → delta_exit (flow from node to target module)
551                if node_out_deg > 0.0 {
552                    for (v, w) in graph.out_neighbors(node) {
553                        let target_module = partition[v];
554                        let lf = (w / node_out_deg) * node_exit;
555                        *delta_exit_map.entry(target_module).or_default() += lf;
556                    }
557                }
558                // In-edges → delta_enter (flow from source module to node)
559                for (v, w) in graph.in_neighbors(node) {
560                    let source_module = partition[v];
561                    let v_out_deg = graph.out_degree_of(v);
562                    if v_out_deg > 0.0 {
563                        let lf = (w / v_out_deg) * flow_data[v].exit_flow;
564                        *delta_enter_map.entry(source_module).or_default() += lf;
565                    }
566                }
567            } else {
568                // Undirected: out_neighbors gives all neighbours (CSR stores both dirs).
569                if node_out_deg > 0.0 {
570                    for (v, w) in graph.out_neighbors(node) {
571                        let target_module = partition[v];
572                        // exit: flow from node → v
573                        let exit_lf = (w / node_out_deg) * node_exit;
574                        *delta_exit_map.entry(target_module).or_default() += exit_lf;
575                        // enter: flow from v → node
576                        let v_out_deg = graph.out_degree_of(v);
577                        if v_out_deg > 0.0 {
578                            let enter_lf = (w / v_out_deg) * flow_data[v].exit_flow;
579                            *delta_enter_map.entry(target_module).or_default() += enter_lf;
580                        }
581                    }
582                }
583            }
584
585            // Merge into DeltaFlow entries
586            let mut all_modules: FxHashMap<usize, ()> = FxHashMap::default();
587            for &m in delta_exit_map.keys() {
588                all_modules.insert(m, ());
589            }
590            for &m in delta_enter_map.keys() {
591                all_modules.insert(m, ());
592            }
593
594            let mut delta_flows: FxHashMap<usize, DeltaFlow> = FxHashMap::default();
595            for &m in all_modules.keys() {
596                delta_flows.insert(
597                    m,
598                    DeltaFlow {
599                        module: m,
600                        delta_exit: delta_exit_map.get(&m).copied().unwrap_or(0.0),
601                        delta_enter: delta_enter_map.get(&m).copied().unwrap_or(0.0),
602                    },
603                );
604            }
605
606            // Self-module delta (edges between node and its own module)
607            let self_delta = delta_flows.remove(&old_module).unwrap_or(DeltaFlow {
608                module: old_module,
609                delta_exit: 0.0,
610                delta_enter: 0.0,
611            });
612
613            // ── Evaluate candidate modules ──
614
615            let mut best_module = old_module;
616            let mut best_delta = 0.0_f64; // must be strictly negative to move
617
618            for (&new_module, new_delta) in &delta_flows {
619                if new_module == old_module {
620                    continue;
621                }
622                let dl = map_eq.get_delta_codelength_on_moving_node(
623                    &flow_data[node],
624                    old_module,
625                    new_module,
626                    &self_delta,
627                    new_delta,
628                );
629                if dl < best_delta {
630                    best_delta = dl;
631                    best_module = new_module;
632                }
633            }
634
635            // Try a new empty module (let node form its own singleton)
636            let new_module_id = map_eq.module_data.len();
637            map_eq.module_data.push(ModuleFlowData {
638                flow: 0.0,
639                enter_flow: 0.0,
640                exit_flow: 0.0,
641            });
642            let empty_delta = DeltaFlow {
643                module: new_module_id,
644                delta_exit: 0.0,
645                delta_enter: 0.0,
646            };
647            let dl = map_eq.get_delta_codelength_on_moving_node(
648                &flow_data[node],
649                old_module,
650                new_module_id,
651                &self_delta,
652                &empty_delta,
653            );
654            if dl < best_delta {
655                best_module = new_module_id;
656                // keep the new entry
657            } else {
658                map_eq.module_data.pop();
659            }
660
661            // ── Apply move if improvement found ──
662
663            if best_module != old_module {
664                let actual_new_delta = if best_module == new_module_id {
665                    empty_delta
666                } else {
667                    delta_flows
668                        .get(&best_module)
669                        .cloned()
670                        .unwrap_or(DeltaFlow {
671                            module: best_module,
672                            delta_exit: 0.0,
673                            delta_enter: 0.0,
674                        })
675                };
676
677                map_eq.update_codelength_on_moving_node(
678                    &flow_data[node],
679                    old_module,
680                    best_module,
681                    &self_delta,
682                    &actual_new_delta,
683                );
684                partition[node] = best_module;
685                improved = true;
686                changed = true;
687            }
688        }
689
690        if !improved {
691            break;
692        }
693    }
694
695    changed
696}
697
698// ── Graph Aggregation ────────────────────────────────────────────────────────
699
700/// Build a super-node graph by aggregating nodes within the same module.
701///
702/// Each unique module in the partition becomes a super-node. Edges between
703/// nodes in the same module become self-loops. Edges between nodes in
704/// different modules are merged with weights summed.
705///
706/// For directed graphs each directed edge (u→v) is aggregated separately.
707/// For undirected graphs edges are canonicalised to avoid double counting.
708pub fn aggregate_graph(graph: &GraphData, partition: &[usize]) -> GraphData {
709    let n = graph.node_count();
710    if n == 0 {
711        return GraphDataBuilder::new(0).build().unwrap();
712    }
713
714    // Map module IDs → contiguous super-node IDs
715    let mut module_to_super: FxHashMap<usize, usize> = FxHashMap::default();
716    let mut orig_to_super = vec![0usize; n];
717    let mut num_super = 0usize;
718    for i in 0..n {
719        let m = partition[i];
720        let super_id = *module_to_super.entry(m).or_insert_with(|| {
721            let id = num_super;
722            num_super += 1;
723            id
724        });
725        orig_to_super[i] = super_id;
726    }
727
728    if num_super == 0 {
729        return GraphDataBuilder::new(0).build().unwrap();
730    }
731
732    // Aggregate edges
733    let mut edge_map: FxHashMap<(usize, usize), f64> = FxHashMap::default();
734
735    if graph.is_directed() {
736        for u in 0..n {
737            for (v, w) in graph.out_neighbors(u) {
738                let su = orig_to_super[u];
739                let sv = orig_to_super[v];
740                *edge_map.entry((su, sv)).or_default() += w;
741            }
742        }
743    } else {
744        for u in 0..n {
745            for (v, w) in graph.out_neighbors(u) {
746                if u == v {
747                    // Self-loop — counted once in the CSR
748                    let su = orig_to_super[u];
749                    *edge_map.entry((su, su)).or_default() += w;
750                } else if v > u {
751                    // Canonical ordering to avoid double counting
752                    let su = orig_to_super[u];
753                    let sv = orig_to_super[v];
754                    let key = if su <= sv { (su, sv) } else { (sv, su) };
755                    *edge_map.entry(key).or_default() += w;
756                }
757            }
758        }
759    }
760
761    // Build aggregated graph
762    let mut builder = GraphDataBuilder::new(num_super);
763    if graph.is_directed() {
764        builder = builder.directed();
765    }
766    for ((u, v), w) in &edge_map {
767        builder.add_edge(*u, *v, *w).unwrap();
768    }
769
770    // Set node weights (sum of original node weights)
771    let mut super_weights = vec![0.0f64; num_super];
772    for i in 0..n {
773        super_weights[orig_to_super[i]] += graph.node_weight(i);
774    }
775    for (node, &w) in super_weights.iter().enumerate() {
776        if (w - 1.0).abs() > f64::EPSILON {
777            builder.set_node_weight(node, w).unwrap();
778        }
779    }
780
781    builder.build().unwrap()
782}
783
784// ── Multi-Level Recursion ────────────────────────────────────────────────────
785
786/// Multi-level Infomap: local moving + aggregation, repeated recursively.
787///
788/// Returns `(partition, codelength)` where `partition[node] = module`.
789///
790/// The algorithm:
791/// 1. Compute boundary flows from PageRank values.
792/// 2. Create a `MapEquation` with each node in its own singleton module.
793/// 3. Run local moving to optimise the partition.
794/// 4. If nodes moved, aggregate the graph and recurse on the super-node graph.
795/// 5. Project the aggregate partition back to original nodes.
796pub fn run_multi_level(
797    graph: &GraphData,
798    flow_data: &[FlowData],
799    config: &InfomapConfig,
800) -> (Vec<usize>, f64) {
801    run_multi_level_impl(graph, flow_data, config, 0)
802}
803
804/// Recursive helper for [`run_multi_level`].
805fn run_multi_level_impl(
806    graph: &GraphData,
807    flow_data: &[FlowData],
808    config: &InfomapConfig,
809    depth: usize,
810) -> (Vec<usize>, f64) {
811    let n = graph.node_count();
812    if n == 0 {
813        return (Vec::new(), 0.0);
814    }
815    if n == 1 {
816        let map_eq = MapEquation::new(
817            &[FlowData {
818                flow: flow_data[0].flow,
819                enter_flow: 0.0,
820                exit_flow: 0.0,
821                teleport_flow: 0.0,
822            }],
823            0.0,
824        );
825        return (vec![0], map_eq.codelength());
826    }
827
828    // Enrich flow_data with enter/exit boundary flows (idempotent)
829    let mut enriched = flow_data.to_vec();
830    if enriched.iter().all(|fd| fd.enter_flow == 0.0 && fd.exit_flow == 0.0) {
831        populate_boundary_flows(graph, &mut enriched, config.teleportation_rate);
832    }
833
834    // Singleton partition
835    let mut partition: Vec<usize> = (0..n).collect();
836    let mut map_eq = MapEquation::new(&enriched, 0.0);
837    let mut rng = StdRng::seed_from_u64(config.seed.unwrap_or(0).wrapping_add(depth as u64));
838
839    let improved = run_local_moving(&mut map_eq, &enriched, graph, &mut partition, &mut rng);
840
841    // Renumber partition to contiguous IDs before aggregation
842    let max_mod = *partition.iter().max().unwrap_or(&0);
843    let mut renumber = vec![usize::MAX; max_mod + 1];
844    let mut next_id = 0usize;
845    for p in partition.iter_mut() {
846        if renumber[*p] == usize::MAX {
847            renumber[*p] = next_id;
848            next_id += 1;
849        }
850        *p = renumber[*p];
851    }
852
853    // Recurse on aggregated graph if nodes moved and depth limit not reached
854    if improved && depth < config.max_iterations {
855        let aggregate = aggregate_graph(graph, &partition);
856        if aggregate.node_count() < n {
857            // Recompute PageRank flow on the aggregated graph
858            let agg_flow = compute_flow(
859                &aggregate,
860                config.teleportation_rate,
861                config.tolerance,
862                config.max_iterations,
863            );
864            let (agg_partition, _agg_codelength) =
865                run_multi_level_impl(&aggregate, &agg_flow, config, depth + 1);
866
867            // Project aggregate partition back to original nodes
868            // partition[i] = super-node for original node i (contiguous 0..num_super)
869            // agg_partition[partition[i]] = aggregate module for that super-node
870            for i in 0..n {
871                partition[i] = agg_partition[partition[i]];
872            }
873        }
874    }
875
876    // Renumber partition to contiguous module IDs
877    let max_mod = *partition.iter().max().unwrap_or(&0);
878    let mut mapping = vec![usize::MAX; max_mod + 1];
879    let mut next_id = 0usize;
880    for p in partition.iter_mut() {
881        if mapping[*p] == usize::MAX {
882            mapping[*p] = next_id;
883            next_id += 1;
884        }
885        *p = mapping[*p];
886    }
887
888    // Recompute codelength on the original graph with the final partition
889    // using a fresh MapEquation (avoid accumulated floating-point drift)
890    let cl = compute_codelength_for_partition(graph, &enriched, &partition, config.teleportation_rate);
891
892    (partition, cl)
893}
894
895/// Compute the Map Equation codelength for a given partition on a graph.
896///
897/// Builds the MapEquation state from scratch: sums per-module flows and
898/// computes inter-module enter/exit flows from link-flow boundary data.
899fn compute_codelength_for_partition(
900    graph: &GraphData,
901    flow_data: &[FlowData],
902    partition: &[usize],
903    teleport_rate: f64,
904) -> f64 {
905    let n = graph.node_count();
906    if n == 0 {
907        return 0.0;
908    }
909
910    let damping = 1.0 - teleport_rate;
911    let num_modules = *partition.iter().max().unwrap_or(&0) + 1;
912
913    // Accumulate per-module flow
914    let mut module_flow = vec![0.0; num_modules];
915
916    for i in 0..n {
917        module_flow[partition[i]] += flow_data[i].flow;
918    }
919
920    // Compute inter-module enter/exit via link flows
921    let mut module_enter = vec![0.0; num_modules];
922    let mut module_exit = vec![0.0; num_modules];
923
924    for u in 0..n {
925        let u_out_deg = graph.out_degree_of(u);
926        if u_out_deg > 0.0 {
927            for (v, w) in graph.out_neighbors(u) {
928                let mu = partition[u];
929                let mv = partition[v];
930                if mu != mv {
931                    let lf = damping * (w / u_out_deg) * flow_data[u].flow;
932                    module_exit[mu] += lf;
933                    module_enter[mv] += lf;
934                }
935            }
936        }
937    }
938
939    // Build module_data
940    let module_data: Vec<ModuleFlowData> = (0..num_modules)
941        .map(|m| ModuleFlowData {
942            flow: module_flow[m],
943            enter_flow: module_enter[m],
944            exit_flow: module_exit[m],
945        })
946        .collect();
947
948    let enter_flow: f64 = module_enter.iter().sum();
949    let node_flow_log: f64 = flow_data.iter().map(|fd| plogp(fd.flow)).sum();
950
951    let map_eq = MapEquation {
952        module_data,
953        enter_flow,
954        enter_flow_log: plogp(enter_flow),
955        enter_log_enter: module_enter.iter().map(|&x| plogp(x)).sum(),
956        exit_log_exit: module_exit.iter().map(|&x| plogp(x)).sum(),
957        flow_log_flow: (0..num_modules)
958            .map(|m| plogp(module_exit[m] + module_flow[m]))
959            .sum(),
960        node_flow_log,
961        exit_network_flow: 0.0,
962    };
963
964    map_eq.codelength()
965}
966
967// ── Node Flow Computation ─────────────────────────────────────────────────────
968
969/// Compute per-node enter/exit boundary flows from PageRank values.
970///
971/// Convenience wrapper that copies PageRank `flow` values and populates
972/// `enter_flow` / `exit_flow` from link-flow boundary data.
973///
974/// * `flow_data` — PageRank flow values (only `.flow` field used).
975/// * `graph` — graph structure.
976/// * `teleport_rate` — teleportation probability α.
977///
978/// Returns a new `Vec<FlowData>` with all boundary fields populated.
979#[must_use]
980pub fn compute_node_flows(
981    graph: &GraphData,
982    flow_data: &[FlowData],
983    teleport_rate: f64,
984) -> Vec<FlowData> {
985    let n = flow_data.len();
986    let mut result = flow_data.to_vec();
987    let damping = 1.0 - teleport_rate;
988
989    let mut enter_flow = vec![0.0; n];
990    let mut exit_flow = vec![0.0; n];
991
992    for u in 0..n {
993        let out_deg = graph.out_degree_of(u);
994        if out_deg > 0.0 {
995            for (v, w) in graph.out_neighbors(u) {
996                let link_flow = damping * (w / out_deg) * result[u].flow;
997                exit_flow[u] += link_flow;
998                enter_flow[v] += link_flow;
999            }
1000        }
1001    }
1002
1003    for i in 0..n {
1004        result[i].enter_flow = enter_flow[i];
1005        result[i].exit_flow = exit_flow[i];
1006    }
1007
1008    result
1009}
1010
1011// ── Fine / Coarse Tuning ─────────────────────────────────────────────────────
1012
1013/// Fine-tuning: re-run local moving starting from the current partition.
1014///
1015/// Each node is re-examined for possible moves to neighbouring modules.
1016/// Accepts moves that reduce codelength (ΔL < 0). Repeats until no
1017/// improvement.
1018///
1019/// Returns `true` if at least one node was moved.
1020pub fn fine_tuning(
1021    map_eq: &mut MapEquation,
1022    flow_data: &[FlowData],
1023    graph: &GraphData,
1024    partition: &mut [usize],
1025    rng: &mut StdRng,
1026) -> bool {
1027    run_local_moving(map_eq, flow_data, graph, partition, rng)
1028}
1029
1030/// Coarse-tuning: second pass of local moving on the current partition.
1031///
1032/// For the P1 two-level Infomap, coarse-tuning is implemented as another
1033/// pass of local moving. It treats the current modules as atomic units and
1034/// tries to further merge or split them.
1035///
1036/// Returns `true` if at least one node was moved.
1037pub fn coarse_tuning(
1038    map_eq: &mut MapEquation,
1039    flow_data: &[FlowData],
1040    graph: &GraphData,
1041    partition: &mut [usize],
1042    rng: &mut StdRng,
1043) -> bool {
1044    run_local_moving(map_eq, flow_data, graph, partition, rng)
1045}
1046
1047/// Renumber a partition so that module IDs are contiguous starting from 0.
1048fn renumber_partition(partition: &mut [usize]) {
1049    let max_mod = *partition.iter().max().unwrap_or(&0);
1050    let mut mapping = vec![usize::MAX; max_mod + 1];
1051    let mut next_id = 0usize;
1052    for p in partition.iter_mut() {
1053        if mapping[*p] == usize::MAX {
1054            mapping[*p] = next_id;
1055            next_id += 1;
1056        }
1057        *p = mapping[*p];
1058    }
1059}
1060
1061/// Build a `MapEquation` state from a given partition and enriched flow data.
1062///
1063/// Computes per-module flow totals and inter-module enter/exit boundary flows.
1064fn build_map_equation_for_partition(
1065    partition: &[usize],
1066    enriched: &[FlowData],
1067    graph: &GraphData,
1068    teleport_rate: f64,
1069) -> MapEquation {
1070    let n = partition.len();
1071    let num_mod = *partition.iter().max().unwrap_or(&0) + 1;
1072    let damping = 1.0 - teleport_rate;
1073
1074    let mut m_flow = vec![0.0; num_mod];
1075    let mut m_enter = vec![0.0; num_mod];
1076    let mut m_exit = vec![0.0; num_mod];
1077
1078    for i in 0..n {
1079        m_flow[partition[i]] += enriched[i].flow;
1080    }
1081    for u in 0..n {
1082        let u_od = graph.out_degree_of(u);
1083        if u_od > 0.0 {
1084            for (v, w) in graph.out_neighbors(u) {
1085                let mu = partition[u];
1086                let mv = partition[v];
1087                if mu != mv {
1088                    let lf = damping * (w / u_od) * enriched[u].flow;
1089                    m_exit[mu] += lf;
1090                    m_enter[mv] += lf;
1091                }
1092            }
1093        }
1094    }
1095
1096    let enter_flow: f64 = m_enter.iter().sum();
1097    let node_flow_log: f64 = enriched.iter().map(|fd| plogp(fd.flow)).sum();
1098
1099    MapEquation {
1100        module_data: (0..num_mod)
1101            .map(|m| ModuleFlowData {
1102                flow: m_flow[m],
1103                enter_flow: m_enter[m],
1104                exit_flow: m_exit[m],
1105            })
1106            .collect(),
1107        enter_flow,
1108        enter_flow_log: plogp(enter_flow),
1109        enter_log_enter: m_enter.iter().map(|&x| plogp(x)).sum(),
1110        exit_log_exit: m_exit.iter().map(|&x| plogp(x)).sum(),
1111        flow_log_flow: (0..num_mod)
1112            .map(|m| plogp(m_exit[m] + m_flow[m]))
1113            .sum(),
1114        node_flow_log,
1115        exit_network_flow: 0.0,
1116    }
1117}
1118
1119// ── Infomap Struct ───────────────────────────────────────────────────────────
1120
1121/// Infomap community detection algorithm.
1122///
1123/// Wraps the full Infomap pipeline: PageRank flow computation, multi-level
1124/// local moving, fine/coarse tuning, and multi-trial optimisation.
1125///
1126/// # Example
1127///
1128/// ```ignore
1129/// use leiden_rs::infomap::{Infomap, InfomapConfig};
1130/// use leiden_rs::graph::GraphDataBuilder;
1131///
1132/// let mut b = GraphDataBuilder::new(6);
1133/// b.add_edge(0, 1, 5.0).unwrap();
1134/// b.add_edge(1, 2, 5.0).unwrap();
1135/// b.add_edge(0, 2, 5.0).unwrap();
1136/// b.add_edge(3, 4, 5.0).unwrap();
1137/// b.add_edge(4, 5, 5.0).unwrap();
1138/// b.add_edge(3, 5, 5.0).unwrap();
1139/// b.add_edge(2, 3, 0.1).unwrap();
1140/// let graph = b.build().unwrap();
1141///
1142/// let config = InfomapConfig { seed: Some(42), ..Default::default() };
1143/// let infomap = Infomap::new(config);
1144/// let result = infomap.run(&graph);
1145/// ```
1146pub struct Infomap {
1147    config: InfomapConfig,
1148}
1149
1150impl Infomap {
1151    /// Create a new Infomap instance with the given configuration.
1152    #[must_use = "constructor returns a new instance"]
1153    pub fn new(config: InfomapConfig) -> Self {
1154        Self { config }
1155    }
1156
1157    /// Run the full Infomap pipeline on the given graph.
1158    ///
1159    /// 1. Compute PageRank flow.
1160    /// 2. Compute node enter/exit boundary flows.
1161    /// 3. For each trial (with a different seed):
1162    ///    a. Run multi-level local moving + aggregation.
1163    ///    b. Fine-tune the resulting partition.
1164    ///    c. Alternate coarse-tune and fine-tune until convergence.
1165    /// 4. Keep the partition with the best codelength across all trials.
1166    ///
1167    /// Returns an [`InfomapOutput`] with the best partition found.
1168    pub fn run(&self, graph: &GraphData) -> InfomapOutput {
1169        let n = graph.node_count();
1170        if n == 0 {
1171            return InfomapOutput {
1172                partition: Partition::new(0),
1173                codelength: 0.0,
1174                num_levels: 1,
1175                iterations: 0,
1176            };
1177        }
1178
1179        let flow_values = compute_flow(
1180            graph,
1181            self.config.teleportation_rate,
1182            self.config.tolerance,
1183            self.config.max_iterations,
1184        );
1185
1186        let flow_data = compute_node_flows(graph, &flow_values, self.config.teleportation_rate);
1187        let mut enriched = flow_data.clone();
1188        populate_boundary_flows(graph, &mut enriched, self.config.teleportation_rate);
1189
1190        let mut best_partition: Option<Vec<usize>> = None;
1191        let mut best_codelength = f64::INFINITY;
1192        let mut total_iterations = 0usize;
1193
1194        for trial in 0..self.config.num_trials {
1195            let seed = self
1196                .config
1197                .seed
1198                .map(|s| s.wrapping_add((trial as u64).wrapping_mul(0x9e3779b97f4a7c15)));
1199            let mut rng = StdRng::seed_from_u64(seed.unwrap_or_else(|| {
1200                (trial as u64).wrapping_mul(0x517cc1b727220a95)
1201            }));
1202
1203            let trial_config = InfomapConfig {
1204                seed,
1205                ..self.config.clone()
1206            };
1207            let (mut partition, _cl) = run_multi_level(graph, &flow_data, &trial_config);
1208            total_iterations += 1;
1209
1210            let mut map_eq = build_map_equation_for_partition(
1211                &partition, &enriched, graph, self.config.teleportation_rate,
1212            );
1213
1214            let _ = fine_tuning(&mut map_eq, &enriched, graph, &mut partition, &mut rng);
1215            renumber_partition(&mut partition);
1216
1217            for _ in 0..10 {
1218                let cl_before = compute_codelength_for_partition(
1219                    graph, &enriched, &partition, self.config.teleportation_rate,
1220                );
1221
1222                map_eq = build_map_equation_for_partition(
1223                    &partition, &enriched, graph, self.config.teleportation_rate,
1224                );
1225
1226                let improved1 = coarse_tuning(
1227                    &mut map_eq, &enriched, graph, &mut partition, &mut rng,
1228                );
1229                renumber_partition(&mut partition);
1230
1231                map_eq = build_map_equation_for_partition(
1232                    &partition, &enriched, graph, self.config.teleportation_rate,
1233                );
1234
1235                let improved2 = fine_tuning(
1236                    &mut map_eq, &enriched, graph, &mut partition, &mut rng,
1237                );
1238                renumber_partition(&mut partition);
1239                total_iterations += 1;
1240
1241                if !improved1 && !improved2 {
1242                    break;
1243                }
1244
1245                let cl_after = compute_codelength_for_partition(
1246                    graph, &enriched, &partition, self.config.teleportation_rate,
1247                );
1248
1249                if (cl_before - cl_after).abs() < self.config.tolerance {
1250                    break;
1251                }
1252            }
1253
1254            let cl = compute_codelength_for_partition(
1255                graph, &enriched, &partition, self.config.teleportation_rate,
1256            );
1257            if cl < best_codelength {
1258                best_codelength = cl;
1259                best_partition = Some(partition);
1260            }
1261        }
1262
1263        let mut partition = best_partition.unwrap_or_else(|| (0..n).collect());
1264        renumber_partition(&mut partition);
1265
1266        InfomapOutput {
1267            partition: Partition::from_membership(partition),
1268            codelength: best_codelength,
1269            num_levels: 1,
1270            iterations: total_iterations,
1271        }
1272    }
1273}
1274
1275// ── Tests ────────────────────────────────────────────────────────────────────
1276
1277#[cfg(test)]
1278mod tests {
1279    use super::*;
1280    use crate::graph::GraphDataBuilder;
1281
1282    // ── plogp tests ──
1283
1284    #[test]
1285    fn test_plogp_zero() {
1286        // plogp(0.0) must be 0.0, never NaN.
1287        let result = plogp(0.0);
1288        assert!(
1289            result == 0.0,
1290            "plogp(0.0) should be exactly 0.0, got {result}"
1291        );
1292        assert!(!result.is_nan(), "plogp(0.0) must not be NaN");
1293    }
1294
1295    #[test]
1296    fn test_plogp_negative() {
1297        assert_eq!(plogp(-1.0), 0.0, "plogp(negative) should be 0.0");
1298        assert_eq!(plogp(-0.001), 0.0);
1299    }
1300
1301    #[test]
1302    fn test_plogp_value() {
1303        // plogp(0.5) = 0.5 * log2(0.5) = 0.5 * (-1) = -0.5
1304        let result = plogp(0.5);
1305        assert!(
1306            (result - (-0.5)).abs() < 1e-10,
1307            "plogp(0.5) should be -0.5, got {result}"
1308        );
1309    }
1310
1311    #[test]
1312    fn test_plogp_one() {
1313        // plogp(1.0) = 1.0 * log2(1.0) = 0.0
1314        let result = plogp(1.0);
1315        assert!(
1316            result.abs() < 1e-10,
1317            "plogp(1.0) should be 0.0, got {result}"
1318        );
1319    }
1320
1321    // ── FlowData tests ──
1322
1323    #[test]
1324    fn test_flow_data_construction() {
1325        let fd = FlowData {
1326            flow: 0.25,
1327            enter_flow: 0.1,
1328            exit_flow: 0.15,
1329            teleport_flow: 0.05,
1330        };
1331        assert!((fd.flow - 0.25).abs() < 1e-10);
1332        assert!((fd.enter_flow - 0.1).abs() < 1e-10);
1333        assert!((fd.exit_flow - 0.15).abs() < 1e-10);
1334        assert!((fd.teleport_flow - 0.05).abs() < 1e-10);
1335    }
1336
1337    #[test]
1338    fn test_flow_data_default_fields() {
1339        let fd = FlowData {
1340            flow: 0.5,
1341            enter_flow: 0.0,
1342            exit_flow: 0.0,
1343            teleport_flow: 0.0,
1344        };
1345        assert!((fd.flow - 0.5).abs() < 1e-10);
1346        assert_eq!(fd.enter_flow, 0.0);
1347        assert_eq!(fd.exit_flow, 0.0);
1348        assert_eq!(fd.teleport_flow, 0.0);
1349    }
1350
1351    // ── DeltaFlow tests ──
1352
1353    #[test]
1354    fn test_delta_flow_construction() {
1355        let df = DeltaFlow {
1356            module: 2,
1357            delta_exit: 0.3,
1358            delta_enter: 0.1,
1359        };
1360        assert_eq!(df.module, 2);
1361        assert!((df.delta_exit - 0.3).abs() < 1e-10);
1362        assert!((df.delta_enter - 0.1).abs() < 1e-10);
1363    }
1364
1365    // ── InfomapConfig tests ──
1366
1367    #[test]
1368    fn test_infomap_config_default() {
1369        let cfg = InfomapConfig::default();
1370        assert_eq!(cfg.seed, None);
1371        assert_eq!(cfg.max_iterations, 100);
1372        assert!((cfg.teleportation_rate - 0.15).abs() < 1e-10);
1373        assert_eq!(cfg.num_trials, 10);
1374        assert!((cfg.tolerance - 1e-10).abs() < 1e-20);
1375    }
1376
1377    #[test]
1378    fn test_infomap_config_new() {
1379        let cfg = InfomapConfig::new(Some(42));
1380        assert_eq!(cfg.seed, Some(42));
1381        assert_eq!(cfg.max_iterations, 100); // defaults preserved
1382    }
1383
1384    // ── InfomapOutput tests ──
1385
1386    #[test]
1387    fn test_infomap_output_construction() {
1388        let output = InfomapOutput {
1389            partition: Partition::new(3),
1390            codelength: 1.5,
1391            num_levels: 2,
1392            iterations: 10,
1393        };
1394        assert_eq!(output.partition.len(), 3);
1395        assert!((output.codelength - 1.5).abs() < 1e-10);
1396        assert_eq!(output.num_levels, 2);
1397        assert_eq!(output.iterations, 10);
1398    }
1399
1400    // ── compute_flow (PageRank) tests ──
1401
1402    #[test]
1403    fn test_pagerank_empty_graph() {
1404        let graph = GraphDataBuilder::new(0).build().unwrap();
1405        let result = compute_flow(&graph, 0.15, 1e-10, 100);
1406        assert!(result.is_empty());
1407    }
1408
1409    #[test]
1410    fn test_pagerank_single_node() {
1411        let graph = GraphDataBuilder::new(1).build().unwrap();
1412        let result = compute_flow(&graph, 0.15, 1e-10, 100);
1413        assert_eq!(result.len(), 1);
1414        assert!((result[0].flow - 1.0).abs() < 1e-6);
1415    }
1416
1417    #[test]
1418    fn test_pagerank_triangle() {
1419        // Undirected 3-node triangle: symmetric → equal PageRank 1/3 each.
1420        let mut b = GraphDataBuilder::new(3);
1421        b.add_edge(0, 1, 1.0).unwrap();
1422        b.add_edge(1, 2, 1.0).unwrap();
1423        b.add_edge(0, 2, 1.0).unwrap();
1424        let graph = b.build().unwrap();
1425
1426        let result = compute_flow(&graph, 0.15, 1e-10, 200);
1427        assert_eq!(result.len(), 3);
1428
1429        let expected = 1.0 / 3.0;
1430        for (i, fd) in result.iter().enumerate() {
1431            assert!(
1432                (fd.flow - expected).abs() < 1e-6,
1433                "node {i}: flow = {}, expected {expected}",
1434                fd.flow
1435            );
1436        }
1437
1438        // Total flow must sum to 1.0.
1439        let total: f64 = result.iter().map(|fd| fd.flow).sum();
1440        assert!(
1441            (total - 1.0).abs() < 1e-6,
1442            "total flow = {total}, expected 1.0"
1443        );
1444    }
1445
1446    #[test]
1447    fn test_pagerank_dangling() {
1448        // Directed graph: 0 → 1, node 2 has no out-edges (dangling).
1449        // Flow should still sum to 1.0 and dangling node should have non-zero flow.
1450        let mut b = GraphDataBuilder::new(3).directed();
1451        b.add_edge(0, 1, 1.0).unwrap();
1452        // Node 2 has no edges at all (isolated + dangling).
1453        let graph = b.build().unwrap();
1454
1455        let result = compute_flow(&graph, 0.15, 1e-10, 200);
1456        assert_eq!(result.len(), 3);
1457
1458        // Total flow must sum to 1.0.
1459        let total: f64 = result.iter().map(|fd| fd.flow).sum();
1460        assert!(
1461            (total - 1.0).abs() < 1e-6,
1462            "total flow = {total}, expected 1.0"
1463        );
1464
1465        // Dangling/isolated node 2 must have non-zero flow (gets flow via teleportation).
1466        assert!(
1467            result[2].flow > 0.0,
1468            "dangling node 2 should have flow > 0, got {}",
1469            result[2].flow
1470        );
1471    }
1472
1473    #[test]
1474    fn test_pagerank_directed_path() {
1475        // Directed 2-node path: 0 → 1.
1476        // Node 0 has out-degree 1, node 1 is a dangling node (no out-edges).
1477        // With teleportation, node 1 should have higher PageRank than node 0
1478        // because node 0 sends all its flow to node 1, plus node 1 gets
1479        // teleportation flow.
1480        let mut b = GraphDataBuilder::new(2).directed();
1481        b.add_edge(0, 1, 1.0).unwrap();
1482        let graph = b.build().unwrap();
1483
1484        let result = compute_flow(&graph, 0.15, 1e-10, 200);
1485        assert_eq!(result.len(), 2);
1486
1487        let total: f64 = result.iter().map(|fd| fd.flow).sum();
1488        assert!(
1489            (total - 1.0).abs() < 1e-6,
1490            "total flow = {total}, expected 1.0"
1491        );
1492
1493        // Both nodes must have positive flow.
1494        assert!(result[0].flow > 0.0, "node 0 flow should be positive");
1495        assert!(result[1].flow > 0.0, "node 1 flow should be positive");
1496
1497        // Node 1 should have higher flow: it receives from 0 + teleportation.
1498        assert!(
1499            result[1].flow > result[0].flow,
1500            "node 1 ({}) should have higher flow than node 0 ({})",
1501            result[1].flow,
1502            result[0].flow
1503        );
1504    }
1505
1506    #[test]
1507    fn test_pagerank_disconnected() {
1508        // 4 nodes: two disconnected edges (0-1, 2-3), undirected.
1509        // Each component should have equal flow within it.
1510        let mut b = GraphDataBuilder::new(4);
1511        b.add_edge(0, 1, 1.0).unwrap();
1512        b.add_edge(2, 3, 1.0).unwrap();
1513        let graph = b.build().unwrap();
1514
1515        let result = compute_flow(&graph, 0.15, 1e-10, 200);
1516        assert_eq!(result.len(), 4);
1517
1518        let total: f64 = result.iter().map(|fd| fd.flow).sum();
1519        assert!(
1520            (total - 1.0).abs() < 1e-6,
1521            "total flow = {total}, expected 1.0"
1522        );
1523
1524        // Due to teleportation, all nodes converge to ~0.25 (uniform).
1525        // The graph is symmetric with equal degree for all nodes.
1526        for (i, fd) in result.iter().enumerate() {
1527            assert!(
1528                (fd.flow - 0.25).abs() < 0.05,
1529                "node {i}: flow = {}, expected ~0.25",
1530                fd.flow
1531            );
1532        }
1533    }
1534
1535    #[test]
1536    fn test_pagerank_converges_before_max_iter() {
1537        // Small graph should converge well before 10000 iterations.
1538        let mut b = GraphDataBuilder::new(3);
1539        b.add_edge(0, 1, 1.0).unwrap();
1540        b.add_edge(1, 2, 1.0).unwrap();
1541        b.add_edge(2, 0, 1.0).unwrap();
1542        let graph = b.build().unwrap();
1543
1544        let result_strict = compute_flow(&graph, 0.15, 1e-10, 200);
1545        let result_loose = compute_flow(&graph, 0.15, 1e-10, 200);
1546
1547        // Both should produce essentially the same result.
1548        for (a, b) in result_strict.iter().zip(result_loose.iter()) {
1549            assert!(
1550                (a.flow - b.flow).abs() < 1e-10,
1551                "results should be identical"
1552            );
1553        }
1554    }
1555
1556    // ── MapEquation / ΔL tests ──
1557
1558    /// Helper: build a FlowData with the given flow, enter_flow, exit_flow.
1559    fn fd(flow: f64, enter_flow: f64, exit_flow: f64) -> FlowData {
1560        FlowData {
1561            flow,
1562            enter_flow,
1563            exit_flow,
1564            teleport_flow: 0.0,
1565        }
1566    }
1567
1568    /// 4-node test graph node flows (sum = 1.0).
1569    ///
1570    /// Edge flows:
1571    ///   0→1: 0.05, 1→0: 0.04, 0→2: 0.03, 2→0: 0.02, 0→3: 0.04, 3→0: 0.04
1572    ///   1→2: 0.03, 2→1: 0.02, 1→3: 0.02, 3→1: 0.02, 2→3: 0.04, 3→2: 0.02
1573    ///
1574    /// Node flows:    [0.30, 0.25, 0.25, 0.20]
1575    /// Node enter:    [0.10, 0.09, 0.08, 0.10]  (total incoming edge flow)
1576    /// Node exit:     [0.12, 0.09, 0.08, 0.08]  (total outgoing edge flow)
1577    fn four_node_flows() -> [FlowData; 4] {
1578        [
1579            fd(0.30, 0.10, 0.12),
1580            fd(0.25, 0.09, 0.09),
1581            fd(0.25, 0.08, 0.08),
1582            fd(0.20, 0.10, 0.08),
1583        ]
1584    }
1585
1586    #[test]
1587    fn test_map_equation_singleton_partition_codelength() {
1588        let nodes = four_node_flows();
1589        let map_eq = MapEquation::new(&nodes, 0.0);
1590
1591        let cl = map_eq.codelength();
1592        // Hand-computed: 1.889169354568307
1593        assert!(
1594            (cl - 1.889169354568307).abs() < 1e-12,
1595            "singleton codelength: got {cl:.15}, expected 1.889169354568307"
1596        );
1597
1598        // Standalone function should give same result.
1599        assert!(
1600            (calc_codelength(&map_eq) - cl).abs() < 1e-15,
1601            "calc_codelength should match method"
1602        );
1603    }
1604
1605    #[test]
1606    fn test_map_equation_calc_codelength_matches_terms() {
1607        let nodes = four_node_flows();
1608        let map_eq = MapEquation::new(&nodes, 0.0);
1609
1610        // Verify cached terms match independent recomputation.
1611        let expected_enter_flow_log = plogp(map_eq.enter_flow);
1612        let expected_enter_log_enter: f64 =
1613            map_eq.module_data.iter().map(|m| plogp(m.enter_flow)).sum();
1614        let expected_exit_log_exit: f64 =
1615            map_eq.module_data.iter().map(|m| plogp(m.exit_flow)).sum();
1616        let expected_flow_log_flow: f64 = map_eq
1617            .module_data
1618            .iter()
1619            .map(|m| plogp(m.exit_flow + m.flow))
1620            .sum();
1621
1622        assert!(
1623            (map_eq.enter_flow_log - expected_enter_flow_log).abs() < 1e-15,
1624            "enter_flow_log mismatch"
1625        );
1626        assert!(
1627            (map_eq.enter_log_enter - expected_enter_log_enter).abs() < 1e-15,
1628            "enter_log_enter mismatch"
1629        );
1630        assert!(
1631            (map_eq.exit_log_exit - expected_exit_log_exit).abs() < 1e-15,
1632            "exit_log_exit mismatch"
1633        );
1634        assert!(
1635            (map_eq.flow_log_flow - expected_flow_log_flow).abs() < 1e-15,
1636            "flow_log_flow mismatch"
1637        );
1638    }
1639
1640    #[test]
1641    fn test_map_equation_recalc_terms() {
1642        let nodes = four_node_flows();
1643        let mut map_eq = MapEquation::new(&nodes, 0.0);
1644        let original_cl = map_eq.codelength();
1645
1646        map_eq.recalc_terms();
1647
1648        assert!(
1649            (map_eq.codelength() - original_cl).abs() < 1e-15,
1650            "recalc_terms should not change codelength"
1651        );
1652    }
1653
1654    // ── ΔL Test A: singleton → singleton ──
1655
1656    #[test]
1657    fn test_delta_codelength_singleton_to_singleton() {
1658        // Move node 0 from module 0 (singleton) to module 1 (singleton).
1659        //
1660        // DeltaFlow for old module (0, self-module, singleton):
1661        //   deltaExit=0, deltaEnter=0 (no internal edges in a singleton)
1662        // DeltaFlow for new module (1):
1663        //   deltaExit = flow 0→1 = 0.05
1664        //   deltaEnter = flow 1→0 = 0.04
1665        //
1666        // Hand-computed ΔL = 0.058917207167547 (positive → bad move)
1667
1668        let nodes = four_node_flows();
1669        let map_eq = MapEquation::new(&nodes, 0.0);
1670
1671        let current = nodes[0].clone();
1672        let old_delta = DeltaFlow {
1673            module: 0,
1674            delta_exit: 0.0,
1675            delta_enter: 0.0,
1676        };
1677        let new_delta = DeltaFlow {
1678            module: 1,
1679            delta_exit: 0.05,
1680            delta_enter: 0.04,
1681        };
1682
1683        let dl = map_eq.get_delta_codelength_on_moving_node(
1684            &current, 0, 1, &old_delta, &new_delta,
1685        );
1686
1687        let expected = 0.058917207167547;
1688        assert!(
1689            (dl - expected).abs() < 1e-12,
1690            "ΔL singleton→singleton: got {dl:.15}, expected {expected:.15}"
1691        );
1692
1693        // Verify: codelength_after - codelength_before should equal ΔL.
1694        let cl_before = map_eq.codelength();
1695        let mut map_eq2 = map_eq.clone();
1696        map_eq2.update_codelength_on_moving_node(
1697            &current, 0, 1, &old_delta, &new_delta,
1698        );
1699        let cl_after = map_eq2.codelength();
1700        assert!(
1701            ((cl_after - cl_before) - dl).abs() < 1e-12,
1702            "codelength diff ({:.15}) should equal ΔL ({:.15})",
1703            cl_after - cl_before,
1704            dl
1705        );
1706    }
1707
1708    // ── ΔL Test B: singleton into multi-node module ──
1709
1710    #[test]
1711    fn test_delta_codelength_into_multi_node_module() {
1712        // Initial partition: {0,1}, {2}, {3}
1713        // Module 0 ({0,1}): flow=0.55, enter=0.10, exit=0.12
1714        // Module 1 ({2}):    flow=0.25, enter=0.08, exit=0.08
1715        // Module 2 ({3}):    flow=0.20, enter=0.10, exit=0.08
1716        //
1717        // Move node 2 (flow=0.25, enter=0.08, exit=0.08) from module 1 into module 0.
1718        //
1719        // DeltaFlow for old module (1, self-module, singleton):
1720        //   deltaExit=0, deltaEnter=0
1721        // DeltaFlow for new module (0):
1722        //   deltaExit = flow 2→{0,1} = 2→0(0.02) + 2→1(0.02) = 0.04
1723        //   deltaEnter = flow {0,1}→2 = 0→2(0.03) + 1→2(0.03) = 0.06
1724        //
1725        // Hand-computed ΔL = 0.188460591871736 (positive → bad move)
1726
1727        let nodes = four_node_flows();
1728        let module_data = vec![
1729            ModuleFlowData {
1730                flow: 0.55,
1731                enter_flow: 0.10,
1732                exit_flow: 0.12,
1733            },
1734            ModuleFlowData {
1735                flow: 0.25,
1736                enter_flow: 0.08,
1737                exit_flow: 0.08,
1738            },
1739            ModuleFlowData {
1740                flow: 0.20,
1741                enter_flow: 0.10,
1742                exit_flow: 0.08,
1743            },
1744        ];
1745        let enter_flow = 0.28_f64;
1746        let enter_log_enter: f64 = module_data.iter().map(|m| plogp(m.enter_flow)).sum();
1747        let exit_log_exit: f64 = module_data.iter().map(|m| plogp(m.exit_flow)).sum();
1748        let flow_log_flow: f64 = module_data.iter().map(|m| plogp(m.exit_flow + m.flow)).sum();
1749        let map_eq = MapEquation {
1750            module_data,
1751            enter_flow,
1752            enter_flow_log: plogp(enter_flow),
1753            enter_log_enter,
1754            exit_log_exit,
1755            flow_log_flow,
1756            node_flow_log: nodes.iter().map(|n| plogp(n.flow)).sum(),
1757            exit_network_flow: 0.0,
1758        };
1759
1760        let current = nodes[2].clone();
1761        let old_delta = DeltaFlow {
1762            module: 1,
1763            delta_exit: 0.0,
1764            delta_enter: 0.0,
1765        };
1766        let new_delta = DeltaFlow {
1767            module: 0,
1768            delta_exit: 0.04,
1769            delta_enter: 0.06,
1770        };
1771
1772        let dl = map_eq.get_delta_codelength_on_moving_node(
1773            &current, 1, 0, &old_delta, &new_delta,
1774        );
1775
1776        let expected = 0.188460591871736;
1777        assert!(
1778            (dl - expected).abs() < 1e-12,
1779            "ΔL into multi-node module: got {dl:.15}, expected {expected:.15}"
1780        );
1781
1782        let cl_before = map_eq.codelength();
1783        let mut map_eq2 = map_eq.clone();
1784        map_eq2.update_codelength_on_moving_node(
1785            &current, 1, 0, &old_delta, &new_delta,
1786        );
1787        let cl_after = map_eq2.codelength();
1788        assert!(
1789            ((cl_after - cl_before) - dl).abs() < 1e-12,
1790            "codelength diff ({:.15}) should equal ΔL ({:.15})",
1791            cl_after - cl_before,
1792            dl
1793        );
1794    }
1795
1796    // ── ΔL Test C: out of multi-node module into empty module ──
1797
1798    #[test]
1799    fn test_delta_codelength_out_of_multi_node_module() {
1800        // Initial partition: {0,1,2}, {3}
1801        // Module 0 ({0,1,2}): flow=0.80, enter=0.08, exit=0.10
1802        // Module 1 ({3}):      flow=0.20, enter=0.10, exit=0.08
1803        //
1804        // Move node 1 (flow=0.25, enter=0.09, exit=0.09) from module 0 into
1805        // empty module 2.
1806        //
1807        // DeltaFlow for old module (0):
1808        //   deltaExit = flow 1→{0,2} = 1→0(0.04) + 1→2(0.03) = 0.07
1809        //   deltaEnter = flow {0,2}→1 = 0→1(0.05) + 2→1(0.02) = 0.07
1810        // DeltaFlow for new module (2, empty):
1811        //   deltaExit = 0, deltaEnter = 0
1812        //
1813        // Hand-computed ΔL = -0.038503252540231 (negative → good move!)
1814
1815        let nodes = four_node_flows();
1816        let module_data = vec![
1817            ModuleFlowData {
1818                flow: 0.80,
1819                enter_flow: 0.08,
1820                exit_flow: 0.10,
1821            },
1822            ModuleFlowData {
1823                flow: 0.20,
1824                enter_flow: 0.10,
1825                exit_flow: 0.08,
1826            },
1827            ModuleFlowData {
1828                flow: 0.0,
1829                enter_flow: 0.0,
1830                exit_flow: 0.0,
1831            },
1832        ];
1833        let enter_flow = 0.18_f64;
1834        let enter_log_enter: f64 = module_data.iter().map(|m| plogp(m.enter_flow)).sum();
1835        let exit_log_exit: f64 = module_data.iter().map(|m| plogp(m.exit_flow)).sum();
1836        let flow_log_flow: f64 = module_data.iter().map(|m| plogp(m.exit_flow + m.flow)).sum();
1837        let map_eq = MapEquation {
1838            module_data,
1839            enter_flow,
1840            enter_flow_log: plogp(enter_flow),
1841            enter_log_enter,
1842            exit_log_exit,
1843            flow_log_flow,
1844            node_flow_log: nodes.iter().map(|n| plogp(n.flow)).sum(),
1845            exit_network_flow: 0.0,
1846        };
1847
1848        let current = nodes[1].clone();
1849        let old_delta = DeltaFlow {
1850            module: 0,
1851            delta_exit: 0.07,
1852            delta_enter: 0.07,
1853        };
1854        let new_delta = DeltaFlow {
1855            module: 2,
1856            delta_exit: 0.0,
1857            delta_enter: 0.0,
1858        };
1859
1860        let dl = map_eq.get_delta_codelength_on_moving_node(
1861            &current, 0, 2, &old_delta, &new_delta,
1862        );
1863
1864        let expected = -0.038503252540231;
1865        assert!(
1866            (dl - expected).abs() < 1e-12,
1867            "ΔL out of multi-node module: got {dl:.15}, expected {expected:.15}"
1868        );
1869
1870        let cl_before = map_eq.codelength();
1871        let mut map_eq2 = map_eq.clone();
1872        map_eq2.update_codelength_on_moving_node(
1873            &current, 0, 2, &old_delta, &new_delta,
1874        );
1875        let cl_after = map_eq2.codelength();
1876        assert!(
1877            ((cl_after - cl_before) - dl).abs() < 1e-12,
1878            "codelength diff ({:.15}) should equal ΔL ({:.15})",
1879            cl_after - cl_before,
1880            dl
1881        );
1882
1883        // Verify the updated module data is correct.
1884        // After move: {0,2}, {3}, {1}
1885        // Module 0 ({0,2}): flow=0.55, enter=0.13, exit=0.15
1886        // Module 1 ({3}):   flow=0.20, enter=0.10, exit=0.08 (unchanged)
1887        // Module 2 ({1}):   flow=0.25, enter=0.09, exit=0.09
1888        assert!(
1889            (map_eq2.module_data[0].flow - 0.55).abs() < 1e-12,
1890            "module 0 flow after move"
1891        );
1892        assert!(
1893            (map_eq2.module_data[0].enter_flow - 0.13).abs() < 1e-12,
1894            "module 0 enter after move"
1895        );
1896        assert!(
1897            (map_eq2.module_data[0].exit_flow - 0.15).abs() < 1e-12,
1898            "module 0 exit after move"
1899        );
1900        assert!(
1901            (map_eq2.module_data[2].flow - 0.25).abs() < 1e-12,
1902            "module 2 flow after move"
1903        );
1904        assert!(
1905            (map_eq2.module_data[2].enter_flow - 0.09).abs() < 1e-12,
1906            "module 2 enter after move"
1907        );
1908        assert!(
1909            (map_eq2.module_data[2].exit_flow - 0.09).abs() < 1e-12,
1910            "module 2 exit after move"
1911        );
1912    }
1913
1914    #[test]
1915    fn test_map_equation_update_recalc_consistency() {
1916        // After any update, recalc_terms() should produce identical cached values.
1917        let nodes = four_node_flows();
1918        let mut map_eq = MapEquation::new(&nodes, 0.0);
1919
1920        let current = nodes[0].clone();
1921        let old_delta = DeltaFlow {
1922            module: 0,
1923            delta_exit: 0.0,
1924            delta_enter: 0.0,
1925        };
1926        let new_delta = DeltaFlow {
1927            module: 1,
1928            delta_exit: 0.05,
1929            delta_enter: 0.04,
1930        };
1931
1932        map_eq.update_codelength_on_moving_node(
1933            &current, 0, 1, &old_delta, &new_delta,
1934        );
1935        let cl_incremental = map_eq.codelength();
1936
1937        map_eq.recalc_terms();
1938        let cl_recalc = map_eq.codelength();
1939
1940        assert!(
1941            (cl_incremental - cl_recalc).abs() < 1e-15,
1942            "incremental update ({cl_incremental:.15}) should match recalc ({cl_recalc:.15})"
1943        );
1944    }
1945
1946    #[test]
1947    fn test_map_equation_add_remove_flow_roundtrip() {
1948        let nodes = four_node_flows();
1949        let map_eq = MapEquation::new(&nodes, 0.0);
1950        let mut map_eq2 = map_eq.clone();
1951
1952        // Add node 0's flow to module 1, then remove it.
1953        map_eq2.add_flow_to_module(1, &nodes[0]);
1954        map_eq2.remove_flow_from_module(1, &nodes[0]);
1955
1956        // Module 1 should be back to original.
1957        assert!(
1958            (map_eq2.module_data[1].flow - map_eq.module_data[1].flow).abs() < 1e-15,
1959            "flow roundtrip"
1960        );
1961        assert!(
1962            (map_eq2.module_data[1].enter_flow - map_eq.module_data[1].enter_flow).abs() < 1e-15,
1963            "enter roundtrip"
1964        );
1965        assert!(
1966            (map_eq2.module_data[1].exit_flow - map_eq.module_data[1].exit_flow).abs() < 1e-15,
1967            "exit roundtrip"
1968        );
1969    }
1970
1971    // ── Local Moving / Aggregation / Multi-Level Tests ──
1972
1973    #[test]
1974    fn test_infomap_local_moving_reduces_codelength() {
1975        let mut b = GraphDataBuilder::new(6);
1976        b.add_edge(0, 1, 5.0).unwrap();
1977        b.add_edge(1, 2, 5.0).unwrap();
1978        b.add_edge(0, 2, 5.0).unwrap();
1979        b.add_edge(3, 4, 5.0).unwrap();
1980        b.add_edge(4, 5, 5.0).unwrap();
1981        b.add_edge(3, 5, 5.0).unwrap();
1982        b.add_edge(2, 3, 0.1).unwrap();
1983        let graph = b.build().unwrap();
1984
1985        let mut flow_data = compute_flow(&graph, 0.15, 1e-10, 200);
1986        populate_boundary_flows(&graph, &mut flow_data, 0.15);
1987
1988        let mut map_eq = MapEquation::new(&flow_data, 0.0);
1989        let cl_before = map_eq.codelength();
1990
1991        let mut partition: Vec<usize> = (0..6).collect();
1992        let mut rng = StdRng::seed_from_u64(42);
1993        let changed = run_local_moving(&mut map_eq, &flow_data, &graph, &mut partition, &mut rng);
1994
1995        assert!(changed, "local moving should move at least one node");
1996        let cl_after = map_eq.codelength();
1997        assert!(
1998            cl_after < cl_before,
1999            "codelength should decrease: before={cl_before:.6}, after={cl_after:.6}"
2000        );
2001    }
2002
2003    #[test]
2004    fn test_infomap_local_moving_finds_two_communities() {
2005        let mut b = GraphDataBuilder::new(6);
2006        b.add_edge(0, 1, 5.0).unwrap();
2007        b.add_edge(1, 2, 5.0).unwrap();
2008        b.add_edge(0, 2, 5.0).unwrap();
2009        b.add_edge(3, 4, 5.0).unwrap();
2010        b.add_edge(4, 5, 5.0).unwrap();
2011        b.add_edge(3, 5, 5.0).unwrap();
2012        b.add_edge(2, 3, 0.1).unwrap();
2013        let graph = b.build().unwrap();
2014
2015        let mut flow_data = compute_flow(&graph, 0.15, 1e-10, 200);
2016        populate_boundary_flows(&graph, &mut flow_data, 0.15);
2017
2018        let mut map_eq = MapEquation::new(&flow_data, 0.0);
2019        let mut partition: Vec<usize> = (0..6).collect();
2020        let mut rng = StdRng::seed_from_u64(42);
2021        run_local_moving(&mut map_eq, &flow_data, &graph, &mut partition, &mut rng);
2022
2023        assert_eq!(partition[0], partition[1], "nodes 0,1 same community");
2024        assert_eq!(partition[1], partition[2], "nodes 1,2 same community");
2025        assert_eq!(partition[3], partition[4], "nodes 3,4 same community");
2026        assert_eq!(partition[4], partition[5], "nodes 4,5 same community");
2027        assert_ne!(partition[0], partition[3], "two distinct communities");
2028    }
2029
2030    #[test]
2031    fn test_infomap_convergence_monotone_codelength() {
2032        let mut b = GraphDataBuilder::new(6);
2033        b.add_edge(0, 1, 3.0).unwrap();
2034        b.add_edge(1, 2, 3.0).unwrap();
2035        b.add_edge(0, 2, 3.0).unwrap();
2036        b.add_edge(3, 4, 3.0).unwrap();
2037        b.add_edge(4, 5, 3.0).unwrap();
2038        b.add_edge(3, 5, 3.0).unwrap();
2039        b.add_edge(2, 3, 0.5).unwrap();
2040        let graph = b.build().unwrap();
2041
2042        let mut flow_data = compute_flow(&graph, 0.15, 1e-10, 200);
2043        populate_boundary_flows(&graph, &mut flow_data, 0.15);
2044
2045        let mut map_eq = MapEquation::new(&flow_data, 0.0);
2046        let mut partition: Vec<usize> = (0..6).collect();
2047        let mut rng = StdRng::seed_from_u64(123);
2048
2049        let mut codelengths: Vec<f64> = vec![map_eq.codelength()];
2050
2051        for _ in 0..5 {
2052            let n = graph.node_count();
2053            let mut order: Vec<usize> = (0..n).collect();
2054            order.shuffle(&mut rng);
2055
2056            let mut any_moved = false;
2057            for &node in &order {
2058                let old_module = partition[node];
2059                let node_exit = flow_data[node].exit_flow;
2060                let node_out_deg = graph.out_degree_of(node);
2061
2062                let mut delta_exit_map: FxHashMap<usize, f64> = FxHashMap::default();
2063                let mut delta_enter_map: FxHashMap<usize, f64> = FxHashMap::default();
2064
2065                if node_out_deg > 0.0 {
2066                    for (v, w) in graph.out_neighbors(node) {
2067                        let tm = partition[v];
2068                        *delta_exit_map.entry(tm).or_default() += (w / node_out_deg) * node_exit;
2069                        let v_out_deg = graph.out_degree_of(v);
2070                        if v_out_deg > 0.0 {
2071                            *delta_enter_map.entry(tm).or_default() +=
2072                                (w / v_out_deg) * flow_data[v].exit_flow;
2073                        }
2074                    }
2075                }
2076
2077                let mut all_modules: FxHashMap<usize, ()> = FxHashMap::default();
2078                for &m in delta_exit_map.keys() {
2079                    all_modules.insert(m, ());
2080                }
2081                for &m in delta_enter_map.keys() {
2082                    all_modules.insert(m, ());
2083                }
2084
2085                let mut delta_flows: FxHashMap<usize, DeltaFlow> = FxHashMap::default();
2086                for &m in all_modules.keys() {
2087                    delta_flows.insert(
2088                        m,
2089                        DeltaFlow {
2090                            module: m,
2091                            delta_exit: delta_exit_map.get(&m).copied().unwrap_or(0.0),
2092                            delta_enter: delta_enter_map.get(&m).copied().unwrap_or(0.0),
2093                        },
2094                    );
2095                }
2096
2097                let self_delta = delta_flows.remove(&old_module).unwrap_or(DeltaFlow {
2098                    module: old_module,
2099                    delta_exit: 0.0,
2100                    delta_enter: 0.0,
2101                });
2102
2103                let mut best_module = old_module;
2104                let mut best_delta = 0.0_f64;
2105
2106                for (&nm, nd) in &delta_flows {
2107                    let dl = map_eq.get_delta_codelength_on_moving_node(
2108                        &flow_data[node], old_module, nm, &self_delta, nd,
2109                    );
2110                    if dl < best_delta {
2111                        best_delta = dl;
2112                        best_module = nm;
2113                    }
2114                }
2115
2116                if best_module != old_module {
2117                    let actual = delta_flows.get(&best_module).cloned().unwrap_or(DeltaFlow {
2118                        module: best_module,
2119                        delta_exit: 0.0,
2120                        delta_enter: 0.0,
2121                    });
2122                    map_eq.update_codelength_on_moving_node(
2123                        &flow_data[node], old_module, best_module, &self_delta, &actual,
2124                    );
2125                    partition[node] = best_module;
2126                    any_moved = true;
2127                }
2128            }
2129
2130            codelengths.push(map_eq.codelength());
2131            if !any_moved {
2132                break;
2133            }
2134        }
2135
2136        for i in 1..codelengths.len() {
2137            assert!(
2138                codelengths[i] <= codelengths[i - 1] + 1e-12,
2139                "codelength must decrease monotonically: step {} => {:.12} > {:.12}",
2140                i,
2141                codelengths[i],
2142                codelengths[i - 1]
2143            );
2144        }
2145    }
2146
2147    #[test]
2148    fn test_infomap_aggregate_graph_two_communities() {
2149        let mut b = GraphDataBuilder::new(4);
2150        b.add_edge(0, 1, 2.0).unwrap();
2151        b.add_edge(1, 2, 1.0).unwrap();
2152        b.add_edge(2, 3, 2.0).unwrap();
2153        let graph = b.build().unwrap();
2154
2155        let partition: Vec<usize> = vec![0, 0, 1, 1];
2156        let agg = aggregate_graph(&graph, &partition);
2157
2158        assert_eq!(agg.node_count(), 2, "should have 2 super-nodes");
2159
2160        let nbrs_0: Vec<(usize, f64)> = agg.neighbors(0).collect();
2161        let has_self_0 = nbrs_0.iter().any(|(n, w)| *n == 0 && (*w - 2.0).abs() < 1e-10);
2162        let has_bridge_0 = nbrs_0.iter().any(|(n, w)| *n == 1 && (*w - 1.0).abs() < 1e-10);
2163        assert!(has_self_0, "super-node 0 should have self-loop w=2.0");
2164        assert!(has_bridge_0, "super-node 0 should have edge to super 1 w=1.0");
2165    }
2166
2167    #[test]
2168    fn test_infomap_aggregate_graph_single_community() {
2169        let mut b = GraphDataBuilder::new(3);
2170        b.add_edge(0, 1, 1.0).unwrap();
2171        b.add_edge(1, 2, 1.0).unwrap();
2172        let graph = b.build().unwrap();
2173
2174        let partition: Vec<usize> = vec![0, 0, 0];
2175        let agg = aggregate_graph(&graph, &partition);
2176
2177        assert_eq!(agg.node_count(), 1, "single community -> 1 super-node");
2178        assert!((agg.node_weight(0) - 3.0).abs() < 1e-10, "weight = 3.0");
2179    }
2180
2181    #[test]
2182    fn test_infomap_aggregate_graph_directed() {
2183        let mut b = GraphDataBuilder::new(4).directed();
2184        b.add_edge(0, 1, 2.0).unwrap();
2185        b.add_edge(1, 0, 1.0).unwrap();
2186        b.add_edge(2, 3, 3.0).unwrap();
2187        b.add_edge(3, 2, 1.5).unwrap();
2188        b.add_edge(1, 2, 0.5).unwrap();
2189        let graph = b.build().unwrap();
2190
2191        let partition: Vec<usize> = vec![0, 0, 1, 1];
2192        let agg = aggregate_graph(&graph, &partition);
2193
2194        assert_eq!(agg.node_count(), 2);
2195        assert!(agg.is_directed());
2196
2197        let out_0: Vec<(usize, f64)> = agg.out_neighbors(0).collect();
2198        let has_self = out_0.iter().any(|(n, w)| *n == 0 && (*w - 3.0).abs() < 1e-10);
2199        let has_out = out_0.iter().any(|(n, w)| *n == 1 && (*w - 0.5).abs() < 1e-10);
2200        assert!(has_self, "directed self-loop 0->0 w=3.0");
2201        assert!(has_out, "directed edge 0->1 w=0.5");
2202    }
2203
2204    #[test]
2205    fn test_infomap_multi_level_planted_partition() {
2206        use crate::generators::generate_planted_partition;
2207        use crate::metrics::nmi;
2208
2209        let (graph, ground_truth) =
2210            generate_planted_partition(30, 3, 0.8, 0.05, Some(42)).unwrap();
2211
2212        let flow_data = compute_flow(&graph, 0.15, 1e-10, 200);
2213        let config = InfomapConfig {
2214            seed: Some(42),
2215            max_iterations: 10,
2216            ..Default::default()
2217        };
2218        let (partition, codelength) = run_multi_level(&graph, &flow_data, &config);
2219
2220        assert!(
2221            codelength.is_finite() && codelength >= 0.0,
2222            "codelength should be finite and non-negative, got {codelength}"
2223        );
2224        assert_eq!(partition.len(), 30);
2225        assert!(partition.iter().all(|&m| m < 30));
2226
2227        let num_comms = *partition.iter().max().unwrap_or(&0) + 1;
2228        assert!(
2229            (2..=5).contains(&num_comms),
2230            "expected 2-5 communities, found {num_comms}"
2231        );
2232
2233        let score = nmi(&ground_truth, &partition);
2234        assert!(
2235            score > 0.7,
2236            "NMI should be > 0.7 for strong planted partition, got {score:.4}"
2237        );
2238    }
2239
2240    #[test]
2241    fn test_infomap_multi_level_single_node() {
2242        let graph = GraphDataBuilder::new(1).build().unwrap();
2243        let flow_data = compute_flow(&graph, 0.15, 1e-10, 200);
2244        let config = InfomapConfig::default();
2245        let (partition, cl) = run_multi_level(&graph, &flow_data, &config);
2246        assert_eq!(partition, vec![0]);
2247        assert!(cl >= 0.0);
2248    }
2249
2250    #[test]
2251    fn test_infomap_multi_level_empty_graph() {
2252        let graph = GraphDataBuilder::new(0).build().unwrap();
2253        let flow_data = compute_flow(&graph, 0.15, 1e-10, 200);
2254        let config = InfomapConfig::default();
2255        let (partition, cl) = run_multi_level(&graph, &flow_data, &config);
2256        assert!(partition.is_empty());
2257        assert!((cl - 0.0).abs() < 1e-10);
2258    }
2259
2260    #[test]
2261    fn test_infomap_multi_level_triangle() {
2262        let mut b = GraphDataBuilder::new(3);
2263        b.add_edge(0, 1, 1.0).unwrap();
2264        b.add_edge(1, 2, 1.0).unwrap();
2265        b.add_edge(0, 2, 1.0).unwrap();
2266        let graph = b.build().unwrap();
2267
2268        let flow_data = compute_flow(&graph, 0.15, 1e-10, 200);
2269        let config = InfomapConfig {
2270            seed: Some(42),
2271            ..Default::default()
2272        };
2273        let (partition, _cl) = run_multi_level(&graph, &flow_data, &config);
2274
2275        assert_eq!(partition[0], partition[1], "triangle should be one community");
2276        assert_eq!(partition[1], partition[2], "triangle should be one community");
2277    }
2278
2279    #[test]
2280    fn test_infomap_populate_boundary_flows() {
2281        let mut b = GraphDataBuilder::new(3);
2282        b.add_edge(0, 1, 1.0).unwrap();
2283        b.add_edge(1, 2, 1.0).unwrap();
2284        b.add_edge(0, 2, 1.0).unwrap();
2285        let graph = b.build().unwrap();
2286
2287        let mut flow_data = compute_flow(&graph, 0.15, 1e-10, 200);
2288        assert!(flow_data.iter().all(|fd| fd.enter_flow == 0.0 && fd.exit_flow == 0.0));
2289
2290        populate_boundary_flows(&graph, &mut flow_data, 0.15);
2291
2292        for (i, fd) in flow_data.iter().enumerate() {
2293            assert!(fd.enter_flow > 0.0, "node {i} enter_flow should be positive");
2294            assert!(fd.exit_flow > 0.0, "node {i} exit_flow should be positive");
2295        }
2296
2297        for (i, fd) in flow_data.iter().enumerate() {
2298            assert!(
2299                (fd.enter_flow - fd.exit_flow).abs() < 1e-10,
2300                "node {i}: enter ({:.6}) should ~= exit ({:.6})",
2301                fd.enter_flow,
2302                fd.exit_flow
2303            );
2304        }
2305
2306        let total_exit: f64 = flow_data.iter().map(|fd| fd.exit_flow).sum();
2307        assert!(
2308            (total_exit - 0.85).abs() < 1e-6,
2309            "total exit flow should be ~0.85, got {total_exit:.6}"
2310        );
2311    }
2312
2313    // ── compute_node_flows tests ──
2314
2315    #[test]
2316    fn test_infomap_compute_node_flows() {
2317        let mut b = GraphDataBuilder::new(3);
2318        b.add_edge(0, 1, 1.0).unwrap();
2319        b.add_edge(1, 2, 1.0).unwrap();
2320        b.add_edge(0, 2, 1.0).unwrap();
2321        let graph = b.build().unwrap();
2322
2323        let flow_data = compute_flow(&graph, 0.15, 1e-10, 200);
2324        let result = compute_node_flows(&graph, &flow_data, 0.15);
2325
2326        for (i, fd) in result.iter().enumerate() {
2327            assert!(fd.flow > 0.0, "node {i} flow should be positive");
2328            assert!(fd.enter_flow > 0.0, "node {i} enter_flow should be positive");
2329            assert!(fd.exit_flow > 0.0, "node {i} exit_flow should be positive");
2330        }
2331    }
2332
2333    // ── Infomap struct tests ──
2334
2335    #[test]
2336    fn test_infomap_struct_empty_graph() {
2337        let graph = GraphDataBuilder::new(0).build().unwrap();
2338        let config = InfomapConfig::default();
2339        let infomap = Infomap::new(config);
2340        let result = infomap.run(&graph);
2341        assert_eq!(result.partition.len(), 0);
2342        assert!((result.codelength - 0.0).abs() < 1e-10);
2343    }
2344
2345    #[test]
2346    fn test_infomap_struct_single_node() {
2347        let graph = GraphDataBuilder::new(1).build().unwrap();
2348        let config = InfomapConfig { seed: Some(42), ..Default::default() };
2349        let infomap = Infomap::new(config);
2350        let result = infomap.run(&graph);
2351        assert_eq!(result.partition.len(), 1);
2352        assert!(result.codelength.is_finite());
2353    }
2354
2355    #[test]
2356    fn test_infomap_struct_two_communities() {
2357        let mut b = GraphDataBuilder::new(6);
2358        b.add_edge(0, 1, 5.0).unwrap();
2359        b.add_edge(1, 2, 5.0).unwrap();
2360        b.add_edge(0, 2, 5.0).unwrap();
2361        b.add_edge(3, 4, 5.0).unwrap();
2362        b.add_edge(4, 5, 5.0).unwrap();
2363        b.add_edge(3, 5, 5.0).unwrap();
2364        b.add_edge(2, 3, 0.1).unwrap();
2365        let graph = b.build().unwrap();
2366
2367        let config = InfomapConfig {
2368            seed: Some(42),
2369            num_trials: 3,
2370            ..Default::default()
2371        };
2372        let infomap = Infomap::new(config);
2373        let result = infomap.run(&graph);
2374
2375        assert_eq!(result.partition.len(), 6);
2376        assert!(result.codelength > 0.0 && result.codelength.is_finite());
2377        assert_eq!(result.num_levels, 1);
2378        assert!(result.iterations > 0);
2379
2380        let membership: Vec<usize> = (0..result.partition.len())
2381            .map(|i| result.partition.community_of(i))
2382            .collect();
2383
2384        assert_eq!(membership[0], membership[1]);
2385        assert_eq!(membership[1], membership[2]);
2386        assert_eq!(membership[3], membership[4]);
2387        assert_eq!(membership[4], membership[5]);
2388        assert_ne!(membership[0], membership[3]);
2389    }
2390
2391    #[test]
2392    fn test_infomap_struct_planted_partition_nmi() {
2393        use crate::generators::generate_planted_partition;
2394        use crate::metrics::nmi;
2395
2396        let (graph, ground_truth) =
2397            generate_planted_partition(30, 3, 0.8, 0.05, Some(42)).unwrap();
2398
2399        let config = InfomapConfig {
2400            seed: Some(42),
2401            num_trials: 5,
2402            ..Default::default()
2403        };
2404        let infomap = Infomap::new(config);
2405        let result = infomap.run(&graph);
2406
2407        let membership: Vec<usize> = (0..result.partition.len())
2408            .map(|i| result.partition.community_of(i))
2409            .collect();
2410
2411        let score = nmi(&ground_truth, &membership);
2412        assert!(
2413            score > 0.7,
2414            "NMI should be > 0.7 for strong planted partition, got {score:.4}"
2415        );
2416    }
2417
2418    #[test]
2419    fn test_infomap_struct_multi_trial_improves() {
2420        let mut b = GraphDataBuilder::new(6);
2421        b.add_edge(0, 1, 5.0).unwrap();
2422        b.add_edge(1, 2, 5.0).unwrap();
2423        b.add_edge(0, 2, 5.0).unwrap();
2424        b.add_edge(3, 4, 5.0).unwrap();
2425        b.add_edge(4, 5, 5.0).unwrap();
2426        b.add_edge(3, 5, 5.0).unwrap();
2427        b.add_edge(2, 3, 0.1).unwrap();
2428        let graph = b.build().unwrap();
2429
2430        let config = InfomapConfig {
2431            seed: Some(42),
2432            num_trials: 5,
2433            ..Default::default()
2434        };
2435        let infomap = Infomap::new(config);
2436        let result = infomap.run(&graph);
2437
2438        assert!(
2439            result.codelength > 0.0,
2440            "codelength should be positive, got {}",
2441            result.codelength
2442        );
2443        assert!(result.codelength.is_finite());
2444
2445        let membership: Vec<usize> = (0..result.partition.len())
2446            .map(|i| result.partition.community_of(i))
2447            .collect();
2448        let num_comms = membership.iter().max().unwrap() + 1;
2449        assert!(
2450            (2..=3).contains(&num_comms),
2451            "expected 2-3 communities, got {num_comms}"
2452        );
2453    }
2454
2455    #[test]
2456    fn test_infomap_fine_coarse_tuning() {
2457        let mut b = GraphDataBuilder::new(6);
2458        b.add_edge(0, 1, 5.0).unwrap();
2459        b.add_edge(1, 2, 5.0).unwrap();
2460        b.add_edge(0, 2, 5.0).unwrap();
2461        b.add_edge(3, 4, 5.0).unwrap();
2462        b.add_edge(4, 5, 5.0).unwrap();
2463        b.add_edge(3, 5, 5.0).unwrap();
2464        b.add_edge(2, 3, 0.1).unwrap();
2465        let graph = b.build().unwrap();
2466
2467        let mut flow_data = compute_flow(&graph, 0.15, 1e-10, 200);
2468        populate_boundary_flows(&graph, &mut flow_data, 0.15);
2469
2470        let mut map_eq = MapEquation::new(&flow_data, 0.0);
2471        let mut partition: Vec<usize> = (0..6).collect();
2472        let mut rng = StdRng::seed_from_u64(42);
2473
2474        run_local_moving(&mut map_eq, &flow_data, &graph, &mut partition, &mut rng);
2475        renumber_partition(&mut partition);
2476
2477        let cl_after_local = map_eq.codelength();
2478
2479        let ft = fine_tuning(&mut map_eq, &flow_data, &graph, &mut partition, &mut rng);
2480        let cl_after_fine = map_eq.codelength();
2481
2482        assert!(
2483            cl_after_fine <= cl_after_local + 1e-12,
2484            "fine-tuning should not increase codelength: before={cl_after_local:.6}, after={cl_after_fine:.6}"
2485        );
2486
2487        let ct = coarse_tuning(&mut map_eq, &flow_data, &graph, &mut partition, &mut rng);
2488        let cl_after_coarse = map_eq.codelength();
2489
2490        assert!(
2491            cl_after_coarse <= cl_after_fine + 1e-12,
2492            "coarse-tuning should not increase codelength: before={cl_after_fine:.6}, after={cl_after_coarse:.6}"
2493        );
2494    }
2495
2496    #[test]
2497    fn test_infomap_renumber_partition() {
2498        let mut partition = vec![5, 5, 3, 7, 3];
2499        renumber_partition(&mut partition);
2500        assert_eq!(partition, vec![0, 0, 1, 2, 1]);
2501    }
2502}