1mod 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
22pub const SUSCEPTIBLE: u8 = 0;
25pub const INFECTED: u8 = 1;
27pub const RESISTANT: u8 = 2;
29
30pub const EDGE_OPEN: u8 = 0;
33pub const EDGE_BLOCKED: u8 = 1;
35
36pub const PALETTE: [[u8; 4]; 3] = [
38 [0x00, 0x7A, 0xF5, 0xFF], [0xE4, 0x37, 0x48, 0xFF], [0x80, 0x80, 0x80, 0xFF], ];
42
43pub const EDGE_PALETTE: [[u8; 4]; 2] = [
46 [0xC8, 0xC8, 0xC8, 0xB0], [0x50, 0x50, 0x50, 0x90], ];
49const NETWORKS: &[&str] = &["Random", "Geometric"];
53const RANDOM: usize = 0;
54const GEOMETRIC: usize = 1;
55
56const PLACED: f32 = 0.95;
60
61const EDGE_CHUNK: usize = 8192;
63
64henad_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}
79henad_core::actions! {
83 const REWIRE = ActionDescriptor::new("rewire", "Rewire a link");
84}
85#[derive(Debug)]
89pub struct VirusNetwork;
90
91#[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
104pub(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 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 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 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 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
213fn 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
225fn 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
252fn 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 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 #[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 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 #[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 #[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(¶ms(&[
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(¶ms(&[("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 ¶ms(&[("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 #[test]
440 fn flipping_the_direction_keeps_every_edge() {
441 let mut state = State::from_params(¶ms(&[("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}