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:
@@ -1,47 +0,0 @@
|
||||
use crate::{
|
||||
N_INF,
|
||||
drift::{ConstantDrift, Drift},
|
||||
gaussian::Gaussian,
|
||||
player::Player,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Agent<D: Drift = ConstantDrift> {
|
||||
pub player: Player<D>,
|
||||
pub message: Gaussian,
|
||||
pub last_time: i64,
|
||||
}
|
||||
|
||||
impl<D: Drift> Agent<D> {
|
||||
pub(crate) fn receive(&self, elapsed: i64) -> Gaussian {
|
||||
if self.message != N_INF {
|
||||
self.message
|
||||
.forget(self.player.drift.variance_delta(elapsed))
|
||||
} else {
|
||||
self.player.prior
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Agent<ConstantDrift> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
player: Player::default(),
|
||||
message: N_INF,
|
||||
last_time: i64::MIN,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clean<'a, D: Drift + 'a, A: Iterator<Item = &'a mut Agent<D>>>(
|
||||
agents: A,
|
||||
last_time: bool,
|
||||
) {
|
||||
for a in agents {
|
||||
a.message = N_INF;
|
||||
|
||||
if last_time {
|
||||
a.last_time = i64::MIN;
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -10,8 +10,8 @@ impl AbsDiffEq for Gaussian {
|
||||
}
|
||||
|
||||
fn abs_diff_eq(&self, other: &Self, epsilon: Self::Epsilon) -> bool {
|
||||
f64::abs_diff_eq(&self.mu, &other.mu, epsilon)
|
||||
&& f64::abs_diff_eq(&self.sigma, &other.sigma, epsilon)
|
||||
f64::abs_diff_eq(&self.mu(), &other.mu(), epsilon)
|
||||
&& f64::abs_diff_eq(&self.sigma(), &other.sigma(), epsilon)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ impl RelativeEq for Gaussian {
|
||||
epsilon: Self::Epsilon,
|
||||
max_relative: Self::Epsilon,
|
||||
) -> bool {
|
||||
f64::relative_eq(&self.mu, &other.mu, epsilon, max_relative)
|
||||
&& f64::relative_eq(&self.sigma, &other.sigma, epsilon, max_relative)
|
||||
f64::relative_eq(&self.mu(), &other.mu(), epsilon, max_relative)
|
||||
&& f64::relative_eq(&self.sigma(), &other.sigma(), epsilon, max_relative)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ impl UlpsEq for Gaussian {
|
||||
}
|
||||
|
||||
fn ulps_eq(&self, other: &Self, epsilon: Self::Epsilon, max_ulps: u32) -> bool {
|
||||
f64::ulps_eq(&self.mu, &other.mu, epsilon, max_ulps)
|
||||
&& f64::ulps_eq(&self.sigma, &other.sigma, epsilon, max_ulps)
|
||||
f64::ulps_eq(&self.mu(), &other.mu(), epsilon, max_ulps)
|
||||
&& f64::ulps_eq(&self.sigma(), &other.sigma(), epsilon, max_ulps)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
use crate::{factor::VarStore, gaussian::Gaussian};
|
||||
|
||||
/// Reusable scratch buffers for `Game::likelihoods`.
|
||||
///
|
||||
/// A `TimeSlice` owns one arena; all events in the slice share it across
|
||||
/// the convergence iterations. All Vecs are cleared (not dropped) on
|
||||
/// `reset()` so their heap capacity is reused across games.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ScratchArena {
|
||||
pub(crate) vars: VarStore,
|
||||
pub(crate) sort_buf: Vec<usize>,
|
||||
pub(crate) inv_buf: Vec<usize>,
|
||||
pub(crate) team_prior: Vec<Gaussian>,
|
||||
pub(crate) lhood_lose: Vec<Gaussian>,
|
||||
pub(crate) lhood_win: Vec<Gaussian>,
|
||||
}
|
||||
|
||||
impl ScratchArena {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn reset(&mut self) {
|
||||
self.vars.clear();
|
||||
self.sort_buf.clear();
|
||||
self.inv_buf.clear();
|
||||
self.team_prior.clear();
|
||||
self.lhood_lose.clear();
|
||||
self.lhood_win.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{N_INF, gaussian::Gaussian};
|
||||
|
||||
#[test]
|
||||
fn reset_keeps_capacity() {
|
||||
let mut arena = ScratchArena::new();
|
||||
arena.vars.alloc(N_INF);
|
||||
arena.sort_buf.push(42);
|
||||
arena.team_prior.push(Gaussian::from_ms(0.0, 1.0));
|
||||
let var_cap = arena.vars.marginals.capacity();
|
||||
let sort_cap = arena.sort_buf.capacity();
|
||||
let prior_cap = arena.team_prior.capacity();
|
||||
arena.reset();
|
||||
assert_eq!(arena.vars.len(), 0);
|
||||
assert_eq!(arena.sort_buf.len(), 0);
|
||||
assert_eq!(arena.team_prior.len(), 0);
|
||||
assert_eq!(arena.vars.marginals.capacity(), var_cap);
|
||||
assert_eq!(arena.sort_buf.capacity(), sort_cap);
|
||||
assert_eq!(arena.team_prior.capacity(), prior_cap);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::{
|
||||
N_INF,
|
||||
drift::{ConstantDrift, Drift},
|
||||
gaussian::Gaussian,
|
||||
rating::Rating,
|
||||
time::Time,
|
||||
};
|
||||
|
||||
/// Per-history, temporal state for someone competing.
|
||||
///
|
||||
/// Renamed from `Agent` in T2; the former `.player` field is now
|
||||
/// `.rating` to match the `Player → Rating` rename.
|
||||
#[derive(Debug)]
|
||||
pub struct Competitor<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
||||
pub rating: Rating<T, D>,
|
||||
pub message: Gaussian,
|
||||
pub last_time: Option<T>,
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>> Competitor<T, D> {
|
||||
/// Compute the message received at time `now`, with drift accumulated
|
||||
/// from `self.last_time` (if any) to `now`.
|
||||
pub(crate) fn receive(&self, now: &T) -> Gaussian {
|
||||
if self.message != N_INF {
|
||||
let elapsed_variance = match &self.last_time {
|
||||
Some(last) => self.rating.drift.variance_delta(last, now),
|
||||
None => 0.0,
|
||||
};
|
||||
self.message.forget(elapsed_variance)
|
||||
} else {
|
||||
self.rating.prior
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the message using a pre-cached elapsed count (in `Time::elapsed_to` units).
|
||||
///
|
||||
/// Used in convergence sweeps where the elapsed was cached at slice-construction time
|
||||
/// and should not be recomputed from `last_time` (which may have shifted).
|
||||
pub(crate) fn receive_for_elapsed(&self, elapsed: i64) -> Gaussian {
|
||||
if self.message != N_INF {
|
||||
self.message
|
||||
.forget(self.rating.drift.variance_for_elapsed(elapsed))
|
||||
} else {
|
||||
self.rating.prior
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Competitor<i64, ConstantDrift> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
rating: Rating::default(),
|
||||
message: N_INF,
|
||||
last_time: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clean<'a, T, D, C>(competitors: C, last_time: bool)
|
||||
where
|
||||
T: Time + 'a,
|
||||
D: Drift<T> + 'a,
|
||||
C: Iterator<Item = &'a mut Competitor<T, D>>,
|
||||
{
|
||||
for c in competitors {
|
||||
c.message = N_INF;
|
||||
if last_time {
|
||||
c.last_time = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Convergence configuration and reporting.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use smallvec::SmallVec;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ConvergenceOptions {
|
||||
pub max_iter: usize,
|
||||
pub epsilon: f64,
|
||||
}
|
||||
|
||||
impl Default for ConvergenceOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_iter: crate::ITERATIONS,
|
||||
epsilon: crate::EPSILON,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-hoc summary of a `History::converge` call.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConvergenceReport {
|
||||
pub iterations: usize,
|
||||
pub final_step: (f64, f64),
|
||||
pub log_evidence: f64,
|
||||
pub converged: bool,
|
||||
pub per_iteration_time: SmallVec<[Duration; 32]>,
|
||||
pub slices_skipped: usize,
|
||||
}
|
||||
+27
-5
@@ -1,14 +1,36 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
pub trait Drift: Copy + Debug {
|
||||
fn variance_delta(&self, elapsed: i64) -> f64;
|
||||
use crate::time::Time;
|
||||
|
||||
/// Governs how much a competitor's skill can drift between two time points.
|
||||
///
|
||||
/// Generic over `T: Time` so seasonal or calendar-aware drift is expressible
|
||||
/// without going through `i64`.
|
||||
pub trait Drift<T: Time>: Copy + Debug {
|
||||
/// Variance added to the skill prior for elapsed time `from -> to`.
|
||||
///
|
||||
/// Called with `from <= to`; returning zero means no drift accumulates.
|
||||
fn variance_delta(&self, from: &T, to: &T) -> f64;
|
||||
|
||||
/// Variance added for a pre-computed elapsed count (in the same units as
|
||||
/// `T::elapsed_to`). Used where the elapsed is already cached as `i64`.
|
||||
fn variance_for_elapsed(&self, elapsed: i64) -> f64;
|
||||
}
|
||||
|
||||
/// Simple constant-per-unit-time drift.
|
||||
///
|
||||
/// For `Time = i64`: variance added is `(to - from) * gamma^2`.
|
||||
/// For `Time = Untimed`: elapsed is always 0, so drift is always 0.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ConstantDrift(pub f64);
|
||||
|
||||
impl Drift for ConstantDrift {
|
||||
fn variance_delta(&self, elapsed: i64) -> f64 {
|
||||
elapsed as f64 * self.0 * self.0
|
||||
impl<T: Time> Drift<T> for ConstantDrift {
|
||||
fn variance_delta(&self, from: &T, to: &T) -> f64 {
|
||||
let elapsed = from.elapsed_to(to).max(0) as f64;
|
||||
elapsed * self.0 * self.0
|
||||
}
|
||||
|
||||
fn variance_for_elapsed(&self, elapsed: i64) -> f64 {
|
||||
elapsed.max(0) as f64 * self.0 * self.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum InferenceError {
|
||||
/// Expected and actual lengths of some array-shaped input differ.
|
||||
MismatchedShape {
|
||||
kind: &'static str,
|
||||
expected: usize,
|
||||
got: usize,
|
||||
},
|
||||
/// A probability value is outside `[0, 1]`.
|
||||
InvalidProbability { value: f64 },
|
||||
/// Convergence exceeded `max_iter` without falling below `epsilon`.
|
||||
ConvergenceFailed {
|
||||
last_step: (f64, f64),
|
||||
iterations: usize,
|
||||
},
|
||||
/// Negative precision: a Gaussian with `pi < 0` slipped into an API call.
|
||||
NegativePrecision { pi: f64 },
|
||||
}
|
||||
|
||||
impl fmt::Display for InferenceError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MismatchedShape {
|
||||
kind,
|
||||
expected,
|
||||
got,
|
||||
} => {
|
||||
write!(f, "{kind}: expected length {expected}, got {got}")
|
||||
}
|
||||
Self::InvalidProbability { value } => {
|
||||
write!(f, "probability must be in [0, 1]; got {value}")
|
||||
}
|
||||
Self::ConvergenceFailed {
|
||||
last_step,
|
||||
iterations,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"convergence failed after {iterations} iterations; last step = {last_step:?}"
|
||||
)
|
||||
}
|
||||
Self::NegativePrecision { pi } => {
|
||||
write!(f, "precision must be non-negative; got {pi}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InferenceError {}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
//! Typed event description for bulk ingestion.
|
||||
//!
|
||||
//! `Event<T, K>` is the new public event shape (spec Section 4). Replaces
|
||||
//! the nested `Vec<Vec<Vec<Index>>>`, `Vec<Vec<f64>>`, `Vec<Vec<Vec<f64>>>`
|
||||
//! that the old `add_events_with_prior` took.
|
||||
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{gaussian::Gaussian, outcome::Outcome, time::Time};
|
||||
|
||||
/// A single match at time `time` involving some number of teams.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Event<T: Time, K> {
|
||||
pub time: T,
|
||||
pub teams: SmallVec<[Team<K>; 4]>,
|
||||
pub outcome: Outcome,
|
||||
}
|
||||
|
||||
/// A team: list of members competing together.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Team<K> {
|
||||
pub members: SmallVec<[Member<K>; 4]>,
|
||||
}
|
||||
|
||||
impl<K> Team<K> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
members: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_members<I: IntoIterator<Item = Member<K>>>(members: I) -> Self {
|
||||
Self {
|
||||
members: members.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> Default for Team<K> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// One member of a team, identified by user key `K`.
|
||||
///
|
||||
/// `weight` defaults to 1.0; a per-event `prior` can override the competitor's
|
||||
/// current skill estimate for this event only.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Member<K> {
|
||||
pub key: K,
|
||||
pub weight: f64,
|
||||
pub prior: Option<Gaussian>,
|
||||
}
|
||||
|
||||
impl<K> Member<K> {
|
||||
pub fn new(key: K) -> Self {
|
||||
Self {
|
||||
key,
|
||||
weight: 1.0,
|
||||
prior: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_weight(mut self, weight: f64) -> Self {
|
||||
self.weight = weight;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_prior(mut self, prior: Gaussian) -> Self {
|
||||
self.prior = Some(prior);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: a member is a user key with default weight 1.0 and no prior.
|
||||
impl<K> From<K> for Member<K> {
|
||||
fn from(key: K) -> Self {
|
||||
Self::new(key)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Outcome;
|
||||
|
||||
#[test]
|
||||
fn member_new_has_unit_weight_no_prior() {
|
||||
let m = Member::new("alice");
|
||||
assert_eq!(m.key, "alice");
|
||||
assert_eq!(m.weight, 1.0);
|
||||
assert!(m.prior.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_builder_methods_chain() {
|
||||
let m = Member::new("alice")
|
||||
.with_weight(0.5)
|
||||
.with_prior(Gaussian::from_ms(20.0, 5.0));
|
||||
assert_eq!(m.weight, 0.5);
|
||||
assert!(m.prior.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_from_key() {
|
||||
let m: Member<&str> = "bob".into();
|
||||
assert_eq!(m.key, "bob");
|
||||
assert_eq!(m.weight, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_with_members_collects() {
|
||||
let t: Team<&str> = Team::with_members([Member::new("a"), Member::new("b")]);
|
||||
assert_eq!(t.members.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_construction() {
|
||||
use smallvec::smallvec;
|
||||
let e: Event<i64, &str> = Event {
|
||||
time: 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a")]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
};
|
||||
assert_eq!(e.teams.len(), 2);
|
||||
assert_eq!(e.time, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{
|
||||
InferenceError, Outcome,
|
||||
drift::Drift,
|
||||
event::{Event, Member, Team},
|
||||
history::History,
|
||||
observer::Observer,
|
||||
time::Time,
|
||||
};
|
||||
|
||||
pub struct EventBuilder<'h, T, D, O, K>
|
||||
where
|
||||
T: Time,
|
||||
D: Drift<T>,
|
||||
O: Observer<T>,
|
||||
K: Eq + std::hash::Hash + Clone,
|
||||
{
|
||||
history: &'h mut History<T, D, O, K>,
|
||||
event: Event<T, K>,
|
||||
current_team_idx: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K>
|
||||
where
|
||||
T: Time,
|
||||
D: Drift<T>,
|
||||
O: Observer<T>,
|
||||
K: Eq + std::hash::Hash + Clone,
|
||||
{
|
||||
pub(crate) fn new(history: &'h mut History<T, D, O, K>, time: T) -> Self {
|
||||
Self {
|
||||
history,
|
||||
event: Event {
|
||||
time,
|
||||
teams: SmallVec::new(),
|
||||
outcome: Outcome::Ranked(SmallVec::new()),
|
||||
},
|
||||
current_team_idx: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a team by its member keys (weight 1.0 each, no prior overrides).
|
||||
pub fn team<I: IntoIterator<Item = K>>(mut self, keys: I) -> Self {
|
||||
let members: SmallVec<[Member<K>; 4]> = keys.into_iter().map(Member::new).collect();
|
||||
self.event.teams.push(Team { members });
|
||||
self.current_team_idx = Some(self.event.teams.len() - 1);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set per-member weights for the most recently added team.
|
||||
///
|
||||
/// Panics in debug builds if called before `.team(...)` or if the length
|
||||
/// doesn't match the team's member count.
|
||||
pub fn weights<I: IntoIterator<Item = f64>>(mut self, weights: I) -> Self {
|
||||
let idx = self
|
||||
.current_team_idx
|
||||
.expect(".weights(...) called before any .team(...)");
|
||||
let ws: Vec<f64> = weights.into_iter().collect();
|
||||
let team = &mut self.event.teams[idx];
|
||||
debug_assert_eq!(
|
||||
ws.len(),
|
||||
team.members.len(),
|
||||
"weights length must match team size"
|
||||
);
|
||||
for (m, w) in team.members.iter_mut().zip(ws) {
|
||||
m.weight = w;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set explicit ranks per team (length must equal number of teams).
|
||||
pub fn ranking<I: IntoIterator<Item = u32>>(mut self, ranks: I) -> Self {
|
||||
self.event.outcome = Outcome::ranking(ranks);
|
||||
self
|
||||
}
|
||||
|
||||
/// Mark team `winner_idx` as winner; others tied for last.
|
||||
pub fn winner(mut self, winner_idx: u32) -> Self {
|
||||
self.event.outcome = Outcome::winner(winner_idx, self.event.teams.len() as u32);
|
||||
self
|
||||
}
|
||||
|
||||
/// All teams tied.
|
||||
pub fn draw(mut self) -> Self {
|
||||
self.event.outcome = Outcome::draw(self.event.teams.len() as u32);
|
||||
self
|
||||
}
|
||||
|
||||
/// Commit the event to the history.
|
||||
pub fn commit(self) -> Result<(), InferenceError> {
|
||||
self.history.add_events(std::iter::once(self.event))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Factor graph machinery for within-game inference.
|
||||
|
||||
use crate::gaussian::Gaussian;
|
||||
|
||||
/// Identifier for a variable in a `VarStore`.
|
||||
///
|
||||
/// Variables hold the current Gaussian marginal and are owned by exactly one
|
||||
/// `VarStore`. `VarId` is meaningful only within its owning store.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct VarId(pub u32);
|
||||
|
||||
/// Flat storage of variable marginals.
|
||||
///
|
||||
/// Variables are allocated by `alloc()` and accessed by `VarId`. The store is
|
||||
/// reused across `Game::ranked_with_arena` calls (it lives in the `ScratchArena`); call
|
||||
/// `clear()` before reuse.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct VarStore {
|
||||
pub(crate) marginals: Vec<Gaussian>,
|
||||
}
|
||||
|
||||
impl VarStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.marginals.clear();
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.marginals.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.marginals.is_empty()
|
||||
}
|
||||
|
||||
pub fn alloc(&mut self, init: Gaussian) -> VarId {
|
||||
let id = VarId(self.marginals.len() as u32);
|
||||
self.marginals.push(init);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn get(&self, id: VarId) -> Gaussian {
|
||||
self.marginals[id.0 as usize]
|
||||
}
|
||||
|
||||
pub fn set(&mut self, id: VarId, g: Gaussian) {
|
||||
self.marginals[id.0 as usize] = g;
|
||||
}
|
||||
}
|
||||
|
||||
/// A factor in the EP graph.
|
||||
///
|
||||
/// Factors hold their own outgoing messages and propagate them by reading
|
||||
/// connected variable marginals from a `VarStore` and writing back updated
|
||||
/// marginals.
|
||||
pub trait Factor {
|
||||
/// Update outgoing messages and write back to the var store.
|
||||
///
|
||||
/// Returns the max delta `(|Δmu|, |Δsigma|)` across writes this
|
||||
/// propagation. Used by the `Schedule` to detect convergence.
|
||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64);
|
||||
|
||||
/// Optional log-evidence contribution. Default 0.0 (no contribution).
|
||||
fn log_evidence(&self, _vars: &VarStore) -> f64 {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum dispatcher for the built-in factor types.
|
||||
///
|
||||
/// Using an enum instead of `Box<dyn Factor>` keeps factor data inline and
|
||||
/// avoids virtual-call overhead in the hot inference loop.
|
||||
#[derive(Debug)]
|
||||
pub enum BuiltinFactor {
|
||||
TeamSum(team_sum::TeamSumFactor),
|
||||
RankDiff(rank_diff::RankDiffFactor),
|
||||
Trunc(trunc::TruncFactor),
|
||||
}
|
||||
|
||||
impl Factor for BuiltinFactor {
|
||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||
match self {
|
||||
Self::TeamSum(f) => f.propagate(vars),
|
||||
Self::RankDiff(f) => f.propagate(vars),
|
||||
Self::Trunc(f) => f.propagate(vars),
|
||||
}
|
||||
}
|
||||
|
||||
fn log_evidence(&self, vars: &VarStore) -> f64 {
|
||||
match self {
|
||||
Self::Trunc(f) => f.log_evidence(vars),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod rank_diff;
|
||||
pub mod team_sum;
|
||||
pub mod trunc;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::N_INF;
|
||||
|
||||
#[test]
|
||||
fn alloc_assigns_sequential_ids() {
|
||||
let mut store = VarStore::new();
|
||||
let a = store.alloc(N_INF);
|
||||
let b = store.alloc(N_INF);
|
||||
let c = store.alloc(N_INF);
|
||||
assert_eq!(a, VarId(0));
|
||||
assert_eq!(b, VarId(1));
|
||||
assert_eq!(c, VarId(2));
|
||||
assert_eq!(store.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_returns_initial_value() {
|
||||
let mut store = VarStore::new();
|
||||
let g = Gaussian::from_ms(2.5, 1.0);
|
||||
let id = store.alloc(g);
|
||||
assert_eq!(store.get(id), g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_updates_value() {
|
||||
let mut store = VarStore::new();
|
||||
let id = store.alloc(N_INF);
|
||||
let new = Gaussian::from_ms(3.0, 0.5);
|
||||
store.set(id, new);
|
||||
assert_eq!(store.get(id), new);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_resets_length_keeping_capacity() {
|
||||
let mut store = VarStore::new();
|
||||
store.alloc(N_INF);
|
||||
store.alloc(N_INF);
|
||||
let cap = store.marginals.capacity();
|
||||
store.clear();
|
||||
assert_eq!(store.len(), 0);
|
||||
assert_eq!(store.marginals.capacity(), cap);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use crate::factor::{Factor, VarId, VarStore};
|
||||
|
||||
/// Maintains the constraint `diff = team_a - team_b` between three vars.
|
||||
///
|
||||
/// On each propagation:
|
||||
/// - Reads marginals at `team_a` and `team_b` (which already incorporate any
|
||||
/// incoming messages from neighboring factors).
|
||||
/// - Computes `new_diff = team_a - team_b` (variance addition; see Gaussian::Sub).
|
||||
/// - Writes the new marginal to `diff`.
|
||||
/// - Returns the delta against the previous diff value.
|
||||
///
|
||||
/// This factor does NOT store an outgoing message; the diff variable is
|
||||
/// effectively replaced on each propagation. The TruncFactor on the same diff
|
||||
/// var holds the EP-divide message that produces the cavity.
|
||||
#[derive(Debug)]
|
||||
pub struct RankDiffFactor {
|
||||
pub team_a: VarId,
|
||||
pub team_b: VarId,
|
||||
pub diff: VarId,
|
||||
}
|
||||
|
||||
impl Factor for RankDiffFactor {
|
||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||
let a = vars.get(self.team_a);
|
||||
let b = vars.get(self.team_b);
|
||||
let new_diff = a - b;
|
||||
let old = vars.get(self.diff);
|
||||
vars.set(self.diff, new_diff);
|
||||
old.delta(new_diff)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{N_INF, gaussian::Gaussian};
|
||||
|
||||
#[test]
|
||||
fn diff_of_two_known_gaussians() {
|
||||
let mut vars = VarStore::new();
|
||||
let team_a = vars.alloc(Gaussian::from_ms(25.0, 3.0));
|
||||
let team_b = vars.alloc(Gaussian::from_ms(20.0, 4.0));
|
||||
let diff = vars.alloc(N_INF);
|
||||
|
||||
let mut f = RankDiffFactor {
|
||||
team_a,
|
||||
team_b,
|
||||
diff,
|
||||
};
|
||||
f.propagate(&mut vars);
|
||||
|
||||
let result = vars.get(diff);
|
||||
// mu = 25 - 20 = 5; var = 9 + 16 = 25; sigma = 5
|
||||
assert!((result.mu() - 5.0).abs() < 1e-12);
|
||||
assert!((result.sigma() - 5.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_zero_on_repeat() {
|
||||
let mut vars = VarStore::new();
|
||||
let team_a = vars.alloc(Gaussian::from_ms(10.0, 2.0));
|
||||
let team_b = vars.alloc(Gaussian::from_ms(8.0, 1.0));
|
||||
let diff = vars.alloc(N_INF);
|
||||
|
||||
let mut f = RankDiffFactor {
|
||||
team_a,
|
||||
team_b,
|
||||
diff,
|
||||
};
|
||||
f.propagate(&mut vars);
|
||||
let (dmu, dsig) = f.propagate(&mut vars);
|
||||
assert!(dmu < 1e-12);
|
||||
assert!(dsig < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_reflects_team_change() {
|
||||
let mut vars = VarStore::new();
|
||||
let team_a = vars.alloc(Gaussian::from_ms(10.0, 1.0));
|
||||
let team_b = vars.alloc(Gaussian::from_ms(0.0, 1.0));
|
||||
let diff = vars.alloc(N_INF);
|
||||
|
||||
let mut f = RankDiffFactor {
|
||||
team_a,
|
||||
team_b,
|
||||
diff,
|
||||
};
|
||||
f.propagate(&mut vars);
|
||||
|
||||
// change team_a, repropagate; delta should be positive
|
||||
vars.set(team_a, Gaussian::from_ms(15.0, 1.0));
|
||||
let (dmu, _dsig) = f.propagate(&mut vars);
|
||||
assert!(dmu > 4.0, "expected ~5 delta, got {}", dmu);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use crate::{
|
||||
N00,
|
||||
factor::{Factor, VarId, VarStore},
|
||||
gaussian::Gaussian,
|
||||
};
|
||||
|
||||
/// Computes the weighted sum of player performances into a team-perf var.
|
||||
///
|
||||
/// Inputs are pre-computed player performance Gaussians (i.e., rating priors
|
||||
/// already with beta² noise added via `Rating::performance()`). The factor
|
||||
/// runs once per game and writes the weighted sum to the output var.
|
||||
#[derive(Debug)]
|
||||
pub struct TeamSumFactor {
|
||||
pub inputs: Vec<(Gaussian, f64)>,
|
||||
pub out: VarId,
|
||||
}
|
||||
|
||||
impl Factor for TeamSumFactor {
|
||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||
let perf = self.inputs.iter().fold(N00, |acc, (g, w)| acc + (*g * *w));
|
||||
let old = vars.get(self.out);
|
||||
vars.set(self.out, perf);
|
||||
old.delta(perf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::N_INF;
|
||||
|
||||
#[test]
|
||||
fn single_player_unit_weight() {
|
||||
let mut vars = VarStore::new();
|
||||
let out = vars.alloc(N_INF);
|
||||
let g = Gaussian::from_ms(25.0, 5.0);
|
||||
let mut f = TeamSumFactor {
|
||||
inputs: vec![(g, 1.0)],
|
||||
out,
|
||||
};
|
||||
|
||||
f.propagate(&mut vars);
|
||||
let result = vars.get(out);
|
||||
assert!((result.mu() - 25.0).abs() < 1e-12);
|
||||
assert!((result.sigma() - 5.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_players_summed() {
|
||||
let mut vars = VarStore::new();
|
||||
let out = vars.alloc(N_INF);
|
||||
let g1 = Gaussian::from_ms(20.0, 3.0);
|
||||
let g2 = Gaussian::from_ms(30.0, 4.0);
|
||||
let mut f = TeamSumFactor {
|
||||
inputs: vec![(g1, 1.0), (g2, 1.0)],
|
||||
out,
|
||||
};
|
||||
|
||||
f.propagate(&mut vars);
|
||||
let result = vars.get(out);
|
||||
// sum: mu = 20 + 30 = 50, var = 9 + 16 = 25, sigma = 5
|
||||
assert!((result.mu() - 50.0).abs() < 1e-12);
|
||||
assert!((result.sigma() - 5.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighted_inputs() {
|
||||
let mut vars = VarStore::new();
|
||||
let out = vars.alloc(N_INF);
|
||||
let g = Gaussian::from_ms(10.0, 2.0);
|
||||
let mut f = TeamSumFactor {
|
||||
inputs: vec![(g, 2.0)],
|
||||
out,
|
||||
};
|
||||
|
||||
f.propagate(&mut vars);
|
||||
let result = vars.get(out);
|
||||
// g * 2.0: mu = 10*2 = 20, sigma = 2*2 = 4
|
||||
assert!((result.mu() - 20.0).abs() < 1e-12);
|
||||
assert!((result.sigma() - 4.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_is_zero_on_repeat_propagate() {
|
||||
let mut vars = VarStore::new();
|
||||
let out = vars.alloc(N_INF);
|
||||
let g = Gaussian::from_ms(5.0, 1.0);
|
||||
let mut f = TeamSumFactor {
|
||||
inputs: vec![(g, 1.0)],
|
||||
out,
|
||||
};
|
||||
|
||||
f.propagate(&mut vars);
|
||||
let (dmu, dsig) = f.propagate(&mut vars);
|
||||
assert!(dmu < 1e-12, "expected ~0 delta on repeat, got {}", dmu);
|
||||
assert!(dsig < 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use crate::{
|
||||
N_INF, approx, cdf,
|
||||
factor::{Factor, VarId, VarStore},
|
||||
gaussian::Gaussian,
|
||||
};
|
||||
|
||||
/// EP truncation factor on a diff variable.
|
||||
///
|
||||
/// Implements the rectified-Gaussian approximation that turns a diff
|
||||
/// distribution into a "this team rank-beats that team" or "tied" likelihood.
|
||||
/// Stores its outgoing message to the diff variable so the cavity computation
|
||||
/// produces the correct EP message on each propagation.
|
||||
#[derive(Debug)]
|
||||
pub struct TruncFactor {
|
||||
pub diff: VarId,
|
||||
pub margin: f64,
|
||||
pub tie: bool,
|
||||
/// Outgoing message to the diff variable (initial: N_INF, the EP identity).
|
||||
pub(crate) msg: Gaussian,
|
||||
/// Cached evidence (linear, not log) computed from the cavity on first propagation.
|
||||
pub(crate) evidence_cached: Option<f64>,
|
||||
}
|
||||
|
||||
impl TruncFactor {
|
||||
pub fn new(diff: VarId, margin: f64, tie: bool) -> Self {
|
||||
Self {
|
||||
diff,
|
||||
margin,
|
||||
tie,
|
||||
msg: N_INF,
|
||||
evidence_cached: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Factor for TruncFactor {
|
||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||
let marginal = vars.get(self.diff);
|
||||
// Cavity: marginal divided by our outgoing message.
|
||||
let cavity = marginal / self.msg;
|
||||
|
||||
// First-time-only: cache the evidence contribution from the cavity.
|
||||
if self.evidence_cached.is_none() {
|
||||
self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie));
|
||||
}
|
||||
|
||||
// Apply the truncation approximation to the cavity.
|
||||
let trunc = approx(cavity, self.margin, self.tie);
|
||||
|
||||
// New outgoing message such that cavity * new_msg = trunc.
|
||||
let new_msg = trunc / cavity;
|
||||
let old_msg = self.msg;
|
||||
self.msg = new_msg;
|
||||
|
||||
// Update the marginal: marginal_new = cavity * new_msg = trunc.
|
||||
vars.set(self.diff, trunc);
|
||||
|
||||
old_msg.delta(new_msg)
|
||||
}
|
||||
|
||||
fn log_evidence(&self, _vars: &VarStore) -> f64 {
|
||||
self.evidence_cached.unwrap_or(1.0).ln()
|
||||
}
|
||||
}
|
||||
|
||||
/// P(diff > margin) for non-tie, P(|diff| < margin) for tie.
|
||||
fn cavity_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 {
|
||||
if tie {
|
||||
cdf(margin, diff.mu(), diff.sigma()) - cdf(-margin, diff.mu(), diff.sigma())
|
||||
} else {
|
||||
1.0 - cdf(margin, diff.mu(), diff.sigma())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::factor::VarStore;
|
||||
|
||||
#[test]
|
||||
fn idempotent_after_convergence() {
|
||||
// After enough iterations, propagate should return ~0 delta.
|
||||
let mut vars = VarStore::new();
|
||||
let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0));
|
||||
|
||||
let mut f = TruncFactor::new(diff, 0.0, false);
|
||||
|
||||
// Propagate many times; delta should drop toward 0.
|
||||
let mut last = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..20 {
|
||||
last = f.propagate(&mut vars);
|
||||
}
|
||||
assert!(last.0 < 1e-10, "expected converged delta, got {}", last.0);
|
||||
assert!(last.1 < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evidence_cached_on_first_propagate() {
|
||||
let mut vars = VarStore::new();
|
||||
let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0));
|
||||
|
||||
let mut f = TruncFactor::new(diff, 0.0, false);
|
||||
assert!(f.evidence_cached.is_none());
|
||||
|
||||
f.propagate(&mut vars);
|
||||
assert!(f.evidence_cached.is_some());
|
||||
let first = f.evidence_cached.unwrap();
|
||||
|
||||
// Evidence should be P(diff > 0) for diff ~ N(2, 9) ≈ 0.748
|
||||
assert!(first > 0.7);
|
||||
assert!(first < 0.8);
|
||||
|
||||
// Subsequent propagations don't change it.
|
||||
f.propagate(&mut vars);
|
||||
assert_eq!(f.evidence_cached.unwrap(), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tie_evidence_uses_two_sided() {
|
||||
let mut vars = VarStore::new();
|
||||
let diff = vars.alloc(Gaussian::from_ms(0.0, 2.0));
|
||||
|
||||
let mut f = TruncFactor::new(diff, 1.0, true);
|
||||
f.propagate(&mut vars);
|
||||
|
||||
// For diff ~ N(0, 4), tie=true with margin=1: P(-1 < diff < 1) ≈ 0.383
|
||||
let ev = f.evidence_cached.unwrap();
|
||||
assert!(ev > 0.35 && ev < 0.42);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Factor-graph public API.
|
||||
//!
|
||||
//! Power users can construct custom factor graphs via `Game::custom` (T2
|
||||
//! minimal; full ergonomics in T4) and drive them with custom `Schedule`
|
||||
//! implementations.
|
||||
|
||||
pub use crate::{
|
||||
factor::{
|
||||
BuiltinFactor, Factor, VarId, VarStore, rank_diff::RankDiffFactor, team_sum::TeamSumFactor,
|
||||
trunc::TruncFactor,
|
||||
},
|
||||
schedule::{EpsilonOrMax, Schedule, ScheduleReport},
|
||||
};
|
||||
+420
-177
@@ -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);
|
||||
|
||||
+149
-147
@@ -2,143 +2,159 @@ use std::ops;
|
||||
|
||||
use crate::{MU, N_INF, SIGMA};
|
||||
|
||||
/// A Gaussian distribution stored in natural parameters.
|
||||
///
|
||||
/// `pi = 1 / sigma^2` (precision)
|
||||
/// `tau = mu * pi` (precision-adjusted mean)
|
||||
///
|
||||
/// Multiplication and division in message passing become pure adds/subs of
|
||||
/// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and
|
||||
/// `sigma()` are accessors computed on demand.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub struct Gaussian {
|
||||
pub mu: f64,
|
||||
pub sigma: f64,
|
||||
pi: f64,
|
||||
tau: f64,
|
||||
}
|
||||
|
||||
impl Gaussian {
|
||||
/// Construct from mean and standard deviation.
|
||||
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
|
||||
Gaussian { mu, sigma }
|
||||
}
|
||||
|
||||
fn pi(&self) -> f64 {
|
||||
if self.sigma > 0.0 {
|
||||
self.sigma.powi(-2)
|
||||
if sigma == f64::INFINITY {
|
||||
Self { pi: 0.0, tau: 0.0 }
|
||||
} else if sigma == 0.0 {
|
||||
// Point mass at mu. tau = mu * pi = mu * inf.
|
||||
// For mu == 0 this is 0; for mu != 0 it is inf * mu = inf (IEEE).
|
||||
// Only N00 (mu=0, sigma=0) is used in practice.
|
||||
Self {
|
||||
pi: f64::INFINITY,
|
||||
tau: if mu == 0.0 { 0.0 } else { f64::INFINITY },
|
||||
}
|
||||
} else {
|
||||
f64::INFINITY
|
||||
let pi = 1.0 / (sigma * sigma);
|
||||
Self { pi, tau: mu * pi }
|
||||
}
|
||||
}
|
||||
|
||||
fn tau(&self) -> f64 {
|
||||
if self.sigma > 0.0 {
|
||||
self.mu * self.pi()
|
||||
/// Construct directly from natural parameters.
|
||||
#[inline]
|
||||
pub(crate) const fn from_natural(pi: f64, tau: f64) -> Self {
|
||||
Self { pi, tau }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn pi(&self) -> f64 {
|
||||
self.pi
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn tau(&self) -> f64 {
|
||||
self.tau
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn mu(&self) -> f64 {
|
||||
if self.pi == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
self.tau / self.pi
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn sigma(&self) -> f64 {
|
||||
if self.pi == 0.0 {
|
||||
f64::INFINITY
|
||||
} else if self.pi.is_infinite() {
|
||||
0.0
|
||||
} else {
|
||||
1.0 / self.pi.sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn delta(&self, m: Gaussian) -> (f64, f64) {
|
||||
((self.mu - m.mu).abs(), (self.sigma - m.sigma).abs())
|
||||
pub(crate) fn delta(&self, other: Gaussian) -> (f64, f64) {
|
||||
(
|
||||
(self.mu() - other.mu()).abs(),
|
||||
(self.sigma() - other.sigma()).abs(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn exclude(&self, m: Gaussian) -> Self {
|
||||
Self {
|
||||
mu: self.mu - m.mu,
|
||||
sigma: (self.sigma.powi(2) - m.sigma.powi(2)).sqrt(),
|
||||
pub(crate) fn exclude(&self, other: Gaussian) -> Self {
|
||||
let var = self.sigma().powi(2) - other.sigma().powi(2);
|
||||
if var <= 0.0 {
|
||||
// When sigma_self ≈ sigma_other (including ULP-level rounding differences
|
||||
// from the pi→sigma accessor round-trip), the excluded contribution is N00.
|
||||
// Computing from_ms(tiny_mu, 0.0) would give {pi:inf, tau:inf}, whose
|
||||
// mu() = inf/inf = NaN. Returning N00 is correct: when both Gaussians
|
||||
// carry the same variance, the residual is a point mass at 0.
|
||||
return Gaussian::from_ms(0.0, 0.0);
|
||||
}
|
||||
let mu = self.mu() - other.mu();
|
||||
Self::from_ms(mu, var.sqrt())
|
||||
}
|
||||
|
||||
pub(crate) fn forget(&self, variance_delta: f64) -> Self {
|
||||
Self {
|
||||
mu: self.mu,
|
||||
sigma: (self.sigma.powi(2) + variance_delta).sqrt(),
|
||||
}
|
||||
let var = self.sigma().powi(2) + variance_delta;
|
||||
Self::from_ms(self.mu(), var.sqrt())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Gaussian {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mu: MU,
|
||||
sigma: SIGMA,
|
||||
}
|
||||
Self::from_ms(MU, SIGMA)
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Add<Gaussian> for Gaussian {
|
||||
type Output = Gaussian;
|
||||
|
||||
/// Variance addition: (mu1 + mu2, sqrt(σ1² + σ2²)).
|
||||
/// Used for combining performance and noise; rare relative to mul/div.
|
||||
fn add(self, rhs: Gaussian) -> Self::Output {
|
||||
Gaussian {
|
||||
mu: self.mu + rhs.mu,
|
||||
sigma: (self.sigma.powi(2) + rhs.sigma.powi(2)).sqrt(),
|
||||
}
|
||||
let mu = self.mu() + rhs.mu();
|
||||
let var = self.sigma().powi(2) + rhs.sigma().powi(2);
|
||||
Self::from_ms(mu, var.sqrt())
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Sub<Gaussian> for Gaussian {
|
||||
type Output = Gaussian;
|
||||
|
||||
/// (mu1 - mu2, sqrt(σ1² + σ2²)). Same sigma combination as Add.
|
||||
fn sub(self, rhs: Gaussian) -> Self::Output {
|
||||
Gaussian {
|
||||
mu: self.mu - rhs.mu,
|
||||
sigma: (self.sigma.powi(2) + rhs.sigma.powi(2)).sqrt(),
|
||||
}
|
||||
let mu = self.mu() - rhs.mu();
|
||||
let var = self.sigma().powi(2) + rhs.sigma().powi(2);
|
||||
Self::from_ms(mu, var.sqrt())
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Mul<Gaussian> for Gaussian {
|
||||
type Output = Gaussian;
|
||||
|
||||
/// Factor product: nat-param add. Hot path — two f64 additions, no sqrt.
|
||||
fn mul(self, rhs: Gaussian) -> Self::Output {
|
||||
let (mu, sigma) = if self.sigma == 0.0 || rhs.sigma == 0.0 {
|
||||
let mu = self.mu / (self.sigma.powi(2) / rhs.sigma.powi(2) + 1.0)
|
||||
+ rhs.mu / (rhs.sigma.powi(2) / self.sigma.powi(2) + 1.0);
|
||||
|
||||
let sigma = (1.0 / ((1.0 / self.sigma.powi(2)) + (1.0 / rhs.sigma.powi(2)))).sqrt();
|
||||
|
||||
(mu, sigma)
|
||||
} else {
|
||||
mu_sigma(self.tau() + rhs.tau(), self.pi() + rhs.pi())
|
||||
};
|
||||
|
||||
Gaussian { mu, sigma }
|
||||
Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau)
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Mul<f64> for Gaussian {
|
||||
type Output = Gaussian;
|
||||
|
||||
fn mul(self, rhs: f64) -> Self::Output {
|
||||
if rhs.is_finite() {
|
||||
Self {
|
||||
mu: self.mu * rhs,
|
||||
sigma: self.sigma * rhs,
|
||||
}
|
||||
} else {
|
||||
N_INF
|
||||
fn mul(self, scalar: f64) -> Self::Output {
|
||||
if !scalar.is_finite() {
|
||||
return N_INF;
|
||||
}
|
||||
if scalar == 0.0 {
|
||||
// Scaling by 0 collapses to a point mass at 0 (sigma' = 0, mu' = 0).
|
||||
// This is N00, the additive identity, NOT N_INF.
|
||||
return Gaussian::from_ms(0.0, 0.0);
|
||||
}
|
||||
// sigma' = sigma * |scalar| => pi' = pi / scalar²
|
||||
// mu' = mu * scalar => tau' = tau / scalar
|
||||
Self::from_natural(self.pi / (scalar * scalar), self.tau / scalar)
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Div<Gaussian> for Gaussian {
|
||||
type Output = Gaussian;
|
||||
|
||||
/// Cavity: nat-param sub. Hot path — two f64 subtractions, no sqrt.
|
||||
fn div(self, rhs: Gaussian) -> Self::Output {
|
||||
let (mu, sigma) = if self.sigma == 0.0 || rhs.sigma == 0.0 {
|
||||
let mu = self.mu / (1.0 - self.sigma.powi(2) / rhs.sigma.powi(2))
|
||||
+ rhs.mu / (rhs.sigma.powi(2) / self.sigma.powi(2) - 1.0);
|
||||
|
||||
let sigma = (1.0 / ((1.0 / self.sigma.powi(2)) - (1.0 / rhs.sigma.powi(2)))).sqrt();
|
||||
|
||||
(mu, sigma)
|
||||
} else {
|
||||
mu_sigma(self.tau() - rhs.tau(), self.pi() - rhs.pi())
|
||||
};
|
||||
|
||||
Gaussian { mu, sigma }
|
||||
}
|
||||
}
|
||||
|
||||
fn mu_sigma(tau: f64, pi: f64) -> (f64, f64) {
|
||||
if pi > 0.0 {
|
||||
(tau / pi, (1.0 / pi).sqrt())
|
||||
} else if (pi + 1e-5) < 0.0 {
|
||||
panic!("precision should be greater than 0");
|
||||
} else {
|
||||
(0.0, f64::INFINITY)
|
||||
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,85 +164,71 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_add() {
|
||||
let n = Gaussian {
|
||||
mu: 25.0,
|
||||
sigma: 25.0 / 3.0,
|
||||
};
|
||||
|
||||
let m = Gaussian {
|
||||
mu: 0.0,
|
||||
sigma: 1.0,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
n + m,
|
||||
Gaussian {
|
||||
mu: 25.0,
|
||||
sigma: 8.393118874676116
|
||||
}
|
||||
);
|
||||
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||
let m = Gaussian::from_ms(0.0, 1.0);
|
||||
let r = n + m;
|
||||
assert!((r.mu() - 25.0).abs() < 1e-12);
|
||||
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sub() {
|
||||
let n = Gaussian {
|
||||
mu: 25.0,
|
||||
sigma: 25.0 / 3.0,
|
||||
};
|
||||
|
||||
let m = Gaussian {
|
||||
mu: 1.0,
|
||||
sigma: 1.0,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
n - m,
|
||||
Gaussian {
|
||||
mu: 24.0,
|
||||
sigma: 8.393118874676116
|
||||
}
|
||||
);
|
||||
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||
let m = Gaussian::from_ms(1.0, 1.0);
|
||||
let r = n - m;
|
||||
assert!((r.mu() - 24.0).abs() < 1e-12);
|
||||
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mul() {
|
||||
let n = Gaussian {
|
||||
mu: 25.0,
|
||||
sigma: 25.0 / 3.0,
|
||||
};
|
||||
|
||||
let m = Gaussian {
|
||||
mu: 0.0,
|
||||
sigma: 1.0,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
n * m,
|
||||
Gaussian {
|
||||
mu: 0.35488958990536273,
|
||||
sigma: 0.992876838486922
|
||||
}
|
||||
);
|
||||
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||
let m = Gaussian::from_ms(0.0, 1.0);
|
||||
let r = n * m;
|
||||
assert!((r.mu() - 0.35488958990536273).abs() < 1e-10);
|
||||
assert!((r.sigma() - 0.992876838486922).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_div() {
|
||||
let n = Gaussian {
|
||||
mu: 25.0,
|
||||
sigma: 25.0 / 3.0,
|
||||
};
|
||||
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||
let m = Gaussian::from_ms(0.0, 1.0);
|
||||
let r = m / n;
|
||||
assert!((r.mu() - (-0.3652597402597402)).abs() < 1e-10);
|
||||
assert!((r.sigma() - 1.0072787050317253).abs() < 1e-10);
|
||||
}
|
||||
|
||||
let m = Gaussian {
|
||||
mu: 0.0,
|
||||
sigma: 1.0,
|
||||
};
|
||||
#[test]
|
||||
fn test_n00_is_add_identity() {
|
||||
// N00 (sigma=0) is the additive identity for the variance-convolution Add op.
|
||||
// N_INF (sigma=inf) is the identity for the EP-product Mul op.
|
||||
let g = Gaussian::from_ms(3.0, 2.0);
|
||||
let n00 = Gaussian::from_ms(0.0, 0.0);
|
||||
let r = n00 + g;
|
||||
assert!((r.mu() - g.mu()).abs() < 1e-12);
|
||||
assert!((r.sigma() - g.sigma()).abs() < 1e-12);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
m / n,
|
||||
Gaussian {
|
||||
mu: -0.3652597402597402,
|
||||
sigma: 1.0072787050317253
|
||||
}
|
||||
);
|
||||
#[test]
|
||||
fn test_mul_is_factor_product() {
|
||||
// n * m in nat-params should be pi_n + pi_m, tau_n + tau_m
|
||||
let n = Gaussian::from_ms(2.0, 3.0);
|
||||
let m = Gaussian::from_ms(1.0, 2.0);
|
||||
let r = n * m;
|
||||
let expected_pi = n.pi() + m.pi();
|
||||
let expected_tau = n.tau() + m.tau();
|
||||
assert!((r.pi() - expected_pi).abs() < 1e-15);
|
||||
assert!((r.tau() - expected_tau).abs() < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_div_is_cavity() {
|
||||
let n = Gaussian::from_ms(2.0, 1.0);
|
||||
let m = Gaussian::from_ms(1.0, 2.0);
|
||||
let r = n / m;
|
||||
let expected_pi = n.pi() - m.pi();
|
||||
let expected_tau = n.tau() - m.tau();
|
||||
assert!((r.pi() - expected_pi).abs() < 1e-15);
|
||||
assert!((r.tau() - expected_tau).abs() < 1e-15);
|
||||
}
|
||||
}
|
||||
|
||||
+905
-553
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
use std::{
|
||||
borrow::{Borrow, ToOwned},
|
||||
collections::HashMap,
|
||||
hash::Hash,
|
||||
};
|
||||
|
||||
use crate::Index;
|
||||
|
||||
/// Maps user keys to internal `Index` handles.
|
||||
///
|
||||
/// Renamed from the former `IndexMap` to avoid colliding with the `indexmap`
|
||||
/// crate. Power users can promote `&K` to `Index` via `get_or_create` and
|
||||
/// skip the lookup on subsequent hot-path calls.
|
||||
#[derive(Debug)]
|
||||
pub struct KeyTable<K>(HashMap<K, Index>);
|
||||
|
||||
impl<K> KeyTable<K>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
{
|
||||
pub fn new() -> Self {
|
||||
Self(HashMap::new())
|
||||
}
|
||||
|
||||
pub fn get<Q: ?Sized + Hash + Eq>(&self, k: &Q) -> Option<Index>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
{
|
||||
self.0.get(k).cloned()
|
||||
}
|
||||
|
||||
pub fn get_or_create<Q: ?Sized + Hash + Eq + ToOwned<Owned = K>>(&mut self, k: &Q) -> Index
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
{
|
||||
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()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> Default for KeyTable<K>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
{
|
||||
fn default() -> Self {
|
||||
KeyTable::new()
|
||||
}
|
||||
}
|
||||
+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]
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
use crate::{N_INF, gaussian::Gaussian};
|
||||
|
||||
pub(crate) struct TeamMessage {
|
||||
pub(crate) prior: Gaussian,
|
||||
pub(crate) likelihood_lose: Gaussian,
|
||||
pub(crate) likelihood_win: Gaussian,
|
||||
pub(crate) likelihood_draw: Gaussian,
|
||||
}
|
||||
|
||||
impl TeamMessage {
|
||||
/*
|
||||
pub(crate) fn p(&self) -> Gaussian {
|
||||
self.prior * self.likelihood_lose * self.likelihood_win * self.likelihood_draw
|
||||
}
|
||||
*/
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn posterior_win(&self) -> Gaussian {
|
||||
self.prior * self.likelihood_lose * self.likelihood_draw
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn posterior_lose(&self) -> Gaussian {
|
||||
self.prior * self.likelihood_win * self.likelihood_draw
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn likelihood(&self) -> Gaussian {
|
||||
self.likelihood_win * self.likelihood_lose * self.likelihood_draw
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TeamMessage {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prior: N_INF,
|
||||
likelihood_lose: N_INF,
|
||||
likelihood_win: N_INF,
|
||||
likelihood_draw: N_INF,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
pub(crate) struct DrawMessage {
|
||||
pub(crate) prior: Gaussian,
|
||||
pub(crate) prior_team: Gaussian,
|
||||
pub(crate) likelihood_lose: Gaussian,
|
||||
pub(crate) likelihood_win: Gaussian,
|
||||
}
|
||||
|
||||
impl DrawMessage {
|
||||
pub(crate) fn p(&self) -> Gaussian {
|
||||
self.prior_team * self.likelihood_lose * self.likelihood_win
|
||||
}
|
||||
|
||||
pub(crate) fn posterior_win(&self) -> Gaussian {
|
||||
self.prior_team * self.likelihood_lose
|
||||
}
|
||||
|
||||
pub(crate) fn posterior_lose(&self) -> Gaussian {
|
||||
self.prior_team * self.likelihood_win
|
||||
}
|
||||
|
||||
pub(crate) fn likelihood(&self) -> Gaussian {
|
||||
self.likelihood_win * self.likelihood_lose
|
||||
}
|
||||
}
|
||||
*/
|
||||
pub(crate) struct DiffMessage {
|
||||
pub(crate) prior: Gaussian,
|
||||
pub(crate) likelihood: Gaussian,
|
||||
}
|
||||
|
||||
impl DiffMessage {
|
||||
/*
|
||||
pub(crate) fn p(&self) -> Gaussian {
|
||||
self.prior * self.likelihood
|
||||
}
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Observer trait for progress reporting during convergence.
|
||||
//!
|
||||
//! Replaces the old `verbose: bool` + `println!` path. Callers wire in any
|
||||
//! observer that implements the trait; default methods are no-ops so users
|
||||
//! override only what they need.
|
||||
|
||||
use crate::time::Time;
|
||||
|
||||
/// Receives progress callbacks during `History::converge`.
|
||||
///
|
||||
/// All methods have default no-op implementations; implement only what's
|
||||
/// interesting. Send/Sync is NOT required in T2 (added in T3 along with
|
||||
/// Rayon support).
|
||||
pub trait Observer<T: Time> {
|
||||
/// Called after each convergence iteration across the whole history.
|
||||
fn on_iteration_end(&self, _iter: usize, _max_step: (f64, f64)) {}
|
||||
|
||||
/// Called after each time slice is processed within an iteration.
|
||||
fn on_batch_processed(&self, _time: &T, _slice_idx: usize, _n_events: usize) {}
|
||||
|
||||
/// Called once when convergence completes (or max iters is reached).
|
||||
fn on_converged(&self, _iters: usize, _final_step: (f64, f64), _converged: bool) {}
|
||||
}
|
||||
|
||||
/// ZST no-op observer; the default when none is configured.
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
pub struct NullObserver;
|
||||
|
||||
impl<T: Time> Observer<T> for NullObserver {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn null_observer_compiles_for_i64() {
|
||||
let o = NullObserver;
|
||||
<NullObserver as Observer<i64>>::on_iteration_end(&o, 1, (0.0, 0.0));
|
||||
<NullObserver as Observer<i64>>::on_converged(&o, 5, (1e-6, 1e-6), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_observer_compiles_for_untimed() {
|
||||
use crate::Untimed;
|
||||
let o = NullObserver;
|
||||
<NullObserver as Observer<Untimed>>::on_iteration_end(&o, 1, (0.0, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Outcome of a match.
|
||||
//!
|
||||
//! In T2, only `Ranked` is supported; `Scored` will be added together with
|
||||
//! `MarginFactor` in T4. The enum is `#[non_exhaustive]` so adding `Scored`
|
||||
//! is non-breaking for downstream `match` expressions.
|
||||
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// Final outcome of a match.
|
||||
///
|
||||
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
|
||||
/// teams. `ranks.len()` must equal the number of teams in the event.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Outcome {
|
||||
Ranked(SmallVec<[u32; 4]>),
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
/// `N`-team outcome where team `winner` won and everyone else tied for last.
|
||||
///
|
||||
/// Panics if `winner >= n`.
|
||||
pub fn winner(winner: u32, n: u32) -> Self {
|
||||
assert!(winner < n, "winner index {winner} out of range 0..{n}");
|
||||
let ranks: SmallVec<[u32; 4]> = (0..n).map(|i| if i == winner { 0 } else { 1 }).collect();
|
||||
Self::Ranked(ranks)
|
||||
}
|
||||
|
||||
/// All `n` teams tied.
|
||||
pub fn draw(n: u32) -> Self {
|
||||
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
|
||||
}
|
||||
|
||||
/// Explicit per-team ranking.
|
||||
pub fn ranking<I: IntoIterator<Item = u32>>(ranks: I) -> Self {
|
||||
Self::Ranked(ranks.into_iter().collect())
|
||||
}
|
||||
|
||||
pub fn team_count(&self) -> usize {
|
||||
match self {
|
||||
Self::Ranked(r) => r.len(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn as_ranks(&self) -> &[u32] {
|
||||
match self {
|
||||
Self::Ranked(r) => r,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn winner_two_teams() {
|
||||
let o = Outcome::winner(0, 2);
|
||||
assert_eq!(o.as_ranks(), &[0u32, 1]);
|
||||
assert_eq!(o.team_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winner_three_teams_second_wins() {
|
||||
let o = Outcome::winner(1, 3);
|
||||
assert_eq!(o.as_ranks(), &[1u32, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_teams() {
|
||||
let o = Outcome::draw(3);
|
||||
assert_eq!(o.as_ranks(), &[0u32, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranking_from_iter() {
|
||||
let o = Outcome::ranking([2, 0, 1]);
|
||||
assert_eq!(o.as_ranks(), &[2u32, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "winner index 2 out of range")]
|
||||
fn winner_out_of_range_panics() {
|
||||
let _ = Outcome::winner(2, 2);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
use crate::{
|
||||
BETA, GAMMA,
|
||||
drift::{ConstantDrift, Drift},
|
||||
gaussian::Gaussian,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Player<D: Drift = ConstantDrift> {
|
||||
pub(crate) prior: Gaussian,
|
||||
pub(crate) beta: f64,
|
||||
pub(crate) drift: D,
|
||||
}
|
||||
|
||||
impl<D: Drift> Player<D> {
|
||||
pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self {
|
||||
Self { prior, beta, drift }
|
||||
}
|
||||
|
||||
pub(crate) fn performance(&self) -> Gaussian {
|
||||
self.prior.forget(self.beta.powi(2))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Player<ConstantDrift> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prior: Gaussian::default(),
|
||||
beta: BETA,
|
||||
drift: ConstantDrift(GAMMA),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use crate::{
|
||||
BETA, GAMMA,
|
||||
drift::{ConstantDrift, Drift},
|
||||
gaussian::Gaussian,
|
||||
time::Time,
|
||||
};
|
||||
|
||||
/// Static rating configuration: prior skill, performance noise `beta`, drift.
|
||||
///
|
||||
/// Renamed from `Player` in T2; `Rating` better describes the data
|
||||
/// (a configuration) vs. a person (who's a `Competitor` with state).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
||||
pub(crate) prior: Gaussian,
|
||||
pub(crate) beta: f64,
|
||||
pub(crate) drift: D,
|
||||
pub(crate) _time: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>> Rating<T, D> {
|
||||
pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self {
|
||||
Self {
|
||||
prior,
|
||||
beta,
|
||||
drift,
|
||||
_time: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn performance(&self) -> Gaussian {
|
||||
self.prior.forget(self.beta.powi(2))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Rating<i64, ConstantDrift> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prior: Gaussian::default(),
|
||||
beta: BETA,
|
||||
drift: ConstantDrift(GAMMA),
|
||||
_time: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
//! Schedule trait and built-in implementations.
|
||||
//!
|
||||
//! A schedule drives factor propagation to convergence. The default
|
||||
//! `EpsilonOrMax` performs one TeamSum sweep (setup) then alternating
|
||||
//! forward/backward sweeps over the iterating factors until the max
|
||||
//! delta drops below epsilon or `max` iterations is reached.
|
||||
|
||||
use crate::factor::{BuiltinFactor, Factor, VarStore};
|
||||
|
||||
/// Result returned by a `Schedule::run` call.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ScheduleReport {
|
||||
pub iterations: usize,
|
||||
pub final_step: (f64, f64),
|
||||
pub converged: bool,
|
||||
}
|
||||
|
||||
/// Drives factor propagation to convergence.
|
||||
pub trait Schedule {
|
||||
fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport;
|
||||
}
|
||||
|
||||
/// Default schedule: sweep forward then backward until step ≤ eps or iter == max.
|
||||
///
|
||||
/// Matches the existing `Game::likelihoods` loop bit-for-bit when given the
|
||||
/// same factor layout (TeamSums first, then alternating RankDiff/Trunc pairs).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct EpsilonOrMax {
|
||||
pub eps: f64,
|
||||
pub max: usize,
|
||||
}
|
||||
|
||||
impl Default for EpsilonOrMax {
|
||||
fn default() -> Self {
|
||||
// Matches today's hard-coded tolerance and iteration cap.
|
||||
Self { eps: 1e-6, max: 10 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Schedule for EpsilonOrMax {
|
||||
fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport {
|
||||
// Partition: leading run of TeamSum factors run exactly once (setup).
|
||||
let n_setup = factors
|
||||
.iter()
|
||||
.position(|f| !matches!(f, BuiltinFactor::TeamSum(_)))
|
||||
.unwrap_or(factors.len());
|
||||
|
||||
for f in factors[..n_setup].iter_mut() {
|
||||
f.propagate(vars);
|
||||
}
|
||||
|
||||
let mut iterations = 0;
|
||||
let mut final_step = (f64::INFINITY, f64::INFINITY);
|
||||
let mut converged = false;
|
||||
|
||||
if n_setup < factors.len() {
|
||||
for _ in 0..self.max {
|
||||
let mut step = (0.0_f64, 0.0_f64);
|
||||
|
||||
// Forward sweep over iterating factors.
|
||||
for f in factors[n_setup..].iter_mut() {
|
||||
let d = f.propagate(vars);
|
||||
step.0 = step.0.max(d.0);
|
||||
step.1 = step.1.max(d.1);
|
||||
}
|
||||
|
||||
// Backward sweep.
|
||||
for f in factors[n_setup..].iter_mut().rev() {
|
||||
let d = f.propagate(vars);
|
||||
step.0 = step.0.max(d.0);
|
||||
step.1 = step.1.max(d.1);
|
||||
}
|
||||
|
||||
iterations += 1;
|
||||
final_step = step;
|
||||
|
||||
if step.0 <= self.eps && step.1 <= self.eps {
|
||||
converged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScheduleReport {
|
||||
iterations,
|
||||
final_step,
|
||||
converged,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{N_INF, factor::team_sum::TeamSumFactor, gaussian::Gaussian};
|
||||
|
||||
#[test]
|
||||
fn schedule_runs_setup_factors_once() {
|
||||
// Single TeamSum factor; schedule should propagate it exactly once and report 0 iterations.
|
||||
let mut vars = VarStore::new();
|
||||
let out = vars.alloc(N_INF);
|
||||
let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor {
|
||||
inputs: vec![(Gaussian::from_ms(5.0, 1.0), 1.0)],
|
||||
out,
|
||||
})];
|
||||
let schedule = EpsilonOrMax::default();
|
||||
let report = schedule.run(&mut factors, &mut vars);
|
||||
assert_eq!(report.iterations, 0);
|
||||
// The team-perf var should hold the sum.
|
||||
let result = vars.get(out);
|
||||
assert!((result.mu() - 5.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_marks_converged_when_no_iterating_factors() {
|
||||
// No iterating factors → 0 iterations, converged stays false (loop never ran).
|
||||
let mut vars = VarStore::new();
|
||||
let out = vars.alloc(N_INF);
|
||||
let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor {
|
||||
inputs: vec![(Gaussian::from_ms(0.0, 1.0), 1.0)],
|
||||
out,
|
||||
})];
|
||||
let report = EpsilonOrMax::default().run(&mut factors, &mut vars);
|
||||
assert_eq!(report.iterations, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod competitor_store;
|
||||
mod skill_store;
|
||||
|
||||
pub use competitor_store::CompetitorStore;
|
||||
pub(crate) use skill_store::SkillStore;
|
||||
@@ -0,0 +1,130 @@
|
||||
use crate::{Index, time_slice::Skill};
|
||||
|
||||
/// Dense Vec-backed store for per-agent skill state within a TimeSlice.
|
||||
///
|
||||
/// Indexed directly by Index.0, eliminating HashMap hashing in the inner
|
||||
/// convergence loop. Uses a parallel `present` mask so iteration skips
|
||||
/// absent slots without incurring per-slot Option overhead in the hot path.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SkillStore {
|
||||
skills: Vec<Skill>,
|
||||
present: Vec<bool>,
|
||||
n_present: usize,
|
||||
}
|
||||
|
||||
impl SkillStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn ensure_capacity(&mut self, idx: usize) {
|
||||
if idx >= self.skills.len() {
|
||||
self.skills.resize_with(idx + 1, Skill::default);
|
||||
self.present.resize(idx + 1, false);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, idx: Index, skill: Skill) {
|
||||
self.ensure_capacity(idx.0);
|
||||
if !self.present[idx.0] {
|
||||
self.n_present += 1;
|
||||
}
|
||||
self.skills[idx.0] = skill;
|
||||
self.present[idx.0] = true;
|
||||
}
|
||||
|
||||
pub fn get(&self, idx: Index) -> Option<&Skill> {
|
||||
if idx.0 < self.present.len() && self.present[idx.0] {
|
||||
Some(&self.skills[idx.0])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, idx: Index) -> Option<&mut Skill> {
|
||||
if idx.0 < self.present.len() && self.present[idx.0] {
|
||||
Some(&mut self.skills[idx.0])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn contains(&self, idx: Index) -> bool {
|
||||
idx.0 < self.present.len() && self.present[idx.0]
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.n_present
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.n_present == 0
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (Index, &Skill)> {
|
||||
self.present.iter().enumerate().filter_map(|(i, &p)| {
|
||||
if p {
|
||||
Some((Index(i), &self.skills[i]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Skill)> {
|
||||
self.skills
|
||||
.iter_mut()
|
||||
.zip(self.present.iter())
|
||||
.enumerate()
|
||||
.filter_map(|(i, (s, &p))| if p { Some((Index(i), s)) } else { None })
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> impl Iterator<Item = Index> + '_ {
|
||||
self.present
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &p)| if p { Some(Index(i)) } else { None })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn insert_then_get() {
|
||||
let mut store = SkillStore::new();
|
||||
let idx = Index(3);
|
||||
store.insert(idx, Skill::default());
|
||||
assert!(store.contains(idx));
|
||||
assert_eq!(store.len(), 1);
|
||||
assert!(store.get(idx).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_returns_none() {
|
||||
let store = SkillStore::new();
|
||||
assert!(store.get(Index(0)).is_none());
|
||||
assert!(!store.contains(Index(42)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_skips_absent_slots() {
|
||||
let mut store = SkillStore::new();
|
||||
store.insert(Index(0), Skill::default());
|
||||
store.insert(Index(5), Skill::default());
|
||||
let keys: Vec<Index> = store.keys().collect();
|
||||
assert_eq!(keys, vec![Index(0), Index(5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_insert_does_not_double_count() {
|
||||
let mut store = SkillStore::new();
|
||||
store.insert(Index(2), Skill::default());
|
||||
store.insert(Index(2), Skill::default());
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
//! Generic time axis for `History`.
|
||||
//!
|
||||
//! Users pick the `Time` type based on their domain: `Untimed` when no
|
||||
//! time axis is meaningful, `i64` for integer day/second timestamps.
|
||||
//! Additional impls can be added behind feature flags.
|
||||
|
||||
/// A timestamp on the global ordering axis.
|
||||
///
|
||||
/// Must be `Ord + Copy` so slices can sort events, and `'static` so
|
||||
/// `History` can store it by value without lifetimes.
|
||||
pub trait Time: Copy + Ord + 'static {
|
||||
/// How much time elapsed between `self` and `later`.
|
||||
///
|
||||
/// Used by `Drift<T>::variance_delta` to compute skill drift. Returning
|
||||
/// zero means no drift accumulates between the two points. Return value
|
||||
/// must be non-negative for `self <= later`.
|
||||
fn elapsed_to(&self, later: &Self) -> i64;
|
||||
}
|
||||
|
||||
/// Zero-sized type representing "no time axis."
|
||||
///
|
||||
/// Used as the default `Time` when events are unordered. Elapsed is always 0,
|
||||
/// so no drift accumulates across slices.
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Untimed;
|
||||
|
||||
impl Time for Untimed {
|
||||
fn elapsed_to(&self, _later: &Self) -> i64 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
impl Time for i64 {
|
||||
fn elapsed_to(&self, later: &Self) -> i64 {
|
||||
later - self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn untimed_elapsed_is_zero() {
|
||||
assert_eq!(Untimed.elapsed_to(&Untimed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i64_elapsed_is_difference() {
|
||||
assert_eq!(5i64.elapsed_to(&10), 5);
|
||||
assert_eq!(10i64.elapsed_to(&5), -5);
|
||||
assert_eq!(0i64.elapsed_to(&0), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
//! A single time step's worth of events.
|
||||
//!
|
||||
//! Renamed from `Batch` in T2.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
Index, N_INF, agent::Agent, drift::Drift, game::Game, gaussian::Gaussian, player::Player,
|
||||
Index, N_INF,
|
||||
arena::ScratchArena,
|
||||
drift::Drift,
|
||||
game::Game,
|
||||
gaussian::Gaussian,
|
||||
rating::Rating,
|
||||
storage::{CompetitorStore, SkillStore},
|
||||
time::Time,
|
||||
tuple_gt, tuple_max,
|
||||
};
|
||||
|
||||
@@ -39,22 +50,22 @@ struct Item {
|
||||
}
|
||||
|
||||
impl Item {
|
||||
fn within_prior<D: Drift>(
|
||||
fn within_prior<T: Time, D: Drift<T>>(
|
||||
&self,
|
||||
online: bool,
|
||||
forward: bool,
|
||||
skills: &HashMap<Index, Skill>,
|
||||
agents: &HashMap<Index, Agent<D>>,
|
||||
) -> Player<D> {
|
||||
let r = &agents[&self.agent].player;
|
||||
let skill = &skills[&self.agent];
|
||||
skills: &SkillStore,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
) -> Rating<T, D> {
|
||||
let r = &agents[self.agent].rating;
|
||||
let skill = skills.get(self.agent).unwrap();
|
||||
|
||||
if online {
|
||||
Player::new(skill.online, r.beta, r.drift)
|
||||
Rating::new(skill.online, r.beta, r.drift)
|
||||
} else if forward {
|
||||
Player::new(skill.forward, r.beta, r.drift)
|
||||
Rating::new(skill.forward, r.beta, r.drift)
|
||||
} else {
|
||||
Player::new(skill.posterior() / self.likelihood, r.beta, r.drift)
|
||||
Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,13 +91,13 @@ impl Event {
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
pub(crate) fn within_priors<D: Drift>(
|
||||
pub(crate) fn within_priors<T: Time, D: Drift<T>>(
|
||||
&self,
|
||||
online: bool,
|
||||
forward: bool,
|
||||
skills: &HashMap<Index, Skill>,
|
||||
agents: &HashMap<Index, Agent<D>>,
|
||||
) -> Vec<Vec<Player<D>>> {
|
||||
skills: &SkillStore,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
) -> Vec<Vec<Rating<T, D>>> {
|
||||
self.teams
|
||||
.iter()
|
||||
.map(|team| {
|
||||
@@ -100,29 +111,31 @@ impl Event {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Batch {
|
||||
pub struct TimeSlice<T: Time = i64> {
|
||||
pub(crate) events: Vec<Event>,
|
||||
pub(crate) skills: HashMap<Index, Skill>,
|
||||
pub(crate) time: i64,
|
||||
pub(crate) skills: SkillStore,
|
||||
pub(crate) time: T,
|
||||
p_draw: f64,
|
||||
arena: ScratchArena,
|
||||
}
|
||||
|
||||
impl Batch {
|
||||
pub fn new(time: i64, p_draw: f64) -> Self {
|
||||
impl<T: Time> TimeSlice<T> {
|
||||
pub fn new(time: T, p_draw: f64) -> Self {
|
||||
Self {
|
||||
events: Vec::new(),
|
||||
skills: HashMap::new(),
|
||||
skills: SkillStore::new(),
|
||||
time,
|
||||
p_draw,
|
||||
arena: ScratchArena::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_events<D: Drift>(
|
||||
pub fn add_events<D: Drift<T>>(
|
||||
&mut self,
|
||||
composition: Vec<Vec<Vec<Index>>>,
|
||||
results: Vec<Vec<f64>>,
|
||||
weights: Vec<Vec<Vec<f64>>>,
|
||||
agents: &HashMap<Index, Agent<D>>,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
) {
|
||||
let mut unique = Vec::with_capacity(10);
|
||||
|
||||
@@ -137,16 +150,16 @@ impl Batch {
|
||||
});
|
||||
|
||||
for idx in this_agent {
|
||||
let elapsed = compute_elapsed(agents[&idx].last_time, self.time);
|
||||
let elapsed = compute_elapsed(agents[*idx].last_time.as_ref(), &self.time);
|
||||
|
||||
if let Some(skill) = self.skills.get_mut(idx) {
|
||||
if let Some(skill) = self.skills.get_mut(*idx) {
|
||||
skill.elapsed = elapsed;
|
||||
skill.forward = agents[&idx].receive(elapsed);
|
||||
skill.forward = agents[*idx].receive(&self.time);
|
||||
} else {
|
||||
self.skills.insert(
|
||||
*idx,
|
||||
Skill {
|
||||
forward: agents[&idx].receive(elapsed),
|
||||
forward: agents[*idx].receive(&self.time),
|
||||
elapsed,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -204,23 +217,28 @@ impl Batch {
|
||||
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
|
||||
self.skills
|
||||
.iter()
|
||||
.map(|(&idx, skill)| (idx, skill.posterior()))
|
||||
.map(|(idx, skill)| (idx, skill.posterior()))
|
||||
.collect::<HashMap<_, _>>()
|
||||
}
|
||||
|
||||
pub fn iteration<D: Drift>(&mut self, from: usize, agents: &HashMap<Index, Agent<D>>) {
|
||||
pub fn iteration<D: Drift<T>>(&mut self, from: usize, agents: &CompetitorStore<T, D>) {
|
||||
for event in self.events.iter_mut().skip(from) {
|
||||
let teams = event.within_priors(false, false, &self.skills, agents);
|
||||
let result = event.outputs();
|
||||
|
||||
let g = Game::new(teams, &result, &event.weights, self.p_draw);
|
||||
let g = Game::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
&mut self.arena,
|
||||
);
|
||||
|
||||
for (t, team) in event.teams.iter_mut().enumerate() {
|
||||
for (i, item) in team.items.iter_mut().enumerate() {
|
||||
self.skills.get_mut(&item.agent).unwrap().likelihood =
|
||||
(self.skills[&item.agent].likelihood / item.likelihood)
|
||||
* g.likelihoods[t][i];
|
||||
|
||||
let old_likelihood = self.skills.get(item.agent).unwrap().likelihood;
|
||||
let new_likelihood = (old_likelihood / item.likelihood) * g.likelihoods[t][i];
|
||||
self.skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
|
||||
item.likelihood = g.likelihoods[t][i];
|
||||
}
|
||||
}
|
||||
@@ -230,7 +248,7 @@ impl Batch {
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn convergence<D: Drift>(&mut self, agents: &HashMap<Index, Agent<D>>) -> usize {
|
||||
pub(crate) fn convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
|
||||
let epsilon = 1e-6;
|
||||
let iterations = 20;
|
||||
|
||||
@@ -255,56 +273,60 @@ impl Batch {
|
||||
}
|
||||
|
||||
pub(crate) fn forward_prior_out(&self, agent: &Index) -> Gaussian {
|
||||
let skill = &self.skills[agent];
|
||||
|
||||
let skill = self.skills.get(*agent).unwrap();
|
||||
skill.forward * skill.likelihood
|
||||
}
|
||||
|
||||
pub(crate) fn backward_prior_out<D: Drift>(
|
||||
pub(crate) fn backward_prior_out<D: Drift<T>>(
|
||||
&self,
|
||||
agent: &Index,
|
||||
agents: &HashMap<Index, Agent<D>>,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
) -> Gaussian {
|
||||
let skill = &self.skills[agent];
|
||||
let skill = self.skills.get(*agent).unwrap();
|
||||
let n = skill.likelihood * skill.backward;
|
||||
|
||||
n.forget(agents[agent].player.drift.variance_delta(skill.elapsed))
|
||||
n.forget(
|
||||
agents[*agent]
|
||||
.rating
|
||||
.drift
|
||||
.variance_for_elapsed(skill.elapsed),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn new_backward_info<D: Drift>(&mut self, agents: &HashMap<Index, Agent<D>>) {
|
||||
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
||||
for (agent, skill) in self.skills.iter_mut() {
|
||||
skill.backward = agents[agent].message;
|
||||
}
|
||||
|
||||
self.iteration(0, agents);
|
||||
}
|
||||
|
||||
pub(crate) fn new_forward_info<D: Drift>(&mut self, agents: &HashMap<Index, Agent<D>>) {
|
||||
pub(crate) fn new_forward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
||||
for (agent, skill) in self.skills.iter_mut() {
|
||||
skill.forward = agents[agent].receive(skill.elapsed);
|
||||
skill.forward = agents[agent].receive_for_elapsed(skill.elapsed);
|
||||
}
|
||||
|
||||
self.iteration(0, agents);
|
||||
}
|
||||
|
||||
pub(crate) fn log_evidence<D: Drift>(
|
||||
pub(crate) fn log_evidence<D: Drift<T>>(
|
||||
&self,
|
||||
online: bool,
|
||||
targets: &[Index],
|
||||
forward: bool,
|
||||
agents: &HashMap<Index, Agent<D>>,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
) -> f64 {
|
||||
// log_evidence is infrequent; a local arena avoids needing &mut self.
|
||||
let mut arena = ScratchArena::new();
|
||||
|
||||
if targets.is_empty() {
|
||||
if online || forward {
|
||||
self.events
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(_, event)| {
|
||||
Game::new(
|
||||
.map(|event| {
|
||||
Game::ranked_with_arena(
|
||||
event.within_priors(online, forward, &self.skills, agents),
|
||||
&event.outputs(),
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
&mut arena,
|
||||
)
|
||||
.evidence
|
||||
.ln()
|
||||
@@ -325,11 +347,12 @@ impl Batch {
|
||||
.any(|item| targets.contains(&item.agent))
|
||||
})
|
||||
.map(|(_, event)| {
|
||||
Game::new(
|
||||
Game::ranked_with_arena(
|
||||
event.within_priors(online, forward, &self.skills, agents),
|
||||
&event.outputs(),
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
&mut arena,
|
||||
)
|
||||
.evidence
|
||||
.ln()
|
||||
@@ -377,14 +400,8 @@ impl Batch {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn compute_elapsed(last_time: i64, actual_time: i64) -> i64 {
|
||||
if last_time == i64::MIN {
|
||||
0
|
||||
} else if last_time == i64::MAX {
|
||||
1
|
||||
} else {
|
||||
actual_time - last_time
|
||||
}
|
||||
pub(crate) fn compute_elapsed<T: Time>(last: Option<&T>, current: &T) -> i64 {
|
||||
last.map(|l| l.elapsed_to(current).max(0)).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -392,11 +409,14 @@ mod tests {
|
||||
use approx::assert_ulps_eq;
|
||||
|
||||
use super::*;
|
||||
use crate::{IndexMap, agent::Agent, drift::ConstantDrift, player::Player};
|
||||
use crate::{
|
||||
KeyTable, competitor::Competitor, drift::ConstantDrift, rating::Rating,
|
||||
storage::CompetitorStore,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_one_event_each() {
|
||||
let mut index_map = IndexMap::new();
|
||||
let mut index_map = KeyTable::new();
|
||||
|
||||
let a = index_map.get_or_create("a");
|
||||
let b = index_map.get_or_create("b");
|
||||
@@ -405,13 +425,13 @@ mod tests {
|
||||
let e = index_map.get_or_create("e");
|
||||
let f = index_map.get_or_create("f");
|
||||
|
||||
let mut agents = HashMap::new();
|
||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||
|
||||
for agent in [a, b, c, d, e, f] {
|
||||
agents.insert(
|
||||
agent,
|
||||
Agent {
|
||||
player: Player::new(
|
||||
Competitor {
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
@@ -421,9 +441,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
let mut batch = Batch::new(0, 0.0);
|
||||
let mut time_slice = TimeSlice::new(0i64, 0.0);
|
||||
|
||||
batch.add_events(
|
||||
time_slice.add_events(
|
||||
vec![
|
||||
vec![vec![a], vec![b]],
|
||||
vec![vec![c], vec![d]],
|
||||
@@ -434,7 +454,7 @@ mod tests {
|
||||
&agents,
|
||||
);
|
||||
|
||||
let post = batch.posteriors();
|
||||
let post = time_slice.posteriors();
|
||||
|
||||
assert_ulps_eq!(
|
||||
post[&a],
|
||||
@@ -467,12 +487,12 @@ mod tests {
|
||||
epsilon = 1e-6
|
||||
);
|
||||
|
||||
assert_eq!(batch.convergence(&agents), 1);
|
||||
assert_eq!(time_slice.convergence(&agents), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_strength() {
|
||||
let mut index_map = IndexMap::new();
|
||||
let mut index_map = KeyTable::new();
|
||||
|
||||
let a = index_map.get_or_create("a");
|
||||
let b = index_map.get_or_create("b");
|
||||
@@ -481,13 +501,13 @@ mod tests {
|
||||
let e = index_map.get_or_create("e");
|
||||
let f = index_map.get_or_create("f");
|
||||
|
||||
let mut agents = HashMap::new();
|
||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||
|
||||
for agent in [a, b, c, d, e, f] {
|
||||
agents.insert(
|
||||
agent,
|
||||
Agent {
|
||||
player: Player::new(
|
||||
Competitor {
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
@@ -497,9 +517,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
let mut batch = Batch::new(0, 0.0);
|
||||
let mut time_slice = TimeSlice::new(0i64, 0.0);
|
||||
|
||||
batch.add_events(
|
||||
time_slice.add_events(
|
||||
vec![
|
||||
vec![vec![a], vec![b]],
|
||||
vec![vec![a], vec![c]],
|
||||
@@ -510,7 +530,7 @@ mod tests {
|
||||
&agents,
|
||||
);
|
||||
|
||||
let post = batch.posteriors();
|
||||
let post = time_slice.posteriors();
|
||||
|
||||
assert_ulps_eq!(
|
||||
post[&a],
|
||||
@@ -528,9 +548,9 @@ mod tests {
|
||||
epsilon = 1e-6
|
||||
);
|
||||
|
||||
assert!(batch.convergence(&agents) > 1);
|
||||
assert!(time_slice.convergence(&agents) > 1);
|
||||
|
||||
let post = batch.posteriors();
|
||||
let post = time_slice.posteriors();
|
||||
|
||||
assert_ulps_eq!(
|
||||
post[&a],
|
||||
@@ -551,7 +571,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_add_events() {
|
||||
let mut index_map = IndexMap::new();
|
||||
let mut index_map = KeyTable::new();
|
||||
|
||||
let a = index_map.get_or_create("a");
|
||||
let b = index_map.get_or_create("b");
|
||||
@@ -560,13 +580,13 @@ mod tests {
|
||||
let e = index_map.get_or_create("e");
|
||||
let f = index_map.get_or_create("f");
|
||||
|
||||
let mut agents = HashMap::new();
|
||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||
|
||||
for agent in [a, b, c, d, e, f] {
|
||||
agents.insert(
|
||||
agent,
|
||||
Agent {
|
||||
player: Player::new(
|
||||
Competitor {
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
@@ -576,9 +596,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
let mut batch = Batch::new(0, 0.0);
|
||||
let mut time_slice = TimeSlice::new(0i64, 0.0);
|
||||
|
||||
batch.add_events(
|
||||
time_slice.add_events(
|
||||
vec![
|
||||
vec![vec![a], vec![b]],
|
||||
vec![vec![a], vec![c]],
|
||||
@@ -589,9 +609,9 @@ mod tests {
|
||||
&agents,
|
||||
);
|
||||
|
||||
batch.convergence(&agents);
|
||||
time_slice.convergence(&agents);
|
||||
|
||||
let post = batch.posteriors();
|
||||
let post = time_slice.posteriors();
|
||||
|
||||
assert_ulps_eq!(
|
||||
post[&a],
|
||||
@@ -609,7 +629,7 @@ mod tests {
|
||||
epsilon = 1e-6
|
||||
);
|
||||
|
||||
batch.add_events(
|
||||
time_slice.add_events(
|
||||
vec![
|
||||
vec![vec![a], vec![b]],
|
||||
vec![vec![a], vec![c]],
|
||||
@@ -620,11 +640,11 @@ mod tests {
|
||||
&agents,
|
||||
);
|
||||
|
||||
assert_eq!(batch.events.len(), 6);
|
||||
assert_eq!(time_slice.events.len(), 6);
|
||||
|
||||
batch.convergence(&agents);
|
||||
time_slice.convergence(&agents);
|
||||
|
||||
let post = batch.posteriors();
|
||||
let post = time_slice.posteriors();
|
||||
|
||||
assert_ulps_eq!(
|
||||
post[&a],
|
||||
Reference in New Issue
Block a user