Skip to main content

oxiphysics_core/
network_dynamics.rs

1#![allow(clippy::needless_range_loop, clippy::ptr_arg)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Network/graph dynamics: epidemic models, opinion dynamics, synchronization.
6//!
7//! This module implements:
8//! - [`NetworkGraph`]: adjacency list representation, directed/undirected, weighted edges
9//! - [`SirModel`]: Susceptible-Infectious-Recovered epidemic model (ODE + network)
10//! - [`SeirdModel`]: extended SEIRD epidemic (Exposed + Deceased compartments)
11//! - [`OpinionDynamics`]: Deffuant-Weisbuch bounded confidence model
12//! - [`DeGrootModel`]: consensus update rule
13//! - [`VoterModel`]: majority rule opinion dynamics
14//! - [`KuramotoModel`]: coupled oscillators synchronization
15//! - [`RandomWalkGraph`]: random walk on graph, hitting time, cover time
16//! - [`PageRank`]: power iteration with dangling node handling
17//! - [`CommunityDetection`]: modularity Q, greedy modularity maximization
18//! - [`NetworkRobustness`]: percolation threshold, giant component fraction
19//! - [`SmallWorld`]: Watts-Strogatz rewiring, clustering coefficient, path length
20
21#![allow(dead_code)]
22#![allow(clippy::too_many_arguments)]
23
24use std::collections::{HashMap, VecDeque};
25use std::f64::consts::PI;
26
27// ─────────────────────────────────────────────────────────────────────────────
28// Internal LCG RNG (no external rand dependency needed in tests)
29// ─────────────────────────────────────────────────────────────────────────────
30
31struct Lcg {
32    state: u64,
33}
34
35impl Lcg {
36    fn new(seed: u64) -> Self {
37        Self {
38            state: seed.wrapping_add(1),
39        }
40    }
41
42    fn next_u64(&mut self) -> u64 {
43        self.state = self
44            .state
45            .wrapping_mul(6_364_136_223_846_793_005)
46            .wrapping_add(1_442_695_040_888_963_407);
47        self.state
48    }
49
50    fn next_f64(&mut self) -> f64 {
51        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
52    }
53
54    fn next_range(&mut self, lo: usize, hi: usize) -> usize {
55        lo + (self.next_u64() as usize % (hi - lo))
56    }
57}
58
59// ─────────────────────────────────────────────────────────────────────────────
60// NetworkGraph
61// ─────────────────────────────────────────────────────────────────────────────
62
63/// Adjacency-list graph for network dynamics simulations.
64///
65/// Supports directed and undirected graphs with optional edge weights.
66/// Node indices are in `0..n`.
67#[derive(Clone)]
68pub struct NetworkGraph {
69    /// Number of nodes.
70    pub n: usize,
71    /// Whether the graph is directed.
72    pub directed: bool,
73    /// Adjacency list: `adj[i]` = list of `(neighbor, weight)`.
74    pub adj: Vec<Vec<(usize, f64)>>,
75}
76
77impl NetworkGraph {
78    /// Creates an empty graph with `n` nodes.
79    pub fn new(n: usize, directed: bool) -> Self {
80        Self {
81            n,
82            directed,
83            adj: vec![Vec::new(); n],
84        }
85    }
86
87    /// Adds a directed edge `u → v` with weight `w`.
88    pub fn add_edge(&mut self, u: usize, v: usize, w: f64) {
89        self.adj[u].push((v, w));
90        if !self.directed {
91            self.adj[v].push((u, w));
92        }
93    }
94
95    /// Returns the degree (out-degree for directed) of node `u`.
96    pub fn degree(&self, u: usize) -> usize {
97        self.adj[u].len()
98    }
99
100    /// Returns the number of edges (undirected counts each once).
101    pub fn edge_count(&self) -> usize {
102        let total: usize = self.adj.iter().map(|a| a.len()).sum();
103        if self.directed { total } else { total / 2 }
104    }
105
106    /// Builds a complete undirected graph (all edges weight 1).
107    pub fn complete(n: usize) -> Self {
108        let mut g = Self::new(n, false);
109        for i in 0..n {
110            for j in (i + 1)..n {
111                g.add_edge(i, j, 1.0);
112            }
113        }
114        g
115    }
116
117    /// Builds a ring graph of `n` nodes.
118    pub fn ring(n: usize) -> Self {
119        let mut g = Self::new(n, false);
120        for i in 0..n {
121            g.add_edge(i, (i + 1) % n, 1.0);
122        }
123        g
124    }
125
126    /// Builds an Erdős-Rényi random graph G(n, p) with given seed.
127    pub fn erdos_renyi(n: usize, p: f64, seed: u64) -> Self {
128        let mut g = Self::new(n, false);
129        let mut rng = Lcg::new(seed);
130        for i in 0..n {
131            for j in (i + 1)..n {
132                if rng.next_f64() < p {
133                    g.add_edge(i, j, 1.0);
134                }
135            }
136        }
137        g
138    }
139
140    /// Returns BFS distances from source node `s` (-1 means unreachable).
141    pub fn bfs_distances(&self, s: usize) -> Vec<i64> {
142        let mut dist = vec![-1i64; self.n];
143        dist[s] = 0;
144        let mut queue = VecDeque::new();
145        queue.push_back(s);
146        while let Some(u) = queue.pop_front() {
147            for &(v, _) in &self.adj[u] {
148                if dist[v] < 0 {
149                    dist[v] = dist[u] + 1;
150                    queue.push_back(v);
151                }
152            }
153        }
154        dist
155    }
156
157    /// Average shortest path length (only reachable pairs).
158    pub fn average_path_length(&self) -> f64 {
159        let mut total = 0.0f64;
160        let mut count = 0usize;
161        for s in 0..self.n {
162            let dists = self.bfs_distances(s);
163            for d in &dists {
164                if *d > 0 {
165                    total += *d as f64;
166                    count += 1;
167                }
168            }
169        }
170        if count == 0 {
171            f64::INFINITY
172        } else {
173            total / count as f64
174        }
175    }
176
177    /// Local clustering coefficient for node `u`.
178    pub fn clustering_coefficient(&self, u: usize) -> f64 {
179        let neighbors: Vec<usize> = self.adj[u].iter().map(|&(v, _)| v).collect();
180        let k = neighbors.len();
181        if k < 2 {
182            return 0.0;
183        }
184        let nbr_set: std::collections::HashSet<usize> = neighbors.iter().cloned().collect();
185        let mut triangles = 0usize;
186        for &v in &neighbors {
187            for &(w, _) in &self.adj[v] {
188                if w != u && nbr_set.contains(&w) {
189                    triangles += 1;
190                }
191            }
192        }
193        triangles as f64 / (k * (k - 1)) as f64
194    }
195
196    /// Mean clustering coefficient over all nodes.
197    pub fn mean_clustering(&self) -> f64 {
198        let s: f64 = (0..self.n).map(|u| self.clustering_coefficient(u)).sum();
199        s / self.n as f64
200    }
201}
202
203// ─────────────────────────────────────────────────────────────────────────────
204// SIR Model
205// ─────────────────────────────────────────────────────────────────────────────
206
207/// SIR epidemic model (ODE version for homogeneous mixing).
208///
209/// Compartments: S (susceptible), I (infectious), R (recovered).
210/// dS/dt = -β S I / N
211/// dI/dt =  β S I / N - γ I
212/// dR/dt =  γ I
213pub struct SirModel {
214    /// Transmission rate β.
215    pub beta: f64,
216    /// Recovery rate γ.
217    pub gamma: f64,
218    /// Population size N.
219    pub n: f64,
220}
221
222/// State of SIR model at one time step.
223#[derive(Clone, Debug)]
224pub struct SirState {
225    /// Susceptible fraction/count.
226    pub s: f64,
227    /// Infectious fraction/count.
228    pub i: f64,
229    /// Recovered fraction/count.
230    pub r: f64,
231}
232
233impl SirModel {
234    /// Creates a new SIR model.
235    pub fn new(beta: f64, gamma: f64, n: f64) -> Self {
236        Self { beta, gamma, n }
237    }
238
239    /// Basic reproduction number R₀ = β/γ.
240    pub fn r0(&self) -> f64 {
241        self.beta / self.gamma
242    }
243
244    /// Herd immunity threshold: fraction that needs immunity = 1 - 1/R₀.
245    pub fn herd_immunity_threshold(&self) -> f64 {
246        let r0 = self.r0();
247        if r0 <= 1.0 { 0.0 } else { 1.0 - 1.0 / r0 }
248    }
249
250    /// Integrates the ODE for `steps` using Euler method with step `dt`.
251    pub fn integrate(&self, s0: f64, i0: f64, r0_val: f64, dt: f64, steps: usize) -> Vec<SirState> {
252        let mut traj = Vec::with_capacity(steps + 1);
253        let (mut s, mut i, mut r) = (s0, i0, r0_val);
254        traj.push(SirState { s, i, r });
255        for _ in 0..steps {
256            let ds = -self.beta * s * i / self.n;
257            let di = self.beta * s * i / self.n - self.gamma * i;
258            let dr = self.gamma * i;
259            s += dt * ds;
260            i += dt * di;
261            r += dt * dr;
262            s = s.max(0.0);
263            i = i.max(0.0);
264            r = r.max(0.0);
265            traj.push(SirState { s, i, r });
266        }
267        traj
268    }
269
270    /// Network SIR: discrete-time stochastic simulation on a graph.
271    ///
272    /// Returns `(s_counts, i_counts, r_counts)` over time.
273    pub fn simulate_on_graph(
274        &self,
275        graph: &NetworkGraph,
276        initially_infected: &[usize],
277        dt: f64,
278        steps: usize,
279        seed: u64,
280    ) -> (Vec<usize>, Vec<usize>, Vec<usize>) {
281        let n = graph.n;
282        // 0 = S, 1 = I, 2 = R
283        let mut state = vec![0u8; n];
284        for &idx in initially_infected {
285            if idx < n {
286                state[idx] = 1;
287            }
288        }
289        let mut rng = Lcg::new(seed);
290        let mut s_ts = Vec::with_capacity(steps + 1);
291        let mut i_ts = Vec::with_capacity(steps + 1);
292        let mut r_ts = Vec::with_capacity(steps + 1);
293        let count_state = |state: &[u8], val: u8| state.iter().filter(|&&x| x == val).count();
294        s_ts.push(count_state(&state, 0));
295        i_ts.push(count_state(&state, 1));
296        r_ts.push(count_state(&state, 2));
297        for _ in 0..steps {
298            let mut next = state.clone();
299            for u in 0..n {
300                if state[u] == 1 {
301                    // recovery
302                    if rng.next_f64() < self.gamma * dt {
303                        next[u] = 2;
304                    }
305                } else if state[u] == 0 {
306                    // infection from neighbors
307                    let inf_neighbors =
308                        graph.adj[u].iter().filter(|&&(v, _)| state[v] == 1).count();
309                    let p_inf = 1.0 - (1.0 - self.beta * dt).powi(inf_neighbors as i32);
310                    if rng.next_f64() < p_inf {
311                        next[u] = 1;
312                    }
313                }
314            }
315            state = next;
316            s_ts.push(count_state(&state, 0));
317            i_ts.push(count_state(&state, 1));
318            r_ts.push(count_state(&state, 2));
319        }
320        (s_ts, i_ts, r_ts)
321    }
322}
323
324// ─────────────────────────────────────────────────────────────────────────────
325// SEIRD Model
326// ─────────────────────────────────────────────────────────────────────────────
327
328/// Extended SEIRD epidemic model: Susceptible, Exposed, Infected, Recovered, Deceased.
329///
330/// dS/dt = -β S I / N
331/// dE/dt =  β S I / N - σ E
332/// dI/dt =  σ E - γ I - μ I
333/// dR/dt =  γ I
334/// dD/dt =  μ I
335pub struct SeirdModel {
336    /// Transmission rate β.
337    pub beta: f64,
338    /// Incubation rate σ (1/latent period).
339    pub sigma: f64,
340    /// Recovery rate γ.
341    pub gamma: f64,
342    /// Disease-induced mortality rate μ.
343    pub mu: f64,
344    /// Population size N.
345    pub n: f64,
346}
347
348/// State of SEIRD model.
349#[derive(Clone, Debug)]
350pub struct SeirdState {
351    /// Susceptible.
352    pub s: f64,
353    /// Exposed.
354    pub e: f64,
355    /// Infectious.
356    pub i: f64,
357    /// Recovered.
358    pub r: f64,
359    /// Deceased.
360    pub d: f64,
361}
362
363impl SeirdModel {
364    /// Creates a new SEIRD model.
365    pub fn new(beta: f64, sigma: f64, gamma: f64, mu: f64, n: f64) -> Self {
366        Self {
367            beta,
368            sigma,
369            gamma,
370            mu,
371            n,
372        }
373    }
374
375    /// Basic reproduction number R₀ = β / (γ + μ).
376    pub fn r0(&self) -> f64 {
377        self.beta / (self.gamma + self.mu)
378    }
379
380    /// Case fatality ratio: μ / (γ + μ).
381    pub fn case_fatality_ratio(&self) -> f64 {
382        self.mu / (self.gamma + self.mu)
383    }
384
385    /// Integrates ODE for `steps` using Euler with step `dt`.
386    pub fn integrate(
387        &self,
388        s0: f64,
389        e0: f64,
390        i0: f64,
391        r0: f64,
392        d0: f64,
393        dt: f64,
394        steps: usize,
395    ) -> Vec<SeirdState> {
396        let mut traj = Vec::with_capacity(steps + 1);
397        let (mut s, mut e, mut i, mut r, mut d) = (s0, e0, i0, r0, d0);
398        traj.push(SeirdState { s, e, i, r, d });
399        for _ in 0..steps {
400            let ds = -self.beta * s * i / self.n;
401            let de = self.beta * s * i / self.n - self.sigma * e;
402            let di = self.sigma * e - self.gamma * i - self.mu * i;
403            let dr = self.gamma * i;
404            let dd = self.mu * i;
405            s = (s + dt * ds).max(0.0);
406            e = (e + dt * de).max(0.0);
407            i = (i + dt * di).max(0.0);
408            r += dt * dr;
409            d += dt * dd;
410            traj.push(SeirdState { s, e, i, r, d });
411        }
412        traj
413    }
414}
415
416// ─────────────────────────────────────────────────────────────────────────────
417// Opinion Dynamics
418// ─────────────────────────────────────────────────────────────────────────────
419
420/// Deffuant-Weisbuch bounded confidence opinion dynamics.
421///
422/// Agents update opinions toward each other only when |o_i - o_j| < ε.
423/// Update: o_i += μ(o_j - o_i), o_j += μ(o_i - o_j).
424pub struct OpinionDynamics {
425    /// Confidence bound ε: agents interact only when |o_i - o_j| < epsilon.
426    pub epsilon: f64,
427    /// Convergence parameter μ ∈ (0, 0.5].
428    pub mu: f64,
429}
430
431impl OpinionDynamics {
432    /// Creates a new Deffuant-Weisbuch model.
433    pub fn new(epsilon: f64, mu: f64) -> Self {
434        Self { epsilon, mu }
435    }
436
437    /// Runs `steps` of the Deffuant model on `opinions` (modified in place).
438    ///
439    /// At each step a random pair is chosen; if they are within ε they converge.
440    pub fn run(&self, opinions: &mut Vec<f64>, steps: usize, seed: u64) {
441        let n = opinions.len();
442        if n < 2 {
443            return;
444        }
445        let mut rng = Lcg::new(seed);
446        for _ in 0..steps {
447            let i = rng.next_range(0, n);
448            let j_raw = rng.next_range(0, n - 1);
449            let j = if j_raw >= i { j_raw + 1 } else { j_raw };
450            let diff = opinions[j] - opinions[i];
451            if diff.abs() < self.epsilon {
452                opinions[i] += self.mu * diff;
453                opinions[j] -= self.mu * diff;
454            }
455        }
456    }
457
458    /// Returns the number of opinion clusters (groups within ε/2 of each other).
459    pub fn count_clusters(&self, opinions: &[f64]) -> usize {
460        let mut sorted = opinions.to_vec();
461        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
462        if sorted.is_empty() {
463            return 0;
464        }
465        let mut clusters = 1usize;
466        for w in sorted.windows(2) {
467            if w[1] - w[0] >= self.epsilon / 2.0 {
468                clusters += 1;
469            }
470        }
471        clusters
472    }
473}
474
475// ─────────────────────────────────────────────────────────────────────────────
476// DeGroot Consensus Model
477// ─────────────────────────────────────────────────────────────────────────────
478
479/// DeGroot consensus model: linear opinion pooling on a network.
480///
481/// Each agent i updates: o_i(t+1) = Σ_j T_{ij} o_j(t)
482/// where T is a row-stochastic trust matrix.
483pub struct DeGrootModel {
484    /// Row-stochastic trust matrix T (n×n stored row-major).
485    pub trust: Vec<Vec<f64>>,
486}
487
488impl DeGrootModel {
489    /// Creates a DeGroot model from a trust matrix.
490    ///
491    /// Rows must sum to 1 (caller's responsibility).
492    pub fn new(trust: Vec<Vec<f64>>) -> Self {
493        Self { trust }
494    }
495
496    /// Builds a uniform averaging DeGroot model from a graph.
497    pub fn from_graph(graph: &NetworkGraph) -> Self {
498        let n = graph.n;
499        let mut trust = vec![vec![0.0f64; n]; n];
500        for u in 0..n {
501            let deg = graph.adj[u].len();
502            if deg == 0 {
503                trust[u][u] = 1.0;
504            } else {
505                for &(v, _) in &graph.adj[u] {
506                    trust[u][v] = 1.0 / deg as f64;
507                }
508            }
509        }
510        Self { trust }
511    }
512
513    /// Returns the number of agents.
514    pub fn n(&self) -> usize {
515        self.trust.len()
516    }
517
518    /// Applies one update step to `opinions`.
519    pub fn step(&self, opinions: &[f64]) -> Vec<f64> {
520        let n = opinions.len();
521        let mut next = vec![0.0; n];
522        for i in 0..n {
523            for j in 0..n {
524                next[i] += self.trust[i][j] * opinions[j];
525            }
526        }
527        next
528    }
529
530    /// Runs `steps` iterations, returns final opinions.
531    pub fn run(&self, opinions: &[f64], steps: usize) -> Vec<f64> {
532        let mut ops = opinions.to_vec();
533        for _ in 0..steps {
534            ops = self.step(&ops);
535        }
536        ops
537    }
538
539    /// Checks if opinions have converged to consensus (max spread < tol).
540    pub fn has_consensus(&self, opinions: &[f64], tol: f64) -> bool {
541        let min = opinions.iter().cloned().fold(f64::INFINITY, f64::min);
542        let max = opinions.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
543        max - min < tol
544    }
545}
546
547// ─────────────────────────────────────────────────────────────────────────────
548// Voter Model
549// ─────────────────────────────────────────────────────────────────────────────
550
551/// Voter model: each agent copies a random neighbor's opinion.
552pub struct VoterModel {
553    /// Number of opinion states (default 2 for binary).
554    pub n_states: usize,
555}
556
557impl VoterModel {
558    /// Creates a new voter model.
559    pub fn new(n_states: usize) -> Self {
560        Self { n_states }
561    }
562
563    /// Runs `steps` asynchronous updates on integer `opinions` (values 0..n_states-1).
564    pub fn run(&self, graph: &NetworkGraph, opinions: &mut Vec<usize>, steps: usize, seed: u64) {
565        let n = graph.n;
566        if n == 0 {
567            return;
568        }
569        let mut rng = Lcg::new(seed);
570        for _ in 0..steps {
571            let i = rng.next_range(0, n);
572            if graph.adj[i].is_empty() {
573                continue;
574            }
575            let nb_idx = rng.next_range(0, graph.adj[i].len());
576            let j = graph.adj[i][nb_idx].0;
577            opinions[i] = opinions[j];
578        }
579    }
580
581    /// Returns fraction of agents holding opinion `state`.
582    pub fn fraction(&self, opinions: &[usize], state: usize) -> f64 {
583        let count = opinions.iter().filter(|&&x| x == state).count();
584        count as f64 / opinions.len() as f64
585    }
586
587    /// Returns true if all agents hold the same opinion (consensus).
588    pub fn is_consensus(&self, opinions: &[usize]) -> bool {
589        opinions.windows(2).all(|w| w[0] == w[1])
590    }
591}
592
593// ─────────────────────────────────────────────────────────────────────────────
594// Kuramoto Model
595// ─────────────────────────────────────────────────────────────────────────────
596
597/// Kuramoto model of coupled oscillators.
598///
599/// dθ_i/dt = ω_i + (K/N) Σ_j sin(θ_j - θ_i)
600///
601/// The order parameter r = |Σ e^{iθ_j}| / N measures synchrony.
602pub struct KuramotoModel {
603    /// Natural frequencies ω_i.
604    pub omega: Vec<f64>,
605    /// Coupling strength K.
606    pub k: f64,
607}
608
609impl KuramotoModel {
610    /// Creates a Kuramoto model from natural frequencies and coupling K.
611    pub fn new(omega: Vec<f64>, k: f64) -> Self {
612        Self { omega, k }
613    }
614
615    /// Generates Lorentzian-distributed (Cauchy) natural frequencies.
616    ///
617    /// Mean `omega0`, half-width `gamma`. Critical coupling K_c = 2γ.
618    pub fn lorentzian(n: usize, omega0: f64, gamma_width: f64, seed: u64) -> Self {
619        let mut rng = Lcg::new(seed);
620        let omega: Vec<f64> = (0..n)
621            .map(|_| {
622                // Inverse CDF of Cauchy: omega0 + gamma * tan(pi*(u - 0.5))
623                let u = rng.next_f64();
624                omega0 + gamma_width * (PI * (u - 0.5)).tan()
625            })
626            .collect();
627        Self {
628            omega,
629            k: 2.0 * gamma_width,
630        }
631    }
632
633    /// Number of oscillators.
634    pub fn n(&self) -> usize {
635        self.omega.len()
636    }
637
638    /// Computes the order parameter r ∈ \[0, 1\].
639    ///
640    /// r = |1/N Σ e^{iθ_j}|
641    pub fn order_parameter(phases: &[f64]) -> f64 {
642        let n = phases.len() as f64;
643        let re: f64 = phases.iter().map(|&t| t.cos()).sum::<f64>() / n;
644        let im: f64 = phases.iter().map(|&t| t.sin()).sum::<f64>() / n;
645        (re * re + im * im).sqrt()
646    }
647
648    /// Mean phase angle (argument of the complex order parameter).
649    pub fn mean_phase(phases: &[f64]) -> f64 {
650        let re: f64 = phases.iter().map(|&t| t.cos()).sum::<f64>();
651        let im: f64 = phases.iter().map(|&t| t.sin()).sum::<f64>();
652        im.atan2(re)
653    }
654
655    /// Integrates for `steps` using RK4 with step `dt`.
656    ///
657    /// Returns trajectory of order parameters.
658    pub fn integrate(&self, phases0: &[f64], dt: f64, steps: usize) -> (Vec<f64>, Vec<f64>) {
659        let n = self.n();
660        let mut phases = phases0.to_vec();
661        let mut times = Vec::with_capacity(steps + 1);
662        let mut r_vals = Vec::with_capacity(steps + 1);
663        times.push(0.0);
664        r_vals.push(Self::order_parameter(&phases));
665
666        let deriv = |ph: &[f64]| -> Vec<f64> {
667            (0..n)
668                .map(|i| {
669                    let coupling: f64 = ph.iter().map(|&t| (t - ph[i]).sin()).sum::<f64>();
670                    self.omega[i] + self.k / n as f64 * coupling
671                })
672                .collect()
673        };
674
675        for step in 0..steps {
676            let k1 = deriv(&phases);
677            let p2: Vec<f64> = phases
678                .iter()
679                .zip(&k1)
680                .map(|(&p, &k)| p + 0.5 * dt * k)
681                .collect();
682            let k2 = deriv(&p2);
683            let p3: Vec<f64> = phases
684                .iter()
685                .zip(&k2)
686                .map(|(&p, &k)| p + 0.5 * dt * k)
687                .collect();
688            let k3 = deriv(&p3);
689            let p4: Vec<f64> = phases.iter().zip(&k3).map(|(&p, &k)| p + dt * k).collect();
690            let k4 = deriv(&p4);
691            for i in 0..n {
692                phases[i] += dt / 6.0 * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]);
693            }
694            times.push((step + 1) as f64 * dt);
695            r_vals.push(Self::order_parameter(&phases));
696        }
697        (times, r_vals)
698    }
699
700    /// Estimated critical coupling K_c = 2 / (π g(0)) for Lorentzian with width γ → K_c = 2γ.
701    ///
702    /// For a general distribution approximated by the frequency spread σ: K_c ≈ 2σ/√π.
703    pub fn critical_coupling_estimate(&self) -> f64 {
704        let mean = self.omega.iter().sum::<f64>() / self.n() as f64;
705        let var: f64 =
706            self.omega.iter().map(|&w| (w - mean).powi(2)).sum::<f64>() / self.n() as f64;
707        2.0 * var.sqrt() / PI.sqrt()
708    }
709}
710
711// ─────────────────────────────────────────────────────────────────────────────
712// Random Walk on Graph
713// ─────────────────────────────────────────────────────────────────────────────
714
715/// Random walk on a graph with analysis of hitting and cover times.
716pub struct RandomWalkGraph {
717    graph: NetworkGraph,
718}
719
720impl RandomWalkGraph {
721    /// Creates a random walk handler for the given graph.
722    pub fn new(graph: NetworkGraph) -> Self {
723        Self { graph }
724    }
725
726    /// Simulates a random walk starting at `start` for `max_steps`.
727    ///
728    /// Returns the sequence of visited nodes.
729    pub fn walk(&self, start: usize, max_steps: usize, seed: u64) -> Vec<usize> {
730        let mut rng = Lcg::new(seed);
731        let mut path = Vec::with_capacity(max_steps + 1);
732        let mut current = start;
733        path.push(current);
734        for _ in 0..max_steps {
735            let neighbors = &self.graph.adj[current];
736            if neighbors.is_empty() {
737                break;
738            }
739            let idx = rng.next_range(0, neighbors.len());
740            current = neighbors[idx].0;
741            path.push(current);
742        }
743        path
744    }
745
746    /// Estimates hitting time from `start` to `target` by Monte Carlo.
747    pub fn hitting_time_estimate(
748        &self,
749        start: usize,
750        target: usize,
751        trials: usize,
752        seed: u64,
753    ) -> f64 {
754        let mut rng = Lcg::new(seed);
755        let mut total = 0usize;
756        let mut successes = 0usize;
757        for t in 0..trials {
758            let mut current = start;
759            let walk_seed = seed.wrapping_add(t as u64 * 1_000_003);
760            let _ = walk_seed; // use rng instead
761            let mut steps = 0usize;
762            let max_steps = self.graph.n * self.graph.n * 10;
763            loop {
764                if current == target {
765                    break;
766                }
767                let neighbors = &self.graph.adj[current];
768                if neighbors.is_empty() || steps >= max_steps {
769                    break;
770                }
771                let idx = rng.next_range(0, neighbors.len());
772                current = neighbors[idx].0;
773                steps += 1;
774            }
775            if current == target {
776                total += steps;
777                successes += 1;
778            }
779        }
780        if successes == 0 {
781            f64::INFINITY
782        } else {
783            total as f64 / successes as f64
784        }
785    }
786
787    /// Estimates cover time (expected steps to visit all nodes) by Monte Carlo.
788    pub fn cover_time_estimate(&self, start: usize, trials: usize, seed: u64) -> f64 {
789        let n = self.graph.n;
790        let mut rng = Lcg::new(seed);
791        let mut total_steps = 0usize;
792        for _ in 0..trials {
793            let mut visited = vec![false; n];
794            let mut current = start;
795            visited[current] = true;
796            let mut steps = 0usize;
797            let max_steps = n * n * 100;
798            while visited.iter().any(|&v| !v) && steps < max_steps {
799                let neighbors = &self.graph.adj[current];
800                if neighbors.is_empty() {
801                    break;
802                }
803                let idx = rng.next_range(0, neighbors.len());
804                current = neighbors[idx].0;
805                visited[current] = true;
806                steps += 1;
807            }
808            total_steps += steps;
809        }
810        total_steps as f64 / trials as f64
811    }
812
813    /// Stationary distribution via power iteration on the transition matrix.
814    pub fn stationary_distribution(&self, tol: f64, max_iter: usize) -> Vec<f64> {
815        let n = self.graph.n;
816        let mut pi = vec![1.0 / n as f64; n];
817        for _ in 0..max_iter {
818            let mut next = vec![0.0f64; n];
819            for u in 0..n {
820                let deg = self.graph.adj[u].len();
821                if deg == 0 {
822                    continue;
823                }
824                let w = pi[u] / deg as f64;
825                for &(v, _) in &self.graph.adj[u] {
826                    next[v] += w;
827                }
828            }
829            // normalize
830            let sum: f64 = next.iter().sum();
831            if sum > 0.0 {
832                for x in &mut next {
833                    *x /= sum;
834                }
835            }
836            let diff: f64 = pi.iter().zip(&next).map(|(a, b)| (a - b).abs()).sum();
837            pi = next;
838            if diff < tol {
839                break;
840            }
841        }
842        pi
843    }
844}
845
846// ─────────────────────────────────────────────────────────────────────────────
847// PageRank
848// ─────────────────────────────────────────────────────────────────────────────
849
850/// PageRank via power iteration with teleportation and dangling node handling.
851pub struct PageRank {
852    /// Damping factor d (typically 0.85).
853    pub damping: f64,
854    /// Convergence tolerance.
855    pub tol: f64,
856    /// Maximum iterations.
857    pub max_iter: usize,
858}
859
860impl PageRank {
861    /// Creates a new PageRank solver.
862    pub fn new(damping: f64, tol: f64, max_iter: usize) -> Self {
863        Self {
864            damping,
865            tol,
866            max_iter,
867        }
868    }
869
870    /// Computes PageRank scores for the given directed graph.
871    ///
872    /// Handles dangling nodes (no out-edges) by distributing their rank uniformly.
873    pub fn compute(&self, graph: &NetworkGraph) -> Vec<f64> {
874        let n = graph.n;
875        let inv_n = 1.0 / n as f64;
876        let mut pr = vec![inv_n; n];
877        // Build out-degree array
878        let out_deg: Vec<usize> = (0..n).map(|u| graph.adj[u].len()).collect();
879
880        for _ in 0..self.max_iter {
881            // Sum rank from dangling nodes
882            let dangling_sum: f64 = (0..n).filter(|&u| out_deg[u] == 0).map(|u| pr[u]).sum();
883            let mut next = vec![0.0f64; n];
884            for u in 0..n {
885                if out_deg[u] == 0 {
886                    continue;
887                }
888                let contrib = self.damping * pr[u] / out_deg[u] as f64;
889                for &(v, _) in &graph.adj[u] {
890                    next[v] += contrib;
891                }
892            }
893            let teleport = (1.0 - self.damping + self.damping * dangling_sum) * inv_n;
894            for x in &mut next {
895                *x += teleport;
896            }
897            // Normalize
898            let sum: f64 = next.iter().sum();
899            if sum > 0.0 {
900                for x in &mut next {
901                    *x /= sum;
902                }
903            }
904            let diff: f64 = pr.iter().zip(&next).map(|(a, b)| (a - b).abs()).sum();
905            pr = next;
906            if diff < self.tol {
907                break;
908            }
909        }
910        pr
911    }
912}
913
914// ─────────────────────────────────────────────────────────────────────────────
915// Community Detection
916// ─────────────────────────────────────────────────────────────────────────────
917
918/// Community detection via modularity maximization.
919///
920/// Modularity Q = (1/2m) Σ_{ij} \[A_{ij} - k_i k_j / 2m\] δ(c_i, c_j)
921pub struct CommunityDetection;
922
923impl CommunityDetection {
924    /// Computes modularity Q for a partition given by `communities` (node → community id).
925    pub fn modularity(graph: &NetworkGraph, communities: &[usize]) -> f64 {
926        let m = graph.edge_count() as f64;
927        if m == 0.0 {
928            return 0.0;
929        }
930        let n = graph.n;
931        let degree: Vec<f64> = (0..n).map(|u| graph.adj[u].len() as f64).collect();
932        let mut q = 0.0f64;
933        for u in 0..n {
934            for &(v, _) in &graph.adj[u] {
935                if communities[u] == communities[v] {
936                    q += 1.0 - degree[u] * degree[v] / (2.0 * m);
937                }
938            }
939        }
940        q / (2.0 * m)
941    }
942
943    /// Greedy modularity maximization (Clauset-Newman-Moore simplified).
944    ///
945    /// Starts with each node in its own community and greedily merges.
946    /// Returns the best community assignment found.
947    pub fn greedy_modularity(graph: &NetworkGraph) -> Vec<usize> {
948        let n = graph.n;
949        let mut communities: Vec<usize> = (0..n).collect();
950        let mut best_q = Self::modularity(graph, &communities);
951        let mut improved = true;
952        while improved {
953            improved = false;
954            // Try merging each pair of neighboring communities
955            let mut edge_pairs: Vec<(usize, usize)> = Vec::new();
956            for u in 0..n {
957                for &(v, _) in &graph.adj[u] {
958                    let cu = communities[u];
959                    let cv = communities[v];
960                    if cu != cv {
961                        let pair = if cu < cv { (cu, cv) } else { (cv, cu) };
962                        edge_pairs.push(pair);
963                    }
964                }
965            }
966            edge_pairs.sort();
967            edge_pairs.dedup();
968            for (ca, cb) in edge_pairs {
969                let mut trial = communities.clone();
970                for x in &mut trial {
971                    if *x == cb {
972                        *x = ca;
973                    }
974                }
975                let q = Self::modularity(graph, &trial);
976                if q > best_q + 1e-10 {
977                    best_q = q;
978                    communities = trial;
979                    improved = true;
980                }
981            }
982        }
983        // Remap community ids to 0..k
984        let mut id_map: HashMap<usize, usize> = HashMap::new();
985        let mut next_id = 0usize;
986        for c in &mut communities {
987            let entry = id_map.entry(*c).or_insert_with(|| {
988                let id = next_id;
989                next_id += 1;
990                id
991            });
992            *c = *entry;
993        }
994        communities
995    }
996
997    /// Returns number of communities in a partition.
998    pub fn n_communities(communities: &[usize]) -> usize {
999        let max = communities.iter().cloned().max().unwrap_or(0);
1000        max + 1
1001    }
1002}
1003
1004// ─────────────────────────────────────────────────────────────────────────────
1005// Network Robustness
1006// ─────────────────────────────────────────────────────────────────────────────
1007
1008/// Network robustness analysis via percolation theory.
1009pub struct NetworkRobustness;
1010
1011impl NetworkRobustness {
1012    /// Computes size of the giant component (largest connected component) as fraction of N.
1013    pub fn giant_component_fraction(graph: &NetworkGraph) -> f64 {
1014        let n = graph.n;
1015        if n == 0 {
1016            return 0.0;
1017        }
1018        let comp_id = Self::connected_components(graph);
1019        let mut sizes: HashMap<usize, usize> = HashMap::new();
1020        for &c in &comp_id {
1021            *sizes.entry(c).or_insert(0) += 1;
1022        }
1023        *sizes.values().max().unwrap_or(&0) as f64 / n as f64
1024    }
1025
1026    /// Connected components via BFS, returns component id for each node.
1027    pub fn connected_components(graph: &NetworkGraph) -> Vec<usize> {
1028        let n = graph.n;
1029        let mut comp = vec![usize::MAX; n];
1030        let mut comp_id = 0usize;
1031        for start in 0..n {
1032            if comp[start] != usize::MAX {
1033                continue;
1034            }
1035            let mut queue = VecDeque::new();
1036            queue.push_back(start);
1037            comp[start] = comp_id;
1038            while let Some(u) = queue.pop_front() {
1039                for &(v, _) in &graph.adj[u] {
1040                    if comp[v] == usize::MAX {
1041                        comp[v] = comp_id;
1042                        queue.push_back(v);
1043                    }
1044                }
1045            }
1046            comp_id += 1;
1047        }
1048        comp
1049    }
1050
1051    /// Simulates random node removal: returns giant component fraction vs fraction removed.
1052    pub fn percolation_curve(graph: &NetworkGraph, seed: u64) -> Vec<(f64, f64)> {
1053        let n = graph.n;
1054        let mut rng = Lcg::new(seed);
1055        // Random order of removal
1056        let mut order: Vec<usize> = (0..n).collect();
1057        for i in (1..n).rev() {
1058            let j = rng.next_range(0, i + 1);
1059            order.swap(i, j);
1060        }
1061        let mut curve = Vec::with_capacity(n + 1);
1062        let gc0 = Self::giant_component_fraction(graph);
1063        curve.push((0.0, gc0));
1064        let mut active = vec![true; n];
1065        for (step, &node) in order.iter().enumerate() {
1066            active[node] = false;
1067            // Build subgraph
1068            let active_nodes: Vec<usize> = (0..n).filter(|&i| active[i]).collect();
1069            let an = active_nodes.len();
1070            if an == 0 {
1071                curve.push(((step + 1) as f64 / n as f64, 0.0));
1072                continue;
1073            }
1074            let mut idx_map = vec![usize::MAX; n];
1075            for (new_i, &old_i) in active_nodes.iter().enumerate() {
1076                idx_map[old_i] = new_i;
1077            }
1078            let mut sub = NetworkGraph::new(an, graph.directed);
1079            for &u in &active_nodes {
1080                for &(v, w) in &graph.adj[u] {
1081                    if active[v]
1082                        && idx_map[u] < an
1083                        && idx_map[v] < an
1084                        && (graph.directed || idx_map[u] < idx_map[v])
1085                    {
1086                        sub.add_edge(idx_map[u], idx_map[v], w);
1087                    }
1088                }
1089            }
1090            let gc = Self::giant_component_fraction(&sub);
1091            curve.push(((step + 1) as f64 / n as f64, gc));
1092        }
1093        curve
1094    }
1095
1096    /// Estimated bond percolation threshold p_c ≈ 1 / (`k` ) for Erdős-Rényi graphs.
1097    pub fn bond_percolation_threshold(mean_degree: f64) -> f64 {
1098        if mean_degree <= 0.0 {
1099            return 1.0;
1100        }
1101        1.0 / mean_degree
1102    }
1103}
1104
1105// ─────────────────────────────────────────────────────────────────────────────
1106// Small World (Watts-Strogatz)
1107// ─────────────────────────────────────────────────────────────────────────────
1108
1109/// Watts-Strogatz small-world network model.
1110pub struct SmallWorld;
1111
1112impl SmallWorld {
1113    /// Generates a Watts-Strogatz graph: ring of `n` nodes, each connected to `k` nearest
1114    /// neighbors on each side, then each edge rewired with probability `p`.
1115    pub fn watts_strogatz(n: usize, k: usize, p: f64, seed: u64) -> NetworkGraph {
1116        let mut g = NetworkGraph::new(n, false);
1117        let mut rng = Lcg::new(seed);
1118        // Build ring lattice
1119        for i in 0..n {
1120            for j in 1..=k {
1121                let neighbor = (i + j) % n;
1122                g.add_edge(i, neighbor, 1.0);
1123            }
1124        }
1125        // Rewire
1126        // We rebuild edges from scratch to allow rewiring
1127        let mut adj_new: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
1128        let mut visited_edges: std::collections::HashSet<(usize, usize)> =
1129            std::collections::HashSet::new();
1130        for i in 0..n {
1131            for j in 1..=k {
1132                let neighbor = (i + j) % n;
1133                if rng.next_f64() < p {
1134                    // Rewire: choose new target uniformly at random (not self, not existing)
1135                    let mut new_nb = rng.next_range(0, n);
1136                    let mut attempts = 0;
1137                    while (new_nb == i || visited_edges.contains(&(i.min(new_nb), i.max(new_nb))))
1138                        && attempts < n * 2
1139                    {
1140                        new_nb = rng.next_range(0, n);
1141                        attempts += 1;
1142                    }
1143                    if new_nb != i && !visited_edges.contains(&(i.min(new_nb), i.max(new_nb))) {
1144                        let e = (i.min(new_nb), i.max(new_nb));
1145                        visited_edges.insert(e);
1146                        adj_new[i].push((new_nb, 1.0));
1147                        adj_new[new_nb].push((i, 1.0));
1148                    }
1149                } else {
1150                    let e = (i.min(neighbor), i.max(neighbor));
1151                    if !visited_edges.contains(&e) {
1152                        visited_edges.insert(e);
1153                        adj_new[i].push((neighbor, 1.0));
1154                        adj_new[neighbor].push((i, 1.0));
1155                    }
1156                }
1157            }
1158        }
1159        NetworkGraph {
1160            n,
1161            directed: false,
1162            adj: adj_new,
1163        }
1164    }
1165
1166    /// Computes the small-world coefficient σ = C/C_rand / L/L_rand.
1167    ///
1168    /// σ > 1 indicates small-world behavior.
1169    pub fn small_world_coefficient(graph: &NetworkGraph) -> f64 {
1170        let n = graph.n;
1171        if n == 0 {
1172            return 0.0;
1173        }
1174        let c = graph.mean_clustering();
1175        let l = graph.average_path_length();
1176        // Random graph reference: C_rand ≈ <k> / N, L_rand ≈ ln(N) / ln(<k>)
1177        let mean_k = graph.adj.iter().map(|a| a.len()).sum::<usize>() as f64 / n as f64;
1178        if mean_k <= 1.0 || n <= 1 {
1179            return 0.0;
1180        }
1181        let c_rand = mean_k / n as f64;
1182        let l_rand = (n as f64).ln() / mean_k.ln();
1183        if c_rand == 0.0 || l_rand == 0.0 || l == 0.0 {
1184            return 0.0;
1185        }
1186        (c / c_rand) / (l / l_rand)
1187    }
1188}
1189
1190// ─────────────────────────────────────────────────────────────────────────────
1191// Tests
1192// ─────────────────────────────────────────────────────────────────────────────
1193
1194#[cfg(test)]
1195mod tests {
1196    use super::*;
1197
1198    // ── NetworkGraph tests ──────────────────────────────────────────────────
1199
1200    #[test]
1201    fn test_network_graph_basic() {
1202        let mut g = NetworkGraph::new(4, false);
1203        g.add_edge(0, 1, 1.0);
1204        g.add_edge(1, 2, 1.0);
1205        g.add_edge(2, 3, 1.0);
1206        assert_eq!(g.edge_count(), 3);
1207        assert_eq!(g.degree(1), 2);
1208    }
1209
1210    #[test]
1211    fn test_network_graph_directed() {
1212        let mut g = NetworkGraph::new(3, true);
1213        g.add_edge(0, 1, 1.0);
1214        g.add_edge(1, 2, 1.0);
1215        assert_eq!(g.degree(0), 1);
1216        assert_eq!(g.degree(1), 1);
1217        assert_eq!(g.degree(2), 0);
1218        assert_eq!(g.edge_count(), 2);
1219    }
1220
1221    #[test]
1222    fn test_complete_graph() {
1223        let g = NetworkGraph::complete(4);
1224        // K4 has 4*3/2 = 6 edges
1225        assert_eq!(g.edge_count(), 6);
1226        assert_eq!(g.degree(0), 3);
1227    }
1228
1229    #[test]
1230    fn test_ring_graph_bfs() {
1231        let g = NetworkGraph::ring(5);
1232        let dist = g.bfs_distances(0);
1233        assert_eq!(dist[0], 0);
1234        assert_eq!(dist[1], 1);
1235        assert_eq!(dist[4], 1); // ring: 0-4 direct edge
1236        assert_eq!(dist[2], 2);
1237    }
1238
1239    #[test]
1240    fn test_clustering_coefficient_complete() {
1241        let g = NetworkGraph::complete(5);
1242        // In a complete graph every triplet closes → C = 1
1243        let c = g.clustering_coefficient(0);
1244        assert!(
1245            (c - 1.0).abs() < 1e-10,
1246            "clustering should be 1 for complete graph, got {}",
1247            c
1248        );
1249    }
1250
1251    #[test]
1252    fn test_mean_clustering_ring() {
1253        let g = NetworkGraph::ring(6);
1254        let c = g.mean_clustering();
1255        // Ring with k=1: no triangles → C = 0
1256        assert!(c < 1e-10, "ring k=1 clustering should be 0, got {}", c);
1257    }
1258
1259    #[test]
1260    fn test_average_path_length_complete() {
1261        let g = NetworkGraph::complete(4);
1262        let apl = g.average_path_length();
1263        assert!(
1264            (apl - 1.0).abs() < 1e-10,
1265            "complete graph APL should be 1, got {}",
1266            apl
1267        );
1268    }
1269
1270    // ── SIR Model tests ─────────────────────────────────────────────────────
1271
1272    #[test]
1273    fn test_sir_population_conserved() {
1274        let sir = SirModel::new(0.3, 0.1, 1000.0);
1275        let traj = sir.integrate(990.0, 10.0, 0.0, 0.1, 200);
1276        for state in &traj {
1277            let total = state.s + state.i + state.r;
1278            assert!(
1279                (total - 1000.0).abs() < 1.0,
1280                "SIR population drift: {}",
1281                total
1282            );
1283        }
1284    }
1285
1286    #[test]
1287    fn test_sir_r0() {
1288        let sir = SirModel::new(0.3, 0.1, 1000.0);
1289        assert!((sir.r0() - 3.0).abs() < 1e-10);
1290    }
1291
1292    #[test]
1293    fn test_sir_herd_immunity_threshold() {
1294        let sir = SirModel::new(0.3, 0.1, 1000.0);
1295        // R0=3 → p_c = 1 - 1/3 ≈ 0.6667
1296        let hit = sir.herd_immunity_threshold();
1297        assert!(
1298            (hit - 2.0 / 3.0).abs() < 1e-10,
1299            "HIT should be 2/3 for R0=3, got {}",
1300            hit
1301        );
1302    }
1303
1304    #[test]
1305    fn test_sir_herd_immunity_threshold_r0_lt1() {
1306        let sir = SirModel::new(0.05, 0.1, 1000.0);
1307        // R0 < 1 → no epidemic threshold
1308        assert_eq!(sir.herd_immunity_threshold(), 0.0);
1309    }
1310
1311    #[test]
1312    fn test_sir_epidemic_grows_initially() {
1313        let sir = SirModel::new(0.5, 0.1, 1000.0);
1314        let traj = sir.integrate(999.0, 1.0, 0.0, 0.01, 10);
1315        // Infected should grow when S ≈ N and R0 > 1
1316        assert!(traj[5].i > traj[0].i, "infected should grow when R0 > 1");
1317    }
1318
1319    #[test]
1320    fn test_sir_network_population_conserved() {
1321        let g = NetworkGraph::complete(20);
1322        let sir = SirModel::new(0.3, 0.1, 20.0);
1323        let (s_ts, i_ts, r_ts) = sir.simulate_on_graph(&g, &[0], 0.1, 50, 42);
1324        for ((&s, &i), &r) in s_ts.iter().zip(&i_ts).zip(&r_ts) {
1325            assert_eq!(
1326                s + i + r,
1327                20,
1328                "population not conserved: {}+{}+{}={}",
1329                s,
1330                i,
1331                r,
1332                s + i + r
1333            );
1334        }
1335    }
1336
1337    // ── SEIRD Model tests ───────────────────────────────────────────────────
1338
1339    #[test]
1340    fn test_seird_population_conserved() {
1341        let m = SeirdModel::new(0.4, 0.2, 0.15, 0.01, 1000.0);
1342        let traj = m.integrate(990.0, 5.0, 5.0, 0.0, 0.0, 0.1, 100);
1343        for st in &traj {
1344            let total = st.s + st.e + st.i + st.r + st.d;
1345            assert!((total - 1000.0).abs() < 2.0, "SEIRD total drift: {}", total);
1346        }
1347    }
1348
1349    #[test]
1350    fn test_seird_r0() {
1351        let m = SeirdModel::new(0.4, 0.2, 0.15, 0.05, 1000.0);
1352        let r0 = m.r0();
1353        assert!((r0 - 0.4 / 0.2).abs() < 1e-10);
1354    }
1355
1356    #[test]
1357    fn test_seird_deaths_nonnegative() {
1358        let m = SeirdModel::new(0.3, 0.2, 0.1, 0.02, 500.0);
1359        let traj = m.integrate(495.0, 3.0, 2.0, 0.0, 0.0, 0.1, 50);
1360        for st in &traj {
1361            assert!(st.d >= -1e-10, "deaths should be non-negative");
1362        }
1363    }
1364
1365    // ── Opinion Dynamics tests ──────────────────────────────────────────────
1366
1367    #[test]
1368    fn test_opinion_dynamics_opinions_in_range() {
1369        let od = OpinionDynamics::new(0.3, 0.5);
1370        let mut ops: Vec<f64> = (0..20).map(|i| i as f64 / 20.0).collect();
1371        od.run(&mut ops, 5000, 123);
1372        for &o in &ops {
1373            assert!((0.0..=1.0).contains(&o), "opinion out of range: {}", o);
1374        }
1375    }
1376
1377    #[test]
1378    fn test_opinion_dynamics_convergence() {
1379        let od = OpinionDynamics::new(1.0, 0.5); // large epsilon → all interact
1380        let mut ops: Vec<f64> = (0..10).map(|i| i as f64 / 10.0).collect();
1381        od.run(&mut ops, 50000, 77);
1382        // Should converge to single cluster
1383        assert!(od.count_clusters(&ops) <= 2);
1384    }
1385
1386    #[test]
1387    fn test_opinion_dynamics_fragmentation() {
1388        let od = OpinionDynamics::new(0.1, 0.5); // small epsilon
1389        let mut ops: Vec<f64> = (0..20).map(|i| i as f64 / 20.0).collect();
1390        od.run(&mut ops, 10000, 99);
1391        // Multiple clusters expected
1392        let clusters = od.count_clusters(&ops);
1393        assert!(clusters >= 1);
1394    }
1395
1396    // ── DeGroot Model tests ─────────────────────────────────────────────────
1397
1398    #[test]
1399    fn test_degroot_consensus_complete_graph() {
1400        let g = NetworkGraph::complete(5);
1401        let dg = DeGrootModel::from_graph(&g);
1402        let ops = vec![0.0, 0.25, 0.5, 0.75, 1.0];
1403        let final_ops = dg.run(&ops, 100);
1404        let mean = ops.iter().sum::<f64>() / ops.len() as f64;
1405        for &o in &final_ops {
1406            assert!(
1407                (o - mean).abs() < 1e-6,
1408                "consensus failed: {} vs {}",
1409                o,
1410                mean
1411            );
1412        }
1413    }
1414
1415    #[test]
1416    fn test_degroot_opinion_preserves_mean() {
1417        let g = NetworkGraph::ring(6);
1418        let dg = DeGrootModel::from_graph(&g);
1419        let ops = vec![0.1, 0.4, 0.6, 0.2, 0.9, 0.3];
1420        let mean0: f64 = ops.iter().sum::<f64>() / ops.len() as f64;
1421        let final_ops = dg.run(&ops, 50);
1422        let mean1: f64 = final_ops.iter().sum::<f64>() / final_ops.len() as f64;
1423        assert!(
1424            (mean0 - mean1).abs() < 1e-10,
1425            "mean opinion should be preserved"
1426        );
1427    }
1428
1429    // ── Voter Model tests ───────────────────────────────────────────────────
1430
1431    #[test]
1432    fn test_voter_model_fractions_sum_to_one() {
1433        let g = NetworkGraph::complete(10);
1434        let vm = VoterModel::new(2);
1435        let mut ops: Vec<usize> = (0..10).map(|i| i % 2).collect();
1436        vm.run(&g, &mut ops, 1000, 42);
1437        let f0 = vm.fraction(&ops, 0);
1438        let f1 = vm.fraction(&ops, 1);
1439        assert!((f0 + f1 - 1.0).abs() < 1e-10);
1440    }
1441
1442    #[test]
1443    fn test_voter_model_consensus_reached() {
1444        let g = NetworkGraph::complete(8);
1445        let vm = VoterModel::new(2);
1446        // Start with one opinion — should stay in consensus
1447        let mut ops = vec![0usize; 8];
1448        vm.run(&g, &mut ops, 1000, 5);
1449        assert!(vm.is_consensus(&ops));
1450    }
1451
1452    // ── Kuramoto Model tests ────────────────────────────────────────────────
1453
1454    #[test]
1455    fn test_kuramoto_order_parameter_range() {
1456        let omegas = vec![1.0, 1.1, 0.9, 1.05, 0.95];
1457        let km = KuramotoModel::new(omegas, 5.0);
1458        let phases0: Vec<f64> = (0..5).map(|i| i as f64 * 0.1).collect();
1459        let (_, r_vals) = km.integrate(&phases0, 0.01, 100);
1460        for r in &r_vals {
1461            assert!(
1462                *r >= 0.0 && *r <= 1.0 + 1e-10,
1463                "order parameter out of range: {}",
1464                r
1465            );
1466        }
1467    }
1468
1469    #[test]
1470    fn test_kuramoto_fully_synchronized_stays_synchronized() {
1471        // All same frequency → should remain synchronized
1472        let omegas = vec![1.0f64; 5];
1473        let km = KuramotoModel::new(omegas, 1.0);
1474        let phases0 = vec![0.0f64; 5]; // all same phase
1475        let (_, r_vals) = km.integrate(&phases0, 0.01, 100);
1476        let r_final = *r_vals.last().unwrap();
1477        assert!(
1478            r_final > 0.99,
1479            "fully synchronized should stay at r≈1, got {}",
1480            r_final
1481        );
1482    }
1483
1484    #[test]
1485    fn test_kuramoto_order_parameter_formula() {
1486        let phases = vec![0.0f64; 4]; // all at same phase
1487        let r = KuramotoModel::order_parameter(&phases);
1488        assert!((r - 1.0).abs() < 1e-10);
1489    }
1490
1491    #[test]
1492    fn test_kuramoto_order_parameter_opposite_phases() {
1493        // Two groups at 0 and π → r ≈ 0
1494        let phases = vec![0.0, PI, 0.0, PI];
1495        let r = KuramotoModel::order_parameter(&phases);
1496        assert!(r < 1e-10, "opposite phases should give r≈0, got {}", r);
1497    }
1498
1499    #[test]
1500    fn test_kuramoto_critical_coupling_positive() {
1501        let omegas = vec![0.5, 1.0, 1.5, 2.0, 0.8];
1502        let km = KuramotoModel::new(omegas, 1.0);
1503        let kc = km.critical_coupling_estimate();
1504        assert!(kc > 0.0, "critical coupling should be positive");
1505    }
1506
1507    // ── Random Walk tests ───────────────────────────────────────────────────
1508
1509    #[test]
1510    fn test_random_walk_path_length() {
1511        let g = NetworkGraph::ring(10);
1512        let rw = RandomWalkGraph::new(g);
1513        let path = rw.walk(0, 50, 1234);
1514        assert_eq!(path.len(), 51); // start + 50 steps
1515    }
1516
1517    #[test]
1518    fn test_random_walk_stays_in_graph() {
1519        let g = NetworkGraph::complete(8);
1520        let n = g.n;
1521        let rw = RandomWalkGraph::new(g);
1522        let path = rw.walk(0, 100, 7);
1523        for &node in &path {
1524            assert!(node < n, "walker left graph: {}", node);
1525        }
1526    }
1527
1528    #[test]
1529    fn test_stationary_distribution_sums_to_one() {
1530        let g = NetworkGraph::complete(5);
1531        let rw = RandomWalkGraph::new(g);
1532        let pi = rw.stationary_distribution(1e-9, 1000);
1533        let sum: f64 = pi.iter().sum();
1534        assert!(
1535            (sum - 1.0).abs() < 1e-6,
1536            "stationary dist should sum to 1, got {}",
1537            sum
1538        );
1539    }
1540
1541    #[test]
1542    fn test_stationary_distribution_uniform_regular() {
1543        let g = NetworkGraph::complete(4);
1544        let rw = RandomWalkGraph::new(g);
1545        let pi = rw.stationary_distribution(1e-9, 1000);
1546        let expected = 0.25f64;
1547        for &p in &pi {
1548            assert!(
1549                (p - expected).abs() < 1e-6,
1550                "regular graph stationary dist should be uniform, got {}",
1551                p
1552            );
1553        }
1554    }
1555
1556    // ── PageRank tests ──────────────────────────────────────────────────────
1557
1558    #[test]
1559    fn test_pagerank_sums_to_one() {
1560        let mut g = NetworkGraph::new(4, true);
1561        g.add_edge(0, 1, 1.0);
1562        g.add_edge(1, 2, 1.0);
1563        g.add_edge(2, 0, 1.0);
1564        g.add_edge(3, 0, 1.0);
1565        let pr_solver = PageRank::new(0.85, 1e-8, 100);
1566        let pr = pr_solver.compute(&g);
1567        let sum: f64 = pr.iter().sum();
1568        assert!(
1569            (sum - 1.0).abs() < 1e-6,
1570            "PageRank should sum to 1, got {}",
1571            sum
1572        );
1573    }
1574
1575    #[test]
1576    fn test_pagerank_dangling_nodes() {
1577        let mut g = NetworkGraph::new(3, true);
1578        g.add_edge(0, 1, 1.0);
1579        // Node 2 is dangling (no out-edges)
1580        let pr_solver = PageRank::new(0.85, 1e-8, 200);
1581        let pr = pr_solver.compute(&g);
1582        let sum: f64 = pr.iter().sum();
1583        assert!(
1584            (sum - 1.0).abs() < 1e-6,
1585            "PageRank with dangling node should sum to 1"
1586        );
1587    }
1588
1589    #[test]
1590    fn test_pagerank_all_positive() {
1591        let g = NetworkGraph::complete(5);
1592        // Convert to directed
1593        let mut gd = NetworkGraph::new(5, true);
1594        for u in 0..5 {
1595            for &(v, w) in &g.adj[u] {
1596                gd.add_edge(u, v, w);
1597            }
1598        }
1599        let pr_solver = PageRank::new(0.85, 1e-8, 100);
1600        let pr = pr_solver.compute(&gd);
1601        for &p in &pr {
1602            assert!(p > 0.0, "PageRank should be positive");
1603        }
1604    }
1605
1606    // ── Community Detection tests ───────────────────────────────────────────
1607
1608    #[test]
1609    fn test_modularity_trivial_all_same_community() {
1610        let g = NetworkGraph::ring(6);
1611        let communities = vec![0usize; 6]; // all in one community
1612        let q = CommunityDetection::modularity(&g, &communities);
1613        assert!(q <= 1.0, "Q should be <= 1, got {}", q);
1614    }
1615
1616    #[test]
1617    fn test_modularity_two_cliques() {
1618        // Two connected 4-cliques with one bridge → modularity of two-community partition
1619        // should be positive and close to the theoretical optimum.
1620        let mut g = NetworkGraph::new(8, false);
1621        for i in 0..4 {
1622            for j in (i + 1)..4 {
1623                g.add_edge(i, j, 1.0);
1624            }
1625        }
1626        for i in 4..8 {
1627            for j in (i + 1)..8 {
1628                g.add_edge(i, j, 1.0);
1629            }
1630        }
1631        g.add_edge(3, 4, 1.0); // bridge
1632        // Correct 2-community partition should have positive modularity
1633        let com_two = vec![0, 0, 0, 0, 1, 1, 1, 1];
1634        let q_two = CommunityDetection::modularity(&g, &com_two);
1635        assert!(
1636            q_two > 0.0,
1637            "two-clique true partition Q should be positive, got {}",
1638            q_two
1639        );
1640        // Each node in its own community should have lower Q than the 2-community partition
1641        let com_individual: Vec<usize> = (0..8).collect();
1642        let q_individual = CommunityDetection::modularity(&g, &com_individual);
1643        assert!(
1644            q_two > q_individual,
1645            "true 2-community partition Q={} should exceed individual Q={}",
1646            q_two,
1647            q_individual
1648        );
1649    }
1650
1651    #[test]
1652    fn test_greedy_modularity_returns_valid_partition() {
1653        let g = NetworkGraph::complete(6);
1654        let communities = CommunityDetection::greedy_modularity(&g);
1655        assert_eq!(communities.len(), 6);
1656    }
1657
1658    // ── Network Robustness tests ────────────────────────────────────────────
1659
1660    #[test]
1661    fn test_giant_component_complete_graph() {
1662        let g = NetworkGraph::complete(10);
1663        let gc = NetworkRobustness::giant_component_fraction(&g);
1664        assert!(
1665            (gc - 1.0).abs() < 1e-10,
1666            "complete graph should have GC=1, got {}",
1667            gc
1668        );
1669    }
1670
1671    #[test]
1672    fn test_giant_component_disconnected() {
1673        let g = NetworkGraph::new(4, false); // no edges → 4 isolated nodes
1674        let gc = NetworkRobustness::giant_component_fraction(&g);
1675        assert!(
1676            (gc - 0.25).abs() < 1e-10,
1677            "isolated nodes GC=0.25 (each own component), got {}",
1678            gc
1679        );
1680    }
1681
1682    #[test]
1683    fn test_percolation_curve_monotone() {
1684        let g = NetworkGraph::erdos_renyi(20, 0.4, 1);
1685        let curve = NetworkRobustness::percolation_curve(&g, 42);
1686        // The curve should start with a finite GC and end near 0
1687        assert!(!curve.is_empty(), "percolation curve should not be empty");
1688        // First GC (no removal) should be positive for dense ER graph
1689        assert!(curve[0].1 > 0.0, "initial GC should be positive");
1690        // Final GC (all nodes removed) should be 0
1691        assert!(
1692            curve.last().unwrap().1 == 0.0,
1693            "final GC should be 0 when all nodes removed"
1694        );
1695        // Overall trend: GC at 80%+ removal should be less than at start
1696        let early = curve[curve.len() / 5].1;
1697        let late = curve[4 * curve.len() / 5].1;
1698        assert!(
1699            late <= early + 0.2,
1700            "GC should trend downward: early={} late={}",
1701            early,
1702            late
1703        );
1704    }
1705
1706    #[test]
1707    fn test_bond_percolation_threshold() {
1708        let pct = NetworkRobustness::bond_percolation_threshold(4.0);
1709        assert!((pct - 0.25).abs() < 1e-10);
1710    }
1711
1712    // ── Small World tests ───────────────────────────────────────────────────
1713
1714    #[test]
1715    fn test_watts_strogatz_node_count() {
1716        let g = SmallWorld::watts_strogatz(20, 2, 0.1, 42);
1717        assert_eq!(g.n, 20);
1718    }
1719
1720    #[test]
1721    fn test_small_world_low_p_high_clustering() {
1722        // With p=0 (regular lattice with k=3), clustering should be high
1723        let g = SmallWorld::watts_strogatz(30, 3, 0.0, 1);
1724        let c = g.mean_clustering();
1725        assert!(
1726            c > 0.3,
1727            "regular WS lattice should have non-trivial clustering, got {}",
1728            c
1729        );
1730    }
1731
1732    #[test]
1733    fn test_erdos_renyi_mean_degree() {
1734        let n = 100;
1735        let p = 0.1;
1736        let g = NetworkGraph::erdos_renyi(n, p, 999);
1737        let mean_k = g.adj.iter().map(|a| a.len()).sum::<usize>() as f64 / n as f64;
1738        let expected = (n as f64 - 1.0) * p;
1739        // Should be within 3 standard deviations
1740        assert!(
1741            (mean_k - expected).abs() < 3.0 * expected.sqrt() + 2.0,
1742            "ER mean degree {} far from expected {}",
1743            mean_k,
1744            expected
1745        );
1746    }
1747}