From a40c0d6301a8753b05148010c2e135ba1fc61417 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 24 Apr 2026 13:38:21 +0200 Subject: [PATCH] feat(color-group): add greedy within-slice event partitioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ColorGroups holds a partition of event indices into color groups such that events of the same color touch no shared Index. Computed greedily in ingestion order: each event goes into the first color whose existing members are disjoint from the event's indices. Used in T3 for safe within-slice parallelism — events in the same color can run concurrently without touching each other's skills. Part of T3 of docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md. --- src/color_group.rs | 145 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 146 insertions(+) create mode 100644 src/color_group.rs diff --git a/src/color_group.rs b/src/color_group.rs new file mode 100644 index 0000000..af49c20 --- /dev/null +++ b/src/color_group.rs @@ -0,0 +1,145 @@ +//! Greedy graph coloring for within-slice event independence. +//! +//! Events sharing no `Index` can be processed in parallel under async-EP +//! semantics. This module partitions a list of events into "colors" such +//! that events of the same color touch disjoint index sets. +//! +//! The algorithm is greedy: for each event in ingestion order, place it in +//! the lowest-numbered color whose existing members share no `Index`. If +//! no existing color accepts the event, open a new color. +//! +//! Complexity: O(n × c × m) where n is events, c is colors (small, ≤ 5 in +//! practice), and m is average team size. + +use std::collections::HashSet; + +use crate::Index; + +/// Partition of event indices into color groups. +/// +/// Each inner `Vec` holds the indices (into the original events +/// array) of events assigned to one color. Colors are iterated in ascending +/// order by convention. +#[derive(Clone, Debug, Default)] +pub(crate) struct ColorGroups { + pub(crate) groups: Vec>, +} + +impl ColorGroups { + #[allow(dead_code)] + pub(crate) fn new() -> Self { + Self::default() + } + + #[allow(dead_code)] + pub(crate) fn n_colors(&self) -> usize { + self.groups.len() + } + + #[allow(dead_code)] + pub(crate) fn is_empty(&self) -> bool { + self.groups.is_empty() + } + + /// Total event count across all colors. + #[allow(dead_code)] + pub(crate) fn total_events(&self) -> usize { + self.groups.iter().map(|g| g.len()).sum() + } +} + +/// Compute color groups greedily. +/// +/// `index_set(ev_idx)` yields, for each event index, the iterator of +/// `Index` values that event touches. The returned `ColorGroups` has one +/// inner `Vec` per color, containing event indices in the order +/// they were assigned. +#[allow(dead_code)] +pub(crate) fn color_greedy(n_events: usize, index_set: F) -> ColorGroups +where + F: Fn(usize) -> I, + I: IntoIterator, +{ + let mut groups: Vec> = Vec::new(); + let mut members: Vec> = Vec::new(); + + for ev_idx in 0..n_events { + let ev_members: HashSet = index_set(ev_idx).into_iter().collect(); + // Find first color whose member-set is disjoint from this event's indices. + let chosen = members.iter().position(|m| m.is_disjoint(&ev_members)); + let color_idx = match chosen { + Some(c) => c, + None => { + groups.push(Vec::new()); + members.push(HashSet::new()); + groups.len() - 1 + } + }; + groups[color_idx].push(ev_idx); + members[color_idx].extend(ev_members); + } + + ColorGroups { groups } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn idx(i: usize) -> Index { + Index::from(i) + } + + #[test] + fn single_event_gets_one_color() { + let cg = color_greedy(1, |_| vec![idx(0), idx(1)]); + assert_eq!(cg.n_colors(), 1); + assert_eq!(cg.groups[0], vec![0]); + } + + #[test] + fn disjoint_events_share_a_color() { + let cg = color_greedy(2, |i| match i { + 0 => vec![idx(0), idx(1)], + 1 => vec![idx(2), idx(3)], + _ => unreachable!(), + }); + assert_eq!(cg.n_colors(), 1); + assert_eq!(cg.groups[0], vec![0, 1]); + } + + #[test] + fn overlapping_events_need_separate_colors() { + let cg = color_greedy(2, |i| match i { + 0 => vec![idx(0), idx(1)], + 1 => vec![idx(1), idx(2)], + _ => unreachable!(), + }); + assert_eq!(cg.n_colors(), 2); + assert_eq!(cg.groups[0], vec![0]); + assert_eq!(cg.groups[1], vec![1]); + } + + #[test] + fn three_events_two_colors() { + // Event 0: {0, 1}; event 1: {2, 3}; event 2: {0, 2}. + // Greedy: ev0→c0, ev1→c0 (disjoint), ev2 overlaps both→c1. + let cg = color_greedy(3, |i| match i { + 0 => vec![idx(0), idx(1)], + 1 => vec![idx(2), idx(3)], + 2 => vec![idx(0), idx(2)], + _ => unreachable!(), + }); + assert_eq!(cg.n_colors(), 2); + assert_eq!(cg.groups[0], vec![0, 1]); + assert_eq!(cg.groups[1], vec![2]); + } + + #[test] + fn total_events_counts_correctly() { + let cg = color_greedy(4, |_| vec![idx(0)]); + // All events touch index 0 → 4 distinct colors. + assert_eq!(cg.n_colors(), 4); + assert_eq!(cg.total_events(), 4); + } +} diff --git a/src/lib.rs b/src/lib.rs index e6c7d41..6bd9fa7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub(crate) mod arena; mod time; mod time_slice; pub use time_slice::TimeSlice; +mod color_group; mod competitor; mod convergence; pub mod drift;