From e8c9d4ed29d62c1fd2b5f9c29bd5b0f5fd37a164 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 24 Apr 2026 12:55:26 +0200 Subject: [PATCH] feat(api): add Game::ranked, one_v_one, free_for_all, custom constructors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public Game API now returns Result<_, InferenceError> on invalid input (p_draw out of range, outcome rank count mismatches team count). New types: - GameOptions { p_draw, convergence } — bundled config - OwnedGame — owned variant of Game that carries its result and weights internally (no borrow of History's slices). Returned by public constructors to avoid leaking internal borrow lifetimes. The internal Game::new is renamed Game::ranked_with_arena (pub(crate)) and keeps the borrowing-arena signature for History's hot path. All in-crate callers updated (21 call sites: 18 in game.rs tests, 2 in time_slice.rs, 1 in history.rs). Game::custom is a T2-minimal power-user escape hatch exposing raw factor + schedule plumbing. Full ergonomics in T4 (#[doc(hidden)] for now). Game::log_evidence() accessor added on both Game and OwnedGame (was previously accessible only through the pub(crate) evidence field). Co-Authored-By: Claude Sonnet 4.6 --- src/factor/mod.rs | 2 +- src/game.rs | 171 ++++++++++++++++++++++++++++++++++++++++------ src/history.rs | 2 +- src/lib.rs | 2 +- src/time_slice.rs | 12 +++- tests/game.rs | 96 ++++++++++++++++++++++++++ 6 files changed, 257 insertions(+), 28 deletions(-) create mode 100644 tests/game.rs diff --git a/src/factor/mod.rs b/src/factor/mod.rs index 6caf161..da72dbd 100644 --- a/src/factor/mod.rs +++ b/src/factor/mod.rs @@ -12,7 +12,7 @@ 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::new` calls (it lives in the `ScratchArena`); call +/// reused across `Game::ranked_with_arena` calls (it lives in the `ScratchArena`); call /// `clear()` before reuse. #[derive(Debug, Default)] pub struct VarStore { diff --git a/src/game.rs b/src/game.rs index 30f0889..16be834 100644 --- a/src/game.rs +++ b/src/game.rs @@ -12,6 +12,71 @@ use crate::{ 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` owns its inputs so it can +/// be returned freely from public constructors. +#[derive(Debug)] +#[allow(dead_code)] +pub struct OwnedGame> { + teams: Vec>>, + result: Vec, + weights: Vec>, + p_draw: f64, + pub(crate) likelihoods: Vec>, + pub(crate) evidence: f64, +} + +impl> OwnedGame { + pub(crate) fn new( + teams: Vec>>, + result: Vec, + weights: Vec>, + 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> { + 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 = crate::drift::ConstantDrift> { teams: Vec>>, @@ -23,7 +88,7 @@ pub struct Game<'a, T: Time = i64, D: Drift = crate::drift::ConstantDrift> { } impl<'a, T: Time, D: Drift> Game<'a, T, D> { - pub fn new( + pub(crate) fn ranked_with_arena( teams: Vec>>, result: &'a [f64], weights: &'a [Vec], @@ -219,6 +284,68 @@ impl<'a, T: Time, D: Drift> Game<'a, T, D> { }) .collect::>() } + + pub fn log_evidence(&self) -> f64 { + self.evidence.ln() + } +} + +impl> Game<'_, T, D> { + pub fn ranked( + teams: &[&[Rating]], + outcome: crate::Outcome, + options: &GameOptions, + ) -> Result, 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 = ranks.iter().map(|&r| max_rank - r as f64).collect(); + let teams_owned: Vec>> = teams.iter().map(|t| t.to_vec()).collect(); + let weights: Vec> = 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, + b: &Rating, + 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], + outcome: crate::Outcome, + options: &GameOptions, + ) -> Result, crate::InferenceError> { + let teams: Vec>> = players.iter().map(|p| vec![**p]).collect(); + let team_refs: Vec<&[Rating]> = teams.iter().map(|t| t.as_slice()).collect(); + Self::ranked(&team_refs, outcome, options) + } + + #[doc(hidden)] + pub fn custom( + factors: &mut [crate::factors::BuiltinFactor], + vars: &mut crate::factors::VarStore, + schedule: &S, + ) -> crate::factors::ScheduleReport { + schedule.run(factors, vars) + } } #[cfg(test)] @@ -244,7 +371,7 @@ mod tests { ); let w = [vec![1.0], vec![1.0]]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![vec![t_a], vec![t_b]], &[0.0, 1.0], &w, @@ -271,7 +398,7 @@ mod tests { ); let w = [vec![1.0], vec![1.0]]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![vec![t_a], vec![t_b]], &[0.0, 1.0], &w, @@ -290,7 +417,7 @@ mod tests { 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( + let g = Game::ranked_with_arena( vec![vec![t_a], vec![t_b]], &[0.0, 1.0], &w, @@ -323,7 +450,7 @@ mod tests { ]; let w = [vec![1.0], vec![1.0], vec![1.0]]; - let g = Game::new( + let g = Game::ranked_with_arena( teams.clone(), &[1.0, 2.0, 0.0], &w, @@ -339,7 +466,7 @@ 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( + let g = Game::ranked_with_arena( teams.clone(), &[2.0, 1.0, 0.0], &w, @@ -355,7 +482,7 @@ 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, &mut ScratchArena::new()); + 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]; @@ -382,7 +509,7 @@ mod tests { ); let w = [vec![1.0], vec![1.0]]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![vec![t_a], vec![t_b]], &[0.0, 0.0], &w, @@ -409,7 +536,7 @@ mod tests { ); let w = [vec![1.0], vec![1.0]]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![vec![t_a], vec![t_b]], &[0.0, 0.0], &w, @@ -444,7 +571,7 @@ mod tests { ); 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, @@ -480,7 +607,7 @@ mod tests { ); 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, @@ -531,7 +658,7 @@ mod tests { ]; let w = [vec![1.0, 1.0], vec![1.0], vec![1.0, 1.0]]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![t_a, t_b, t_c], &[1.0, 0.0, 0.0], &w, @@ -564,7 +691,7 @@ mod tests { )]; let w = [w_a, w_b]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, @@ -588,7 +715,7 @@ mod tests { let w_b = vec![0.7]; let w = [w_a, w_b]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, @@ -612,7 +739,7 @@ mod tests { let w_b = vec![0.7]; let w = [w_a, w_b]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![t_a, t_b], &[1.0, 0.0], &w, @@ -639,7 +766,7 @@ mod tests { 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( + let g = Game::ranked_with_arena( vec![t_a, t_b], &[1.0, 0.0], &w, @@ -666,7 +793,7 @@ mod tests { 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( + let g = Game::ranked_with_arena( vec![t_a, t_b], &[1.0, 0.0], &w, @@ -709,7 +836,7 @@ mod tests { let w_b = vec![0.9, 0.6]; let w = [w_a, w_b]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, @@ -743,7 +870,7 @@ mod tests { let w_b = vec![0.7, 0.4]; let w = [w_a, w_b]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, @@ -777,7 +904,7 @@ mod tests { let w_b = vec![0.7, 2.4]; let w = [w_a, w_b]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, @@ -808,7 +935,7 @@ 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![R::new( @@ -828,7 +955,7 @@ mod tests { let w_b = vec![1.0, 0.0]; let w = [w_a, w_b]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![t_a, t_b.clone()], &[1.0, 0.0], &w, diff --git a/src/history.rs b/src/history.rs index 7606432..b738996 100644 --- a/src/history.rs +++ b/src/history.rs @@ -773,7 +773,7 @@ mod tests { let observed = h.time_slices[1].skills.get(a).unwrap().posterior(); let w = [vec![1.0], vec![1.0]]; - let p = Game::new( + let p = Game::ranked_with_arena( h.time_slices[1].events[0].within_priors( false, false, diff --git a/src/lib.rs b/src/lib.rs index 09b7657..e6c7d41 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,7 +34,7 @@ pub use drift::{ConstantDrift, Drift}; pub use error::InferenceError; pub use event::{Event, Member, Team}; pub use event_builder::EventBuilder; -pub use game::Game; +pub use game::{Game, GameOptions, OwnedGame}; pub use gaussian::Gaussian; pub use history::History; pub use key_table::KeyTable; diff --git a/src/time_slice.rs b/src/time_slice.rs index 162398a..c1d48fb 100644 --- a/src/time_slice.rs +++ b/src/time_slice.rs @@ -226,7 +226,13 @@ impl TimeSlice { 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, &mut self.arena); + 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() { @@ -315,7 +321,7 @@ impl TimeSlice { self.events .iter() .map(|event| { - Game::new( + Game::ranked_with_arena( event.within_priors(online, forward, &self.skills, agents), &event.outputs(), &event.weights, @@ -341,7 +347,7 @@ impl TimeSlice { .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, diff --git a/tests/game.rs b/tests/game.rs new file mode 100644 index 0000000..0769436 --- /dev/null +++ b/tests/game.rs @@ -0,0 +1,96 @@ +use trueskill_tt::{ + ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, InferenceError, Outcome, Rating, +}; + +type R = Rating; + +fn default_rating() -> R { + R::new( + Gaussian::from_ms(25.0, 25.0 / 3.0), + 25.0 / 6.0, + ConstantDrift(25.0 / 300.0), + ) +} + +#[test] +fn game_ranked_1v1_golden() { + let a = default_rating(); + let b = default_rating(); + let g = Game::::ranked( + &[&[a], &[b]], + Outcome::winner(0, 2), + &GameOptions::default(), + ) + .unwrap(); + let p = g.posteriors(); + assert!(p[0][0].mu() > 25.0); + assert!(p[1][0].mu() < 25.0); + assert!((p[0][0].sigma() - p[1][0].sigma()).abs() < 1e-6); +} + +#[test] +fn game_one_v_one_shortcut() { + let a = default_rating(); + let b = default_rating(); + let (a_post, b_post) = Game::::one_v_one(&a, &b, Outcome::winner(0, 2)).unwrap(); + assert!(a_post.mu() > 25.0); + assert!(b_post.mu() < 25.0); +} + +#[test] +fn game_ranked_rejects_bad_p_draw() { + let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0)); + let err = Game::::ranked( + &[&[a], &[a]], + Outcome::winner(0, 2), + &GameOptions { + p_draw: 1.5, + convergence: ConvergenceOptions::default(), + }, + ) + .unwrap_err(); + assert!(matches!(err, InferenceError::InvalidProbability { .. })); +} + +#[test] +fn game_ranked_rejects_mismatched_ranks() { + let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0)); + let err = Game::::ranked( + &[&[a], &[a]], + Outcome::ranking([0, 1, 2]), + &GameOptions::default(), + ) + .unwrap_err(); + assert!(matches!(err, InferenceError::MismatchedShape { .. })); +} + +#[test] +fn game_free_for_all_three_players() { + let a = default_rating(); + let b = default_rating(); + let c = default_rating(); + let g = Game::::free_for_all( + &[&a, &b, &c], + Outcome::ranking([0, 1, 2]), + &GameOptions::default(), + ) + .unwrap(); + let p = g.posteriors(); + assert_eq!(p.len(), 3); + assert!(p[0][0].mu() > p[1][0].mu()); + assert!(p[1][0].mu() > p[2][0].mu()); +} + +#[test] +fn game_log_evidence_is_finite() { + let a = default_rating(); + let b = default_rating(); + let g = Game::::ranked( + &[&[a], &[b]], + Outcome::winner(0, 2), + &GameOptions::default(), + ) + .unwrap(); + assert!(g.log_evidence().is_finite()); + assert!(g.log_evidence() < 0.0); +}