Skip to main content

scirs2_graph/diffusion/
models.rs

1//! Information diffusion models
2//!
3//! Provides four simulation models on an adjacency representation:
4//!
5//! | Model | Description |
6//! |-------|-------------|
7//! | [`IndependentCascade`] | Each active node activates each inactive neighbour with probability `p(u,v)` |
8//! | [`LinearThreshold`] | Node activates when total in-weight from active neighbours ≥ per-node threshold |
9//! | [`SIRModel`] | Epidemic SIR: β infection probability per edge, γ recovery probability per step |
10//! | [`SISModel`] | Epidemic SIS: β/γ as SIR but recovered nodes return to susceptible |
11
12use std::collections::{HashMap, HashSet, VecDeque};
13
14use scirs2_core::random::{Rng, RngExt};
15
16use crate::error::{GraphError, Result};
17
18// ---------------------------------------------------------------------------
19// Shared adjacency representation
20// ---------------------------------------------------------------------------
21
22/// Compact edge representation: (target_id, edge_weight).
23pub type AdjList = HashMap<usize, Vec<(usize, f64)>>;
24
25// ---------------------------------------------------------------------------
26// SIR node state
27// ---------------------------------------------------------------------------
28
29/// State of a node in the SIR / SIS epidemic model.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum SirState {
32    /// Susceptible — not yet infected.
33    Susceptible,
34    /// Infected (and infectious).
35    Infected,
36    /// Recovered (and immune, in SIR).
37    Recovered,
38}
39
40// ---------------------------------------------------------------------------
41// Result types
42// ---------------------------------------------------------------------------
43
44/// Outcome of a single diffusion simulation.
45#[derive(Debug, Clone)]
46pub struct SimulationResult {
47    /// Set of node IDs that were activated / infected at any point.
48    pub activated: HashSet<usize>,
49    /// Per-step counts: `(susceptible, infected, recovered)`.
50    /// Only populated by epidemic simulations.
51    pub time_series: Vec<(usize, usize, usize)>,
52    /// Final total number of activated nodes (including seeds).
53    pub spread: usize,
54}
55
56// ---------------------------------------------------------------------------
57// Independent Cascade model
58// ---------------------------------------------------------------------------
59
60/// Independent Cascade (IC) diffusion model configuration.
61///
62/// Each active node `u` attempts to activate every inactive neighbour `v` with
63/// probability `p(u, v)` stored as the edge weight.  Each edge is tried **at
64/// most once**.
65#[derive(Debug, Clone)]
66pub struct IndependentCascade {
67    /// Adjacency list: `node → [(neighbour, propagation_probability)]`.
68    pub adjacency: AdjList,
69    /// Total number of nodes in the graph.
70    pub num_nodes: usize,
71}
72
73impl IndependentCascade {
74    /// Create a new IC model.
75    ///
76    /// # Arguments
77    /// * `adjacency` — directed adjacency list with propagation probabilities as weights.
78    /// * `num_nodes` — number of nodes (needed for result reporting).
79    pub fn new(adjacency: AdjList, num_nodes: usize) -> Self {
80        IndependentCascade {
81            adjacency,
82            num_nodes,
83        }
84    }
85
86    /// Build an IC model from a vec of `(source, target, probability)` triples.
87    pub fn from_edges(edges: &[(usize, usize, f64)], num_nodes: usize) -> Self {
88        let mut adjacency: AdjList = HashMap::new();
89        for &(src, tgt, prob) in edges {
90            adjacency.entry(src).or_default().push((tgt, prob));
91        }
92        IndependentCascade::new(adjacency, num_nodes)
93    }
94
95    /// Run a single IC simulation from the given seed set.
96    pub fn simulate(&self, seeds: &[usize]) -> Result<SimulationResult> {
97        simulate_ic(&self.adjacency, seeds)
98    }
99
100    /// Estimate expected spread over `num_simulations` Monte-Carlo runs.
101    pub fn expected_spread(&self, seeds: &[usize], num_simulations: usize) -> Result<f64> {
102        expected_spread_ic(&self.adjacency, seeds, num_simulations)
103    }
104}
105
106// ---------------------------------------------------------------------------
107// Linear Threshold model
108// ---------------------------------------------------------------------------
109
110/// Linear Threshold (LT) diffusion model configuration.
111///
112/// Each node `v` has a threshold `θ_v ∈ [0, 1]` drawn at the start of each
113/// simulation.  Node `v` becomes active when
114/// `Σ_{u active} w(u, v) ≥ θ_v`, where `w(u, v)` is the normalised in-edge
115/// weight.
116#[derive(Debug, Clone)]
117pub struct LinearThreshold {
118    /// Directed adjacency list: `node → [(neighbour, weight)]`.
119    ///
120    /// Weights represent *influence* of `node` on `neighbour`.  They should be
121    /// normalised so that `Σ_{u} w(u, v) ≤ 1` for every `v`.
122    pub adjacency: AdjList,
123    /// Total number of nodes.
124    pub num_nodes: usize,
125    /// Optional fixed thresholds per node (if `None`, drawn uniformly per run).
126    pub thresholds: Option<Vec<f64>>,
127}
128
129impl LinearThreshold {
130    /// Create a new LT model with random per-run thresholds.
131    pub fn new(adjacency: AdjList, num_nodes: usize) -> Self {
132        LinearThreshold {
133            adjacency,
134            num_nodes,
135            thresholds: None,
136        }
137    }
138
139    /// Create an LT model with fixed thresholds.
140    pub fn with_thresholds(adjacency: AdjList, thresholds: Vec<f64>) -> Result<Self> {
141        let num_nodes = thresholds.len();
142        for (i, &t) in thresholds.iter().enumerate() {
143            if !(0.0..=1.0).contains(&t) {
144                return Err(GraphError::InvalidParameter {
145                    param: format!("thresholds[{i}]"),
146                    value: t.to_string(),
147                    expected: "value in [0, 1]".to_string(),
148                    context: "LinearThreshold::with_thresholds".to_string(),
149                });
150            }
151        }
152        Ok(LinearThreshold {
153            adjacency,
154            num_nodes,
155            thresholds: Some(thresholds),
156        })
157    }
158
159    /// Build from `(source, target, weight)` triples.
160    pub fn from_edges(edges: &[(usize, usize, f64)], num_nodes: usize) -> Self {
161        let mut adjacency: AdjList = HashMap::new();
162        for &(src, tgt, w) in edges {
163            adjacency.entry(src).or_default().push((tgt, w));
164        }
165        LinearThreshold::new(adjacency, num_nodes)
166    }
167
168    /// Run a single LT simulation.
169    pub fn simulate(&self, seeds: &[usize]) -> Result<SimulationResult> {
170        simulate_lt(
171            &self.adjacency,
172            self.num_nodes,
173            seeds,
174            self.thresholds.as_deref(),
175        )
176    }
177
178    /// Estimate expected spread.
179    pub fn expected_spread(&self, seeds: &[usize], num_simulations: usize) -> Result<f64> {
180        expected_spread_lt(
181            &self.adjacency,
182            self.num_nodes,
183            seeds,
184            self.thresholds.as_deref(),
185            num_simulations,
186        )
187    }
188}
189
190// ---------------------------------------------------------------------------
191// SIR model
192// ---------------------------------------------------------------------------
193
194/// SIR epidemic model (Susceptible–Infected–Recovered).
195///
196/// At each discrete time step:
197/// 1. Every `Infected` node infects each `Susceptible` neighbour with probability `beta`.
198/// 2. Every `Infected` node transitions to `Recovered` with probability `gamma`.
199#[derive(Debug, Clone)]
200pub struct SIRModel {
201    /// Adjacency list (undirected: each edge stored in both directions).
202    pub adjacency: AdjList,
203    /// Infection probability per edge per step.
204    pub beta: f64,
205    /// Recovery probability per step.
206    pub gamma: f64,
207    /// Total number of nodes.
208    pub num_nodes: usize,
209}
210
211impl SIRModel {
212    /// Create a new SIR model.
213    ///
214    /// # Errors
215    /// Returns [`GraphError::InvalidParameter`] when `beta` or `gamma` ∉ `[0, 1]`.
216    pub fn new(adjacency: AdjList, num_nodes: usize, beta: f64, gamma: f64) -> Result<Self> {
217        if !(0.0..=1.0).contains(&beta) {
218            return Err(GraphError::InvalidParameter {
219                param: "beta".to_string(),
220                value: beta.to_string(),
221                expected: "[0, 1]".to_string(),
222                context: "SIRModel::new".to_string(),
223            });
224        }
225        if !(0.0..=1.0).contains(&gamma) {
226            return Err(GraphError::InvalidParameter {
227                param: "gamma".to_string(),
228                value: gamma.to_string(),
229                expected: "[0, 1]".to_string(),
230                context: "SIRModel::new".to_string(),
231            });
232        }
233        Ok(SIRModel {
234            adjacency,
235            beta,
236            gamma,
237            num_nodes,
238        })
239    }
240
241    /// Build from `(source, target)` edge list (unweighted).
242    pub fn from_edges(
243        edges: &[(usize, usize)],
244        num_nodes: usize,
245        beta: f64,
246        gamma: f64,
247    ) -> Result<Self> {
248        let mut adjacency: AdjList = HashMap::new();
249        for &(src, tgt) in edges {
250            adjacency.entry(src).or_default().push((tgt, 1.0));
251            adjacency.entry(tgt).or_default().push((src, 1.0));
252        }
253        SIRModel::new(adjacency, num_nodes, beta, gamma)
254    }
255
256    /// Run a single SIR simulation from initial infected set.
257    pub fn simulate(&self, initial_infected: &[usize]) -> Result<SimulationResult> {
258        simulate_sir(
259            &self.adjacency,
260            self.num_nodes,
261            initial_infected,
262            self.beta,
263            self.gamma,
264        )
265    }
266}
267
268// ---------------------------------------------------------------------------
269// SIS model
270// ---------------------------------------------------------------------------
271
272/// SIS epidemic model (Susceptible–Infected–Susceptible).
273///
274/// Like SIR except `Recovered` nodes return to `Susceptible` rather than
275/// gaining immunity.  The simulation terminates when no infected nodes remain
276/// or after `max_steps` steps.
277#[derive(Debug, Clone)]
278pub struct SISModel {
279    /// Adjacency list.
280    pub adjacency: AdjList,
281    /// Infection probability per edge per step.
282    pub beta: f64,
283    /// Recovery probability per step (returns to Susceptible).
284    pub gamma: f64,
285    /// Total number of nodes.
286    pub num_nodes: usize,
287    /// Maximum simulation steps before forced termination.
288    pub max_steps: usize,
289}
290
291impl SISModel {
292    /// Create a new SIS model.
293    ///
294    /// # Errors
295    /// Returns [`GraphError::InvalidParameter`] when `beta` or `gamma` ∉ `[0, 1]`.
296    pub fn new(
297        adjacency: AdjList,
298        num_nodes: usize,
299        beta: f64,
300        gamma: f64,
301        max_steps: usize,
302    ) -> Result<Self> {
303        if !(0.0..=1.0).contains(&beta) {
304            return Err(GraphError::InvalidParameter {
305                param: "beta".to_string(),
306                value: beta.to_string(),
307                expected: "[0, 1]".to_string(),
308                context: "SISModel::new".to_string(),
309            });
310        }
311        if !(0.0..=1.0).contains(&gamma) {
312            return Err(GraphError::InvalidParameter {
313                param: "gamma".to_string(),
314                value: gamma.to_string(),
315                expected: "[0, 1]".to_string(),
316                context: "SISModel::new".to_string(),
317            });
318        }
319        Ok(SISModel {
320            adjacency,
321            beta,
322            gamma,
323            num_nodes,
324            max_steps,
325        })
326    }
327
328    /// Build from unweighted edge list.
329    pub fn from_edges(
330        edges: &[(usize, usize)],
331        num_nodes: usize,
332        beta: f64,
333        gamma: f64,
334        max_steps: usize,
335    ) -> Result<Self> {
336        let mut adjacency: AdjList = HashMap::new();
337        for &(src, tgt) in edges {
338            adjacency.entry(src).or_default().push((tgt, 1.0));
339            adjacency.entry(tgt).or_default().push((src, 1.0));
340        }
341        SISModel::new(adjacency, num_nodes, beta, gamma, max_steps)
342    }
343
344    /// Run a single SIS simulation from initial infected set.
345    pub fn simulate(&self, initial_infected: &[usize]) -> Result<SimulationResult> {
346        simulate_sis(
347            &self.adjacency,
348            self.num_nodes,
349            initial_infected,
350            self.beta,
351            self.gamma,
352            self.max_steps,
353        )
354    }
355}
356
357// ---------------------------------------------------------------------------
358// Free functions – IC
359// ---------------------------------------------------------------------------
360
361/// Simulate one run of the Independent Cascade model.
362///
363/// # Arguments
364/// * `adjacency` — directed adjacency list with propagation probabilities.
365/// * `seeds` — initial active node IDs.
366///
367/// # Returns
368/// [`SimulationResult`] with `time_series` empty (IC is a cascade, not
369/// time-stepped).
370pub fn simulate_ic(adjacency: &AdjList, seeds: &[usize]) -> Result<SimulationResult> {
371    let mut rng = scirs2_core::random::rng();
372    let mut active: HashSet<usize> = seeds.iter().cloned().collect();
373    let mut queue: VecDeque<usize> = seeds.iter().cloned().collect();
374
375    while let Some(node) = queue.pop_front() {
376        if let Some(neighbors) = adjacency.get(&node) {
377            for &(nbr, prob) in neighbors {
378                if !active.contains(&nbr) && rng.random::<f64>() < prob {
379                    active.insert(nbr);
380                    queue.push_back(nbr);
381                }
382            }
383        }
384    }
385
386    let spread = active.len();
387    Ok(SimulationResult {
388        activated: active,
389        time_series: Vec::new(),
390        spread,
391    })
392}
393
394/// Estimate the expected spread of a seed set under the IC model using
395/// Monte-Carlo averaging over `num_simulations` independent runs.
396///
397/// # Arguments
398/// * `adjacency` — directed adjacency list with propagation probabilities.
399/// * `seeds` — initial seed set.
400/// * `num_simulations` — number of Monte-Carlo trials.
401pub fn expected_spread(
402    adjacency: &AdjList,
403    seeds: &[usize],
404    num_simulations: usize,
405) -> Result<f64> {
406    expected_spread_ic(adjacency, seeds, num_simulations)
407}
408
409fn expected_spread_ic(adjacency: &AdjList, seeds: &[usize], num_simulations: usize) -> Result<f64> {
410    if num_simulations == 0 {
411        return Err(GraphError::InvalidParameter {
412            param: "num_simulations".to_string(),
413            value: "0".to_string(),
414            expected: ">= 1".to_string(),
415            context: "expected_spread_ic".to_string(),
416        });
417    }
418    let mut total = 0.0_f64;
419    for _ in 0..num_simulations {
420        let result = simulate_ic(adjacency, seeds)?;
421        total += result.spread as f64;
422    }
423    Ok(total / num_simulations as f64)
424}
425
426// ---------------------------------------------------------------------------
427// Free functions – LT
428// ---------------------------------------------------------------------------
429
430/// Simulate one run of the Linear Threshold model.
431///
432/// # Arguments
433/// * `adjacency` — directed adjacency list `source → [(target, weight)]`.
434///   For LT the weights should satisfy `Σ_{u} w(u,v) ≤ 1`.
435/// * `num_nodes` — total number of nodes (used to allocate per-node state).
436/// * `seeds` — initial active set.
437/// * `fixed_thresholds` — if `Some`, use these thresholds; otherwise draw
438///   uniformly from `[0, 1]` per run.
439pub fn simulate_lt(
440    adjacency: &AdjList,
441    num_nodes: usize,
442    seeds: &[usize],
443    fixed_thresholds: Option<&[f64]>,
444) -> Result<SimulationResult> {
445    // Build reverse adjacency: target → [(source, weight)]
446    let mut reverse: HashMap<usize, Vec<(usize, f64)>> = HashMap::new();
447    for (&src, nbrs) in adjacency {
448        for &(tgt, w) in nbrs {
449            reverse.entry(tgt).or_default().push((src, w));
450        }
451    }
452
453    let mut rng = scirs2_core::random::rng();
454
455    // Assign thresholds
456    let thresholds: Vec<f64> = match fixed_thresholds {
457        Some(t) => {
458            if t.len() < num_nodes {
459                return Err(GraphError::InvalidParameter {
460                    param: "fixed_thresholds".to_string(),
461                    value: format!("len={}", t.len()),
462                    expected: format!(">= num_nodes={num_nodes}"),
463                    context: "simulate_lt".to_string(),
464                });
465            }
466            t.to_vec()
467        }
468        None => (0..num_nodes).map(|_| rng.random::<f64>()).collect(),
469    };
470
471    let mut active: HashSet<usize> = seeds.iter().cloned().collect();
472    let mut changed = true;
473
474    // Iterative activation until no new node can be activated
475    while changed {
476        changed = false;
477        // Collect candidate nodes: inactive nodes with at least one active in-neighbour
478        let candidates: Vec<usize> = reverse
479            .keys()
480            .filter(|&&node| !active.contains(&node))
481            .cloned()
482            .collect();
483
484        for node in candidates {
485            let weight_sum: f64 = reverse
486                .get(&node)
487                .map(|in_nbrs| {
488                    in_nbrs
489                        .iter()
490                        .filter(|(src, _)| active.contains(src))
491                        .map(|(_, w)| w)
492                        .sum()
493                })
494                .unwrap_or(0.0);
495
496            let threshold = if node < thresholds.len() {
497                thresholds[node]
498            } else {
499                1.0
500            };
501
502            if weight_sum >= threshold {
503                active.insert(node);
504                changed = true;
505            }
506        }
507    }
508
509    let spread = active.len();
510    Ok(SimulationResult {
511        activated: active,
512        time_series: Vec::new(),
513        spread,
514    })
515}
516
517fn expected_spread_lt(
518    adjacency: &AdjList,
519    num_nodes: usize,
520    seeds: &[usize],
521    fixed_thresholds: Option<&[f64]>,
522    num_simulations: usize,
523) -> Result<f64> {
524    if num_simulations == 0 {
525        return Err(GraphError::InvalidParameter {
526            param: "num_simulations".to_string(),
527            value: "0".to_string(),
528            expected: ">= 1".to_string(),
529            context: "expected_spread_lt".to_string(),
530        });
531    }
532    let mut total = 0.0_f64;
533    for _ in 0..num_simulations {
534        let result = simulate_lt(adjacency, num_nodes, seeds, fixed_thresholds)?;
535        total += result.spread as f64;
536    }
537    Ok(total / num_simulations as f64)
538}
539
540// ---------------------------------------------------------------------------
541// Free functions – SIR
542// ---------------------------------------------------------------------------
543
544/// Simulate one run of the SIR epidemic model.
545///
546/// # Arguments
547/// * `adjacency` — (undirected) adjacency list; each undirected edge should
548///   appear in both directions.
549/// * `num_nodes` — total number of nodes.
550/// * `initial_infected` — nodes initially in the `Infected` state.
551/// * `beta` — infection probability per edge per time step.
552/// * `gamma` — recovery probability per time step.
553pub fn simulate_sir(
554    adjacency: &AdjList,
555    num_nodes: usize,
556    initial_infected: &[usize],
557    beta: f64,
558    gamma: f64,
559) -> Result<SimulationResult> {
560    if !(0.0..=1.0).contains(&beta) || !(0.0..=1.0).contains(&gamma) {
561        return Err(GraphError::InvalidParameter {
562            param: "beta/gamma".to_string(),
563            value: format!("beta={beta}, gamma={gamma}"),
564            expected: "both in [0, 1]".to_string(),
565            context: "simulate_sir".to_string(),
566        });
567    }
568
569    let mut rng = scirs2_core::random::rng();
570    let mut states: Vec<SirState> = vec![SirState::Susceptible; num_nodes];
571    for &node in initial_infected {
572        if node < num_nodes {
573            states[node] = SirState::Infected;
574        }
575    }
576
577    let mut time_series: Vec<(usize, usize, usize)> = Vec::new();
578    let mut ever_infected: HashSet<usize> = initial_infected.iter().cloned().collect();
579
580    loop {
581        let n_infected = states.iter().filter(|&&s| s == SirState::Infected).count();
582        let n_recovered = states.iter().filter(|&&s| s == SirState::Recovered).count();
583        let n_susceptible = num_nodes - n_infected - n_recovered;
584        time_series.push((n_susceptible, n_infected, n_recovered));
585
586        if n_infected == 0 {
587            break;
588        }
589
590        let mut next_states = states.clone();
591
592        // Infection step
593        for node in 0..num_nodes {
594            if states[node] == SirState::Infected {
595                if let Some(neighbors) = adjacency.get(&node) {
596                    for &(nbr, _) in neighbors {
597                        if nbr < num_nodes
598                            && states[nbr] == SirState::Susceptible
599                            && rng.random::<f64>() < beta
600                        {
601                            next_states[nbr] = SirState::Infected;
602                            ever_infected.insert(nbr);
603                        }
604                    }
605                }
606            }
607        }
608
609        // Recovery step
610        for node in 0..num_nodes {
611            if states[node] == SirState::Infected && rng.random::<f64>() < gamma {
612                next_states[node] = SirState::Recovered;
613            }
614        }
615
616        states = next_states;
617    }
618
619    Ok(SimulationResult {
620        activated: ever_infected,
621        time_series,
622        spread: states.iter().filter(|&&s| s == SirState::Recovered).count()
623            + states.iter().filter(|&&s| s == SirState::Infected).count(),
624    })
625}
626
627// ---------------------------------------------------------------------------
628// Free functions – SIS
629// ---------------------------------------------------------------------------
630
631/// Simulate one run of the SIS epidemic model.
632///
633/// Unlike SIR, recovered nodes return to the susceptible state, so endemic
634/// equilibria are possible.  Simulation terminates when no infected nodes
635/// remain or after `max_steps` steps.
636pub fn simulate_sis(
637    adjacency: &AdjList,
638    num_nodes: usize,
639    initial_infected: &[usize],
640    beta: f64,
641    gamma: f64,
642    max_steps: usize,
643) -> Result<SimulationResult> {
644    if !(0.0..=1.0).contains(&beta) || !(0.0..=1.0).contains(&gamma) {
645        return Err(GraphError::InvalidParameter {
646            param: "beta/gamma".to_string(),
647            value: format!("beta={beta}, gamma={gamma}"),
648            expected: "both in [0, 1]".to_string(),
649            context: "simulate_sis".to_string(),
650        });
651    }
652
653    let mut rng = scirs2_core::random::rng();
654    let mut infected: HashSet<usize> = initial_infected.iter().cloned().collect();
655    let mut ever_infected = infected.clone();
656    let mut time_series: Vec<(usize, usize, usize)> = Vec::new();
657
658    for _step in 0..max_steps {
659        let n_infected = infected.len();
660        time_series.push((num_nodes - n_infected, n_infected, 0));
661
662        if n_infected == 0 {
663            break;
664        }
665
666        let mut new_infections: HashSet<usize> = HashSet::new();
667        let mut new_recoveries: HashSet<usize> = HashSet::new();
668
669        for &node in &infected {
670            // Try to infect susceptible neighbours
671            if let Some(neighbors) = adjacency.get(&node) {
672                for &(nbr, _) in neighbors {
673                    if nbr < num_nodes && !infected.contains(&nbr) && rng.random::<f64>() < beta {
674                        new_infections.insert(nbr);
675                        ever_infected.insert(nbr);
676                    }
677                }
678            }
679            // Try to recover
680            if rng.random::<f64>() < gamma {
681                new_recoveries.insert(node);
682            }
683        }
684
685        for node in new_recoveries {
686            infected.remove(&node);
687        }
688        for node in new_infections {
689            infected.insert(node);
690        }
691    }
692
693    let spread = ever_infected.len();
694    Ok(SimulationResult {
695        activated: ever_infected,
696        time_series,
697        spread,
698    })
699}
700
701// ---------------------------------------------------------------------------
702// Tests
703// ---------------------------------------------------------------------------
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    fn star_adjacency(n: usize, prob: f64) -> AdjList {
710        // Hub node 0 connected to nodes 1..n
711        let mut adj: AdjList = HashMap::new();
712        for i in 1..n {
713            adj.entry(0).or_default().push((i, prob));
714        }
715        adj
716    }
717
718    #[test]
719    fn test_simulate_ic_full_spread() {
720        // probability 1.0 → all nodes activated from hub
721        let adj = star_adjacency(6, 1.0);
722        let result = simulate_ic(&adj, &[0]).expect("ic simulation");
723        assert_eq!(result.spread, 6);
724    }
725
726    #[test]
727    fn test_simulate_ic_no_spread() {
728        // probability 0.0 → only seed activated
729        let adj = star_adjacency(6, 0.0);
730        let result = simulate_ic(&adj, &[0]).expect("ic simulation");
731        assert_eq!(result.spread, 1);
732    }
733
734    #[test]
735    fn test_simulate_lt_deterministic_threshold() {
736        // All edges weight 1.0, threshold = 0.5 → all nodes activated immediately
737        let mut adj: AdjList = HashMap::new();
738        // 0 → {1, 2, 3}
739        for i in 1..4_usize {
740            adj.entry(0).or_default().push((i, 1.0));
741        }
742        let thresholds = vec![0.5_f64; 4];
743        let result = simulate_lt(&adj, 4, &[0], Some(&thresholds)).expect("lt simulation");
744        assert!(result.spread >= 1);
745    }
746
747    #[test]
748    fn test_simulate_sir_terminates() {
749        // Simple chain: 0–1–2–3–4
750        let mut adj: AdjList = HashMap::new();
751        for i in 0..4_usize {
752            adj.entry(i).or_default().push((i + 1, 1.0));
753            adj.entry(i + 1).or_default().push((i, 1.0));
754        }
755        let result = simulate_sir(&adj, 5, &[0], 0.8, 0.5).expect("sir");
756        assert!(result.spread >= 1);
757        assert!(!result.time_series.is_empty());
758    }
759
760    #[test]
761    fn test_simulate_sis_terminates() {
762        let mut adj: AdjList = HashMap::new();
763        for i in 0..4_usize {
764            adj.entry(i).or_default().push((i + 1, 1.0));
765            adj.entry(i + 1).or_default().push((i, 1.0));
766        }
767        let result = simulate_sis(&adj, 5, &[0], 0.5, 0.9, 1000).expect("sis");
768        assert!(result.spread >= 1);
769    }
770
771    #[test]
772    fn test_expected_spread_ic() {
773        let adj = star_adjacency(5, 1.0);
774        let spread = expected_spread(&adj, &[0], 50).expect("expected spread");
775        // With prob 1.0, expected spread = 5
776        assert!((spread - 5.0).abs() < 0.01);
777    }
778
779    #[test]
780    fn test_sir_bad_params() {
781        let adj: AdjList = HashMap::new();
782        let err = simulate_sir(&adj, 1, &[], 2.0, 0.5);
783        assert!(err.is_err());
784    }
785
786    #[test]
787    fn test_ic_struct() {
788        let edges = vec![(0_usize, 1_usize, 1.0_f64), (0, 2, 1.0), (0, 3, 1.0)];
789        let ic = IndependentCascade::from_edges(&edges, 4);
790        let res = ic.simulate(&[0]).expect("simulate");
791        assert_eq!(res.spread, 4);
792    }
793
794    #[test]
795    fn test_lt_bad_threshold() {
796        let adj: AdjList = HashMap::new();
797        let err = LinearThreshold::with_thresholds(adj, vec![0.5, 1.5]);
798        assert!(err.is_err());
799    }
800}