use std::{borrow::Borrow, collections::HashMap, hash::Hash, marker::PhantomData}; use crate::{ BETA, GAMMA, Index, MU, P_DRAW, SIGMA, competitor::{self, Competitor}, convergence::{ConvergenceOptions, ConvergenceReport}, drift::{ConstantDrift, Drift}, error::InferenceError, gaussian::Gaussian, key_table::KeyTable, observer::{NullObserver, Observer}, predict::Prediction, rating::Rating, sort_time, storage::CompetitorStore, time::Time, time_slice::{self, EventKind, FilteredStep, TimeSlice}, tuple_gt, tuple_max, }; #[derive(Clone)] pub struct HistoryBuilder< T: Time = i64, D: Drift = ConstantDrift, O: Observer = NullObserver, K: Eq + Hash + Clone = &'static str, > { mu: f64, sigma: f64, beta: f64, drift: D, p_draw: f64, score_sigma: f64, convergence: ConvergenceOptions, observer: O, _time: PhantomData, _key: PhantomData, } impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder { pub fn mu(mut self, mu: f64) -> Self { self.mu = mu; self } pub fn sigma(mut self, sigma: f64) -> Self { self.sigma = sigma; self } pub fn beta(mut self, beta: f64) -> Self { self.beta = beta; self } pub fn drift>(self, drift: D2) -> HistoryBuilder { HistoryBuilder { drift, mu: self.mu, sigma: self.sigma, beta: self.beta, p_draw: self.p_draw, score_sigma: self.score_sigma, convergence: self.convergence, observer: self.observer, _time: self._time, _key: self._key, } } /// Probability that two evenly-matched sides draw. /// /// Must be in `[0.0, 1.0)`. A zero draw probability asserts that draws /// cannot occur, so ingesting a tied outcome then fails with /// `InferenceError::TieWithoutDrawProbability`. /// /// # Panics /// /// Panics if `p_draw` is outside `[0.0, 1.0)` or is NaN. pub fn p_draw(mut self, p_draw: f64) -> Self { assert!( (0.0..1.0).contains(&p_draw), "p_draw must be in [0.0, 1.0) (got {p_draw})" ); self.p_draw = p_draw; self } /// Default observation noise for scored outcomes. /// /// # Panics /// /// Panics if `score_sigma` is not strictly positive. pub fn score_sigma(mut self, score_sigma: f64) -> Self { assert!( score_sigma > 0.0, "score_sigma must be positive (got {score_sigma})" ); self.score_sigma = score_sigma; self } /// Convergence tolerance, iteration cap, and EP damping. /// /// # Panics /// /// Panics if `alpha` is outside `(0.0, 1.0]`, or if `epsilon` is negative /// or NaN. An `alpha` of zero would leave every EP update unapplied, so /// inference would silently return the priors. pub fn convergence(mut self, opts: ConvergenceOptions) -> Self { assert!( opts.alpha > 0.0 && opts.alpha <= 1.0, "convergence alpha must be in (0.0, 1.0] (got {})", opts.alpha ); assert!( opts.epsilon >= 0.0, "convergence epsilon must be non-negative (got {})", opts.epsilon ); self.convergence = opts; self } pub fn observer>(self, observer: O2) -> HistoryBuilder { HistoryBuilder { mu: self.mu, sigma: self.sigma, beta: self.beta, drift: self.drift, p_draw: self.p_draw, score_sigma: self.score_sigma, convergence: self.convergence, observer, _time: self._time, _key: self._key, } } pub fn build(self) -> History { History { size: 0, time_slices: Vec::new(), agents: CompetitorStore::new(), keys: KeyTable::new(), mu: self.mu, sigma: self.sigma, beta: self.beta, drift: self.drift, p_draw: self.p_draw, score_sigma: self.score_sigma, convergence: self.convergence, observer: self.observer, } } } impl Default for HistoryBuilder { fn default() -> Self { Self { mu: MU, sigma: SIGMA, beta: BETA, drift: ConstantDrift(GAMMA), p_draw: P_DRAW, score_sigma: 1.0, convergence: ConvergenceOptions::default(), observer: NullObserver, _time: PhantomData, _key: PhantomData, } } } /// Configuration a caller attached to a competitor via `Member`. /// /// Carries *what was explicitly set* rather than a merged `Rating`, so a member /// that sets only `drift_scale` does not also assert the default prior — which /// would spuriously conflict with a prior seeded on an earlier event. #[derive(Clone, Copy, Default)] pub(crate) struct CompetitorConfig { prior: Option, drift_scale: Option, } impl CompetitorConfig { fn is_empty(self) -> bool { self.prior.is_none() && self.drift_scale.is_none() } } pub struct History< T: Time = i64, D: Drift = ConstantDrift, O: Observer = NullObserver, K: Eq + Hash + Clone = &'static str, > { size: usize, pub(crate) time_slices: Vec>, pub(crate) agents: CompetitorStore, keys: KeyTable, mu: f64, sigma: f64, beta: f64, drift: D, p_draw: f64, score_sigma: f64, convergence: ConvergenceOptions, observer: O, } impl Default for History { fn default() -> Self { HistoryBuilder::default().build() } } impl History { #[must_use] pub fn builder() -> HistoryBuilder { HistoryBuilder::default() } } impl History { /// Like `builder()` but uses a custom key type `K` instead of the default `&'static str`. #[must_use] pub fn builder_with_key() -> HistoryBuilder { HistoryBuilder { mu: MU, sigma: SIGMA, beta: BETA, drift: ConstantDrift(GAMMA), p_draw: P_DRAW, score_sigma: 1.0, convergence: ConvergenceOptions::default(), observer: NullObserver, _time: PhantomData, _key: PhantomData, } } } impl, O: Observer, K: Eq + Hash + Clone> History { pub fn intern(&mut self, key: &Q) -> Index where K: Borrow, Q: Hash + Eq + ToOwned + ?Sized, { self.keys.get_or_create(key) } pub fn lookup(&self, key: &Q) -> Option where K: Borrow, Q: Hash + Eq + ToOwned + ?Sized, { self.keys.get(key) } } impl, O: Observer, K: Eq + Hash + Clone> History { fn iteration(&mut self) -> (f64, f64) { let mut step = (0.0, 0.0); if self.time_slices.is_empty() { return step; } competitor::clean(self.agents.values_mut(), false); for j in (0..self.time_slices.len() - 1).rev() { for agent in self.time_slices[j + 1].skills.keys() { self.agents.get_mut(agent).unwrap().message = Some(self.time_slices[j + 1].backward_prior_out(&agent, &self.agents)); } let old = self.time_slices[j].posteriors(); self.time_slices[j].new_backward_info(&self.agents); self.observer.on_slice_processed( &self.time_slices[j].time, j, self.time_slices[j].events.len(), ); let new = self.time_slices[j].posteriors(); step = old .iter() .fold(step, |step, (a, old)| tuple_max(step, old.delta(new[a]))); } competitor::clean(self.agents.values_mut(), false); for j in 1..self.time_slices.len() { for agent in self.time_slices[j - 1].skills.keys() { self.agents.get_mut(agent).unwrap().message = Some(self.time_slices[j - 1].forward_prior_out(&agent)); } let old = self.time_slices[j].posteriors(); self.time_slices[j].new_forward_info(&self.agents); self.observer.on_slice_processed( &self.time_slices[j].time, j, self.time_slices[j].events.len(), ); let new = self.time_slices[j].posteriors(); step = old .iter() .fold(step, |step, (a, old)| tuple_max(step, old.delta(new[a]))); } if self.time_slices.len() == 1 { let old = self.time_slices[0].posteriors(); self.time_slices[0].iteration(0, &self.agents); self.observer.on_slice_processed( &self.time_slices[0].time, 0, self.time_slices[0].events.len(), ); let new = self.time_slices[0].posteriors(); step = old .iter() .fold(step, |step, (a, old)| tuple_max(step, old.delta(new[a]))); } step } /// Number of distinct time slices in the history. #[must_use] pub fn time_slices_len(&self) -> usize { self.time_slices.len() } /// Learning curves for all competitors, keyed by their user-facing key. pub fn learning_curves(&self) -> HashMap> { #[cfg(feature = "rayon")] { use rayon::prelude::*; let per_slice: Vec> = self .time_slices .par_iter() .map(|ts| { ts.skills .iter() .map(|(idx, sk)| (idx, ts.time, sk.posterior())) .collect() }) .collect(); let mut data: HashMap> = HashMap::new(); for slice_contrib in per_slice { for (idx, t, g) in slice_contrib { if let Some(key) = self.keys.key(idx).cloned() { data.entry(key).or_default().push((t, g)); } } } data } #[cfg(not(feature = "rayon"))] { let mut data: HashMap> = HashMap::new(); for slice in &self.time_slices { for (idx, skill) in slice.skills.iter() { if let Some(key) = self.keys.key(idx).cloned() { data.entry(key) .or_default() .push((slice.time, skill.posterior())); } } } data } } /// Skill estimate at the latest time slice the competitor appears in. pub fn current_skill(&self, key: &Q) -> Option where K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { let idx = self.keys.get(key)?; self.time_slices .iter() .rev() .find_map(|ts| ts.skills.get(idx).map(|sk| sk.posterior())) } /// Learning curve for a single key: (time, posterior) pairs in time order. pub fn learning_curve(&self, key: &Q) -> Vec<(T, Gaussian)> where K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { let Some(idx) = self.keys.get(key) else { return Vec::new(); }; self.time_slices .iter() .filter_map(|ts| ts.skills.get(idx).map(|sk| (ts.time, sk.posterior()))) .collect() } /// Filtered learning curves for all competitors, keyed by user-facing key. /// /// Each point is the posterior using only events up to and including that /// time — "what we knew then". Contrast `learning_curves`, whose points /// are smoothed and so incorporate rounds played later. /// /// Runs a full forward pass per call and caches nothing. This is the /// entry point for multi-key work — see `filtered_learning_curve` for /// why calling that once per key is far more expensive. pub fn filtered_learning_curves(&self) -> HashMap> { let mut data: HashMap> = HashMap::new(); for (time, step) in self.filtered_pass() { for (agent, posterior) in step.posteriors { if let Some(key) = self.keys.key(agent).cloned() { data.entry(key).or_default().push((time, posterior)); } } } data } /// Filtered learning curve for a single key: (time, posterior) pairs in /// time order. /// /// Despite mirroring `learning_curve`'s signature, this is not the cheap /// per-key lookup that method is: it runs a full forward pass, O(events), /// discarding every posterior but the requested key's. N keys fetched /// this way costs O(N * events); use `filtered_learning_curves` for /// multi-key work instead — it computes the same pass once. pub fn filtered_learning_curve(&self, key: &Q) -> Vec<(T, Gaussian)> where K: Borrow, Q: Hash + Eq + ?Sized, { let Some(idx) = self.keys.get(key) else { return Vec::new(); }; self.filtered_pass() .into_iter() .filter_map(|(time, step)| { step.posteriors .iter() .find(|(agent, _)| *agent == idx) .map(|&(_, posterior)| (time, posterior)) }) .collect() } /// Sum per-slice evidence. /// /// `forward` selects `skill.forward` as each event's prior instead of the /// cavity. That is a genuine forward-only (filtering) quantity ONLY on a /// history that has never been converged: `iteration` alternates backward /// and forward sweeps, so from the second iteration onward the likelihood /// feeding the forward message has already absorbed backward information. /// For a filtering quantity that holds after convergence, use /// `filtered_log_evidence`. pub(crate) fn log_evidence_internal(&self, forward: bool, targets: &[Index]) -> f64 { // Bound before the closure so it captures the store rather than all of // `&self`: capturing `&History` would drag `KeyTable` in and demand // `K: Sync` from every caller, which the key type need not satisfy. let agents = &self.agents; #[cfg(feature = "rayon")] { use rayon::prelude::*; let per_slice: Vec = self .time_slices .par_iter() .map(|ts| ts.log_evidence(targets, forward, agents)) .collect(); per_slice.into_iter().sum() } #[cfg(not(feature = "rayon"))] { self.time_slices .iter() .map(|ts| ts.log_evidence(targets, forward, agents)) .sum() } } /// Total log-evidence across the history. pub fn log_evidence(&self) -> f64 { self.log_evidence_internal(false, &[]) } /// Log-evidence restricted to time slices containing at least one of the /// given keys. Useful for leave-one-out cross-validation. pub fn log_evidence_for(&self, keys: &[&Q]) -> f64 where K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { let targets: Vec = keys.iter().filter_map(|k| self.keys.get(*k)).collect(); self.log_evidence_internal(false, &targets) } /// Walk the slices in time order carrying forward messages only. /// /// This is the forward half of `iteration` with the backward half never /// run. It reads `self` and mutates nothing. fn filtered_pass(&self) -> Vec<(T, FilteredStep)> { let mut messages: HashMap = HashMap::new(); let mut pass = Vec::with_capacity(self.time_slices.len()); for slice in &self.time_slices { let step = slice.filtered_step(&messages, &self.agents); for &(agent, posterior) in &step.posteriors { messages.insert(agent, posterior); } pass.push((slice.time, step)); } pass } /// Total log-evidence under forward-only (filtering) information. /// /// Each event is scored using only what was known before that *time*, /// which is the right quantity for prequential scoring and model /// comparison. Events sharing a timestamp still inform each other /// through the within-slice sweep, so within one slice this is not a /// guarantee that event A is scored independently of simultaneous event /// B. Contrast `log_evidence`, whose per-event priors carry information /// from events that had not happened yet. /// /// Runs a full forward pass per call and caches nothing. The result does /// not depend on whether `converge` has been called. #[must_use] pub fn filtered_log_evidence(&self) -> f64 { self.filtered_pass() .iter() .map(|(_, step)| step.log_evidence) .sum() } /// The configured observer. /// /// `History` takes its observer by value, so this is how a caller inspects /// one it did not keep a handle to. For an observer that accumulates /// state, prefer passing an `Arc` and keeping a clone — see the /// [`Observer`] docs. #[must_use] pub fn observer(&self) -> &O { &self.observer } /// Consume the history and return its observer. /// /// Useful for reclaiming a non-shared observer's accumulated state after /// `converge` without needing interior mutability. #[must_use] pub fn into_observer(self) -> O { self.observer } /// Every team's member skills, validated. /// /// # Errors /// /// `NotEnoughTeams`, `EmptyTeam`, or `UnknownKey`. Unknown keys are /// reported rather than dropped — silently skipping them would turn a team /// of strangers into a confident-looking prediction about nobody, which is /// the failure this replaced. fn member_skills(&self, teams: &[&[&K]]) -> Result>, InferenceError> where K: std::fmt::Debug, { if teams.len() < 2 { return Err(InferenceError::NotEnoughTeams { got: teams.len() }); } let mut gathered = Vec::with_capacity(teams.len()); for (team_idx, team) in teams.iter().enumerate() { if team.is_empty() { return Err(InferenceError::EmptyTeam { team: team_idx }); } let mut members = Vec::with_capacity(team.len()); for (member_idx, key) in team.iter().enumerate() { let unknown = InferenceError::UnknownKey { team: team_idx, member: member_idx, key: format!("{key:?}"), }; let index = self.keys.get(*key).ok_or(unknown.clone())?; members.push( self.time_slices .iter() .rev() .find_map(|ts| ts.skills.get(index).map(|s| s.posterior())) .ok_or(unknown)?, ); } gathered.push(members); } Ok(gathered) } /// Each team's performance Gaussian, and its member count. /// /// Performance is skill inflated by `beta`: the question a prediction /// answers is "how will they do today", not "how good are they". /// /// # Errors /// /// As [`History::member_skills`]. fn performances(&self, teams: &[&[&K]]) -> Result<(Vec, Vec), InferenceError> where K: std::fmt::Debug, { let skills = self.member_skills(teams)?; let performances = skills .iter() .map(|team| { team.iter() .fold(crate::N00, |acc, s| acc + s.forget(self.beta.powi(2))) }) .collect(); let sizes = skills.iter().map(Vec::len).collect(); Ok((performances, sizes)) } /// Draw margins per team pair. /// /// Inference derives the margin per rank-adjacent pair from those two /// teams' betas (`Game::likelihoods`), so prediction must too — a single /// game-wide margin would describe a different model than the one that /// will actually be fitted. fn margins(&self, sizes: &[usize]) -> crate::predict::Margins { let beta_sq = self.beta.powi(2); let p_draw = self.p_draw; crate::predict::Margins::new(sizes.len(), |i, j| { if p_draw == 0.0 { 0.0 } else { let sd = ((sizes[i] + sizes[j]) as f64 * beta_sq).sqrt(); crate::compute_margin(p_draw, sd) } }) } /// Draw-probability quality metric for the given teams (key slices). /// /// Values range roughly `[0, 1]`; 1 == perfectly matched. Supports any /// number of teams. /// /// Note this answers "is this matchup *fair*", which is not the same as /// "is this matchup *informative*" — the two coincide for two evenly /// matched teams and diverge elsewhere. /// /// # Preconditions /// /// Every key must already be known to the history — that is, must have /// appeared in an ingested event. An unknown key is `UnknownKey`, not a /// silently dropped member. If your caller cannot guarantee that, pre-filter /// with [`History::lookup`] or [`History::current_skill`]; treating the /// error as "no information" and substituting a neutral value turns a /// whole-team miss into a plausible constant, which is invisible to any /// test that does not assert on variation. /// /// # Errors /// /// `NotEnoughTeams`, `EmptyTeam`, or `UnknownKey`. pub fn predict_quality(&self, teams: &[&[&K]]) -> Result where K: std::fmt::Debug, { let groups = self.member_skills(teams)?; let group_refs: Vec<&[Gaussian]> = groups.iter().map(Vec::as_slice).collect(); Ok(crate::quality(&group_refs, self.beta)) } /// Expected information gain of running this matchup, in nats. /// /// Answers "which comparison should I run next" rather than "who will /// win": the outcome-weighted divergence between current beliefs and the /// beliefs each possible result would produce. Higher means the result /// would teach you more. /// /// Uses each competitor's current skill as the prior, and the history's /// own `beta`, `drift` and `p_draw`, so the outcomes weighted here are the /// ones that would actually be fitted if the matchup were played and /// recorded. /// /// Distinct from [`History::predict_quality`], which measures *fairness*. /// The two coincide for two evenly matched competitors and diverge /// elsewhere. See [`expected_information_gain`](crate::expected_information_gain) /// for the scale, the analytic `ln k` ceiling, and the cost. /// /// # Errors /// /// # Preconditions /// /// Every key must already be known to the history — that is, must have /// appeared in an ingested event. An unknown key is `UnknownKey`, not a /// silently dropped member. If your caller cannot guarantee that, pre-filter /// with [`History::lookup`] or [`History::current_skill`]; treating the /// error as "no information" and substituting a neutral value turns a /// whole-team miss into a plausible constant, which is invisible to any /// test that does not assert on variation. /// /// As [`History::member_skills`], plus `TooManyTeams` and anything /// inference returns for a hypothetical outcome. pub fn expected_information_gain(&self, teams: &[&[&K]]) -> Result where K: std::fmt::Debug, { let skills = self.member_skills(teams)?; let ratings: Vec>> = skills .iter() .map(|team| { team.iter() .map(|&skill| Rating::new(skill, self.beta, self.drift)) .collect() }) .collect(); let team_refs: Vec<&[Rating]> = ratings.iter().map(Vec::as_slice).collect(); crate::expected_information_gain( &team_refs, &crate::GameOptions { p_draw: self.p_draw, score_sigma: self.score_sigma, convergence: self.convergence, }, ) } /// `P(team i finishes strictly first)`, for every team. /// /// Supports any number of teams. Because performances are independent /// Gaussians, this separates into a one-dimensional integral per team — /// no multivariate orthant probability is involved — and is evaluated by /// adaptive quadrature to within the precision of the underlying normal /// CDF (~1e-8). /// /// With a zero `p_draw` these sum to one. With a positive `p_draw` the /// shortfall is the probability that the top place is shared. /// /// Cheap at any team count: cost grows as the square of the team count, /// not factorially. Prefer this to [`History::predict_outcome`] when you /// only need to know who wins. /// /// # Preconditions /// /// Every key must already be known to the history — that is, must have /// appeared in an ingested event. An unknown key is `UnknownKey`, not a /// silently dropped member. If your caller cannot guarantee that, pre-filter /// with [`History::lookup`] or [`History::current_skill`]; treating the /// error as "no information" and substituting a neutral value turns a /// whole-team miss into a plausible constant, which is invisible to any /// test that does not assert on variation. /// /// # Errors /// /// `NotEnoughTeams`, `EmptyTeam`, or `UnknownKey`. pub fn predict_win_probabilities(&self, teams: &[&[&K]]) -> Result, InferenceError> where K: std::fmt::Debug, { let (performances, sizes) = self.performances(teams)?; Ok(crate::predict::win_probabilities( &performances, &self.margins(&sizes), )) } /// The full distribution over finishing orders. /// /// Every entry is a rank vector — the shape [`crate::Outcome::ranking`] /// takes, equal ranks meaning a tie — paired with its probability. The /// entries are exhaustive and disjoint, so they sum to one; that identity /// is the strongest available check on the numerics and is worth asserting /// in tests via [`Prediction::total`]. /// /// Accounts for `p_draw`: with a positive draw probability, tied outcomes /// carry real mass rather than being silently omitted. /// /// # Cost /// /// This enumerates the outcome space, which holds `n! * 2^(n-1)` events — /// 24 at three teams, 192 at four, 1_920 at five, 23_040 at six. Each /// costs one `O(teams * grid)` pass, so this is milliseconds at three or /// four teams and seconds at six. Above /// [`MAX_TEAMS_FOR_DISTRIBUTION`](crate::MAX_PREDICTED_TEAMS) it returns /// `TooManyTeams` rather than hanging. When you need one specific ordering /// use [`History::predict_ranking`], and when you only need the winner use /// [`History::predict_win_probabilities`]; both stay cheap at any size. /// /// # Preconditions /// /// Every key must already be known to the history — that is, must have /// appeared in an ingested event. An unknown key is `UnknownKey`, not a /// silently dropped member. If your caller cannot guarantee that, pre-filter /// with [`History::lookup`] or [`History::current_skill`]; treating the /// error as "no information" and substituting a neutral value turns a /// whole-team miss into a plausible constant, which is invisible to any /// test that does not assert on variation. /// /// # Errors /// /// `NotEnoughTeams`, `EmptyTeam`, `UnknownKey`, or `TooManyTeams`. pub fn predict_outcome(&self, teams: &[&[&K]]) -> Result where K: std::fmt::Debug, { if teams.len() > crate::MAX_PREDICTED_TEAMS { return Err(InferenceError::TooManyTeams { got: teams.len(), max: crate::MAX_PREDICTED_TEAMS, }); } let (performances, sizes) = self.performances(teams)?; Ok(Prediction::new(crate::predict::outcome_distribution( &performances, &self.margins(&sizes), ))) } /// Probability of one specific finishing order. /// /// `ranks` follows [`crate::Outcome::ranking`]: lower is better, and equal /// values mean those teams tied. Teams sharing a rank may finish in any /// internal order, so this sums over those orders rather than picking one. /// /// Unlike [`History::predict_outcome`] this does not enumerate the outcome /// space, so it stays cheap at any team count — use it when you know which /// orderings you care about. /// /// # Errors /// /// # Preconditions /// /// Every key must already be known to the history — that is, must have /// appeared in an ingested event. An unknown key is `UnknownKey`, not a /// silently dropped member. If your caller cannot guarantee that, pre-filter /// with [`History::lookup`] or [`History::current_skill`]; treating the /// error as "no information" and substituting a neutral value turns a /// whole-team miss into a plausible constant, which is invisible to any /// test that does not assert on variation. /// /// `NotEnoughTeams`, `EmptyTeam`, `UnknownKey`, or `MismatchedShape` if /// `ranks` does not have one entry per team. pub fn predict_ranking(&self, teams: &[&[&K]], ranks: &[u32]) -> Result where K: std::fmt::Debug, { if ranks.len() != teams.len() { return Err(InferenceError::MismatchedShape { kind: "ranks vs teams", expected: teams.len(), got: ranks.len(), }); } let (performances, sizes) = self.performances(teams)?; Ok(crate::predict::ranking_probability( &performances, &self.margins(&sizes), ranks, )) } /// Run the full forward+backward convergence loop and return a summary. /// /// Failing to reach `epsilon` within `max_iter` is not an error: the /// returned report carries `converged: false` and the final step. /// /// # Errors /// /// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has /// broken down at that point and further iterations cannot recover, so the /// loop stops rather than reporting a NaN step as convergence. pub fn converge(&mut self) -> Result { use std::time::Instant; use smallvec::SmallVec; let opts = self.convergence; if self.time_slices.is_empty() { return Ok(ConvergenceReport { iterations: 0, final_step: (0.0, 0.0), log_evidence: 0.0, converged: true, per_iteration_time: SmallVec::new(), }); } let mut step = (f64::INFINITY, f64::INFINITY); let mut i = 0; let mut per_iter: SmallVec<[std::time::Duration; 32]> = SmallVec::new(); while tuple_gt(step, opts.epsilon) && i < opts.max_iter { let t0 = Instant::now(); step = self.iteration(); per_iter.push(t0.elapsed()); i += 1; self.observer.on_iteration_end(i, step); // A non-finite step means EP has broken down; further iterations // cannot recover, and `tuple_gt` would read NaN as converged. if !crate::step_is_finite(step) { break; } } if !crate::step_is_finite(step) { self.observer.on_converged(i, step, false); return Err(InferenceError::NonFiniteResult { context: "History::converge", step, }); } let converged = crate::step_converged(step, opts.epsilon); let log_evidence = self.log_evidence_internal(false, &[]); self.observer.on_converged(i, step, converged); Ok(ConvergenceReport { iterations: i, final_step: step, log_evidence, converged, per_iteration_time: per_iter, }) } } impl, O: Observer, K: Eq + Hash + Clone> History { pub(crate) fn add_events_with_prior( &mut self, mut composition: Vec>>, mut results: Option>>, times: Vec, mut weights: Option>>>, kinds: Vec, priors: HashMap, ) -> Result<(), InferenceError> { if results .as_ref() .is_some_and(|r| r.len() != composition.len()) { let got = results.as_ref().map_or(0, Vec::len); return Err(InferenceError::MismatchedShape { kind: "results", expected: composition.len(), got, }); } if times.len() != composition.len() { return Err(InferenceError::MismatchedShape { kind: "times", expected: composition.len(), got: times.len(), }); } if weights .as_ref() .is_some_and(|w| w.len() != composition.len()) { let got = weights.as_ref().map_or(0, Vec::len); return Err(InferenceError::MismatchedShape { kind: "weights", expected: composition.len(), got, }); } if kinds.len() != composition.len() { return Err(InferenceError::MismatchedShape { kind: "kinds", expected: composition.len(), got: kinds.len(), }); } // Chokepoint for tie validation: every ingestion route lands here, // including `record_draw`, which builds its results directly rather // than going through `Outcome`. if self.p_draw == 0.0 { for (event_results, kind) in results.iter().flatten().zip(kinds.iter()) { if !matches!(kind, EventKind::Ranked) { continue; } if let Some(teams) = crate::first_tied_output(event_results) { return Err(InferenceError::TieWithoutDrawProbability { teams }); } } } competitor::clean(self.agents.values_mut(), true); let mut this_agent = Vec::with_capacity(1024); for agent in composition.iter().flatten().flatten() { if this_agent.contains(agent) { continue; } this_agent.push(*agent); let config = priors.get(agent).copied().unwrap_or_default(); if self.agents.contains(*agent) { // Seeding a competitor the history already knows. This used to // be dropped on the floor: `remove` was only reached on the // create path, so a prior applied on a competitor's very first // event and was silently ignored ever after. if config.is_empty() { continue; } let rating = &mut self.agents.get_mut(*agent).unwrap().rating; if let Some(prior) = config.prior { rating.prior = prior; } if let Some(scale) = config.drift_scale { rating.drift_scale = scale; } let seeded = rating.prior; if config.prior.is_some() { // The prior is not re-derived every pass the way drift is. // A competitor's earliest slice has its forward message set // to the prior once, at ingestion, and `iteration` refreshes // only slices after the first — so without this, a late // prior would reach the drift terms and nothing else, which // is a subtler version of the silent drop this replaced. // // `clean` has just nulled every message, so the earliest // slice's forward is exactly the prior. for slice in &mut self.time_slices { if let Some(skill) = slice.skills.get_mut(*agent) { skill.forward = seeded; break; } } } } else { let mut rating = Rating::new( Gaussian::from_ms(self.mu, self.sigma), self.beta, self.drift, ); if let Some(prior) = config.prior { rating.prior = prior; } if let Some(scale) = config.drift_scale { rating.drift_scale = scale; } self.agents.insert( *agent, Competitor { rating, message: None, last_time: None, }, ); } } let n = composition.len(); let o = sort_time(×, false); // The chunking loop below MOVES each event's data out of `composition`, // `results` and `weights` instead of cloning it. That is only sound // because `o` is a permutation, so every index is visited exactly once // — visiting one twice would silently yield an empty event rather than // failing. debug_assert!( { let mut seen = vec![false; n]; o.iter() .all(|&idx| !std::mem::replace(&mut seen[idx], true)) }, "sort_time must return a permutation of 0..{n}" ); let mut i = 0; let mut k = 0; while i < n { let mut j = i + 1; let t = times[o[i]]; while j < n && times[o[j]] == t { j += 1; } while self.time_slices.len() > k && self.time_slices[k].time < t { let time_slice = &mut self.time_slices[k]; if k > 0 { time_slice.new_forward_info(&self.agents); } for agent_idx in &this_agent { if let Some(skill) = time_slice.skills.get_mut(*agent_idx) { skill.elapsed = time_slice::compute_elapsed( self.agents[*agent_idx].last_time.as_ref(), &time_slice.time, ); let agent = self.agents.get_mut(*agent_idx).unwrap(); agent.last_time = Some(time_slice.time); agent.message = Some(time_slice.forward_prior_out(agent_idx)); } } k += 1; } let composition = (i..j) .map(|e| std::mem::take(&mut composition[o[e]])) .collect::>(); let results = results.as_mut().map(|results| { (i..j) .map(|e| std::mem::take(&mut results[o[e]])) .collect::>() }); let weights = weights.as_mut().map(|weights| { (i..j) .map(|e| std::mem::take(&mut weights[o[e]])) .collect::>() }); let kinds_chunk: Vec = (i..j).map(|e| kinds[o[e]]).collect(); if self.time_slices.len() > k && self.time_slices[k].time == t { let time_slice = &mut self.time_slices[k]; time_slice.add_events(composition, results, weights, kinds_chunk, &self.agents); for agent_idx in time_slice.skills.keys() { let agent = self.agents.get_mut(agent_idx).unwrap(); agent.last_time = Some(t); agent.message = Some(time_slice.forward_prior_out(&agent_idx)); } k += 1; } else { let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence); time_slice.add_events(composition, results, weights, kinds_chunk, &self.agents); self.time_slices.insert(k, time_slice); let time_slice = &self.time_slices[k]; for agent_idx in time_slice.skills.keys() { let agent = self.agents.get_mut(agent_idx).unwrap(); agent.last_time = Some(t); agent.message = Some(time_slice.forward_prior_out(&agent_idx)); } k += 1; } i = j; } while self.time_slices.len() > k { let time_slice = &mut self.time_slices[k]; time_slice.new_forward_info(&self.agents); for agent_idx in &this_agent { if let Some(skill) = time_slice.skills.get_mut(*agent_idx) { skill.elapsed = time_slice::compute_elapsed( self.agents[*agent_idx].last_time.as_ref(), &time_slice.time, ); let agent = self.agents.get_mut(*agent_idx).unwrap(); agent.last_time = Some(time_slice.time); agent.message = Some(time_slice.forward_prior_out(agent_idx)); } } k += 1; } self.size += n; Ok(()) } /// Record a single two-competitor event that `winner` won. /// /// # Errors /// /// Ingests through the same path as [`History::add_events`], so it returns /// the same errors. A two-team decisive outcome cannot tie, so /// `TieWithoutDrawProbability` is not reachable here. pub fn record_winner(&mut self, winner: &Q, loser: &Q, time: T) -> Result<(), InferenceError> where K: Borrow, Q: Hash + Eq + ToOwned + ?Sized, { let w = self.intern(winner); let l = self.intern(loser); self.add_events_with_prior( vec![vec![vec![w], vec![l]]], Some(vec![vec![1.0, 0.0]]), vec![time], None, vec![EventKind::Ranked], HashMap::new(), ) } /// Record a single two-competitor event that ended level. /// /// # Errors /// /// Ingests through the same path as [`History::add_events`]. Note /// `TieWithoutDrawProbability` *is* reachable here: a draw needs a /// positive `p_draw`. pub fn record_draw(&mut self, a: &Q, b: &Q, time: T) -> Result<(), InferenceError> where K: Borrow, Q: Hash + Eq + ToOwned + ?Sized, { let a_idx = self.intern(a); let b_idx = self.intern(b); self.add_events_with_prior( vec![vec![vec![a_idx], vec![b_idx]]], Some(vec![vec![0.0, 0.0]]), vec![time], None, vec![EventKind::Ranked], HashMap::new(), ) } /// Start a fluent event builder for a single match at `time`. pub fn event(&mut self, time: T) -> crate::event_builder::EventBuilder<'_, T, D, O, K> { crate::event_builder::EventBuilder::new(self, time) } /// Bulk-ingest typed events. /// /// # Errors /// /// - `MismatchedShape` if an event's outcome does not describe the same /// number of teams the event has, or if per-member weights do not match /// the team's membership. /// - `InvalidParameter` if a per-event `score_sigma` override is not /// strictly positive. /// - `TieWithoutDrawProbability` if an event ties two teams while the /// history's `p_draw` is zero. This includes `Outcome::winner(w, n)` for /// `n >= 3`, which ties every loser. pub fn add_events(&mut self, events: I) -> Result<(), InferenceError> where I: IntoIterator>, { use crate::event::Event; let events: Vec> = events.into_iter().collect(); if events.is_empty() { return Ok(()); } let mut composition: Vec>> = Vec::with_capacity(events.len()); let mut results: Vec> = Vec::with_capacity(events.len()); let mut times: Vec = Vec::with_capacity(events.len()); let mut weights: Vec>> = Vec::with_capacity(events.len()); let mut kinds: Vec = Vec::with_capacity(events.len()); let mut priors: HashMap = HashMap::new(); for ev in events { if ev.outcome.team_count() != ev.teams.len() { return Err(InferenceError::MismatchedShape { kind: "outcome vs teams", expected: ev.teams.len(), got: ev.outcome.team_count(), }); } let mut event_comp: Vec> = Vec::with_capacity(ev.teams.len()); let mut event_weights: Vec> = Vec::with_capacity(ev.teams.len()); for team in ev.teams { let mut team_indices: Vec = Vec::with_capacity(team.members.len()); let mut team_weights: Vec = Vec::with_capacity(team.members.len()); for member in team.members { let idx = self.keys.get_or_create(&member.key); team_indices.push(idx); team_weights.push(member.weight); if let Some(scale) = member.drift_scale { // Squaring would make a negative scale behave as its // absolute value, so reject rather than silently // accept a sign the caller cannot have meant. if !scale.is_finite() || scale < 0.0 { return Err(InferenceError::InvalidParameter { name: "drift_scale", value: scale, }); } } // `prior` and `drift_scale` configure the competitor, not // the event. Both land in the same entry so a member may // set either alone. // // Events within a batch are not ordered, so a batch that // sets one field twice with different values has no // well-defined result — "last one wins" would depend on // iteration order, which `tests/ingestion_equivalence.rs` // exists to rule out. Repeating the *same* value is fine, // and is the expected shape when the configuration is a // property of the domain rather than of one event. if member.prior.is_some() || member.drift_scale.is_some() { let entry = priors.entry(idx).or_default(); if let Some(prior) = member.prior { if entry.prior.is_some_and(|held| held != prior) { return Err(InferenceError::ConflictingCompetitorConfig { competitor: idx.get(), field: "prior", }); } entry.prior = Some(prior); } if let Some(scale) = member.drift_scale { if entry.drift_scale.is_some_and(|held| held != scale) { return Err(InferenceError::ConflictingCompetitorConfig { competitor: idx.get(), field: "drift_scale", }); } entry.drift_scale = Some(scale); } } } event_comp.push(team_indices); event_weights.push(team_weights); } composition.push(event_comp); weights.push(event_weights); let event_result: Vec = match &ev.outcome { crate::Outcome::Ranked(ranks) => { let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64; kinds.push(EventKind::Ranked); ranks.iter().map(|&r| max_rank - r as f64).collect() } crate::Outcome::Scored { scores, sigma } => { let resolved = sigma.unwrap_or(self.score_sigma); if resolved <= 0.0 || resolved.is_nan() { return Err(InferenceError::InvalidParameter { name: "score_sigma", value: resolved, }); } kinds.push(EventKind::Scored { score_sigma: resolved, }); scores.to_vec() } }; results.push(event_result); times.push(ev.time); } let weights = if weights.is_empty() { None } else { Some(weights) }; self.add_events_with_prior(composition, Some(results), times, weights, kinds, priors) } } #[cfg(test)] mod tests { use approx::assert_ulps_eq; use smallvec::smallvec; use super::*; use crate::{ ConstantDrift, EPSILON, Event, Game, Gaussian, Member, Outcome, P_DRAW, Team, arena::ScratchArena, }; /// #17: a slice's footprint must be O(competitors in the slice), not /// O(largest global index it touches). The store used to be a dense /// `Vec` indexed by `Index.0`, so the same two-competitor games cost /// 20,000 slots per slice when the competitors sat at the top of a large /// roster. Measured end to end, peak RSS was 309 MB against 52 MB. #[test] fn per_slice_footprint_is_independent_of_index_magnitude() { fn total_skill_slots(high_indices: bool) -> usize { let mut h: History = History::builder_with_key().build(); for i in 0..2_000 { h.intern(&format!("k{i:05}")); } let (a, b) = if high_indices { ("k01998".to_string(), "k01999".to_string()) } else { ("k00000".to_string(), "k00001".to_string()) }; for time in 1..=20i64 { h.record_winner(&a, &b, time).unwrap(); } h.time_slices .iter() .map(|ts| ts.skills.allocated_slots()) .sum() } let low = total_skill_slots(false); let high = total_skill_slots(true); assert_eq!(low, high, "footprint must not depend on index magnitude"); // A dense store over a 2,000-key roster would allocate 20 x 2,000. assert!( high < 1_000, "20 slices of 2 competitors allocated {high} slots" ); } fn make_events_1v1( pairs: &[(&'static str, &'static str)], outcomes: &[Outcome], times: &[i64], ) -> Vec> { pairs .iter() .copied() .zip(outcomes.iter().cloned()) .zip(times.iter().copied()) .map(|(((a, b), outcome), time)| Event { time, teams: smallvec![ Team::with_members([Member::new(a)]), Team::with_members([Member::new(b)]), ], outcome, }) .collect() } #[test] fn test_init() { let mut h = History::builder() .mu(25.0) .sigma(25.0 / 3.0) .beta(25.0 / 6.0) .drift(ConstantDrift(0.15 * 25.0 / 3.0)) .build(); let events = make_events_1v1( &[("a", "b"), ("a", "c"), ("b", "c")], &[ Outcome::winner(0, 2), Outcome::winner(1, 2), Outcome::winner(0, 2), ], &[1, 2, 3], ); h.add_events(events).unwrap(); let a = h.keys.get("a").unwrap(); let b = h.keys.get("b").unwrap(); let c = h.keys.get("c").unwrap(); let p0 = h.time_slices[0].posteriors(); assert_ulps_eq!( p0[&a], Gaussian::from_ms(29.205220, 7.194481), epsilon = 1e-6 ); let observed = h.time_slices[1].skills.get(a).unwrap().forward.sigma(); let gamma: f64 = 0.15 * 25.0 / 3.0; let expected = (gamma.powi(2) + h.time_slices[0] .skills .get(a) .unwrap() .posterior() .sigma() .powi(2)) .sqrt(); assert_ulps_eq!(observed, expected, epsilon = 0.000001); let observed = h.time_slices[1].skills.get(a).unwrap().posterior(); let w = [vec![1.0], vec![1.0]]; let p = Game::ranked_with_arena( h.time_slices[1].events[0].within_priors(false, &h.time_slices[1].skills, &h.agents), &[0.0, 1.0], &w, P_DRAW, crate::ConvergenceOptions::default(), &mut ScratchArena::new(), ) .posteriors(); let expected = p[0][0]; assert_ulps_eq!(observed, expected, epsilon = 1e-6); let _ = (b, c); } #[test] fn test_one_batch() { let mut h1 = History::builder() .mu(25.0) .sigma(25.0 / 3.0) .beta(25.0 / 6.0) .drift(ConstantDrift(0.15 * 25.0 / 3.0)) .build(); let events = make_events_1v1( &[("a", "b"), ("b", "c"), ("c", "a")], &[ Outcome::winner(0, 2), Outcome::winner(0, 2), Outcome::winner(0, 2), ], &[1, 1, 1], ); h1.add_events(events).unwrap(); let a = h1.keys.get("a").unwrap(); let c = h1.keys.get("c").unwrap(); assert_ulps_eq!( h1.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(22.904409, 6.010330), epsilon = 1e-6 ); assert_ulps_eq!( h1.time_slices[0].skills.get(c).unwrap().posterior(), Gaussian::from_ms(25.110318, 5.866311), epsilon = 1e-6 ); let _ = h1.converge().unwrap(); assert_ulps_eq!( h1.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(25.000000, 5.419212), epsilon = 1e-6 ); assert_ulps_eq!( h1.time_slices[0].skills.get(c).unwrap().posterior(), Gaussian::from_ms(25.000000, 5.419212), epsilon = 1e-6 ); let mut h2 = History::builder() .mu(25.0) .sigma(25.0 / 3.0) .beta(25.0 / 6.0) .drift(ConstantDrift(25.0 / 300.0)) .build(); let events = make_events_1v1( &[("a", "b"), ("b", "c"), ("c", "a")], &[ Outcome::winner(0, 2), Outcome::winner(0, 2), Outcome::winner(0, 2), ], &[1, 2, 3], ); h2.add_events(events).unwrap(); let a = h2.keys.get("a").unwrap(); let c = h2.keys.get("c").unwrap(); assert_ulps_eq!( h2.time_slices[2].skills.get(a).unwrap().posterior(), Gaussian::from_ms(22.903522, 6.011017), epsilon = 1e-6 ); assert_ulps_eq!( h2.time_slices[2].skills.get(c).unwrap().posterior(), Gaussian::from_ms(25.110702, 5.866811), epsilon = 1e-6 ); let _ = h2.converge().unwrap(); assert_ulps_eq!( h2.time_slices[2].skills.get(a).unwrap().posterior(), Gaussian::from_ms(24.998668, 5.420053), epsilon = 1e-6 ); assert_ulps_eq!( h2.time_slices[2].skills.get(c).unwrap().posterior(), Gaussian::from_ms(25.000532, 5.419827), epsilon = 1e-6 ); } #[test] fn test_learning_curves() { let mut h = History::builder() .mu(25.0) .sigma(25.0 / 3.0) .beta(25.0 / 6.0) .drift(ConstantDrift(25.0 / 300.0)) .build(); let events = make_events_1v1( &[("a", "b"), ("b", "c"), ("c", "a")], &[ Outcome::winner(0, 2), Outcome::winner(0, 2), Outcome::winner(0, 2), ], &[5, 6, 7], ); h.add_events(events).unwrap(); let _ = h.converge().unwrap(); let lc_a = h.learning_curve("a"); let lc_c = h.learning_curve("c"); let aj_e = lc_a.len(); let cj_e = lc_c.len(); assert_eq!(lc_a[0].0, 5); assert_eq!(lc_a[aj_e - 1].0, 7); assert_ulps_eq!( lc_a[aj_e - 1].1, Gaussian::from_ms(24.998668, 5.420053), epsilon = 1e-6 ); assert_ulps_eq!( lc_c[cj_e - 1].1, Gaussian::from_ms(25.000532, 5.419827), epsilon = 1e-6 ); } #[test] fn test_env_ttt() { let mut h = History::builder() .mu(25.0) .sigma(25.0 / 3.0) .beta(25.0 / 6.0) .drift(ConstantDrift(25.0 / 300.0)) .build(); let events = make_events_1v1( &[("a", "b"), ("a", "c"), ("b", "c")], &[ Outcome::winner(0, 2), Outcome::winner(1, 2), Outcome::winner(0, 2), ], &[1, 2, 3], ); h.add_events(events).unwrap(); let _ = h.converge().unwrap(); let a = h.keys.get("a").unwrap(); let b = h.keys.get("b").unwrap(); let c = h.keys.get("c").unwrap(); assert_eq!(h.time_slices[2].skills.get(b).unwrap().elapsed, 2); assert_eq!(h.time_slices[2].skills.get(c).unwrap().elapsed, 1); assert_ulps_eq!( h.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(25.000267, 5.419423), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[0].skills.get(b).unwrap().posterior(), Gaussian::from_ms(24.999197939, 5.419510957), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[2].skills.get(b).unwrap().posterior(), Gaussian::from_ms(25.001331690, 5.420052840), epsilon = 1e-6 ); } #[test] fn test_teams() { let mut h: History = History::builder() .mu(0.0) .sigma(6.0) .beta(1.0) .drift(ConstantDrift(0.0)) .build(); let events: Vec> = vec![ Event { time: 1, teams: smallvec![ Team::with_members([Member::new("a"), Member::new("b")]), Team::with_members([Member::new("c"), Member::new("d")]), ], outcome: Outcome::winner(0, 2), }, Event { time: 2, teams: smallvec![ Team::with_members([Member::new("e"), Member::new("f")]), Team::with_members([Member::new("b"), Member::new("c")]), ], outcome: Outcome::winner(1, 2), }, Event { time: 3, teams: smallvec![ Team::with_members([Member::new("a"), Member::new("d")]), Team::with_members([Member::new("e"), Member::new("f")]), ], outcome: Outcome::winner(0, 2), }, ]; h.add_events(events).unwrap(); let a = h.keys.get("a").unwrap(); let b = h.keys.get("b").unwrap(); let c = h.keys.get("c").unwrap(); let d = h.keys.get("d").unwrap(); let e = h.keys.get("e").unwrap(); let f = h.keys.get("f").unwrap(); let trueskill_log_evidence = h.log_evidence_internal(false, &[]); let trueskill_log_evidence_forward = h.log_evidence_internal(true, &[]); assert_ulps_eq!( trueskill_log_evidence, trueskill_log_evidence_forward, epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[0].skills.get(b).unwrap().posterior().mu(), -h.time_slices[0].skills.get(c).unwrap().posterior().mu(), epsilon = 1e-6 ); let evidence_second_event = h.log_evidence_internal(false, &[b]).exp() * 2.0; assert_ulps_eq!(0.5, evidence_second_event, epsilon = 1e-6); let evidence_third_event = h.log_evidence_internal(false, &[a]).exp() * 2.0; assert_ulps_eq!(0.669885, evidence_third_event, epsilon = 1e-6); let _ = h.converge().unwrap(); let loocv_hat = h.log_evidence_internal(false, &[]).exp(); let p_d_m_hat = h.log_evidence_internal(true, &[]).exp(); assert_ulps_eq!(loocv_hat, 0.241027, epsilon = 1e-6); assert_ulps_eq!(p_d_m_hat, 0.172432, epsilon = 1e-6); assert_ulps_eq!( h.time_slices[0].skills.get(a).unwrap().posterior(), h.time_slices[0].skills.get(b).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[0].skills.get(c).unwrap().posterior(), h.time_slices[0].skills.get(d).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[1].skills.get(e).unwrap().posterior(), h.time_slices[1].skills.get(f).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(4.084902, 5.106919), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[0].skills.get(c).unwrap().posterior(), Gaussian::from_ms(-0.533029, 5.106919), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[2].skills.get(e).unwrap().posterior(), Gaussian::from_ms(-3.551872, 5.154569), epsilon = 1e-6 ); } #[test] fn test_add_events() { let mut h: History = History::builder() .mu(0.0) .sigma(2.0) .beta(1.0) .drift(ConstantDrift(0.0)) .build(); let events = make_events_1v1( &[("a", "b"), ("a", "c"), ("b", "c")], &[ Outcome::winner(0, 2), Outcome::winner(1, 2), Outcome::winner(0, 2), ], &[1, 2, 3], ); h.add_events(events).unwrap(); let a = h.keys.get("a").unwrap(); let b = h.keys.get("b").unwrap(); let c = h.keys.get("c").unwrap(); let _ = h.converge().unwrap(); assert_eq!(h.time_slices[2].skills.get(b).unwrap().elapsed, 2); assert_eq!(h.time_slices[2].skills.get(c).unwrap().elapsed, 1); assert_ulps_eq!( h.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(0.000000, 1.300610), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[0].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 1.300610), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[2].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 1.300610), epsilon = 1e-6 ); let events2 = make_events_1v1( &[("a", "b"), ("a", "c"), ("b", "c")], &[ Outcome::winner(0, 2), Outcome::winner(1, 2), Outcome::winner(0, 2), ], &[4, 5, 6], ); h.add_events(events2).unwrap(); assert_eq!(h.time_slices.len(), 6); assert_eq!( h.time_slices .iter() .map(|b| b.get_composition()) .collect::>(), vec![ vec![vec![vec![a], vec![b]]], vec![vec![vec![a], vec![c]]], vec![vec![vec![b], vec![c]]], vec![vec![vec![a], vec![b]]], vec![vec![vec![a], vec![c]]], vec![vec![vec![b], vec![c]]] ] ); let _ = h.converge().unwrap(); assert_ulps_eq!( h.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[3].skills.get(a).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[3].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[5].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); } #[test] fn test_only_add_events() { let mut h: History = History::builder() .mu(0.0) .sigma(2.0) .beta(1.0) .drift(ConstantDrift(0.0)) .build(); let events = make_events_1v1( &[("a", "b"), ("a", "c"), ("b", "c")], &[ Outcome::winner(0, 2), Outcome::winner(1, 2), Outcome::winner(0, 2), ], &[1, 2, 3], ); h.add_events(events).unwrap(); let a = h.keys.get("a").unwrap(); let b = h.keys.get("b").unwrap(); let c = h.keys.get("c").unwrap(); let _ = h.converge().unwrap(); assert_eq!(h.time_slices[2].skills.get(b).unwrap().elapsed, 2); assert_eq!(h.time_slices[2].skills.get(c).unwrap().elapsed, 1); assert_ulps_eq!( h.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(0.000000, 1.300610), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[0].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 1.300610), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[2].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 1.300610), epsilon = 1e-6 ); let events2 = make_events_1v1( &[("a", "b"), ("a", "c"), ("b", "c")], &[ Outcome::winner(0, 2), Outcome::winner(1, 2), Outcome::winner(0, 2), ], &[4, 5, 6], ); h.add_events(events2).unwrap(); assert_eq!(h.time_slices.len(), 6); assert_eq!( h.time_slices .iter() .map(|b| b.get_composition()) .collect::>(), vec![ vec![vec![vec![a], vec![b]]], vec![vec![vec![a], vec![c]]], vec![vec![vec![b], vec![c]]], vec![vec![vec![a], vec![b]]], vec![vec![vec![a], vec![c]]], vec![vec![vec![b], vec![c]]] ] ); let _ = h.converge().unwrap(); assert_ulps_eq!( h.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[3].skills.get(a).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[3].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[5].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); } #[test] fn test_log_evidence() { use crate::ConvergenceOptions; let mut h: History = History::builder().build(); // empty results in the old API = team 0 wins; reproduce with Outcome::winner(0,2) let events = make_events_1v1( &[("a", "b"), ("b", "a")], &[Outcome::winner(0, 2), Outcome::winner(0, 2)], &[1, 2], ); h.add_events(events).unwrap(); let a = h.keys.get("a").unwrap(); let b = h.keys.get("b").unwrap(); let p_d_m_2 = h.log_evidence_internal(false, &[]).exp() * 2.0; assert_ulps_eq!(p_d_m_2, 0.17650911, epsilon = 1e-6); assert_ulps_eq!( p_d_m_2, h.log_evidence_internal(true, &[]).exp() * 2.0, epsilon = 1e-6 ); assert_ulps_eq!( p_d_m_2, h.log_evidence_internal(true, &[a]).exp() * 2.0, epsilon = 1e-6 ); assert_ulps_eq!( p_d_m_2, h.log_evidence_internal(false, &[a]).exp() * 2.0, epsilon = 1e-6 ); // run exactly 11 iterations (old test used convergence(11, ...)) h.convergence = ConvergenceOptions { max_iter: 11, epsilon: EPSILON, alpha: 1.0, }; let _ = h.converge().unwrap(); let loocv_approx_2 = h.log_evidence_internal(false, &[]).exp().sqrt(); assert_ulps_eq!(loocv_approx_2, 0.001976774, epsilon = 0.000001); let p_d_m_approx_2 = h.log_evidence_internal(true, &[]).exp() * 2.0; assert!(loocv_approx_2 - p_d_m_approx_2 < 1e-4); assert_ulps_eq!( loocv_approx_2, h.log_evidence_internal(true, &[b]).exp() * 2.0, epsilon = 1e-4 ); let mut h2: History = History::builder().build(); let events = make_events_1v1( &[("a", "b"), ("b", "a")], &[Outcome::winner(0, 2), Outcome::winner(0, 2)], &[1, 2], ); h2.add_events(events).unwrap(); assert_ulps_eq!( ((0.5f64 * 0.1765).ln() / 2.0).exp(), (h2.log_evidence_internal(false, &[]) / 2.0).exp(), epsilon = 1e-4 ); } #[test] fn test_add_events_with_time() { let mut h: History = History::builder() .mu(0.0) .sigma(2.0) .beta(1.0) .drift(ConstantDrift(0.0)) .build(); let events = make_events_1v1( &[("a", "b"), ("a", "c"), ("b", "c")], &[ Outcome::winner(0, 2), Outcome::winner(1, 2), Outcome::winner(0, 2), ], &[0, 10, 20], ); h.add_events(events).unwrap(); let _ = h.converge().unwrap(); let a = h.keys.get("a").unwrap(); let b = h.keys.get("b").unwrap(); let c = h.keys.get("c").unwrap(); let events2 = make_events_1v1( &[("a", "b"), ("a", "c"), ("b", "c")], &[ Outcome::winner(0, 2), Outcome::winner(1, 2), Outcome::winner(0, 2), ], &[15, 10, 0], ); h.add_events(events2).unwrap(); assert_eq!(h.time_slices.len(), 4); assert_eq!( h.time_slices .iter() .map(|ts| ts.events.len()) .collect::>(), vec![2, 2, 1, 1] ); assert_eq!( h.time_slices .iter() .map(|b| b.get_composition()) .collect::>(), vec![ vec![vec![vec![a], vec![b]], vec![vec![b], vec![c]]], vec![vec![vec![a], vec![c]], vec![vec![a], vec![c]]], vec![vec![vec![a], vec![b]]], vec![vec![vec![b], vec![c]]] ] ); assert_eq!( h.time_slices .iter() .map(|b| b.get_results()) .collect::>(), vec![ vec![vec![1.0, 0.0], vec![1.0, 0.0]], vec![vec![0.0, 1.0], vec![0.0, 1.0]], vec![vec![1.0, 0.0]], vec![vec![1.0, 0.0]] ] ); let end = h.time_slices.len() - 1; assert_eq!(h.time_slices[0].skills.get(c).unwrap().elapsed, 0); assert_eq!(h.time_slices[end].skills.get(c).unwrap().elapsed, 10); assert_eq!(h.time_slices[0].skills.get(a).unwrap().elapsed, 0); assert_eq!(h.time_slices[2].skills.get(a).unwrap().elapsed, 5); assert_eq!(h.time_slices[0].skills.get(b).unwrap().elapsed, 0); assert_eq!(h.time_slices[end].skills.get(b).unwrap().elapsed, 5); let _ = h.converge().unwrap(); assert_ulps_eq!( h.time_slices[0].skills.get(b).unwrap().posterior(), h.time_slices[end].skills.get(b).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[0].skills.get(c).unwrap().posterior(), h.time_slices[end].skills.get(c).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( h.time_slices[0].skills.get(c).unwrap().posterior(), h.time_slices[0].skills.get(b).unwrap().posterior(), epsilon = 1e-6 ); // second scenario: team-0 wins (empty results in old API), different composition order let mut h2: History = History::builder() .mu(0.0) .sigma(2.0) .beta(1.0) .drift(ConstantDrift(0.0)) .build(); let events = make_events_1v1( &[("a", "b"), ("c", "a"), ("b", "c")], &[ Outcome::winner(0, 2), Outcome::winner(0, 2), Outcome::winner(0, 2), ], &[0, 10, 20], ); h2.add_events(events).unwrap(); let _ = h2.converge().unwrap(); let a = h2.keys.get("a").unwrap(); let b = h2.keys.get("b").unwrap(); let c = h2.keys.get("c").unwrap(); let events2 = make_events_1v1( &[("a", "b"), ("c", "a"), ("b", "c")], &[ Outcome::winner(0, 2), Outcome::winner(0, 2), Outcome::winner(0, 2), ], &[15, 10, 0], ); h2.add_events(events2).unwrap(); assert_eq!(h2.time_slices.len(), 4); assert_eq!( h2.time_slices .iter() .map(|ts| ts.events.len()) .collect::>(), vec![2, 2, 1, 1] ); assert_eq!( h2.time_slices .iter() .map(|b| b.get_composition()) .collect::>(), vec![ vec![vec![vec![a], vec![b]], vec![vec![b], vec![c]]], vec![vec![vec![c], vec![a]], vec![vec![c], vec![a]]], vec![vec![vec![a], vec![b]]], vec![vec![vec![b], vec![c]]] ] ); assert_eq!( h2.time_slices .iter() .map(|b| b.get_results()) .collect::>(), vec![ vec![vec![1.0, 0.0], vec![1.0, 0.0]], vec![vec![1.0, 0.0], vec![1.0, 0.0]], vec![vec![1.0, 0.0]], vec![vec![1.0, 0.0]] ] ); let end = h2.time_slices.len() - 1; assert_eq!(h2.time_slices[0].skills.get(c).unwrap().elapsed, 0); assert_eq!(h2.time_slices[end].skills.get(c).unwrap().elapsed, 10); assert_eq!(h2.time_slices[0].skills.get(a).unwrap().elapsed, 0); assert_eq!(h2.time_slices[2].skills.get(a).unwrap().elapsed, 5); assert_eq!(h2.time_slices[0].skills.get(b).unwrap().elapsed, 0); assert_eq!(h2.time_slices[end].skills.get(b).unwrap().elapsed, 5); let _ = h2.converge().unwrap(); assert_ulps_eq!( h2.time_slices[0].skills.get(b).unwrap().posterior(), h2.time_slices[end].skills.get(b).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( h2.time_slices[0].skills.get(c).unwrap().posterior(), h2.time_slices[end].skills.get(c).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( h2.time_slices[0].skills.get(c).unwrap().posterior(), h2.time_slices[0].skills.get(b).unwrap().posterior(), epsilon = 1e-6 ); } #[test] fn test_1vs1_weighted() { let mut h: History = History::builder() .mu(2.0) .sigma(6.0) .beta(1.0) .drift(ConstantDrift(0.0)) .build(); // empty results in old API = team 0 wins: a wins event 1, b wins event 2 let events: Vec> = vec![ Event { time: 1, teams: smallvec![ Team::with_members([Member::new("a").with_weight(5.0)]), Team::with_members([Member::new("b").with_weight(4.0)]), ], outcome: Outcome::winner(0, 2), }, Event { time: 2, teams: smallvec![ Team::with_members([Member::new("b").with_weight(5.0)]), Team::with_members([Member::new("a").with_weight(4.0)]), ], outcome: Outcome::winner(0, 2), }, ]; h.add_events(events).unwrap(); let lc_a = h.learning_curve("a"); let lc_b = h.learning_curve("b"); assert_ulps_eq!( lc_a[0].1, Gaussian::from_ms(5.537659, 4.758722), epsilon = 1e-6 ); assert_ulps_eq!( lc_b[0].1, Gaussian::from_ms(-0.830127, 5.239568), epsilon = 1e-6 ); assert_ulps_eq!( lc_a[1].1, Gaussian::from_ms(1.792278067, 4.099566582), epsilon = 1e-6 ); assert_ulps_eq!( lc_b[1].1, Gaussian::from_ms(4.845533, 3.747616), epsilon = 1e-6 ); let _ = h.converge().unwrap(); let lc_a = h.learning_curve("a"); let lc_b = h.learning_curve("b"); assert_ulps_eq!(lc_a[0].1, lc_a[0].1, epsilon = 1e-6); assert_ulps_eq!(lc_b[0].1, lc_a[0].1, epsilon = 1e-6); assert_ulps_eq!(lc_a[1].1, lc_a[0].1, epsilon = 1e-6); assert_ulps_eq!(lc_b[1].1, lc_a[0].1, epsilon = 1e-6); } #[test] fn test_converge_returns_report() { use crate::ConvergenceOptions; let mut h: History = History::builder() .mu(0.0) .sigma(2.0) .beta(1.0) .drift(ConstantDrift(0.0)) .convergence(ConvergenceOptions { max_iter: 30, epsilon: 1e-6, alpha: 1.0, }) .build(); let events = make_events_1v1( &[("a", "b"), ("a", "c"), ("b", "c")], &[ Outcome::winner(0, 2), Outcome::winner(1, 2), Outcome::winner(0, 2), ], &[1, 2, 3], ); h.add_events(events).unwrap(); let report = h.converge().unwrap(); assert!(report.converged); assert!(report.iterations > 0); assert!(report.iterations < 30); assert!(report.final_step.0 <= 1e-6); } #[test] #[should_panic(expected = "score_sigma must be positive")] fn history_builder_rejects_zero_score_sigma() { let _ = History::builder().score_sigma(0.0).build(); } #[test] fn history_propagates_convergence_to_inner_run_chain() { use crate::ConvergenceOptions; let events_for = |h: &mut History| { h.event(0) .team(["a"]) .team(["b"]) .team(["c"]) .team(["d"]) .ranking([0u32, 1, 2, 3]) .commit() .unwrap(); }; let mut h_capped: History = History::builder() .convergence(ConvergenceOptions { max_iter: 1, ..ConvergenceOptions::default() }) .build(); events_for(&mut h_capped); let _ = h_capped.converge().unwrap(); let mut h_full: History = History::builder().build(); events_for(&mut h_full); let _ = h_full.converge().unwrap(); let curves_capped = h_capped.learning_curves(); let curves_full = h_full.learning_curves(); let mut max_diff: f64 = 0.0; for (key, capped_pts) in curves_capped.iter() { let full_pts = curves_full.get(key).expect("agent missing in full"); for (capped, full) in capped_pts.iter().zip(full_pts.iter()) { max_diff = max_diff.max((capped.1.mu() - full.1.mu()).abs()); max_diff = max_diff.max((capped.1.sigma() - full.1.sigma()).abs()); } } assert!( max_diff > 1e-6, "max_iter=1 inner loop should differ from default; max_diff={max_diff}" ); } #[test] fn history_with_damping_reaches_same_fixed_point_as_undamped() { use crate::ConvergenceOptions; let events_for = |h: &mut History| { h.event(0) .team(["a"]) .team(["b"]) .team(["c"]) .team(["d"]) .ranking([0u32, 1, 2, 3]) .commit() .unwrap(); }; let mut h_undamped: History = History::builder().build(); events_for(&mut h_undamped); let _ = h_undamped.converge().unwrap(); let mut h_damped: History = History::builder() .convergence(ConvergenceOptions { alpha: 0.5, max_iter: 200, ..ConvergenceOptions::default() }) .build(); events_for(&mut h_damped); let _ = h_damped.converge().unwrap(); let curves_u = h_undamped.learning_curves(); let curves_d = h_damped.learning_curves(); let mut max_diff: f64 = 0.0; for (key, u_pts) in curves_u.iter() { let d_pts = curves_d.get(key).expect("agent missing in damped"); for (u, d) in u_pts.iter().zip(d_pts.iter()) { max_diff = max_diff.max((u.1.mu() - d.1.mu()).abs()); max_diff = max_diff.max((u.1.sigma() - d.1.sigma()).abs()); } } assert!( max_diff < 1e-3, "α=0.5 should reach the same fixed point as α=1.0; max_diff={max_diff}" ); } #[test] fn outcome_scores_default_sigma_uses_history_default() { use crate::Outcome; // Path A: explicit sigma=0.5 via override. let mut h_a = crate::History::builder().score_sigma(0.5).build(); h_a.add_events([crate::Event { time: 0_i64, teams: smallvec::smallvec![ crate::Team::with_members([crate::Member::new("a")]), crate::Team::with_members([crate::Member::new("b")]), ], outcome: Outcome::scores_with_sigma([3.0, 1.0], 0.5), }]) .unwrap(); let _ = h_a.converge().unwrap(); // Path B: history-wide default 0.5, no per-event override. let mut h_b = crate::History::builder().score_sigma(0.5).build(); h_b.add_events([crate::Event { time: 0_i64, teams: smallvec::smallvec![ crate::Team::with_members([crate::Member::new("a")]), crate::Team::with_members([crate::Member::new("b")]), ], outcome: Outcome::scores([3.0, 1.0]), }]) .unwrap(); let _ = h_b.converge().unwrap(); // Inheritance: posteriors must be bit-equal. let curves_a = h_a.learning_curves(); let curves_b = h_b.learning_curves(); for (key, a_pts) in curves_a.iter() { let b_pts = curves_b.get(key).expect("agent missing in path B"); for (a, b) in a_pts.iter().zip(b_pts.iter()) { assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}"); assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}"); } } } #[test] fn outcome_scores_with_sigma_overrides_history_default() { use crate::Outcome; // Path A: history-wide default 0.5, per-event override 2.0. let mut h_a = crate::History::builder().score_sigma(0.5).build(); h_a.add_events([crate::Event { time: 0_i64, teams: smallvec::smallvec![ crate::Team::with_members([crate::Member::new("a")]), crate::Team::with_members([crate::Member::new("b")]), ], outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0), }]) .unwrap(); let _ = h_a.converge().unwrap(); // Path B: history-wide default 2.0, no per-event override. let mut h_b = crate::History::builder().score_sigma(2.0).build(); h_b.add_events([crate::Event { time: 0_i64, teams: smallvec::smallvec![ crate::Team::with_members([crate::Member::new("a")]), crate::Team::with_members([crate::Member::new("b")]), ], outcome: Outcome::scores([3.0, 1.0]), }]) .unwrap(); let _ = h_b.converge().unwrap(); // Override == default-set-to-the-override-value: bit-equal. let curves_a = h_a.learning_curves(); let curves_b = h_b.learning_curves(); for (key, a_pts) in curves_a.iter() { let b_pts = curves_b.get(key).expect("agent missing in path B"); for (a, b) in a_pts.iter().zip(b_pts.iter()) { assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}"); assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}"); } } // Path C: history-wide default 0.5, no override. Different sigma → different posteriors. let mut h_c = crate::History::builder().score_sigma(0.5).build(); h_c.add_events([crate::Event { time: 0_i64, teams: smallvec::smallvec![ crate::Team::with_members([crate::Member::new("a")]), crate::Team::with_members([crate::Member::new("b")]), ], outcome: Outcome::scores([3.0, 1.0]), }]) .unwrap(); let _ = h_c.converge().unwrap(); let curves_c = h_c.learning_curves(); let mut max_diff: f64 = 0.0; for (key, a_pts) in curves_a.iter() { let c_pts = curves_c.get(key).expect("agent missing in path C"); for (a, c) in a_pts.iter().zip(c_pts.iter()) { max_diff = max_diff.max((a.1.mu() - c.1.mu()).abs()); max_diff = max_diff.max((a.1.sigma() - c.1.sigma()).abs()); } } assert!( max_diff > 1e-6, "override should produce different posteriors from inherited default; max_diff={max_diff}" ); } #[test] fn event_builder_scores_with_sigma_threading() { use crate::Outcome; // Path A: builder fluent API with sigma override. let mut h_a = crate::History::builder().score_sigma(0.5).build(); h_a.event(0_i64) .team(["a"]) .team(["b"]) .scores_with_sigma([3.0, 1.0], 2.0) .commit() .unwrap(); let _ = h_a.converge().unwrap(); // Path B: same outcome via the explicit Outcome constructor. let mut h_b = crate::History::builder().score_sigma(0.5).build(); h_b.add_events([crate::Event { time: 0_i64, teams: smallvec::smallvec![ crate::Team::with_members([crate::Member::new("a")]), crate::Team::with_members([crate::Member::new("b")]), ], outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0), }]) .unwrap(); let _ = h_b.converge().unwrap(); let curves_a = h_a.learning_curves(); let curves_b = h_b.learning_curves(); for (key, a_pts) in curves_a.iter() { let b_pts = curves_b.get(key).expect("agent missing"); for (a, b) in a_pts.iter().zip(b_pts.iter()) { assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}"); assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}"); } } } }