Skip to main content

henad_models/virus_network/
mod.rs

1//! NetLogo's Virus on a Network model, extended with rewiring and a directed variant.
2
3mod lanes;
4mod step;
5mod wiring;
6
7use henad_compute::cpu::primitives::chunked::{STATS_CHUNK, reduce_chunks};
8use henad_compute::for_each_chunk_mut;
9use henad_core::action::ActionDescriptor;
10use henad_core::authoring::model::field::Extent;
11use henad_core::authoring::model::network_model::{NetworkModel, NodeCtx, Nodes};
12use henad_core::authoring::primitives::rng::{next_float, next_index};
13use henad_core::helpers::{
14    bool_param, choice_param, extract_bool, extract_choice, extract_f32, extract_u32, f32_param, u32_param,
15};
16use henad_core::network::Network;
17use henad_core::params::{ParamDescriptor, ParamValue};
18use henad_core::view::{StatDescriptor, StatValue};
19
20pub use crate::virus_network::lanes::{VirusChunk, VirusLanes, VirusRead};
21
22// Node states, which are also indices into `PALETTE`.
23/// State of a node the virus can infect.
24pub const SUSCEPTIBLE: u8 = 0;
25/// State of an infected node.
26pub const INFECTED: u8 = 1;
27/// State of a node the virus can no longer infect.
28pub const RESISTANT: u8 = 2;
29
30// Edge colours, which are also indices into `EDGE_PALETTE`.
31/// Colour of an edge with no resistant endpoint.
32pub const EDGE_OPEN: u8 = 0;
33/// Colour of an edge with a resistant endpoint, which the virus can no longer cross.
34pub const EDGE_BLOCKED: u8 = 1;
35
36/// Node colours, indexed by state.
37pub const PALETTE: [[u8; 4]; 3] = [
38    [0x00, 0x7A, 0xF5, 0xFF], // Susceptible - blue
39    [0xE4, 0x37, 0x48, 0xFF], // Infected - red
40    [0x80, 0x80, 0x80, 0xFF], // Resistant - gray
41];
42
43/// Edge colours, indexed by an edge's colour byte.
44// --8<-- [start:edge_palette]
45pub const EDGE_PALETTE: [[u8; 4]; 2] = [
46    [0xC8, 0xC8, 0xC8, 0xB0], // Open - light gray
47    [0x50, 0x50, 0x50, 0x90], // Blocked - dark gray
48];
49// --8<-- [end:edge_palette]
50
51// Options of the `network` parameter.
52const NETWORKS: &[&str] = &["Random", "Geometric"];
53const RANDOM: usize = 0;
54const GEOMETRIC: usize = 1;
55
56/// Fraction of the world that nodes are placed in.
57///
58/// Like NetLogo, this keeps nodes away from the edges for visual reasons.
59const PLACED: f32 = 0.95;
60
61/// Number of edges per chunk of the recolour pass.
62const EDGE_CHUNK: usize = 8192;
63
64// --8<-- [start:params]
65henad_core::params! {
66    const AVERAGE_NODE_DEGREE = u32_param("average_node_degree", "Average Node Degree", 6, 1, 20).on_reload();
67    const INITIAL_OUTBREAK_SIZE =
68        u32_param("initial_outbreak_size", "Initial Outbreak Size", 3, 1, 10_000).on_reload();
69    const VIRUS_SPREAD_CHANCE =
70        f32_param("virus_spread_chance", "Virus Spread Chance", 0.025, 0.0, 1.0, Some(0.001)).percent();
71    const VIRUS_CHECK_FREQUENCY = u32_param("virus_check_frequency", "Virus Check Frequency", 1, 1, 20);
72    const RECOVERY_CHANCE = f32_param("recovery_chance", "Recovery Chance", 0.05, 0.0, 1.0, Some(0.001)).percent();
73    const GAIN_RESISTANCE_CHANCE =
74        f32_param("gain_resistance_chance", "Gain Resistance Chance", 0.05, 0.0, 1.0, Some(0.01)).percent();
75    const DIRECTED = bool_param("directed", "Directed", false);
76    const NETWORK = choice_param("network", "Network", NETWORKS, RANDOM).on_reload();
77    const KEEP_REWIRING = bool_param("keep_rewiring", "Keep Rewiring", false);
78}
79// --8<-- [end:params]
80
81// --8<-- [start:actions]
82henad_core::actions! {
83    const REWIRE = ActionDescriptor::new("rewire", "Rewire a link");
84}
85// --8<-- [end:actions]
86
87/// Virus on a Network as a [`NetworkModel`].
88#[derive(Debug)]
89pub struct VirusNetwork;
90
91/// Hot parameters for one tick.
92///
93/// Chances are probabilities in `0..=1`. NetLogo's sliders give the same numbers as percentages.
94#[derive(Debug)]
95pub struct VirusParams {
96    pub(crate) spread_chance: f32,
97    pub(crate) check_frequency: u32,
98    pub(crate) recovery_chance: f32,
99    pub(crate) resistance_chance: f32,
100    directed: bool,
101    keep_rewiring: bool,
102}
103
104/// Returns the colour of an edge between nodes in states `a` and `b`.
105///
106/// The edge turns grey once either end is resistant, as NetLogo's `become-resistant` does to `my-links`.
107pub(crate) fn edge_color(a: u8, b: u8) -> u8 {
108    if a == RESISTANT || b == RESISTANT {
109        EDGE_BLOCKED
110    } else {
111        EDGE_OPEN
112    }
113}
114
115impl NetworkModel for VirusNetwork {
116    const NAME: &'static str = "Virus on a Network";
117    const ID: &'static str = "virus_network";
118    const DESCRIPTION: &'static str = "A virus spreading over a network.";
119    const PALETTE: &'static [[u8; 4]] = &PALETTE;
120    const EDGE_PALETTE: &'static [[u8; 4]] = &EDGE_PALETTE;
121    const STATS: &'static [StatDescriptor] = &[
122        StatDescriptor::new("Susceptible", PALETTE[0]),
123        StatDescriptor::new("Infected", PALETTE[1]),
124        StatDescriptor::new("Resistant", PALETTE[2]),
125    ];
126    const ACTIONS: &'static [ActionDescriptor] = ACTION_SPECS;
127    const DEFAULT_NODES: u32 = 10_000;
128    const DEFAULT_EXTENT: Extent = Extent { w: 1_000.0, h: 1_000.0 };
129
130    type Lanes = VirusLanes;
131    type Params = VirusParams;
132    type Aux = ();
133
134    fn param_descriptors() -> Vec<ParamDescriptor> {
135        descriptors()
136    }
137
138    fn from_params(params: &[ParamValue], _extent: Extent) -> VirusParams {
139        VirusParams {
140            spread_chance: extract_f32(params, VIRUS_SPREAD_CHANCE, 0.025),
141            check_frequency: extract_u32(params, VIRUS_CHECK_FREQUENCY, 1).max(1),
142            recovery_chance: extract_f32(params, RECOVERY_CHANCE, 0.05),
143            resistance_chance: extract_f32(params, GAIN_RESISTANCE_CHANCE, 0.05),
144            directed: extract_bool(params, DIRECTED, false),
145            keep_rewiring: extract_bool(params, KEEP_REWIRING, false),
146        }
147    }
148
149    fn directed(params: &VirusParams) -> bool {
150        params.directed
151    }
152
153    fn init(nodes: &mut Nodes<'_, Self>, extent: Extent, params: &[ParamValue], rng: &mut u64) {
154        let lanes = &mut *nodes.lanes;
155        let check_frequency = extract_u32(params, VIRUS_CHECK_FREQUENCY, 1).max(1);
156        let area = Extent {
157            w: extent.w * PLACED,
158            h: extent.h * PLACED,
159        };
160        let (margin_x, margin_y) = (0.5 * (extent.w - area.w), 0.5 * (extent.h - area.h));
161        for i in 0..lanes.pos_x.len() {
162            lanes.pos_x[i] = margin_x + next_float(rng, area.w);
163            lanes.pos_y[i] = margin_y + next_float(rng, area.h);
164            lanes.timer[i] = next_index(rng, check_frequency);
165        }
166
167        let degree = extract_u32(params, AVERAGE_NODE_DEGREE, 6);
168        match extract_choice(params, NETWORK, RANDOM) {
169            GEOMETRIC => wiring::geometric(nodes.graph, &lanes.pos_x, &lanes.pos_y, area, extent, degree, rng),
170            _ => wiring::random(nodes.graph, degree, rng),
171        }
172
173        infect_distinct(&mut lanes.state, extract_u32(params, INITIAL_OUTBREAK_SIZE, 3), rng);
174    }
175
176    // --8<-- [start:global_pass]
177    fn run_global_pass(nodes: &mut Nodes<'_, Self>, params: &VirusParams, _extent: Extent, rng: &mut u64, _tick: u64) {
178        if params.keep_rewiring {
179            wiring::rewire(nodes.graph, &nodes.lanes.state, rng);
180        }
181    }
182    // --8<-- [end:global_pass]
183
184    fn run_node_pass(lanes: &mut VirusLanes, ctx: &NodeCtx<'_, Self>, seed: u64, tick: u64) {
185        step::run(lanes, ctx, seed, tick);
186    }
187
188    #[expect(clippy::single_match, reason = "for future multi-action extendability")]
189    fn act(action: usize, nodes: &mut Nodes<'_, Self>, _extent: Extent, _params: &[ParamValue], rng: &mut u64) {
190        match action {
191            REWIRE => wiring::rewire(nodes.graph, &nodes.lanes.state, rng),
192            _ => {}
193        }
194    }
195
196    // --8<-- [start:prepare_view]
197    fn prepare_view(nodes: &mut Nodes<'_, Self>, _tick: u64) {
198        let state = &nodes.lanes.state;
199        nodes
200            .graph
201            .update_colors(|src, dst, color| recolor(src, dst, color, state));
202    }
203    // --8<-- [end:prepare_view]
204
205    fn stats(lanes: &VirusLanes, _graph: &Network, (): &()) -> Vec<StatValue> {
206        count_states(&lanes.state)
207            .into_iter()
208            .map(|count| StatValue::Scalar(count as f64))
209            .collect()
210    }
211}
212
213/// Infects `count` distinct nodes, like NetLogo's `n-of`.
214///
215/// Uses Floyd's sampling, which draws exactly `count` times no matter how close `count` is to the population.
216fn infect_distinct(state: &mut [u8], count: u32, rng: &mut u64) {
217    let n = state.len() as u32;
218    for j in n - count.min(n)..n {
219        let t = next_index(rng, j + 1);
220        let pick = if state[t as usize] == INFECTED { j } else { t };
221        state[pick as usize] = INFECTED;
222    }
223}
224
225/// Greys every edge that touches a resistant node.
226///
227/// Returns whether any edge changed. Every edge is checked before any edge is written.
228/// A publish while paused, or after the outbreak has died out, finds nothing to change, and a write would make the
229/// snapshot copy every edge again.
230fn recolor(src: &[u32], dst: &[u32], color: &mut [u8], state: &[u8]) -> bool {
231    let wanted = |e: usize| edge_color(state[src[e] as usize], state[dst[e] as usize]);
232    let stale = {
233        let color = &*color;
234        reduce_chunks(
235            color.len(),
236            EDGE_CHUNK,
237            |mut range| range.any(|e| color[e] != wanted(e)),
238            |a, b| a || b,
239            false,
240        )
241    };
242    if stale {
243        for_each_chunk_mut!(color, EDGE_CHUNK, |_c, base, slice| {
244            for (k, c) in slice.iter_mut().enumerate() {
245                *c = wanted(base + k);
246            }
247        });
248    }
249    stale
250}
251
252/// Returns the numbers of susceptible, infected and resistant nodes.
253fn count_states(state: &[u8]) -> [u64; 3] {
254    reduce_chunks(
255        state.len(),
256        STATS_CHUNK,
257        |range| {
258            let mut counts = [0u64; 3];
259            for &s in &state[range] {
260                counts[usize::from(s)] += 1;
261            }
262            counts
263        },
264        |a, b| [a[0] + b[0], a[1] + b[1], a[2] + b[2]],
265        [0; 3],
266    )
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use henad_compute::cpu::network_engine::{NetworkModelState, network_model_param_descriptors};
273    use henad_core::model::SimState as _;
274
275    type State = NetworkModelState<VirusNetwork>;
276
277    /// Returns the default params with some overridden by id.
278    ///
279    /// # Panics
280    ///
281    /// Panics if an id is not declared by the model.
282    fn params(overrides: &[(&str, ParamValue)]) -> Vec<ParamValue> {
283        let descs = network_model_param_descriptors::<VirusNetwork>();
284        for (id, _) in overrides {
285            assert!(descs.iter().any(|d| d.id == *id), "no parameter '{id}'");
286        }
287        descs
288            .iter()
289            .map(|d| {
290                overrides
291                    .iter()
292                    .find(|(id, _)| *id == d.id)
293                    .map_or_else(|| d.kind.default_value(), |(_, v)| v.clone())
294            })
295            .collect()
296    }
297
298    fn index(id: &str) -> usize {
299        network_model_param_descriptors::<VirusNetwork>()
300            .iter()
301            .position(|d| d.id == id)
302            .unwrap_or_else(|| panic!("no parameter '{id}'"))
303    }
304
305    /// Everything a tick decides, except positions, which only the layout moves.
306    #[derive(Debug, PartialEq)]
307    struct Outcome {
308        state: Vec<u8>,
309        timer: Vec<u32>,
310        src: Vec<u32>,
311        dst: Vec<u32>,
312    }
313
314    fn outcome(state: &State) -> Outcome {
315        let (src, dst, _) = state.graph().edges();
316        Outcome {
317            state: state.lanes().state.clone(),
318            timer: state.lanes().timer.clone(),
319            src: src.to_vec(),
320            dst: dst.to_vec(),
321        }
322    }
323
324    /// Returns params busy enough to cross chunk boundaries, with rewiring on.
325    fn busy() -> Vec<ParamValue> {
326        params(&[
327            ("num_agents", ParamValue::U32(6_000)),
328            ("initial_outbreak_size", ParamValue::U32(300)),
329            ("virus_spread_chance", ParamValue::F32(0.08)),
330            ("virus_check_frequency", ParamValue::U32(3)),
331            ("keep_rewiring", ParamValue::Bool(true)),
332        ])
333    }
334
335    fn run_busy(state: &mut State, ticks: u32, publish: bool) {
336        for t in 0..ticks {
337            if t == ticks / 2 {
338                state.set_param(index("directed"), &ParamValue::Bool(true));
339            }
340            state.step();
341            if publish {
342                state.prepare_view();
343            }
344        }
345    }
346
347    #[test]
348    fn the_node_views_implement_debug() {
349        use henad_core::authoring::model::network_model::{NodeCtx, Nodes};
350
351        fn assert_debug<T: std::fmt::Debug>() {}
352        assert_debug::<Nodes<'static, VirusNetwork>>();
353        assert_debug::<NodeCtx<'static, VirusNetwork>>();
354    }
355
356    /// Chunk seeds come from the chunk index,
357    /// and the global pass and the recolour are either sequential or in chunk order,
358    /// so the way rayon splits the work must not affect the result.
359    #[test]
360    fn results_do_not_depend_on_the_thread_count() {
361        fn run(threads: usize) -> (Outcome, Vec<u8>) {
362            let pool = rayon::ThreadPoolBuilder::new()
363                .num_threads(threads)
364                .build()
365                .expect("rayon pool");
366            pool.install(|| {
367                let mut state = State::from_params(&busy());
368                run_busy(&mut state, 40, true);
369                let colors = state.graph().edges().2.to_vec();
370                (outcome(&state), colors)
371            })
372        }
373        assert_eq!(run(1), run(7), "the run depends on the thread count");
374    }
375
376    /// Publishing only recolours edges, so how often it happens must not change the run.
377    #[test]
378    fn results_do_not_depend_on_the_publish_cadence() {
379        let mut every = State::from_params(&busy());
380        let mut never = State::from_params(&busy());
381        run_busy(&mut every, 40, true);
382        run_busy(&mut never, 40, false);
383        assert_eq!(outcome(&every), outcome(&never), "publishing changed the run");
384
385        never.prepare_view();
386        assert_eq!(every.graph().edges().2, never.graph().edges().2, "the colours disagree");
387    }
388
389    #[test]
390    fn the_outbreak_infects_exactly_the_declared_count() {
391        for (nodes, outbreak) in [(50, 7), (50, 50), (50, 80), (1, 1)] {
392            let state = State::from_params(&params(&[
393                ("num_agents", ParamValue::U32(nodes)),
394                ("initial_outbreak_size", ParamValue::U32(outbreak)),
395            ]));
396            let infected = state.lanes().state.iter().filter(|&&s| s == INFECTED).count();
397            assert_eq!(infected, outbreak.min(nodes) as usize, "{outbreak} of {nodes}");
398        }
399    }
400
401    #[test]
402    fn a_recolour_greys_every_edge_touching_a_resistant_node() {
403        let mut state = State::from_params(&params(&[("num_agents", ParamValue::U32(500))]));
404        state.prepare_view();
405        let quiet = state.graph().version();
406        state.prepare_view();
407        assert_eq!(
408            state.graph().version(),
409            quiet,
410            "a recolour with nothing to do moved the version"
411        );
412
413        let mut resistant = State::from_graph(
414            &params(&[("num_agents", ParamValue::U32(500))]),
415            None,
416            |nodes, _extent| {
417                for i in (0..500).step_by(3) {
418                    nodes.lanes.state[i] = RESISTANT;
419                }
420            },
421        );
422        let before = resistant.graph().version();
423        resistant.prepare_view();
424        assert!(
425            resistant.graph().version() > before,
426            "greying edges left the version behind"
427        );
428
429        let state = &resistant.lanes().state;
430        let (src, dst, color) = resistant.graph().edges();
431        for e in 0..src.len() {
432            let touches = state[src[e] as usize] == RESISTANT || state[dst[e] as usize] == RESISTANT;
433            let want = if touches { EDGE_BLOCKED } else { EDGE_OPEN };
434            assert_eq!(color[e], want, "edge {e} from {} to {}", src[e], dst[e]);
435        }
436    }
437
438    /// The edge list keeps its orientation either way, so a flip changes how edges are read but not which edges exist.
439    #[test]
440    fn flipping_the_direction_keeps_every_edge() {
441        let mut state = State::from_params(&params(&[("num_agents", ParamValue::U32(800))]));
442        let (src, dst, _) = state.graph().edges();
443        let (src, dst) = (src.to_vec(), dst.to_vec());
444
445        state.set_param(index("directed"), &ParamValue::Bool(true));
446        state.step();
447        assert!(state.graph().directed());
448        assert_eq!((state.graph().edges().0, state.graph().edges().1), (&src[..], &dst[..]));
449
450        state.set_param(index("directed"), &ParamValue::Bool(false));
451        state.step();
452        assert!(!state.graph().directed());
453        assert_eq!((state.graph().edges().0, state.graph().edges().1), (&src[..], &dst[..]));
454    }
455}