use std::cmp::Ordering; use crate::{ N_INF, N00, arena::ScratchArena, compute_margin, drift::Drift, factor::{Factor, trunc::TruncFactor}, gaussian::Gaussian, player::Player, tuple_gt, tuple_max, }; #[derive(Debug)] pub struct Game<'a, D: Drift> { teams: Vec>>, result: &'a [f64], weights: &'a [Vec], p_draw: f64, pub(crate) likelihoods: Vec>, pub(crate) evidence: f64, } impl<'a, D: Drift> Game<'a, D> { pub fn new( teams: Vec>>, result: &'a [f64], weights: &'a [Vec], p_draw: f64, arena: &mut ScratchArena, ) -> Self { debug_assert!( result.len() == teams.len(), "result must have the same length as teams" ); debug_assert!( weights .iter() .zip(teams.iter()) .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" ); 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 are teams with draw" ); let mut this = Self { teams, result, weights, p_draw, likelihoods: Vec::new(), evidence: 0.0, }; this.likelihoods(arena); this } fn likelihoods(&mut self, arena: &mut ScratchArena) { arena.reset(); let n_teams = self.teams.len(); // 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) }); // Team performance priors (TeamSumFactor logic inlined). let team_prior: Vec = arena .sort_buf .iter() .map(|&t| { self.teams[t] .iter() .zip(self.weights[t].iter()) .fold(N00, |p, (player, &w)| p + (player.performance() * w)) }) .collect(); let n_diffs = n_teams.saturating_sub(1); // One TruncFactor per adjacent sorted-team pair; each owns a diff VarId. let mut trunc: Vec = (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()) }; let vid = arena.vars.alloc(N_INF); TruncFactor::new(vid, margin, tie) }) .collect(); // Per-team messages from neighbouring RankDiff factors (replaces TeamMessage). let mut lhood_lose: Vec = vec![N_INF; n_teams]; let mut lhood_win: Vec = vec![N_INF; n_teams]; // Helpers: team marginal incorporating one side of incoming RankDiff messages. // post_win(i) = what team i presents to the diff factor on its "winning" side. // post_lose(i) = what team i presents to the diff factor on its "losing" side. macro_rules! post_win { ($i:expr) => { team_prior[$i] * lhood_lose[$i] }; } macro_rules! post_lose { ($i:expr) => { team_prior[$i] * lhood_win[$i] }; } let mut step = (f64::INFINITY, f64::INFINITY); let mut iter = 0; while tuple_gt(step, 1e-6) && iter < 10 { step = (0.0_f64, 0.0_f64); // Forward sweep: diffs 0 .. n_diffs-2 (all but the last). for e in 0..n_diffs.saturating_sub(1) { let raw = post_win!(e) - post_lose!(e + 1); // Set diff var = raw × trunc.msg so that cavity = raw. arena.vars.set(trunc[e].diff, raw * trunc[e].msg); let d = trunc[e].propagate(&mut arena.vars); step = tuple_max(step, d); let new_ll = post_win!(e) - trunc[e].msg; step = tuple_max(step, lhood_lose[e + 1].delta(new_ll)); lhood_lose[e + 1] = new_ll; } // Backward sweep: diffs n_diffs-1 .. 1 (reverse, all but the first). for e in (1..n_diffs).rev() { let raw = post_win!(e) - post_lose!(e + 1); arena.vars.set(trunc[e].diff, raw * trunc[e].msg); let d = trunc[e].propagate(&mut arena.vars); step = tuple_max(step, d); let new_lw = post_lose!(e + 1) + trunc[e].msg; step = tuple_max(step, lhood_win[e].delta(new_lw)); lhood_win[e] = new_lw; } iter += 1; } // Special case: exactly 1 diff (2-team game). The loop body is empty // for this case (both ranges are empty), so we run the factor once here. if n_diffs == 1 { let raw = post_win!(0) - post_lose!(1); arena.vars.set(trunc[0].diff, raw * trunc[0].msg); trunc[0].propagate(&mut arena.vars); } // Boundary updates: close the chain at both ends. if n_diffs > 0 { lhood_win[0] = post_lose!(1) + trunc[0].msg; lhood_lose[n_teams - 1] = post_win!(n_teams - 2) - trunc[n_diffs - 1].msg; } // 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(); // Per-team "likelihood" = product of incoming RankDiff messages. let m_t_ft: Vec = (0..n_teams) .map(|si| lhood_win[si] * lhood_lose[si]) .collect(); // Inverse permutation: inv[orig_i] = sorted_i (O(n), avoids clone + O(n²) search). let mut inv = vec![0usize; n_teams]; for (si, &orig_i) in arena.sort_buf.iter().enumerate() { inv[orig_i] = si; } self.likelihoods = self .teams .iter() .zip(self.weights.iter()) .enumerate() .map(|(orig_i, (players, weights))| { let sorted_i = inv[orig_i]; let m = m_t_ft[sorted_i]; 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::>() }) .collect::>(); } pub fn posteriors(&self) -> Vec> { self.likelihoods .iter() .zip(self.teams.iter()) .map(|(l, t)| { l.iter() .zip(t.iter()) .map(|(&l, p)| l * p.prior) .collect::>() }) .collect::>() } } #[cfg(test)] mod tests { use ::approx::assert_ulps_eq; use super::*; use crate::{ConstantDrift, GAMMA, Gaussian, N_INF, Player, arena::ScratchArena}; #[test] fn test_1vs1() { let t_a = Player::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); let t_b = Player::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, &mut ScratchArena::new(), ); let p = g.posteriors(); let a = p[0][0]; let b = p[1][0]; 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( Gaussian::from_ms(29.0, 1.0), 25.0 / 6.0, ConstantDrift(GAMMA), ); let t_b = Player::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, &mut ScratchArena::new(), ); let p = g.posteriors(); let a = p[0][0]; let b = p[1][0]; 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 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, &mut ScratchArena::new(), ); assert_eq!(g.likelihoods[0][0], N_INF); assert_eq!(g.likelihoods[1][0], N_INF); } #[test] fn test_1vs1vs1() { let teams = vec![ vec![Player::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), )], vec![Player::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), )], vec![Player::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( teams.clone(), &[1.0, 2.0, 0.0], &w, 0.0, &mut ScratchArena::new(), ); let p = g.posteriors(); let a = p[0][0]; let b = p[1][0]; assert_ulps_eq!(a, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6); 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, &mut ScratchArena::new(), ); let p = g.posteriors(); let a = p[0][0]; let b = p[1][0]; assert_ulps_eq!(a, Gaussian::from_ms(31.311358, 6.698818), epsilon = 1e-6); 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 p = g.posteriors(); let a = p[0][0]; let b = p[1][0]; let c = p[2][0]; // 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( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); let t_b = Player::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, &mut ScratchArena::new(), ); let p = g.posteriors(); let a = p[0][0]; let b = p[1][0]; 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( Gaussian::from_ms(25.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); let t_b = Player::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, &mut ScratchArena::new(), ); let p = g.posteriors(); let a = p[0][0]; let b = p[1][0]; assert_ulps_eq!(a, Gaussian::from_ms(25.736001, 2.709956), epsilon = 1e-6); assert_ulps_eq!(b, Gaussian::from_ms(28.672888, 1.916471), epsilon = 1e-6); } #[test] fn test_1vs1vs1_draw() { let t_a = Player::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); let t_b = Player::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); let t_c = Player::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( 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(); let a = p[0][0]; let b = p[1][0]; let c = p[2][0]; // 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( Gaussian::from_ms(25.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); let t_b = Player::new( Gaussian::from_ms(25.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); let t_c = Player::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( 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(); let a = p[0][0]; let b = p[1][0]; let c = p[2][0]; assert_ulps_eq!(a, Gaussian::from_ms(25.488507, 2.638208), epsilon = 1e-6); assert_ulps_eq!(b, Gaussian::from_ms(25.510671, 2.628751), epsilon = 1e-6); assert_ulps_eq!(c, Gaussian::from_ms(28.555920, 1.885689), epsilon = 1e-6); } #[test] fn test_2vs1vs2_mixed() { let t_a = vec![ Player::new( Gaussian::from_ms(12.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ), Player::new( Gaussian::from_ms(18.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ), ]; let t_b = vec![Player::new( Gaussian::from_ms(30.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), )]; let t_c = vec![ Player::new( Gaussian::from_ms(14.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ), Player::new( Gaussian::from_ms(16., 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ), ]; 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, &mut ScratchArena::new(), ); let p = g.posteriors(); assert_ulps_eq!(p[0][0], Gaussian::from_ms(13.051, 2.864), epsilon = 1e-3); assert_ulps_eq!(p[0][1], Gaussian::from_ms(19.051, 2.864), epsilon = 1e-3); assert_ulps_eq!(p[1][0], Gaussian::from_ms(29.292, 2.764), epsilon = 1e-3); assert_ulps_eq!(p[2][0], Gaussian::from_ms(13.658, 2.813), epsilon = 1e-3); assert_ulps_eq!(p[2][1], Gaussian::from_ms(15.658, 2.813), epsilon = 1e-3); } #[test] fn test_1vs1_weighted() { let w_a = vec![1.0]; let w_b = vec![2.0]; let t_a = vec![Player::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(0.0), )]; let t_b = vec![Player::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, &mut ScratchArena::new(), ); let p = g.posteriors(); assert_ulps_eq!( p[0][0], Gaussian::from_ms(30.625173, 7.765472), epsilon = 1e-6 ); assert_ulps_eq!( p[1][0], Gaussian::from_ms(13.749653, 5.733840), epsilon = 1e-6 ); let w_a = vec![1.0]; 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, &mut ScratchArena::new(), ); let p = g.posteriors(); assert_ulps_eq!( p[0][0], Gaussian::from_ms(27.630080, 7.206676), epsilon = 1e-6 ); assert_ulps_eq!( p[1][0], Gaussian::from_ms(23.158943, 7.801628), epsilon = 1e-6 ); let w_a = vec![1.6]; 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, &mut ScratchArena::new(), ); let p = g.posteriors(); assert_ulps_eq!( p[0][0], Gaussian::from_ms(26.142438, 7.573088), epsilon = 1e-6 ); assert_ulps_eq!( p[1][0], Gaussian::from_ms(24.500183, 8.193278), epsilon = 1e-6 ); 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 w = [w_a, w_b]; let g = Game::new( 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], Gaussian::from_ms(5.557067, 4.052826), epsilon = 1e-6 ); assert_ulps_eq!( p[1][0], Gaussian::from_ms(2.000000, 6.000000), epsilon = 1e-6 ); 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 w = [w_a, w_b]; let g = Game::new( 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); } #[test] fn test_2vs2_weighted() { let t_a = vec![ Player::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(0.0), ), Player::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(0.0), ), ]; let w_a = vec![0.4, 0.8]; let t_b = vec![ Player::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(0.0), ), Player::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(0.0), ), ]; 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, &mut ScratchArena::new(), ); let p = g.posteriors(); assert_ulps_eq!( p[0][0], Gaussian::from_ms(27.539023, 8.129639), epsilon = 1e-6 ); assert_ulps_eq!( p[0][1], Gaussian::from_ms(30.078046, 7.485372), epsilon = 1e-6 ); assert_ulps_eq!( p[1][0], Gaussian::from_ms(19.287197, 7.243465), epsilon = 1e-6 ); assert_ulps_eq!( p[1][1], Gaussian::from_ms(21.191465, 7.867608), epsilon = 1e-6 ); let w_a = vec![1.3, 1.5]; 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, &mut ScratchArena::new(), ); let p = g.posteriors(); assert_ulps_eq!( p[0][0], Gaussian::from_ms(25.190190, 8.220511), epsilon = 1e-6 ); assert_ulps_eq!( p[0][1], Gaussian::from_ms(25.219450, 8.182783), epsilon = 1e-6 ); assert_ulps_eq!( p[1][0], Gaussian::from_ms(24.897589, 8.300779), epsilon = 1e-6 ); assert_ulps_eq!( p[1][1], Gaussian::from_ms(24.941479, 8.322717), epsilon = 1e-6 ); let w_a = vec![1.6, 0.2]; 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, &mut ScratchArena::new(), ); let p = g.posteriors(); assert_ulps_eq!( p[0][0], Gaussian::from_ms(31.674697, 7.501180), epsilon = 1e-6 ); assert_ulps_eq!( p[0][1], Gaussian::from_ms(25.834337, 8.320970), epsilon = 1e-6 ); assert_ulps_eq!( p[1][0], Gaussian::from_ms(22.079819, 8.180607), epsilon = 1e-6 ); assert_ulps_eq!( p[1][1], Gaussian::from_ms(14.987953, 6.308469), epsilon = 1e-6 ); let w = [vec![1.0, 1.0], vec![1.0]]; let g = Game::new( vec![ t_a.clone(), vec![Player::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(0.0), )], ], &[1.0, 0.0], &w, 0.0, &mut ScratchArena::new(), ); let post_2vs1 = g.posteriors(); let w_a = vec![1.0, 1.0]; 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, &mut ScratchArena::new(), ); let p = g.posteriors(); assert_ulps_eq!(p[0][0], post_2vs1[0][0], epsilon = 1e-6); assert_ulps_eq!(p[0][1], post_2vs1[0][1], epsilon = 1e-6); assert_ulps_eq!(p[1][0], post_2vs1[1][0], epsilon = 1e-6); assert_ulps_eq!(p[1][1], t_b[1].prior, epsilon = 1e-6); } }