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:
2026-04-24 11:20:04 +00:00
committed by logaritmisk
parent a14df02089
commit d2aab82c1e
44 changed files with 10541 additions and 1325 deletions
+420 -177
View File
@@ -1,16 +1,85 @@
use std::cmp::Ordering;
use crate::{
N_INF, N00, approx, compute_margin,
N_INF, N00,
arena::ScratchArena,
compute_margin,
drift::Drift,
evidence,
factor::{Factor, trunc::TruncFactor},
gaussian::Gaussian,
message::{DiffMessage, TeamMessage},
player::Player,
sort_perm, tuple_gt, tuple_max,
rating::Rating,
time::Time,
tuple_gt, tuple_max,
};
#[derive(Clone, Copy, Debug)]
pub struct GameOptions {
pub p_draw: f64,
pub convergence: crate::ConvergenceOptions,
}
impl Default for GameOptions {
fn default() -> Self {
Self {
p_draw: crate::P_DRAW,
convergence: crate::ConvergenceOptions::default(),
}
}
}
/// Owned variant of `Game` returned by public constructors.
///
/// Unlike `Game<'a, T, D>` (which borrows its result/weights slices from
/// History's internal state), `OwnedGame<T, D>` owns its inputs so it can
/// be returned freely from public constructors.
#[derive(Debug)]
pub struct Game<'a, D: Drift> {
teams: Vec<Vec<Player<D>>>,
#[allow(dead_code)]
pub struct OwnedGame<T: Time, D: Drift<T>> {
teams: Vec<Vec<Rating<T, D>>>,
result: Vec<f64>,
weights: Vec<Vec<f64>>,
p_draw: f64,
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) evidence: f64,
}
impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
pub(crate) fn new(
teams: Vec<Vec<Rating<T, D>>>,
result: Vec<f64>,
weights: Vec<Vec<f64>>,
p_draw: f64,
) -> Self {
let mut arena = ScratchArena::new();
let g = Game::ranked_with_arena(teams.clone(), &result, &weights, p_draw, &mut arena);
let likelihoods = g.likelihoods;
let evidence = g.evidence;
Self {
teams,
result,
weights,
p_draw,
likelihoods,
evidence,
}
}
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods
.iter()
.zip(self.teams.iter())
.map(|(l, t)| l.iter().zip(t.iter()).map(|(&l, r)| l * r.prior).collect())
.collect()
}
pub fn log_evidence(&self) -> f64 {
self.evidence.ln()
}
}
#[derive(Debug)]
pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
teams: Vec<Vec<Rating<T, D>>>,
result: &'a [f64],
weights: &'a [Vec<f64>],
p_draw: f64,
@@ -18,18 +87,18 @@ pub struct Game<'a, D: Drift> {
pub(crate) evidence: f64,
}
impl<'a, D: Drift> Game<'a, D> {
pub fn new(
teams: Vec<Vec<Player<D>>>,
impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
pub(crate) fn ranked_with_arena(
teams: Vec<Vec<Rating<T, D>>>,
result: &'a [f64],
weights: &'a [Vec<f64>],
p_draw: f64,
arena: &mut ScratchArena,
) -> Self {
debug_assert!(
(result.len() == teams.len()),
result.len() == teams.len(),
"result must have the same length as teams"
);
debug_assert!(
weights
.iter()
@@ -37,19 +106,17 @@ impl<'a, D: Drift> Game<'a, D> {
.all(|(w, t)| w.len() == t.len()),
"weights must have the same dimensions as teams"
);
debug_assert!(
(0.0..1.0).contains(&p_draw),
"draw probability.must be >= 0.0 and < 1.0"
"draw probability must be >= 0.0 and < 1.0"
);
debug_assert!(
p_draw > 0.0 || {
let mut r = result.to_vec();
r.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
r.windows(2).all(|w| w[0] != w[1])
},
"draw must be > 0.0 if there is teams with draw"
"draw must be > 0.0 if there are teams with draw"
);
let mut this = Self {
@@ -61,124 +128,144 @@ impl<'a, D: Drift> Game<'a, D> {
evidence: 0.0,
};
this.likelihoods();
this.likelihoods(arena);
this
}
fn likelihoods(&mut self) {
let o = sort_perm(self.result, true);
fn likelihoods(&mut self, arena: &mut ScratchArena) {
arena.reset();
let mut team = o
.iter()
.map(|&e| {
let performance = self.teams[e]
.iter()
.zip(self.weights[e].iter())
.fold(N00, |p, (player, &weight)| {
p + (player.performance() * weight)
});
let n_teams = self.teams.len();
TeamMessage {
prior: performance,
..Default::default()
}
})
.collect::<Vec<_>>();
// Sort teams by result descending; reuse arena.sort_buf to avoid allocation.
arena.sort_buf.extend(0..n_teams);
arena.sort_buf.sort_by(|&i, &j| {
self.result[j]
.partial_cmp(&self.result[i])
.unwrap_or(Ordering::Equal)
});
let mut diff = team
.windows(2)
.map(|w| DiffMessage {
prior: w[0].prior - w[1].prior,
likelihood: N_INF,
})
.collect::<Vec<_>>();
// Team performance priors written into arena buffer (capacity reused across games).
arena.team_prior.extend(arena.sort_buf.iter().map(|&t| {
self.teams[t]
.iter()
.zip(self.weights[t].iter())
.fold(N00, |p, (player, &w)| p + (player.performance() * w))
}));
let tie = o
.windows(2)
.map(|e| self.result[e[0]] == self.result[e[1]])
.collect::<Vec<_>>();
let margin = if self.p_draw == 0.0 {
vec![0.0; o.len() - 1]
} else {
o.windows(2)
.map(|w| {
let a: f64 = self.teams[w[0]].iter().map(|a| a.beta.powi(2)).sum();
let b: f64 = self.teams[w[1]].iter().map(|a| a.beta.powi(2)).sum();
let n_diffs = n_teams.saturating_sub(1);
// One TruncFactor per adjacent sorted-team pair; each owns a diff VarId.
// trunc stays local (fresh state per game; Vec capacity is typically small).
let mut trunc: Vec<TruncFactor> = (0..n_diffs)
.map(|i| {
let tie = self.result[arena.sort_buf[i]] == self.result[arena.sort_buf[i + 1]];
let margin = if self.p_draw == 0.0 {
0.0
} else {
let a: f64 = self.teams[arena.sort_buf[i]]
.iter()
.map(|p| p.beta.powi(2))
.sum();
let b: f64 = self.teams[arena.sort_buf[i + 1]]
.iter()
.map(|p| p.beta.powi(2))
.sum();
compute_margin(self.p_draw, (a + b).sqrt())
})
.collect::<Vec<_>>()
};
};
let vid = arena.vars.alloc(N_INF);
TruncFactor::new(vid, margin, tie)
})
.collect();
self.evidence = 1.0;
// Per-team messages from neighbouring RankDiff factors (replaces TeamMessage).
arena.lhood_lose.resize(n_teams, N_INF);
arena.lhood_win.resize(n_teams, N_INF);
let mut step = (f64::INFINITY, f64::INFINITY);
let mut iter = 0;
while tuple_gt(step, 1e-6) && iter < 10 {
step = (0.0, 0.0);
step = (0.0_f64, 0.0_f64);
for e in 0..diff.len() - 1 {
diff[e].prior = team[e].posterior_win() - team[e + 1].posterior_lose();
// Forward sweep: diffs 0 .. n_diffs-2 (all but the last).
for (e, tf) in trunc[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(tf.diff, raw * tf.msg);
let d = tf.propagate(&mut arena.vars);
step = tuple_max(step, d);
if iter == 0 {
self.evidence *= evidence(&diff, &margin, &tie, e);
}
diff[e].likelihood = approx(diff[e].prior, margin[e], tie[e]) / diff[e].prior;
let likelihood_lose = team[e].posterior_win() - diff[e].likelihood;
step = tuple_max(step, team[e + 1].likelihood_lose.delta(likelihood_lose));
team[e + 1].likelihood_lose = likelihood_lose;
let new_ll = pw - tf.msg;
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
arena.lhood_lose[e + 1] = new_ll;
}
for e in (1..diff.len()).rev() {
diff[e].prior = team[e].posterior_win() - team[e + 1].posterior_lose();
// Backward sweep: diffs n_diffs-1 .. 1 (reverse, all but the first).
for (rev_i, tf) in trunc[1..].iter_mut().rev().enumerate() {
let e = n_diffs - 1 - rev_i;
let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(tf.diff, raw * tf.msg);
let d = tf.propagate(&mut arena.vars);
step = tuple_max(step, d);
if iter == 0 && e == diff.len() - 1 {
self.evidence *= evidence(&diff, &margin, &tie, e);
}
diff[e].likelihood = approx(diff[e].prior, margin[e], tie[e]) / diff[e].prior;
let likelihood_win = team[e + 1].posterior_lose() + diff[e].likelihood;
step = tuple_max(step, team[e].likelihood_win.delta(likelihood_win));
team[e].likelihood_win = likelihood_win;
let new_lw = pl + tf.msg;
step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
arena.lhood_win[e] = new_lw;
}
iter += 1;
}
if diff.len() == 1 {
self.evidence = evidence(&diff, &margin, &tie, 0);
diff[0].prior = team[0].posterior_win() - team[1].posterior_lose();
diff[0].likelihood = approx(diff[0].prior, margin[0], tie[0]) / diff[0].prior;
// Special case: exactly 1 diff (2-team game); loop body was empty.
if n_diffs == 1 {
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
- (arena.team_prior[1] * arena.lhood_win[1]);
arena.vars.set(trunc[0].diff, raw * trunc[0].msg);
trunc[0].propagate(&mut arena.vars);
}
let t_end = team.len() - 1;
let d_end = diff.len() - 1;
// Boundary updates: close the chain at both ends.
if n_diffs > 0 {
let pl1 = arena.team_prior[1] * arena.lhood_win[1];
arena.lhood_win[0] = pl1 + trunc[0].msg;
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2];
arena.lhood_lose[n_teams - 1] = pw_last - trunc[n_diffs - 1].msg;
}
team[0].likelihood_win = team[1].posterior_lose() + diff[0].likelihood;
team[t_end].likelihood_lose = team[t_end - 1].posterior_win() - diff[d_end].likelihood;
// Evidence = product of per-diff evidences (each cached on first propagation).
self.evidence = trunc
.iter()
.map(|t| t.evidence_cached.unwrap_or(1.0))
.product();
let m_t_ft = o.into_iter().map(|e| team[e].likelihood());
// Inverse permutation: inv_buf[orig_i] = sorted_i.
arena.inv_buf.resize(n_teams, 0);
for (si, &orig_i) in arena.sort_buf.iter().enumerate() {
arena.inv_buf[orig_i] = si;
}
self.likelihoods = self
.teams
.iter()
.zip(self.weights.iter())
.zip(m_t_ft)
.map(|((p, w), m)| {
let performance = p.iter().zip(w.iter()).fold(N00, |p, (player, &weight)| {
p + (player.performance() * weight)
});
p.iter()
.zip(w.iter())
.map(|(p, &w)| {
((m - performance.exclude(p.performance() * w)) * (1.0 / w))
.forget(p.beta.powi(2))
.enumerate()
.map(|(orig_i, (players, weights))| {
let si = arena.inv_buf[orig_i];
let m = arena.lhood_win[si] * arena.lhood_lose[si];
let performance = players
.iter()
.zip(weights.iter())
.fold(N00, |p, (player, &w)| p + (player.performance() * w));
players
.iter()
.zip(weights.iter())
.map(|(player, &w)| {
((m - performance.exclude(player.performance() * w)) * (1.0 / w))
.forget(player.beta.powi(2))
})
.collect::<Vec<_>>()
})
@@ -197,6 +284,68 @@ impl<'a, D: Drift> Game<'a, D> {
})
.collect::<Vec<_>>()
}
pub fn log_evidence(&self) -> f64 {
self.evidence.ln()
}
}
impl<T: Time, D: Drift<T>> Game<'_, T, D> {
pub fn ranked(
teams: &[&[Rating<T, D>]],
outcome: crate::Outcome,
options: &GameOptions,
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
if !(0.0..1.0).contains(&options.p_draw) {
return Err(crate::InferenceError::InvalidProbability {
value: options.p_draw,
});
}
if outcome.team_count() != teams.len() {
return Err(crate::InferenceError::MismatchedShape {
kind: "outcome ranks vs teams",
expected: teams.len(),
got: outcome.team_count(),
});
}
let ranks = outcome.as_ranks();
let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
let result: Vec<f64> = ranks.iter().map(|&r| max_rank - r as f64).collect();
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
Ok(OwnedGame::new(teams_owned, result, weights, options.p_draw))
}
pub fn one_v_one(
a: &Rating<T, D>,
b: &Rating<T, D>,
outcome: crate::Outcome,
) -> Result<(Gaussian, Gaussian), crate::InferenceError> {
let game = Self::ranked(&[&[*a], &[*b]], outcome, &GameOptions::default())?;
let post = game.posteriors();
Ok((post[0][0], post[1][0]))
}
pub fn free_for_all(
players: &[&Rating<T, D>],
outcome: crate::Outcome,
options: &GameOptions,
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
let teams: Vec<Vec<Rating<T, D>>> = players.iter().map(|p| vec![**p]).collect();
let team_refs: Vec<&[Rating<T, D>]> = teams.iter().map(|t| t.as_slice()).collect();
Self::ranked(&team_refs, outcome, options)
}
#[doc(hidden)]
pub fn custom<S: crate::factors::Schedule>(
factors: &mut [crate::factors::BuiltinFactor],
vars: &mut crate::factors::VarStore,
schedule: &S,
) -> crate::factors::ScheduleReport {
schedule.run(factors, vars)
}
}
#[cfg(test)]
@@ -204,23 +353,31 @@ mod tests {
use ::approx::assert_ulps_eq;
use super::*;
use crate::{ConstantDrift, GAMMA, Gaussian, N_INF, Player};
use crate::{ConstantDrift, GAMMA, Gaussian, N_INF, Rating, arena::ScratchArena};
type R = Rating<i64, ConstantDrift>;
#[test]
fn test_1vs1() {
let t_a = Player::new(
let t_a = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
);
let t_b = Player::new(
let t_b = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0]];
let g = Game::new(vec![vec![t_a], vec![t_b]], &[0.0, 1.0], &w, 0.0);
let g = Game::ranked_with_arena(
vec![vec![t_a], vec![t_b]],
&[0.0, 1.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
@@ -229,19 +386,25 @@ mod tests {
assert_ulps_eq!(a, Gaussian::from_ms(20.794779, 7.194481), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(29.205220, 7.194481), epsilon = 1e-6);
let t_a = Player::new(
let t_a = R::new(
Gaussian::from_ms(29.0, 1.0),
25.0 / 6.0,
ConstantDrift(GAMMA),
);
let t_b = Player::new(
let t_b = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(GAMMA),
);
let w = [vec![1.0], vec![1.0]];
let g = Game::new(vec![vec![t_a], vec![t_b]], &[0.0, 1.0], &w, 0.0);
let g = Game::ranked_with_arena(
vec![vec![t_a], vec![t_b]],
&[0.0, 1.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
@@ -250,11 +413,17 @@ mod tests {
assert_ulps_eq!(a, Gaussian::from_ms(28.896475, 0.996604), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(32.189211, 6.062063), epsilon = 1e-6);
let t_a = Player::new(Gaussian::from_ms(1.139, 0.531), 1.0, ConstantDrift(0.2125));
let t_b = Player::new(Gaussian::from_ms(15.568, 0.51), 1.0, ConstantDrift(0.2125));
let t_a = R::new(Gaussian::from_ms(1.139, 0.531), 1.0, ConstantDrift(0.2125));
let t_b = R::new(Gaussian::from_ms(15.568, 0.51), 1.0, ConstantDrift(0.2125));
let w = [vec![1.0], vec![1.0]];
let g = Game::new(vec![vec![t_a], vec![t_b]], &[0.0, 1.0], &w, 0.0);
let g = Game::ranked_with_arena(
vec![vec![t_a], vec![t_b]],
&[0.0, 1.0],
&w,
0.0,
&mut ScratchArena::new(),
);
assert_eq!(g.likelihoods[0][0], N_INF);
assert_eq!(g.likelihoods[1][0], N_INF);
@@ -263,17 +432,17 @@ mod tests {
#[test]
fn test_1vs1vs1() {
let teams = vec![
vec![Player::new(
vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
)],
vec![Player::new(
vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
)],
vec![Player::new(
vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
@@ -281,7 +450,13 @@ mod tests {
];
let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = Game::new(teams.clone(), &[1.0, 2.0, 0.0], &w, 0.0);
let g = Game::ranked_with_arena(
teams.clone(),
&[1.0, 2.0, 0.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
@@ -291,7 +466,13 @@ mod tests {
assert_ulps_eq!(b, Gaussian::from_ms(31.311358, 6.698818), epsilon = 1e-6);
let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = Game::new(teams.clone(), &[2.0, 1.0, 0.0], &w, 0.0);
let g = Game::ranked_with_arena(
teams.clone(),
&[2.0, 1.0, 0.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
@@ -301,33 +482,40 @@ mod tests {
assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6);
let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = Game::new(teams, &[1.0, 2.0, 0.0], &w, 0.5);
let g = Game::ranked_with_arena(teams, &[1.0, 2.0, 0.0], &w, 0.5, &mut ScratchArena::new());
let p = g.posteriors();
let a = p[0][0];
let b = p[1][0];
let c = p[2][0];
assert_ulps_eq!(a, Gaussian::from_ms(24.999999, 6.092561), epsilon = 1e-6);
// T1 ULP shift: mu rounds to 25.0 (was 24.999999) under natural-parameter storage.
assert_ulps_eq!(a, Gaussian::from_ms(25.0, 6.092561), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(33.379314, 6.483575), epsilon = 1e-6);
assert_ulps_eq!(c, Gaussian::from_ms(16.620685, 6.483575), epsilon = 1e-6);
}
#[test]
fn test_1vs1_draw() {
let t_a = Player::new(
let t_a = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
);
let t_b = Player::new(
let t_b = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0]];
let g = Game::new(vec![vec![t_a], vec![t_b]], &[0.0, 0.0], &w, 0.25);
let g = Game::ranked_with_arena(
vec![vec![t_a], vec![t_b]],
&[0.0, 0.0],
&w,
0.25,
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
@@ -336,19 +524,25 @@ mod tests {
assert_ulps_eq!(a, Gaussian::from_ms(24.999999, 6.469480), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(24.999999, 6.469480), epsilon = 1e-6);
let t_a = Player::new(
let t_a = R::new(
Gaussian::from_ms(25.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
);
let t_b = Player::new(
let t_b = R::new(
Gaussian::from_ms(29.0, 2.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0]];
let g = Game::new(vec![vec![t_a], vec![t_b]], &[0.0, 0.0], &w, 0.25);
let g = Game::ranked_with_arena(
vec![vec![t_a], vec![t_b]],
&[0.0, 0.0],
&w,
0.25,
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
@@ -360,28 +554,29 @@ mod tests {
#[test]
fn test_1vs1vs1_draw() {
let t_a = Player::new(
let t_a = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
);
let t_b = Player::new(
let t_b = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
);
let t_c = Player::new(
let t_c = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = Game::new(
let g = Game::ranked_with_arena(
vec![vec![t_a], vec![t_b], vec![t_c]],
&[0.0, 0.0, 0.0],
&w,
0.25,
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -389,32 +584,35 @@ mod tests {
let b = p[1][0];
let c = p[2][0];
assert_ulps_eq!(a, Gaussian::from_ms(24.999999, 5.729068), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 5.707423), epsilon = 1e-6);
assert_ulps_eq!(c, Gaussian::from_ms(24.999999, 5.729068), epsilon = 1e-6);
// Goldens updated for natural-parameter storage: mu rounds to 25.0 (was 24.999999),
// sigma shifts by ~3e-7 ULPs (within 1e-6 of original). Both bounded differences.
assert_ulps_eq!(a, Gaussian::from_ms(25.0, 5.729069), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(25.0, 5.707424), epsilon = 1e-6);
assert_ulps_eq!(c, Gaussian::from_ms(25.0, 5.729069), epsilon = 1e-6);
let t_a = Player::new(
let t_a = R::new(
Gaussian::from_ms(25.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
);
let t_b = Player::new(
let t_b = R::new(
Gaussian::from_ms(25.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
);
let t_c = Player::new(
let t_c = R::new(
Gaussian::from_ms(29.0, 2.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = Game::new(
let g = Game::ranked_with_arena(
vec![vec![t_a], vec![t_b], vec![t_c]],
&[0.0, 0.0, 0.0],
&w,
0.25,
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -430,29 +628,29 @@ mod tests {
#[test]
fn test_2vs1vs2_mixed() {
let t_a = vec![
Player::new(
R::new(
Gaussian::from_ms(12.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
),
Player::new(
R::new(
Gaussian::from_ms(18.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
),
];
let t_b = vec![Player::new(
let t_b = vec![R::new(
Gaussian::from_ms(30.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
)];
let t_c = vec![
Player::new(
R::new(
Gaussian::from_ms(14.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
),
Player::new(
R::new(
Gaussian::from_ms(16., 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
@@ -460,7 +658,13 @@ mod tests {
];
let w = [vec![1.0, 1.0], vec![1.0], vec![1.0, 1.0]];
let g = Game::new(vec![t_a, t_b, t_c], &[1.0, 0.0, 0.0], &w, 0.25);
let g = Game::ranked_with_arena(
vec![t_a, t_b, t_c],
&[1.0, 0.0, 0.0],
&w,
0.25,
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(p[0][0], Gaussian::from_ms(13.051, 2.864), epsilon = 1e-3);
@@ -475,19 +679,25 @@ mod tests {
let w_a = vec![1.0];
let w_b = vec![2.0];
let t_a = vec![Player::new(
let t_a = vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
)];
let t_b = vec![Player::new(
let t_b = vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
)];
let w = [w_a, w_b];
let g = Game::new(vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, 0.0);
let g = Game::ranked_with_arena(
vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
@@ -505,7 +715,13 @@ mod tests {
let w_b = vec![0.7];
let w = [w_a, w_b];
let g = Game::new(vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, 0.0);
let g = Game::ranked_with_arena(
vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
@@ -523,7 +739,13 @@ mod tests {
let w_b = vec![0.7];
let w = [w_a, w_b];
let g = Game::new(vec![t_a, t_b], &[1.0, 0.0], &w, 0.0);
let g = Game::ranked_with_arena(
vec![t_a, t_b],
&[1.0, 0.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
@@ -540,19 +762,17 @@ mod tests {
let w_a = vec![1.0];
let w_b = vec![0.0];
let t_a = vec![Player::new(
Gaussian::from_ms(2.0, 6.0),
1.0,
ConstantDrift(0.0),
)];
let t_b = vec![Player::new(
Gaussian::from_ms(2.0, 6.0),
1.0,
ConstantDrift(0.0),
)];
let t_a = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
let w = [w_a, w_b];
let g = Game::new(vec![t_a, t_b], &[1.0, 0.0], &w, 0.0);
let g = Game::ranked_with_arena(
vec![t_a, t_b],
&[1.0, 0.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
@@ -569,19 +789,17 @@ mod tests {
let w_a = vec![1.0];
let w_b = vec![-1.0];
let t_a = vec![Player::new(
Gaussian::from_ms(2.0, 6.0),
1.0,
ConstantDrift(0.0),
)];
let t_b = vec![Player::new(
Gaussian::from_ms(2.0, 6.0),
1.0,
ConstantDrift(0.0),
)];
let t_a = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
let w = [w_a, w_b];
let g = Game::new(vec![t_a, t_b], &[1.0, 0.0], &w, 0.0);
let g = Game::ranked_with_arena(
vec![t_a, t_b],
&[1.0, 0.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(p[0][0], p[1][0], epsilon = 1e-6);
@@ -590,12 +808,12 @@ mod tests {
#[test]
fn test_2vs2_weighted() {
let t_a = vec![
Player::new(
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
),
Player::new(
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
@@ -604,12 +822,12 @@ mod tests {
let w_a = vec![0.4, 0.8];
let t_b = vec![
Player::new(
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
),
Player::new(
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
@@ -618,7 +836,13 @@ mod tests {
let w_b = vec![0.9, 0.6];
let w = [w_a, w_b];
let g = Game::new(vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, 0.0);
let g = Game::ranked_with_arena(
vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
@@ -646,7 +870,13 @@ mod tests {
let w_b = vec![0.7, 0.4];
let w = [w_a, w_b];
let g = Game::new(vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, 0.0);
let g = Game::ranked_with_arena(
vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
@@ -674,7 +904,13 @@ mod tests {
let w_b = vec![0.7, 2.4];
let w = [w_a, w_b];
let g = Game::new(vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, 0.0);
let g = Game::ranked_with_arena(
vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
@@ -699,10 +935,10 @@ mod tests {
);
let w = [vec![1.0, 1.0], vec![1.0]];
let g = Game::new(
let g = Game::ranked_with_arena(
vec![
t_a.clone(),
vec![Player::new(
vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
@@ -711,6 +947,7 @@ mod tests {
&[1.0, 0.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let post_2vs1 = g.posteriors();
@@ -718,7 +955,13 @@ mod tests {
let w_b = vec![1.0, 0.0];
let w = [w_a, w_b];
let g = Game::new(vec![t_a, t_b.clone()], &[1.0, 0.0], &w, 0.0);
let g = Game::ranked_with_arena(
vec![t_a, t_b.clone()],
&[1.0, 0.0],
&w,
0.0,
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(p[0][0], post_2vs1[0][0], epsilon = 1e-6);