Skip to main content

henad_models/team_assembly/
mod.rs

1//! NetLogo's Team Assembly model, after GuimerĂ , Uzzi, Spiro and Amaral (2005).
2//!
3//! Each tick one team is assembled from newcomers, previous collaborators and other incumbents, and every pair of its
4//! members is linked. A node that has not joined a team for more than `max_downtime` ticks retires.
5
6mod 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
26// Node colours, which are also indices into `PALETTE`.
27/// Colour of a node outside the last team.
28pub const IDLE: u8 = 0;
29/// Colour of an incumbent member of the last team.
30pub const INCUMBENT: u8 = 1;
31/// Colour of a newcomer member of the last team.
32pub const NEWCOMER: u8 = 2;
33
34// Link colours, which are also indices into `EDGE_PALETTE`.
35/// Colour of a new link between two newcomers.
36pub const NEWCOMER_NEWCOMER: u8 = 0;
37/// Colour of a new link between a newcomer and an incumbent.
38pub const NEWCOMER_INCUMBENT: u8 = 1;
39/// Colour of a new link between two incumbents.
40pub const INCUMBENT_INCUMBENT: u8 = 2;
41/// Colour of a link between members who had collaborated before this team.
42pub const REPEAT: u8 = 3;
43
44/// Node colours, indexed by [`IDLE`], [`INCUMBENT`] and [`NEWCOMER`].
45pub const PALETTE: [[u8; 4]; 3] = [
46    [0x8C, 0x8C, 0x8C, 0xFF], // Idle - gray
47    [0xF5, 0xD0, 0x2E, 0xFF], // Incumbent team member - yellow
48    [0x00, 0x7A, 0xF5, 0xFF], // Newcomer team member - blue
49];
50
51/// Link colours, indexed by an edge's colour byte.
52pub const EDGE_PALETTE: [[u8; 4]; 4] = [
53    [0x3B, 0x82, 0xF6, 0xC0], // Newcomer-newcomer - blue
54    [0x2D, 0xC7, 0x9A, 0xC0], // Newcomer-incumbent - turquoise
55    [0xED, 0xD5, 0x31, 0xC0], // Incumbent-incumbent - yellow
56    [0xE4, 0x37, 0x48, 0xC0], // Previous collaborators - red
57];
58
59/// Colours of the two component stats.
60const COMPONENT_STAT_PALETTE: [[u8; 4]; 2] = [[0xE8, 0xE8, 0xE8, 0xFF], [0xA0, 0x7C, 0xF0, 0xFF]];
61
62/// Number of nodes per chunk of the recolour pass.
63const NODE_CHUNK: usize = 8192;
64
65// --8<-- [start:params]
66henad_core::params! {
67    const TEAM_SIZE = u32_param("team_size", "Team Size", 4, 3, 8);
68    /// Index of the number of ticks a node can go without joining a team before it retires.
69    ///
70    /// NetLogo's slider stops at 100. A larger value grows a larger steady-state population.
71    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// --8<-- [end:params]
76
77/// Team Assembly as a [`NetworkModel`].
78#[derive(Debug)]
79pub struct TeamAssembly;
80
81/// Hot parameters for one tick.
82///
83/// Chances are probabilities in `0..=1`. NetLogo's sliders give the same numbers as percentages.
84#[derive(Debug)]
85pub struct TeamParams {
86    team_size: u32,
87    max_downtime: u32,
88    /// Chance that a member is an incumbent rather than a newcomer, NetLogo's `p`.
89    incumbent_chance: f32,
90    /// Chance that an incumbent member is a previous collaborator of the team, NetLogo's `q`.
91    collaborator_chance: f32,
92}
93
94/// Model state kept outside the lanes and the graph.
95#[derive(Debug, Default)]
96pub struct TeamAux {
97    ring: RetirementRing,
98    /// Live nodes, listed from the graph on the first tick and kept by the model's own spawns and retirements.
99    live: LiveSet,
100    /// Whether `live` has been listed from the graph yet.
101    live_listed: bool,
102    /// Members of the team being assembled, in the order they were picked.
103    team: Vec<u32>,
104    /// Nodes outside the team that are linked to a member, the collaborator candidates for the next member.
105    candidates: Vec<u32>,
106    /// Number of members whose rows are already in `candidates`.
107    scanned: usize,
108    /// Nodes outside the team, listed when uniform draws keep landing in it.
109    eligible: Vec<u32>,
110    /// Nodes retiring this tick.
111    retiring: Vec<u32>,
112    /// Component stats, along with the graph version and node count they were computed at.
113    components: Option<((u64, usize), ComponentStats)>,
114    // Buffers for the component labelling.
115    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    /// Colours the last team's members and labels the components, if the graph has changed since they were last
169    /// labelled.
170    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        // --8<-- [start:components]
188        // A spawn leaves the version alone, and a new node with no edges is its own component.
189        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        // --8<-- [end:components]
196    }
197
198    /// Returns the link counts by colour, the share of nodes in the giant component and the mean component size.
199    ///
200    /// The component stats are the values that [`Self::prepare_view`] last computed.
201    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
236/// Returns the number of links of each colour.
237fn 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    /// Returns the default params with some overridden by id.
265    ///
266    /// # Panics
267    ///
268    /// Panics if an id is not declared by the model.
269    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    /// Everything a run decides, with positions compared bit for bit.
293    #[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    /// Returns params busy enough to cross chunk boundaries, with nodes retiring and slots being reused.
320    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    /// Every pass that runs in parallel either reads the graph or writes in chunk order,
352    /// so the way rayon splits the work must not affect the result.
353    #[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    /// Publishing colours nodes and labels components, and nothing it writes feeds back into the run.
375    #[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    /// A node spawned with no edges is its own component.
388    /// The next publish counts it, although the spawn left the graph's version where it was.
389    #[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    /// NetLogo's setup builds one team whose members are all shown as newcomers, linked as incumbents.
418    #[test]
419    fn setup_builds_cliques_of_the_team_size() {
420        let mut state = State::from_params(&params(&[
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    /// Each incumbent pick is a previous collaborator with probability `q`,
435    /// plus the chance that a draw from every node outside the team lands on a collaborator anyway.
436    ///
437    /// For each incumbent pick after the first, with `c` collaborators among the `e` nodes outside the team so far,
438    /// a collaborator is picked with probability `q + (1 - q) c / e`.
439    /// The hits are compared with the sum of those probabilities, within 5 standard deviations.
440    /// Newcomers keep the population large and sparse, so those probabilities stay well short of 1.
441    #[test]
442    fn collaborators_are_picked_at_rate_q() {
443        const Q: f64 = 0.65;
444        let mut state = State::from_params_seeded(
445            &params(&[
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            // The rows as they stand while the team is picked. Links and retirements come after the last pick.
457            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                // A newcomer came from the `p` draw, and never reached the `q` draw.
470                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}