feat(api): add Game::ranked, one_v_one, free_for_all, custom constructors

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<T, D> — 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 12:55:26 +02:00
parent fe6f028127
commit e8c9d4ed29
6 changed files with 257 additions and 28 deletions

View File

@@ -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 {

View File

@@ -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<T, D>` owns its inputs so it can
/// be returned freely from public constructors.
#[derive(Debug)]
#[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>>>,
@@ -23,7 +88,7 @@ pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
}
impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
pub fn new(
pub(crate) fn ranked_with_arena(
teams: Vec<Vec<Rating<T, D>>>,
result: &'a [f64],
weights: &'a [Vec<f64>],
@@ -219,6 +284,68 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, 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)]
@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -226,7 +226,13 @@ impl<T: Time> TimeSlice<T> {
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<T: Time> TimeSlice<T> {
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<T: Time> TimeSlice<T> {
.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,

96
tests/game.rs Normal file
View File

@@ -0,0 +1,96 @@
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, InferenceError, Outcome, Rating,
};
type R = Rating<i64, ConstantDrift>;
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::<i64, _>::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::<i64, _>::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::<i64, _>::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::<i64, _>::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::<i64, _>::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::<i64, _>::ranked(
&[&[a], &[b]],
Outcome::winner(0, 2),
&GameOptions::default(),
)
.unwrap();
assert!(g.log_evidence().is_finite());
assert!(g.log_evidence() < 0.0);
}