T0 + T1 + T2: engine redesign through new API surface (#1)
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>
This commit was merged in pull request #1.
This commit is contained in:
+39
-101
@@ -1,30 +1,49 @@
|
||||
use std::{
|
||||
borrow::{Borrow, ToOwned},
|
||||
cmp::Reverse,
|
||||
collections::HashMap,
|
||||
f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2},
|
||||
hash::Hash,
|
||||
};
|
||||
|
||||
pub mod agent;
|
||||
#[cfg(feature = "approx")]
|
||||
mod approx;
|
||||
pub mod batch;
|
||||
pub(crate) mod arena;
|
||||
mod time;
|
||||
mod time_slice;
|
||||
pub use time_slice::TimeSlice;
|
||||
mod competitor;
|
||||
mod convergence;
|
||||
pub mod drift;
|
||||
mod error;
|
||||
mod event;
|
||||
mod event_builder;
|
||||
pub(crate) mod factor;
|
||||
pub mod factors;
|
||||
mod game;
|
||||
pub mod gaussian;
|
||||
mod history;
|
||||
mod key_table;
|
||||
mod matrix;
|
||||
mod message;
|
||||
pub mod player;
|
||||
mod observer;
|
||||
mod outcome;
|
||||
mod rating;
|
||||
pub(crate) mod schedule;
|
||||
pub mod storage;
|
||||
|
||||
pub use competitor::Competitor;
|
||||
pub use convergence::{ConvergenceOptions, ConvergenceReport};
|
||||
pub use drift::{ConstantDrift, Drift};
|
||||
pub use game::Game;
|
||||
pub use error::InferenceError;
|
||||
pub use event::{Event, Member, Team};
|
||||
pub use event_builder::EventBuilder;
|
||||
pub use game::{Game, GameOptions, OwnedGame};
|
||||
pub use gaussian::Gaussian;
|
||||
pub use history::History;
|
||||
pub use key_table::KeyTable;
|
||||
use matrix::Matrix;
|
||||
use message::DiffMessage;
|
||||
pub use player::Player;
|
||||
pub use observer::{NullObserver, Observer};
|
||||
pub use outcome::Outcome;
|
||||
pub use rating::Rating;
|
||||
pub use schedule::ScheduleReport;
|
||||
pub use time::{Time, Untimed};
|
||||
|
||||
pub const BETA: f64 = 1.0;
|
||||
pub const MU: f64 = 0.0;
|
||||
@@ -49,61 +68,6 @@ impl From<usize> for Index {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IndexMap<K>(HashMap<K, Index>);
|
||||
|
||||
impl<K> IndexMap<K>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
{
|
||||
pub fn new() -> Self {
|
||||
Self(HashMap::new())
|
||||
}
|
||||
|
||||
pub fn get<Q: ?Sized>(&self, k: &Q) -> Option<Index>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
Q: Hash + Eq + ToOwned<Owned = K>,
|
||||
{
|
||||
self.0.get(k).cloned()
|
||||
}
|
||||
|
||||
pub fn get_or_create<Q: ?Sized>(&mut self, k: &Q) -> Index
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
Q: Hash + Eq + ToOwned<Owned = K>,
|
||||
{
|
||||
if let Some(idx) = self.0.get(k) {
|
||||
*idx
|
||||
} else {
|
||||
let idx = Index::from(self.0.len());
|
||||
|
||||
self.0.insert(k.to_owned(), idx);
|
||||
|
||||
idx
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self, idx: Index) -> Option<&K> {
|
||||
self.0
|
||||
.iter()
|
||||
.find(|&(_, value)| *value == idx)
|
||||
.map(|(key, _)| key)
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> impl Iterator<Item = &K> {
|
||||
self.0.keys()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> Default for IndexMap<K>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
{
|
||||
fn default() -> Self {
|
||||
IndexMap::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn erfc(x: f64) -> f64 {
|
||||
let z = x.abs();
|
||||
let t = 1.0 / (1.0 + z / 2.0);
|
||||
@@ -158,7 +122,7 @@ fn compute_margin(p_draw: f64, sd: f64) -> f64 {
|
||||
ppf(0.5 - p_draw / 2.0, 0.0, sd).abs()
|
||||
}
|
||||
|
||||
fn cdf(x: f64, mu: f64, sigma: f64) -> f64 {
|
||||
pub(crate) fn cdf(x: f64, mu: f64, sigma: f64) -> f64 {
|
||||
let z = -(x - mu) / (sigma * SQRT_2);
|
||||
|
||||
0.5 * erfc(z)
|
||||
@@ -203,9 +167,9 @@ fn trunc(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
||||
}
|
||||
|
||||
pub(crate) fn approx(n: Gaussian, margin: f64, tie: bool) -> Gaussian {
|
||||
let (mu, sigma) = trunc(n.mu, n.sigma, margin, tie);
|
||||
let (mu, sigma) = trunc(n.mu(), n.sigma(), margin, tie);
|
||||
|
||||
Gaussian { mu, sigma }
|
||||
Gaussian::from_ms(mu, sigma)
|
||||
}
|
||||
|
||||
pub(crate) fn tuple_max(v1: (f64, f64), v2: (f64, f64)) -> (f64, f64) {
|
||||
@@ -219,39 +183,18 @@ pub(crate) fn tuple_gt(t: (f64, f64), e: f64) -> bool {
|
||||
t.0 > e || t.1 > e
|
||||
}
|
||||
|
||||
pub(crate) fn sort_perm(x: &[f64], reverse: bool) -> Vec<usize> {
|
||||
let mut v = x.iter().enumerate().collect::<Vec<_>>();
|
||||
pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
|
||||
let mut x: Vec<(usize, T)> = xs.iter().enumerate().map(|(i, &t)| (i, t)).collect();
|
||||
|
||||
if reverse {
|
||||
v.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap());
|
||||
x.sort_by_key(|&(_, t)| Reverse(t));
|
||||
} else {
|
||||
v.sort_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap());
|
||||
}
|
||||
|
||||
v.into_iter().map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn sort_time(xs: &[i64], reverse: bool) -> Vec<usize> {
|
||||
let mut x = xs.iter().enumerate().collect::<Vec<_>>();
|
||||
|
||||
if reverse {
|
||||
x.sort_by_key(|&(_, x)| Reverse(x));
|
||||
} else {
|
||||
x.sort_by_key(|&(_, x)| x);
|
||||
x.sort_by_key(|&(_, t)| t);
|
||||
}
|
||||
|
||||
x.into_iter().map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn evidence(d: &[DiffMessage], margin: &[f64], tie: &[bool], e: usize) -> f64 {
|
||||
if tie[e] {
|
||||
cdf(margin[e], d[e].prior.mu, d[e].prior.sigma)
|
||||
- cdf(-margin[e], d[e].prior.mu, d[e].prior.sigma)
|
||||
} else {
|
||||
1.0 - cdf(margin[e], d[e].prior.mu, d[e].prior.sigma)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the match quality of the given rating groups. A result is the draw probability in the association
|
||||
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
||||
let flatten_ratings = rating_groups
|
||||
@@ -266,13 +209,13 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
||||
let mut mean_matrix = Matrix::new(length, 1);
|
||||
|
||||
for (i, rating) in flatten_ratings.iter().enumerate() {
|
||||
mean_matrix[(i, 0)] = rating.mu;
|
||||
mean_matrix[(i, 0)] = rating.mu();
|
||||
}
|
||||
|
||||
let mut variance_matrix = Matrix::new(length, length);
|
||||
|
||||
for (i, rating) in flatten_ratings.iter().enumerate() {
|
||||
variance_matrix[(i, i)] = rating.sigma.powi(2);
|
||||
variance_matrix[(i, i)] = rating.sigma().powi(2);
|
||||
}
|
||||
|
||||
let mut rotated_a_matrix = Matrix::new(rating_groups.len() - 1, length);
|
||||
@@ -320,14 +263,9 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sort_perm() {
|
||||
assert_eq!(sort_perm(&[0.0, 1.0, 2.0, 0.0], true), vec![2, 1, 0, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_time() {
|
||||
assert_eq!(sort_time(&[0, 1, 2, 0], true), vec![2, 1, 0, 3]);
|
||||
assert_eq!(sort_time(&[0i64, 1, 2, 0], true), vec![2, 1, 0, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user