1mod assembly;
7mod lanes;
8mod live;
9mod ring;
10
11use henad_compute::cpu::primitives::chunked::{STATS_CHUNK, reduce_chunks};
12use henad_compute::cpu::primitives::components::{ComponentStats, label_components};
13use henad_compute::for_each_chunk_mut;
14use henad_core::authoring::model::field::Extent;
15use henad_core::authoring::model::network_model::{NetworkModel, Nodes, SpringParams};
16use henad_core::helpers::{extract_f32, extract_u32, f32_param, u32_param};
17use henad_core::network::Network;
18use henad_core::params::{ParamDescriptor, ParamValue};
19use henad_core::view::{StatDescriptor, StatValue};
20
21use crate::team_assembly::live::LiveSet;
22use crate::team_assembly::ring::RetirementRing;
23
24pub use crate::team_assembly::lanes::{TeamChunk, TeamLanes, TeamRead};
25
26pub const IDLE: u8 = 0;
29pub const INCUMBENT: u8 = 1;
31pub const NEWCOMER: u8 = 2;
33
34pub const NEWCOMER_NEWCOMER: u8 = 0;
37pub const NEWCOMER_INCUMBENT: u8 = 1;
39pub const INCUMBENT_INCUMBENT: u8 = 2;
41pub const REPEAT: u8 = 3;
43
44pub const PALETTE: [[u8; 4]; 3] = [
46 [0x8C, 0x8C, 0x8C, 0xFF], [0xF5, 0xD0, 0x2E, 0xFF], [0x00, 0x7A, 0xF5, 0xFF], ];
50
51pub const EDGE_PALETTE: [[u8; 4]; 4] = [
53 [0x3B, 0x82, 0xF6, 0xC0], [0x2D, 0xC7, 0x9A, 0xC0], [0xED, 0xD5, 0x31, 0xC0], [0xE4, 0x37, 0x48, 0xC0], ];
58
59const COMPONENT_STAT_PALETTE: [[u8; 4]; 2] = [[0xE8, 0xE8, 0xE8, 0xFF], [0xA0, 0x7C, 0xF0, 0xFF]];
61
62const NODE_CHUNK: usize = 8192;
64
65henad_core::params! {
67 const TEAM_SIZE = u32_param("team_size", "Team Size", 4, 3, 8);
68 const MAX_DOWNTIME = u32_param("max_downtime", "Max Downtime", 40, 7, 1_000_000);
72 const P = f32_param("p", "Incumbent Chance", 0.4, 0.0, 1.0, Some(0.01)).percent();
73 const Q = f32_param("q", "Collaborator Chance", 0.65, 0.0, 1.0, Some(0.01)).percent();
74}
75#[derive(Debug)]
79pub struct TeamAssembly;
80
81#[derive(Debug)]
85pub struct TeamParams {
86 team_size: u32,
87 max_downtime: u32,
88 incumbent_chance: f32,
90 collaborator_chance: f32,
92}
93
94#[derive(Debug, Default)]
96pub struct TeamAux {
97 ring: RetirementRing,
98 live: LiveSet,
100 live_listed: bool,
102 team: Vec<u32>,
104 candidates: Vec<u32>,
106 scanned: usize,
108 eligible: Vec<u32>,
110 retiring: Vec<u32>,
112 components: Option<((u64, usize), ComponentStats)>,
114 labels: Vec<u32>,
116 label_scratch: Vec<u32>,
117}
118
119impl NetworkModel for TeamAssembly {
120 const NAME: &'static str = "Team Assembly";
121 const ID: &'static str = "team_assembly";
122 const DESCRIPTION: &'static str = "Teams of newcomers and incumbents grow a collaboration network.";
123 const PALETTE: &'static [[u8; 4]] = &PALETTE;
124 const EDGE_PALETTE: &'static [[u8; 4]] = &EDGE_PALETTE;
125 const STATS: &'static [StatDescriptor] = &[
126 StatDescriptor::new("Newcomer-Newcomer Links", EDGE_PALETTE[0]),
127 StatDescriptor::new("Newcomer-Incumbent Links", EDGE_PALETTE[1]),
128 StatDescriptor::new("Incumbent-Incumbent Links", EDGE_PALETTE[2]),
129 StatDescriptor::new("Previous Collaborator Links", EDGE_PALETTE[3]),
130 StatDescriptor::new("Giant Component Share", COMPONENT_STAT_PALETTE[0]),
131 StatDescriptor::new("Mean Component Size", COMPONENT_STAT_PALETTE[1]),
132 ];
133 const DEFAULT_NODES: u32 = 4;
134 const DEFAULT_EXTENT: Extent = Extent { w: 100.0, h: 100.0 };
135 const LAYOUT: SpringParams = SpringParams {
136 spring: 0.18,
137 length: 0.0,
138 repulsion: 0.05,
139 cutoff: 2.5,
140 saturation: 5.0,
141 };
142
143 type Lanes = TeamLanes;
144 type Params = TeamParams;
145 type Aux = TeamAux;
146
147 fn param_descriptors() -> Vec<ParamDescriptor> {
148 descriptors()
149 }
150
151 fn from_params(params: &[ParamValue], _extent: Extent) -> TeamParams {
152 TeamParams {
153 team_size: extract_u32(params, TEAM_SIZE, 4).max(1),
154 max_downtime: extract_u32(params, MAX_DOWNTIME, 40),
155 incumbent_chance: extract_f32(params, P, 0.4),
156 collaborator_chance: extract_f32(params, Q, 0.65),
157 }
158 }
159
160 fn init(nodes: &mut Nodes<'_, Self>, extent: Extent, params: &[ParamValue], _rng: &mut u64) {
161 assembly::setup(nodes, extent, extract_u32(params, TEAM_SIZE, 4));
162 }
163
164 fn run_global_pass(nodes: &mut Nodes<'_, Self>, params: &TeamParams, extent: Extent, rng: &mut u64, tick: u64) {
165 assembly::assemble(nodes, params, extent, rng, tick);
166 }
167
168 fn prepare_view(nodes: &mut Nodes<'_, Self>, tick: u64) {
171 let last = tick;
172 let lanes = &mut *nodes.lanes;
173 let (team_tick, spawn_tick) = (&lanes.team_tick, &lanes.spawn_tick);
174 for_each_chunk_mut!(lanes.color, NODE_CHUNK, |_c, base, slice| {
175 for (k, color) in slice.iter_mut().enumerate() {
176 let i = base + k;
177 *color = if team_tick[i] != last {
178 IDLE
179 } else if spawn_tick[i] == last {
180 NEWCOMER
181 } else {
182 INCUMBENT
183 };
184 }
185 });
186
187 let key = (nodes.graph.version(), nodes.graph.node_count());
190 let aux = &mut *nodes.aux;
191 if aux.components.is_none_or(|(labeled, _)| labeled != key) {
192 let components = label_components(nodes.graph, &mut aux.labels, &mut aux.label_scratch);
193 aux.components = Some((key, components));
194 }
195 }
197
198 fn stats(_lanes: &TeamLanes, graph: &Network, aux: &TeamAux) -> Vec<StatValue> {
202 let links = count_links(graph.edges().2);
203 let components = aux
204 .components
205 .map_or_else(ComponentStats::default, |(_, components)| components);
206 let nodes = graph.node_count() as f64;
207 let giant_share = if nodes > 0.0 {
208 components.largest as f64 / nodes
209 } else {
210 0.0
211 };
212 let mean_size = if components.count > 0 {
213 nodes / components.count as f64
214 } else {
215 0.0
216 };
217 links
218 .into_iter()
219 .map(|count| count as f64)
220 .chain([giant_share, mean_size])
221 .map(StatValue::Scalar)
222 .collect()
223 }
224
225 fn aux_heap_bytes(aux: &TeamAux) -> usize {
226 let lists = aux.team.capacity()
227 + aux.candidates.capacity()
228 + aux.eligible.capacity()
229 + aux.retiring.capacity()
230 + aux.labels.capacity()
231 + aux.label_scratch.capacity();
232 aux.ring.heap_bytes() + aux.live.heap_bytes() + lists * size_of::<u32>()
233 }
234}
235
236fn count_links(color: &[u8]) -> [u64; 4] {
238 reduce_chunks(
239 color.len(),
240 STATS_CHUNK,
241 |range| {
242 let mut counts = [0u64; 4];
243 for &c in &color[range] {
244 counts[usize::from(c).min(3)] += 1;
245 }
246 counts
247 },
248 |a, b| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]],
249 [0; 4],
250 )
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use std::collections::BTreeSet;
257
258 use henad_compute::cpu::network_engine::{NetworkModelState, network_model_param_descriptors};
259 use henad_core::authoring::model::agent_model::AgentLanes as _;
260 use henad_core::model::SimState as _;
261
262 type State = NetworkModelState<TeamAssembly>;
263
264 fn params(overrides: &[(&str, ParamValue)]) -> Vec<ParamValue> {
270 let descs = network_model_param_descriptors::<TeamAssembly>();
271 for (id, _) in overrides {
272 assert!(descs.iter().any(|d| d.id == *id), "no parameter '{id}'");
273 }
274 descs
275 .iter()
276 .map(|d| {
277 overrides
278 .iter()
279 .find(|(id, _)| *id == d.id)
280 .map_or_else(|| d.kind.default_value(), |(_, v)| v.clone())
281 })
282 .collect()
283 }
284
285 fn index(id: &str) -> usize {
286 network_model_param_descriptors::<TeamAssembly>()
287 .iter()
288 .position(|d| d.id == id)
289 .unwrap_or_else(|| panic!("no parameter '{id}'"))
290 }
291
292 #[derive(Debug, PartialEq)]
294 struct Outcome {
295 occupied: Vec<bool>,
296 spawn_tick: Vec<u64>,
297 team_tick: Vec<u64>,
298 pos: Vec<u32>,
299 src: Vec<u32>,
300 dst: Vec<u32>,
301 link_color: Vec<u8>,
302 }
303
304 fn outcome(state: &State) -> Outcome {
305 let lanes = state.lanes();
306 let graph = state.graph();
307 let (src, dst, link_color) = graph.edges();
308 Outcome {
309 occupied: (0..graph.slot_count() as u32).map(|i| graph.contains_node(i)).collect(),
310 spawn_tick: lanes.spawn_tick.clone(),
311 team_tick: lanes.team_tick.clone(),
312 pos: lanes.pos_x.iter().chain(&lanes.pos_y).map(|v| v.to_bits()).collect(),
313 src: src.to_vec(),
314 dst: dst.to_vec(),
315 link_color: link_color.to_vec(),
316 }
317 }
318
319 fn busy() -> Vec<ParamValue> {
321 params(&[
322 ("num_agents", ParamValue::U32(20_000)),
323 ("team_size", ParamValue::U32(6)),
324 ("max_downtime", ParamValue::U32(12)),
325 ("p", ParamValue::F32(0.7)),
326 ("q", ParamValue::F32(0.5)),
327 ])
328 }
329
330 fn run_busy(state: &mut State, ticks: u32, publish: bool) {
331 for t in 0..ticks {
332 if t == ticks / 2 {
333 state.set_param(index("max_downtime"), &ParamValue::U32(5));
334 }
335 state.step();
336 if publish {
337 state.prepare_view();
338 }
339 }
340 }
341
342 #[test]
343 fn the_node_views_implement_debug() {
344 use henad_core::authoring::model::network_model::{NodeCtx, Nodes};
345
346 fn assert_debug<T: std::fmt::Debug>() {}
347 assert_debug::<Nodes<'static, TeamAssembly>>();
348 assert_debug::<NodeCtx<'static, TeamAssembly>>();
349 }
350
351 #[test]
354 fn results_do_not_depend_on_the_thread_count() {
355 fn run(threads: usize) -> (Outcome, Vec<u8>, Vec<u64>) {
356 let pool = rayon::ThreadPoolBuilder::new()
357 .num_threads(threads)
358 .build()
359 .expect("rayon pool");
360 pool.install(|| {
361 let mut state = State::from_params(&busy());
362 run_busy(&mut state, 60, true);
363 let stats = state
364 .stats()
365 .iter()
366 .map(|entry| entry.value.scalar().to_bits())
367 .collect();
368 (outcome(&state), state.lanes().color.clone(), stats)
369 })
370 }
371 assert_eq!(run(1), run(7), "the run depends on the thread count");
372 }
373
374 #[test]
376 fn results_do_not_depend_on_the_publish_cadence() {
377 let mut every = State::from_params(&busy());
378 let mut never = State::from_params(&busy());
379 run_busy(&mut every, 60, true);
380 run_busy(&mut never, 60, false);
381 assert_eq!(outcome(&every), outcome(&never), "publishing changed the run");
382
383 never.prepare_view();
384 assert_eq!(every.lanes().color, never.lanes().color, "the node colors disagree");
385 }
386
387 #[test]
390 fn a_node_spawned_with_no_edges_is_counted_at_the_next_publish() {
391 let mut lanes = TeamLanes::alloc(4);
392 let mut graph = Network::new(4, false);
393 let mut aux = TeamAux::default();
394 let mut nodes = Nodes::<TeamAssembly> {
395 lanes: &mut lanes,
396 graph: &mut graph,
397 aux: &mut aux,
398 };
399 nodes.graph.add_edge(0, 1, INCUMBENT_INCUMBENT);
400 TeamAssembly::prepare_view(&mut nodes, 0);
401
402 let version = nodes.graph.version();
403 nodes.spawn();
404 assert_eq!(
405 nodes.graph.version(),
406 version,
407 "the spawn moved the version, so this proved little"
408 );
409 TeamAssembly::prepare_view(&mut nodes, 0);
410 assert_eq!(
411 nodes.aux.components.map(|(_, components)| components),
412 Some(ComponentStats { count: 4, largest: 2 }),
413 "the new node was left out of the components"
414 );
415 }
416
417 #[test]
419 fn setup_builds_cliques_of_the_team_size() {
420 let mut state = State::from_params(¶ms(&[
421 ("num_agents", ParamValue::U32(10)),
422 ("team_size", ParamValue::U32(4)),
423 ]));
424 let graph = state.graph();
425 assert_eq!(graph.edge_count(), 6 + 6 + 1, "teams of 4, 4 and 2");
426 assert!(graph.has_edge(0, 3) && graph.has_edge(4, 7) && graph.has_edge(8, 9));
427 assert!(!graph.has_edge(3, 4), "two setup teams were joined");
428 assert!(graph.edges().2.iter().all(|&c| c == INCUMBENT_INCUMBENT));
429
430 state.prepare_view();
431 assert!(state.lanes().color.iter().all(|&c| c == NEWCOMER));
432 }
433
434 #[test]
442 fn collaborators_are_picked_at_rate_q() {
443 const Q: f64 = 0.65;
444 let mut state = State::from_params_seeded(
445 ¶ms(&[
446 ("num_agents", ParamValue::U32(400)),
447 ("team_size", ParamValue::U32(5)),
448 ("max_downtime", ParamValue::U32(30)),
449 ("p", ParamValue::F32(0.8)),
450 ("q", ParamValue::F32(Q as f32)),
451 ]),
452 Some(0x9A7E),
453 );
454 let (mut hits, mut expected, mut variance) = (0.0, 0.0, 0.0);
455 for _ in 0..2_000 {
456 let graph = state.graph();
458 let live: BTreeSet<u32> = (0..graph.slot_count() as u32)
459 .filter(|&i| graph.contains_node(i))
460 .collect();
461 let rows: Vec<Vec<u32>> = (0..graph.slot_count() as u32)
462 .map(|i| graph.in_neighbors(i).to_vec())
463 .collect();
464 state.step();
465
466 let team = state.aux().team.clone();
467 let now = state.tick();
468 for k in 1..team.len() {
469 if state.lanes().spawn_tick[team[k] as usize] == now {
471 continue;
472 }
473 let so_far = &team[..k];
474 let collaborators: BTreeSet<u32> = so_far
475 .iter()
476 .flat_map(|&i| rows.get(i as usize).map_or(&[][..], Vec::as_slice))
477 .copied()
478 .filter(|j| !so_far.contains(j))
479 .collect();
480 if collaborators.is_empty() {
481 continue;
482 }
483 let outside = live.iter().filter(|j| !so_far.contains(j)).count();
484 let p = Q + (1.0 - Q) * collaborators.len() as f64 / outside as f64;
485 hits += f64::from(u8::from(collaborators.contains(&team[k])));
486 expected += p;
487 variance += p * (1.0 - p);
488 }
489 }
490 assert!(
491 variance > 400.0,
492 "the picks were too certain to tell q apart, variance {variance:.1}"
493 );
494 let allowed = 5.0 * variance.sqrt();
495 assert!(
496 (hits - expected).abs() <= allowed,
497 "{hits} collaborator picks, expected {expected:.1} within {allowed:.1}"
498 );
499 }
500}