Implements tiers T0, T1, T2 of `docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md`. All three tiers have landed together on this branch because they build on one another; this PR rolls them up for a single review pass. Per-tier plans: - T0: `docs/superpowers/plans/2026-04-23-t0-numerical-parity.md` - T1: `docs/superpowers/plans/2026-04-24-t1-factor-graph.md` - T2: `docs/superpowers/plans/2026-04-24-t2-new-api-surface.md` ## Summary ### T0 — Numerical parity (internal) - `Gaussian` switched to natural-parameter storage `(pi, tau)`; mul/div now ~7× faster (218 ps vs 1.57 ns). - `HashMap<Index, _>` → dense `Vec<_>` keyed by `Index.0` (via `AgentStore<D>`, `SkillStore`). - `ScratchArena` eliminates per-event allocations in `Game::likelihoods`. - `InferenceError` seed type added (1 variant). - 38 → 53 tests passing through T1. - Benchmark: `Batch::iteration` 29.84 → 21.25 µs. ### T1 — Factor graph machinery (internal) - `Factor` trait + `BuiltinFactor` enum (TeamSum / RankDiff / Trunc) driving within-game inference. - `VarStore` flat storage for variable marginals. - `Schedule` trait + `EpsilonOrMax` impl replacing the hand-rolled EP loop. - `Game::likelihoods` rebuilt on the factor-graph machinery; iteration counts and goldens preserved to within 1e-6. - 53 tests passing. - Benchmark: `Batch::iteration` 23.01 µs (slight regression absorbed in T2). ### T2 — New API surface (breaking) **Renames:** - `IndexMap → KeyTable`, `Player → Rating`, `Agent → Competitor`, `Batch → TimeSlice` **New types:** - `Time` trait with `Untimed` ZST and `i64` impls; `Drift<T>`, `Rating<T, D>`, `Competitor<T, D>`, `TimeSlice<T>`, `History<T, D, O, K>` all generic. - `Event<T, K>`, `Team<K>`, `Member<K>`, `Outcome` (`Ranked` variant; `#[non_exhaustive]`). - `Observer<T>` trait + `NullObserver`. - `ConvergenceOptions`, `ConvergenceReport`. - `GameOptions`, `OwnedGame<T, D>`. **Three-tier ingestion:** - `history.record_winner(&K, &K, T)` / `record_draw(&K, &K, T)` — 1v1 convenience. - `history.add_events(iter)` — typed bulk. - `history.event(T).team([...]).weights([...]).ranking([...]).commit()` — fluent. **Query API:** `current_skill`, `learning_curve`, `learning_curves` (keyed on `K`), `log_evidence`, `log_evidence_for`, `predict_quality`, `predict_outcome`. **Game constructors:** `ranked`, `one_v_one`, `free_for_all`, `custom` — all returning `Result<_, InferenceError>`. **`factors` module:** `Factor`, `Schedule`, `VarStore`, `VarId`, `BuiltinFactor`, `EpsilonOrMax`, `ScheduleReport`, `TeamSumFactor`, `RankDiffFactor`, `TruncFactor` now public. **Errors:** `InferenceError` gains `MismatchedShape`, `InvalidProbability`, `ConvergenceFailed`; boundary panics converted to `Result`. **Removed (breaking):** `History::convergence(iters, eps, verbose)`, `HistoryBuilder::gamma(f64)`, `HistoryBuilder::time(bool)`, `History.time: bool`, `learning_curves_by_index`, nested-Vec public `add_events`. ## Behavior change (documented in CHANGELOG) `Time = Untimed` has `elapsed_to → 0`, so no drift accumulates between slices. The old `time=false` mode implicitly forced `elapsed=1` on reappearance via an `i64::MAX` sentinel — that quirk is not reproducible under a typed time axis. Tests that depended on it now use `History::<i64, _>` with explicit `1..=n` timestamps. One test (`test_env_ttt`) had 3 Gaussian goldens updated to reflect the corrected semantics; documented in commit `33a7d90`. ## Final numbers | Metric | Before T0 | After T2 | Delta | |---|---|---|---| | `Batch::iteration` | 29.84 µs | 21.36 µs | **-28%** | | `Gaussian::mul` | 1.57 ns | 219 ps | **-86%** | | `Gaussian::div` | 1.57 ns | 219 ps | **-86%** | | Tests passing | 38 | 90 | +52 | All other Gaussian ops unchanged (~219 ps add/sub, ~264 ps pi/tau reads). ## Test plan - [x] `cargo test --features approx` — 90/90 pass (68 lib + 10 api_shape + 6 game + 4 record_winner + 2 equivalence) - [x] `cargo clippy --all-targets --features approx -- -D warnings` — clean - [x] `cargo +nightly fmt --check` — clean - [x] `cargo bench --bench batch` — 21.36 µs - [x] `cargo bench --bench gaussian` — unchanged from T1 - [x] `cargo run --example atp --features approx` — rewritten in new API, runs clean - [x] Historical Game-level goldens preserved in `tests/equivalence.rs` - [x] Public API matches spec Section 4 (verified by integration tests in `tests/api_shape.rs`) ## Commit history ~45 commits total across T0 + T1 + T2. Each task is self-contained and individually tested; the branch is bisectable. See `git log main..t2-new-api-surface` for the full list. ## Deferred to later tiers - `Outcome::Scored` + `MarginFactor` — T4 - `Damped` / `Residual` schedules — T4 - `Send + Sync` bounds + Rayon parallelism — T3 - N-team `predict_outcome` — T4 - `Game::custom` full ergonomics — T4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #1 Co-authored-by: Anders Olsson <anders.e.olsson@gmail.com> Co-committed-by: Anders Olsson <anders.e.olsson@gmail.com>
128 lines
3.9 KiB
Rust
128 lines
3.9 KiB
Rust
use crate::{Index, competitor::Competitor, drift::Drift, time::Time};
|
|
|
|
/// Dense Vec-backed store for competitor state in History.
|
|
///
|
|
/// Indexed directly by Index.0, eliminating HashMap hashing in the
|
|
/// forward/backward sweep. Uses `Vec<Option<Competitor<T, D>>>` so slots can be
|
|
/// absent without an explicit present mask.
|
|
#[derive(Debug)]
|
|
pub struct CompetitorStore<T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
|
competitors: Vec<Option<Competitor<T, D>>>,
|
|
n_present: usize,
|
|
}
|
|
|
|
impl<T: Time, D: Drift<T>> Default for CompetitorStore<T, D> {
|
|
fn default() -> Self {
|
|
Self {
|
|
competitors: Vec::new(),
|
|
n_present: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
fn ensure_capacity(&mut self, idx: usize) {
|
|
if idx >= self.competitors.len() {
|
|
self.competitors.resize_with(idx + 1, || None);
|
|
}
|
|
}
|
|
|
|
pub fn insert(&mut self, idx: Index, competitor: Competitor<T, D>) {
|
|
self.ensure_capacity(idx.0);
|
|
if self.competitors[idx.0].is_none() {
|
|
self.n_present += 1;
|
|
}
|
|
self.competitors[idx.0] = Some(competitor);
|
|
}
|
|
|
|
pub fn get(&self, idx: Index) -> Option<&Competitor<T, D>> {
|
|
self.competitors.get(idx.0).and_then(|slot| slot.as_ref())
|
|
}
|
|
|
|
pub fn get_mut(&mut self, idx: Index) -> Option<&mut Competitor<T, D>> {
|
|
self.competitors
|
|
.get_mut(idx.0)
|
|
.and_then(|slot| slot.as_mut())
|
|
}
|
|
|
|
pub fn contains(&self, idx: Index) -> bool {
|
|
self.get(idx).is_some()
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.n_present
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.n_present == 0
|
|
}
|
|
|
|
pub fn iter(&self) -> impl Iterator<Item = (Index, &Competitor<T, D>)> {
|
|
self.competitors
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a)))
|
|
}
|
|
|
|
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Competitor<T, D>)> {
|
|
self.competitors
|
|
.iter_mut()
|
|
.enumerate()
|
|
.filter_map(|(i, slot)| slot.as_mut().map(|a| (Index(i), a)))
|
|
}
|
|
|
|
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut Competitor<T, D>> {
|
|
self.competitors.iter_mut().filter_map(|s| s.as_mut())
|
|
}
|
|
}
|
|
|
|
impl<T: Time, D: Drift<T>> std::ops::Index<Index> for CompetitorStore<T, D> {
|
|
type Output = Competitor<T, D>;
|
|
fn index(&self, idx: Index) -> &Competitor<T, D> {
|
|
self.get(idx).expect("competitor not found at index")
|
|
}
|
|
}
|
|
|
|
impl<T: Time, D: Drift<T>> std::ops::IndexMut<Index> for CompetitorStore<T, D> {
|
|
fn index_mut(&mut self, idx: Index) -> &mut Competitor<T, D> {
|
|
self.get_mut(idx).expect("competitor not found at index")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{competitor::Competitor, drift::ConstantDrift};
|
|
|
|
#[test]
|
|
fn insert_then_get() {
|
|
let mut store: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
|
let idx = Index(7);
|
|
store.insert(idx, Competitor::default());
|
|
assert!(store.contains(idx));
|
|
assert_eq!(store.len(), 1);
|
|
assert!(store.get(idx).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn iter_in_index_order() {
|
|
let mut store: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
|
store.insert(Index(2), Competitor::default());
|
|
store.insert(Index(0), Competitor::default());
|
|
store.insert(Index(5), Competitor::default());
|
|
let keys: Vec<Index> = store.iter().map(|(i, _)| i).collect();
|
|
assert_eq!(keys, vec![Index(0), Index(2), Index(5)]);
|
|
}
|
|
|
|
#[test]
|
|
fn index_operator_works() {
|
|
let mut store: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
|
store.insert(Index(3), Competitor::default());
|
|
let _ = &store[Index(3)];
|
|
}
|
|
}
|