use std::{ borrow::Borrow, collections::{BTreeMap, HashMap, HashSet}, hash::Hash, marker::PhantomData, }; use crate::{ BETA, GAMMA, Index, MU, P_DRAW, SIGMA, competitor::{self, Competitor}, convergence::{ConvergenceOptions, ConvergenceReport}, drift::{ConstantDrift, Drift}, error::InferenceError, event::Member, 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, }; /// Configures a [`History`] before any events are added. /// /// Everything a history needs that is not an event lives here: the prior /// (`mu`, `sigma`), the performance noise `beta`, the draw probability, the /// drift model, the convergence settings, the observer, and what to do about /// an unknown key. None of them can be changed after `build`, because they /// define the model the fit is of. /// /// Two of the setters change the builder's *type* rather than a field — /// [`HistoryBuilder::drift`] and [`HistoryBuilder::observer`] — so bind the /// result rather than calling them on a `&mut`. [`HistoryBuilder::time_type`] /// and [`HistoryBuilder::key_type`] exist for the same reason: to name a type /// parameter that nothing in the call chain would otherwise infer. #[derive(Clone, Debug)] #[must_use = "a builder does nothing until `.build()`"] 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, unknown_keys: crate::UnknownKeys, _time: PhantomData, _key: PhantomData, } impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder { /// Prior mean skill. /// /// # Panics /// /// Panics if `mu` is not finite. A non-finite prior mean poisons every /// posterior derived from it: `converge` reports `NonFiniteResult`, but a /// caller who reads `current_skill` first is handed `tau: NaN`. pub fn mu(mut self, mu: f64) -> Self { assert!(mu.is_finite(), "mu must be finite (got {mu})"); self.mu = mu; self } /// Prior standard deviation: how unsure the model is about a competitor's /// **skill** before it has seen them play. /// /// The first of the two noise knobs, and the one people reach for by /// mistake. `sigma` is *epistemic* — it is what the model does not yet /// know, and evidence shrinks it. [`HistoryBuilder::beta`] is *aleatoric* /// — how much a single showing scatters around the skill, which no amount /// of evidence removes. /// /// So: results move ratings too slowly for your taste → raise `sigma` (or /// `gamma`, if the problem is that skill genuinely moves). A single upset /// swings ratings too far → raise `beta`, because you are telling the model /// that one result is weaker evidence than it assumed. /// /// The default is six betas, deliberately wide: a new competitor's first /// result should move them a long way. /// /// # Panics /// /// Panics unless `sigma` is finite and strictly positive. /// /// Zero and infinity both give a prior precision that is not a number, and /// the whole fit comes back NaN. A *negative* sigma is the quieter half: /// it is only ever squared, so `-8.33` produces bit-identical results to /// `8.33` — a sign the caller cannot have meant, silently ignored. pub fn sigma(mut self, sigma: f64) -> Self { assert!( sigma.is_finite() && sigma > 0.0, "sigma must be finite and positive (got {sigma})" ); self.sigma = sigma; self } /// Per-event performance noise: how much a single showing scatters around /// a competitor's **skill**. /// /// The second noise knob, and the one that sets the scale of the whole /// system — [`SIGMA`](crate::SIGMA) and [`GAMMA`](crate::GAMMA) are both /// defined as multiples of it. Unlike /// [`sigma`](HistoryBuilder::sigma), this is *aleatoric*: it is the /// irreducible day-to-day variation, so evidence never shrinks it. It is /// also what makes an upset possible at all — with `beta == 0` the better /// competitor always wins. /// /// Larger `beta` means each result carries less information, so ratings /// move less per game and the draw margin implied by `p_draw` is wider. /// /// # Panics /// /// Panics unless `beta` is finite and non-negative. /// /// Zero is allowed and meaningful — performance is then exactly skill, and /// the fit differs measurably from a positive `beta` rather than /// degenerating. Negative is rejected for the same reason as a negative /// `sigma` or `Member::with_drift_scale`: `beta` enters only as `beta^2`, /// so a negative value behaves as its absolute value and the sign is lost /// without comment. pub fn beta(mut self, beta: f64) -> Self { assert!( beta.is_finite() && beta >= 0.0, "beta must be finite and non-negative (got {beta})" ); self.beta = beta; self } /// Set the drift model: how far skill may move between appearances. /// /// Changes the builder's type, since `D` is a type parameter — bind the /// result. [`ConstantDrift`] is the default; a custom [`Drift`] impl is /// the way to express a calendar-dependent or per-competitor rule that /// elapsed ticks alone cannot. /// /// Not validated here: the builder cannot inspect an arbitrary /// implementation. `converge` checks the variance each competitor actually /// accumulates and reports `InvalidParameter` if it is negative or /// non-finite. 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, unknown_keys: self.unknown_keys, _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.is_finite() && score_sigma > 0.0, "score_sigma must be finite and positive (got {score_sigma})" ); self.score_sigma = score_sigma; self } /// How predictions treat a key the history has never seen. /// /// Defaults to [`UnknownKeys::Reject`](crate::UnknownKeys::Reject), which /// errors. Set [`UnknownKeys::Prior`](crate::UnknownKeys::Prior) to have an /// unknown competitor answered from the configured prior instead, which /// makes "predict a matchup involving someone new" a first-class question. /// /// This affects predictions only. Ingestion always creates a competitor for /// a key it has not seen, because that is what an event *is*. pub fn unknown_keys(mut self, unknown_keys: crate::UnknownKeys) -> Self { self.unknown_keys = unknown_keys; 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 } /// Change the time axis, keeping every other setting. /// /// The same shape as [`HistoryBuilder::drift`] and /// [`HistoryBuilder::observer`], which already move between type /// parameters. Call it before setting a drift that is specific to one time /// type, since the stored drift and observer must also be valid for `T2`. /// /// ``` /// # use trueskill_tt::{History, Untimed}; /// let mut h = History::builder().time_type::().build(); /// h.record_winner(&"alice", &"bob", Untimed)?; /// h.converge()?; /// assert!(h.current_skill(&"alice").unwrap().mu() > 0.0); /// # Ok::<(), trueskill_tt::InferenceError>(()) /// ``` pub fn time_type(self) -> HistoryBuilder where T2: Time, D: Drift, O: Observer, { 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: self.observer, unknown_keys: self.unknown_keys, _time: PhantomData, _key: PhantomData, } } /// Change the key type, keeping every other setting. /// /// Replaces the former `History::builder_with_key`, which could not be /// turbofished — `K` sat on the `impl` rather than the function, so /// `History::builder_with_key::()` was a compile error and callers /// had to spell the whole `History::builder().key_type::()`. /// /// ``` /// # use trueskill_tt::History; /// let mut h = History::builder().key_type::().build(); /// h.record_winner(&"alice".to_string(), &"bob".to_string(), 1)?; /// # Ok::<(), trueskill_tt::InferenceError>(()) /// ``` pub fn key_type(self) -> 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: self.observer, unknown_keys: self.unknown_keys, _time: PhantomData, _key: PhantomData, } } /// Attach an [`Observer`] to be called as inference progresses. /// /// Changes the builder's type — bind the result. The history takes the /// observer by value; to keep a handle on one that accumulates state, pass /// an `Arc` and keep a clone, or read it back with /// [`History::observer`] / [`History::into_observer`]. 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, unknown_keys: self.unknown_keys, _time: self._time, _key: self._key, } } /// Finish configuring and produce an empty [`History`]. /// /// Every parameter was validated as it was set, so this cannot fail. pub fn build(self) -> History { History { size: 0, time_slices: Vec::new(), competitors: 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, unknown_keys: self.unknown_keys, declared: HashMap::new(), } } } /// Generic over the time axis and the key type, so a builder exists for every /// `T: Time` rather than only for `i64`. /// /// It used to be implemented for the `i64`/`&'static str` instantiation alone. /// That, plus private fields and no `new`, meant a downstream crate could not /// construct a `History` on any other time axis at all — `Untimed` and every /// custom `Drift` were public but unreachable. impl Default for HistoryBuilder { fn default() -> Self { Self { mu: MU, sigma: SIGMA, beta: BETA, drift: ConstantDrift::new(GAMMA), p_draw: P_DRAW, score_sigma: 1.0, convergence: ConvergenceOptions::default(), observer: NullObserver, unknown_keys: crate::UnknownKeys::default(), _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, } /// The joint precision over a history's appearances, with the maps needed to /// address a competitor either at their latest appearance or at a given slice. struct TimeExpanded { /// Row-major precision matrix over appearances. lambda: Vec, /// `(row, slice)` of each competitor's latest appearance. latest: HashMap, /// Row of each `(competitor, slice)` appearance. at_slice: HashMap<(Index, usize), usize>, /// Side length of `lambda`. width: usize, } /// A linear functional resolved against one time slice. struct ResolvedTerms { /// Coefficients over the slice's own competitors, in its ordering. contrast: Vec, /// Coefficients of competitors the slice has never seen, keyed by their /// rendering. Independent of everything in the slice by construction. /// /// A `BTreeMap` rather than a `HashMap`, and that is load-bearing. These /// coefficients are summed, addition is not associative, and Rust seeds its /// default hasher per process — so iterating a `HashMap` here made /// `posterior_of` return different bits run to run on identical input. /// Measured over 40 processes: two distinct sigma bit patterns, and five /// distinct values from `expected_variance_reduction` spanning ~7 ULP. /// Ordered iteration makes the sum reproducible. unseen: BTreeMap, mean: f64, } impl CompetitorConfig { fn is_empty(self) -> bool { self.prior.is_none() && self.drift_scale.is_none() } } /// A fitted history: competitors, their skills over time, and the events that /// produced them. /// /// # Persistence /// /// `History` is deliberately not serializable, and the event log is the /// intended source of truth. This is a decision rather than an omission. /// /// [`converge`](History::converge) reaches a fixed point determined by the /// events, ratings and configuration alone — not by the message state it /// started from, which ingestion resets anyway. So a snapshot of a fitted /// history would carry no information the event log does not; it would be a /// cache of the *computation*, never of the answer. /// /// That also bounds what one could buy. Re-converging an unchanged history /// costs a single iteration — measured at 0.91 ms against 365 ms cold on 2 000 /// events — so a snapshot would make a cold restart cheap. It would do nothing /// for appends: adding one event moves its participants by more than a sigma /// across their whole history, back to their first appearance, so the /// re-convergence is real work rather than repeated work. Cost per append is /// inherently O(history). A design that made appends cheap would be computing a /// filtering estimate — see [`History::filtered_learning_curve`] — rather than /// Through Time. /// /// `tests/reconvergence_equivalence.rs` pins the path-independence this rests /// on. 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) competitors: CompetitorStore, keys: KeyTable, mu: f64, sigma: f64, beta: f64, drift: D, p_draw: f64, score_sigma: f64, convergence: ConvergenceOptions, observer: O, unknown_keys: crate::UnknownKeys, /// Competitor configuration explicitly declared so far, by whichever route. /// /// Kept separate from the applied `Rating` because a `Rating` cannot say /// whether a value was *chosen* or inherited from the history defaults, /// and that is exactly the distinction a conflict check needs. declared: HashMap, } impl Default for History { fn default() -> Self { HistoryBuilder::default().build() } } impl History { /// Start configuring a history. /// /// The defaults are `i64` time, [`ConstantDrift`], no observer and /// `&'static str` keys. Any of the four can be changed — the two type /// parameters that no argument would pin are named with /// [`HistoryBuilder::time_type`] and [`HistoryBuilder::key_type`]. pub fn builder() -> HistoryBuilder { HistoryBuilder::default() } } impl HistoryBuilder { /// A builder on any time axis and key type, with the default drift and no /// observer. /// /// [`History::builder`] is the common case and pins `T = i64`, /// `K = &'static str`. Reach for this — or for the type-changing /// [`HistoryBuilder::time_type`] / [`HistoryBuilder::key_type`] — when /// either needs to be something else. /// /// ``` /// # use trueskill_tt::{History, HistoryBuilder, Untimed}; /// let mut h = HistoryBuilder::::new().build(); /// h.record_winner(&"alice".to_string(), &"bob".to_string(), Untimed)?; /// h.converge()?; /// # Ok::<(), trueskill_tt::InferenceError>(()) /// ``` pub fn new() -> Self { Self::default() } } impl, O: Observer, K: Eq + Hash + Clone> History { /// Promote a key to its [`Index`], creating the entry if it is new. /// /// Interning a key does not register a competitor or give them a rating — /// it only reserves the slot. Use [`History::register`] to declare a /// competitor's configuration up front. pub fn intern(&mut self, key: &Q) -> Index where K: Borrow, Q: Hash + Eq + ToOwned + ?Sized, { self.keys.get_or_create(key) } /// Resolve an existing key to its [`Index`], or `None` if the history has /// never seen it. /// /// The read-only counterpart of [`History::intern`]: it never creates. #[must_use] 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.competitors.values_mut(), false); for j in (0..self.time_slices.len() - 1).rev() { for competitor in self.time_slices[j + 1].skills.keys() { self.competitors.get_mut(competitor).unwrap().message = Some( self.time_slices[j + 1].backward_prior_out(&competitor, &self.competitors), ); } let old = self.time_slices[j].posteriors(); self.time_slices[j].new_backward_info(&self.competitors); 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.competitors.values_mut(), false); for j in 1..self.time_slices.len() { for competitor in self.time_slices[j - 1].skills.keys() { self.competitors.get_mut(competitor).unwrap().message = Some(self.time_slices[j - 1].forward_prior_out(&competitor)); } let old = self.time_slices[j].posteriors(); self.time_slices[j].new_forward_info(&self.competitors); 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.competitors); 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() } /// Every competitor the history knows, in the order they were first seen. /// /// Includes competitors created by [`History::register`] that have not yet /// appeared in an event. /// /// Insertion order, not hash order: a `HashMap` walk differs between /// processes, which would make anything built from this — a standings /// table, a printed report — unreproducible. /// /// ``` /// # use trueskill_tt::History; /// let mut h = History::builder().build(); /// h.record_winner(&"alice", &"bob", 1)?; /// let names: Vec<_> = h.competitors().copied().collect(); /// assert_eq!(names, ["alice", "bob"]); /// # Ok::<(), trueskill_tt::InferenceError>(()) /// ``` #[must_use] pub fn competitors(&self) -> impl ExactSizeIterator { self.keys.keys() } /// How many competitors the history knows. #[must_use] pub fn competitor_count(&self) -> usize { self.keys.len() } /// How many events have been ingested. #[must_use] pub fn event_count(&self) -> usize { self.size } /// Learning curves for all competitors, keyed by their user-facing key. #[must_use] 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. /// Configure a competitor before anything has been observed about them. /// /// The configuration a competitor needs is often a property of the domain /// rather than of any one event — "every layout is static", "this bot sits /// at a known strength". Stating it per-event means every ingestion path /// has to remember it, and the fluent and two-argument paths could not /// state it at all. /// /// ``` /// # use trueskill_tt::{History, Member}; /// let mut h = History::builder().build(); /// h.register(Member::new("layout_7").with_drift_scale(0.0))?; /// /// // Reaches a competitor first seen through any route, including the /// // two-argument one, which cannot carry configuration itself. /// h.record_winner(&"player", &"layout_7", 1)?; /// assert_eq!(h.rating(&"layout_7").unwrap().drift_scale(), 0.0); /// # Ok::<(), trueskill_tt::InferenceError>(()) /// ``` /// /// The competitor exists from this point on, with no appearances, so /// [`History::rating`] can read back what was actually stored — the /// diagnostic that was previously missing entirely. /// /// `weight` is per-event and has no meaning here, so a `Member` carrying a /// non-default one is rejected rather than silently ignored. /// /// # Errors /// /// `AlreadyRegistered` if the competitor already exists, whether from an /// earlier `register` or from an event. `InvalidParameter` for a `weight` /// other than 1.0, or a `drift_scale` that is negative or non-finite. pub fn register(&mut self, member: Member) -> Result<(), InferenceError> where K: std::fmt::Debug, { if member.weight != 1.0 { return Err(InferenceError::InvalidParameter { name: "weight", value: member.weight, }); } if let Some(scale) = member.drift_scale { if !scale.is_finite() || scale < 0.0 { return Err(InferenceError::InvalidParameter { name: "drift_scale", value: scale, }); } } let key = format!("{:?}", member.key); let idx = self.keys.get_or_create(&member.key); if self.competitors.contains(idx) { return Err(InferenceError::AlreadyRegistered { key }); } let mut rating = Rating::new( Gaussian::from_ms(self.mu, self.sigma), self.beta, self.drift, ); if let Some(prior) = member.prior { rating.prior = prior; } if let Some(scale) = member.drift_scale { rating.drift_scale = scale; } self.declared.insert( idx, CompetitorConfig { prior: member.prior, drift_scale: member.drift_scale, }, ); self.competitors.insert( idx, Competitor { rating, message: None, last_time: None, }, ); Ok(()) } /// The configuration in force for a competitor, or `None` if the history /// has never seen them. /// /// Reads back what was actually stored, which is what makes a /// configuration mistake detectable from outside the crate. Every other /// accessor returns what inference *inferred*; this returns what it was /// told. #[must_use] pub fn rating(&self, key: &Q) -> Option> where K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { let idx = self.keys.get(key)?; self.competitors .contains(idx) .then(|| self.competitors[idx].rating) } /// The competitor's latest posterior skill, or `None` if the history has /// never seen the key or they have no appearances. /// /// "Latest" is their own last appearance, which need not be the last slice /// in the history. For everyone at once — a leaderboard — use /// [`History::current_skills`], which is one pass rather than one per key. /// /// This reads whatever the fit currently holds. It does not converge, and /// it does not check that a previous `converge` succeeded. #[must_use] 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())) } /// Every competitor's latest posterior, keyed by user-facing key. /// /// The plural of `current_skill`, and the cheap way to build a /// leaderboard: the alternative was materialising every competitor's full /// smoothed curve via `learning_curves` only to read the last point of /// each. /// /// A competitor that is registered but has never played has no posterior /// and is absent from the map, exactly as `current_skill` returns `None` /// for them. #[must_use] pub fn current_skills(&self) -> HashMap { let mut latest: HashMap = HashMap::new(); // Time order, so a later slice overwrites an earlier one. for slice in &self.time_slices { for (competitor, skill) in slice.skills.iter() { latest.insert(competitor, skill.posterior()); } } latest .into_iter() .filter_map(|(competitor, posterior)| { self.keys.key(competitor).cloned().map(|k| (k, posterior)) }) .collect() } /// Learning curve for a single key: (time, posterior) pairs in time order. /// /// `None` if the history has never seen the key; `Some(vec![])` if it is /// registered but has no appearances. The two used to be the same empty /// `Vec`, so a typo'd key was indistinguishable from a real competitor /// awaiting their first game. #[must_use] pub fn learning_curve(&self, key: &Q) -> Option> where K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { let idx = self.keys.get(key)?; Some( 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. #[must_use] pub fn filtered_learning_curves(&self) -> HashMap> { let mut data: HashMap> = HashMap::new(); for (time, step) in self.filtered_pass(&HashSet::new()) { for (competitor, posterior) in step.posteriors { if let Some(key) = self.keys.key(competitor).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. /// /// `None` for an unknown key, as with `learning_curve`. #[must_use] pub fn filtered_learning_curve(&self, key: &Q) -> Option> where K: Borrow, Q: Hash + Eq + ?Sized, { let idx = self.keys.get(key)?; Some( self.filtered_pass(&HashSet::new()) .into_iter() .filter_map(|(time, step)| { step.posteriors .iter() .find(|(competitor, _)| *competitor == 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 competitors = &self.competitors; #[cfg(feature = "rayon")] { use rayon::prelude::*; let per_slice: Vec = self .time_slices .par_iter() .map(|ts| ts.log_evidence(targets, forward, competitors)) .collect(); per_slice.into_iter().sum() } #[cfg(not(feature = "rayon"))] { self.time_slices .iter() .map(|ts| ts.log_evidence(targets, forward, competitors)) .sum() } } /// Total log-evidence across the history. #[must_use] 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. /// /// # Errors /// /// `UnknownKey` for any key the history has never seen. /// /// This used to `filter_map` unknown keys away, and an empty target list /// means *no restriction* downstream — so a list of entirely unknown keys /// returned the **whole-history** value. Measured on a two-cohort fixture, /// `log_evidence_for(["typo"])` returned exactly `log_evidence()`. On the /// one workload this is documented for, that silently yields the /// un-held-out score: a plausible number that invalidates the comparison it /// was computed for. pub fn log_evidence_for(&self, keys: &[&Q]) -> Result where K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized + std::fmt::Debug, { let targets: Vec = self.resolve_targets(keys)?.into_iter().collect(); Ok(self.log_evidence_internal(false, &targets)) } /// Intern a key list, refusing the whole list if any key is unknown. /// /// Shared by the two `*_for` evidence accessors so they cannot drift apart /// on how an unknown key is reported. fn resolve_targets(&self, keys: &[&Q]) -> Result, InferenceError> where K: Borrow, Q: Hash + Eq + ?Sized + std::fmt::Debug, { let mut targets = HashSet::with_capacity(keys.len()); for (member, key) in keys.iter().enumerate() { let idx = self .keys .get(*key) .ok_or_else(|| InferenceError::UnknownKey { team: 0, member, key: format!("{key:?}"), })?; targets.insert(idx); } Ok(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. /// /// `targets` restricts each step's evidence sum; the messages carried /// forward are unaffected. See `TimeSlice::filtered_step`. fn filtered_pass(&self, targets: &HashSet) -> 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.competitors, targets); for &(competitor, posterior) in &step.posteriors { messages.insert(competitor, 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(&HashSet::new()) .iter() .map(|(_, step)| step.log_evidence) .sum() } /// Filtered log-evidence restricted to events involving at least one of /// the given keys. /// /// The intersection of the two axes the other three evidence accessors /// span: forward-only *and* key-restricted, which is what per-competitor /// prequential scoring needs. `log_evidence_for` is the smoothed /// counterpart, and its per-event priors carry information from events /// that had not happened yet — so it is not the quantity for scoring a /// competitor's history *as it unfolded*. /// /// Restricting filters which events are *scored*, not which are *run*: the /// forward messages still absorb every event, so this is a held-out score /// under the real history, not a score under a counterfactual one where /// nobody else played. /// /// Runs a full forward pass per call and caches nothing. /// /// # Errors /// /// `UnknownKey` for any key the history has never seen — see /// `log_evidence_for` for why an unknown key must not be skipped. pub fn filtered_log_evidence_for(&self, keys: &[&Q]) -> Result where K: Borrow, Q: Hash + Eq + ?Sized + std::fmt::Debug, { let targets = self.resolve_targets(keys)?; Ok(self .filtered_pass(&targets) .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() { // A key can be missing two ways — never interned, or interned // with no recorded skill — and both mean the same thing to a // caller, so they take the same branch. let skill = self.keys.get(*key).and_then(|index| { self.time_slices .iter() .rev() .find_map(|ts| ts.skills.get(index).map(|s| s.posterior())) }); let skill = match skill { Some(skill) => skill, None => match self.unknown_keys { crate::UnknownKeys::Prior => Gaussian::from_ms(self.mu, self.sigma), crate::UnknownKeys::Reject => { return Err(InferenceError::UnknownKey { team: team_idx, member: member_idx, key: format!("{key:?}"), }); } }, }; // The single gate every prediction path reads skills through. // // `converge` refuses to report a NaN fit, but nothing stopped a // caller ignoring that error and predicting anyway. Measured on // a point-mass-prior history with `beta(0.0)`, after `converge` // returned `NonFiniteResult`: `predict_quality` gave `Ok(NaN)`, // `predict_outcome().total()` gave `NaN`, and // `predict_win_probabilities` gave `Ok([0.0, 0.0])` — finite, // plausible, and summing to zero against a doc that promises // one. That last shape is the dangerous one, and it is exactly // what a caller checking `total() ≈ 1` would catch on the // others and miss here. // // Checked on `mu` / `sigma`, not on the natural parameters. // Measured: a point-mass posterior is `pi = inf`, which is a // legitimate converged state that reads back as `mu = 0`, // `sigma = 0` — a natural-parameter finiteness check rejects // it and turns a working prediction into an error. The // question here is whether the *usable* moments exist, and // `mu()` / `sigma()` are what every prediction path consumes. // // This also catches an improper skill (`pi <= 0`), which // `sigma()` reports as infinite. Nothing produces one as a // stored posterior today, and a prediction from an // uninformative skill would be meaningless if it did. if !skill.mu().is_finite() || !skill.sigma().is_finite() { return Err(InferenceError::NonFiniteResult { context: "prediction read a skill with no usable mean or \ variance; the fit did not converge", step: (skill.mu(), skill.sigma()), }); } members.push(skill); } gathered.push(members); } // Degenerate performances: `beta == 0` with every skill a point mass. // // Every prediction here is a statement about how performances *vary*, // and in this configuration nothing varies. The consequences were three // different wrong answers rather than one error. `predict_quality` // **panicked** — "cannot invert a singular matrix", from a // `Result`-returning method, on a history that had converged cleanly — // because the contrast covariance `beta^2 A^T A + A^T S A` is exactly // singular. `predict_win_probabilities` returned `Ok([0.0, 0.0])` // against a doc that promises they sum to one at `p_draw == 0`; the // promise assumes continuous performances, where an exact tie has // measure zero, and point masses break that assumption rather than the // arithmetic. // // Checked once here, at the gate every prediction path reads skills // through, rather than per method — the condition is the same one each // time and it is a property of the parameters, not a numerical // accident. if self.beta == 0.0 && gathered.iter().flatten().all(|g| g.sigma() == 0.0) { return Err(InferenceError::InvalidParameter { name: "beta with point-mass skills", value: 0.0, }); } 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 `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`. /// /// Every prediction reads skills through one gate, which adds two errors to /// all of them: `NonFiniteResult` if a skill has no usable mean or variance /// — the fit did not converge — and `InvalidParameter` if `beta` is zero /// and every skill is a point mass, leaving no performance distribution to /// predict from. 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)) } /// The joint posterior precision over the whole history, time-expanded. /// /// A competitor's skill is not one variable but one per appearance, linked /// by drift. That is the point of Through Time, and it is why a joint over /// a single slice answers almost nothing: competitors are each read at /// *their own* last appearance, and in a history with per-day or per-event /// slices those are different slices. A 76-slice history whose last slice /// holds one competitor can answer no pairwise question at all. /// /// Variables are `(competitor, appearance)`. Factors are the prior on a /// first appearance, the drift between consecutive appearances, and the /// within-slice event contrasts. Consecutive appearances with zero drift /// variance are the same variable rather than two joined by an infinite /// precision, which keeps the matrix positive-definite when a competitor is /// pinned with `drift_scale = 0`. /// /// Returns the matrix, each competitor's row at its latest appearance, and /// the row at each `(competitor, slice)` for time-addressed queries. /// Drift below which two consecutive appearances are one latent variable. /// /// The rule used to be `drift <= 0.0` exactly, and everything above it got an /// explicit `1.0 / drift` precision. That is a representation the matrix cannot /// hold: at `drift = 1e-16` the entry is `1e16`, and `1e16 + 0.28` rounds back /// to `1e16`, so the prior and the event contrasts are annihilated in the /// stored `f64` before the factorisation ever runs. Measured, `drift_scale = /// 1e-10` returned a posterior variance **12 000x too small** — a 111x /// overconfident interval — as `Ok`, and the band just above it returned a /// misleading `JointUnavailable`. /// /// Solved exactly in high precision the same system is perfectly well /// conditioned: it converges smoothly onto the collapsed value and is flat from /// `1e-16` down to `1e-40`. So this is a representation problem, not a /// conditioning one, and scaling cannot fix it — symmetric (Jacobi) /// equilibration was measured **30x worse**, because the information is already /// gone from the assembled matrix by the time a solver sees it. /// /// The threshold balances the two errors that trade off here. Ignoring a real /// drift costs roughly `drift / V`; representing one costs roughly /// `EPSILON * V / drift`, since `1 / drift` swamps the other precisions in the /// row. They cross at `drift ~ V * sqrt(EPSILON)`, which is what this returns. /// `V` is the competitor's own prior variance, so the threshold follows the /// scale each competitor is actually measured on. /// /// Ordinary drift is far above this and is unaffected: the crate's default /// `gamma = 25/300` accumulates `0.0069` per unit time against a threshold of /// `1.0e-6` at the default prior. fn collapse_threshold(prior_variance: f64) -> f64 { // sqrt(f64::EPSILON), as a const rather than a runtime sqrt. const SQRT_EPSILON: f64 = 1.490_116_119_384_765_6e-8; prior_variance * SQRT_EPSILON } fn time_expanded_joint(&self) -> TimeExpanded { let mut latest: HashMap = HashMap::new(); let mut at_slice: HashMap<(Index, usize), usize> = HashMap::new(); // Row of a competitor's previous appearance, and the drift variance // separating it from the current one. let mut previous: HashMap = HashMap::new(); let mut drift_links: Vec<(usize, usize, f64)> = Vec::new(); let mut first_rows: Vec<(usize, Index)> = Vec::new(); let mut n = 0usize; for (slice_idx, slice) in self.time_slices.iter().enumerate() { for (competitor, elapsed) in slice.appearances() { let rating = &self.competitors[competitor].rating; let row = match previous.get(&competitor) { None => { let row = n; n += 1; first_rows.push((row, competitor)); row } Some(&prev) => { let drift = rating.drift_variance_for_elapsed(elapsed); if drift <= Self::collapse_threshold(rating.prior.variance()) { // No drift, or too little to represent: the same // latent skill, not two. See `collapse_threshold`. prev } else { let row = n; n += 1; drift_links.push((prev, row, drift)); row } } }; previous.insert(competitor, row); latest.insert(competitor, (row, slice_idx)); at_slice.insert((competitor, slice_idx), row); } } let mut lambda = vec![0.0; n * n]; for (row, competitor) in first_rows { lambda[row * n + row] += 1.0 / self.competitors[competitor].rating.prior.sigma().powi(2); } for (a, b, drift) in drift_links { lambda[a * n + a] += 1.0 / drift; lambda[b * n + b] += 1.0 / drift; lambda[a * n + b] -= 1.0 / drift; lambda[b * n + a] -= 1.0 / drift; } for (slice_idx, slice) in self.time_slices.iter().enumerate() { for (contrast, noise) in slice.scored_contrasts(&self.competitors) { for (ia, ca) in &contrast { let ra = at_slice[&(*ia, slice_idx)]; for (ib, cb) in &contrast { let rb = at_slice[&(*ib, slice_idx)]; lambda[ra * n + rb] += ca * cb / noise; } } } } TimeExpanded { lambda, latest, at_slice, width: n, } } /// Resolve `terms` into a contrast over the time-expanded rows, the /// coefficients of competitors the history has never seen, and the mean. /// /// `row_for` picks which appearance of a competitor the caller means — /// their latest, or the one at a given time. fn resolve_terms( &self, terms: &[(&K, f64)], width: usize, row_for: impl Fn(Index) -> Option<(usize, usize)>, ) -> Result where K: std::fmt::Debug, { let mut contrast = vec![0.0; width]; let mut unseen: BTreeMap = BTreeMap::new(); let mut mean = 0.0; for (member, (key, coefficient)) in terms.iter().enumerate() { let located = self .keys .get(*key) .and_then(|index| row_for(index).map(|located| (index, located))); match located { Some((index, (row, slice_idx))) => { contrast[row] += coefficient; mean += coefficient * self.time_slices[slice_idx] .skills .get(index) .expect("row came from this slice") .posterior() .mu(); } None => match self.unknown_keys { crate::UnknownKeys::Prior => { mean += coefficient * self.mu; *unseen.entry(format!("{key:?}")).or_insert(0.0) += coefficient; } crate::UnknownKeys::Reject => { return Err(InferenceError::UnknownKey { team: 0, member, key: format!("{key:?}"), }); } }, } } Ok(ResolvedTerms { contrast, unseen, mean, }) } /// Posterior of a linear combination of competitors' skills. /// /// `terms` pairs each competitor with its coefficient, so /// `[(a, 1.0), (b, -1.0)]` is the difference `a - b` and /// `[(score, 1.0), (layout, 1.0)]` is their sum. /// /// # Why this exists /// /// Every other accessor returns a per-competitor marginal, and combining /// marginals assumes independence. Competitors are correlated through every /// event they share — that coupling is the mechanism the model exists to /// exploit — so `sqrt(sa^2 + sb^2)` overstates the width of a difference. /// Measured against the exact posterior on a five-competitor round robin, /// the correlation is +0.857 and the naive form is 2.6x too wide. /// /// The mean is the same combination of the marginal means, which message /// passing already gets exactly right. Only the variance needs the joint. /// /// # Which appearance each competitor is read at /// /// Each competitor is read at *their own* latest appearance, which is where /// [`History::current_skill`] reads them too, so the two agree about which /// posterior they describe. That matters in a Through-Time history: with /// per-day or per-event slices, competitors are rarely all present in any /// one of them. Use [`History::posterior_of_at`] to pin a time instead. /// /// # Asking more than one question /// /// This factorises the joint, uses it once, and throws it away. The /// factorisation is the expensive part and it depends only on the fit, so /// asking `n` questions this way pays for it `n` times. Take a /// [`Joint`] with [`History::joint`] instead — the answers are identical, /// and only the first one pays. /// /// # Cost /// /// A dense solve over the history's *appearances*, not its competitors. A /// drift-free competitor collapses to a single variable however long the /// history, so the same events can differ enormously in cost depending on /// the drift configuration — see [`Joint`], which also amortises this /// across many questions. /// /// # Limitations /// /// Exact only for a history whose events are all scored, because a scored /// likelihood is Gaussian and its factor can be rebuilt exactly. A ranked /// outcome's truncation is approximated by EP, and reconstructing those /// factors needs the converged messages, which inference does not retain — /// so a history containing ranked events returns `JointUnavailable` rather /// than a plausible wrong number. /// /// # Errors /// /// `UnknownKey` for a competitor the history has never seen, and /// `JointUnavailable` for ranked events or a system that is not /// positive-definite. pub fn posterior_of(&self, terms: &[(&K, f64)]) -> Result where K: std::fmt::Debug, { self.joint()?.posterior_of(terms) } /// Posterior of a linear combination, read as of `time`. /// /// Each competitor is taken at their latest appearance at or before `time`, /// which is the same reading [`History::learning_curve`] gives. Use this /// when a comparison must be anchored to a moment — "how did these two /// stand at the end of last season" — rather than to wherever each /// competitor was last seen. /// /// As with [`History::posterior_of`], this factorises the joint for one /// question; [`History::joint`] amortises that across many. /// /// # Errors /// /// As [`History::posterior_of`], plus `UnknownKey` for a competitor with no /// appearance at or before `time`. pub fn posterior_of_at(&self, time: T, terms: &[(&K, f64)]) -> Result where K: std::fmt::Debug, { self.joint()?.posterior_of_at(time, terms) } /// How much observing this matchup would shrink the variance of `target`. /// /// `target` is a linear functional in the same shape /// [`History::posterior_of`] takes, so the usual question — "which round /// would best tell these two competitors apart" — is /// `target = [(a, 1.0), (b, -1.0)]` scored across candidate matchups. /// /// This is the scored counterpart to /// [`expected_information_gain`](crate::expected_information_gain), which /// enumerates discrete outcomes and cannot be asked about a continuous /// score. It is also far cheaper: one linear solve rather than a full /// inference pass per possible outcome. /// /// Scoring a field of candidates is the whole point of this call, and each /// candidate is one question against an unchanged fit — so use /// [`Joint::expected_variance_reduction`] for anything past a single /// candidate, or pay for the factorisation once per candidate. /// /// # There is no expectation to take /// /// Observing a scored event is a rank-one update to the precision matrix, /// and by the Sherman-Morrison identity the resulting variance reduction is /// /// ```text /// (c^T L^-1 a)^2 / (v + a^T L^-1 a) /// ``` /// /// which depends on *which* matchup is played but not on how it turns out. /// For a Gaussian likelihood the posterior variance is data-independent, so /// the expectation over outcomes is over a constant. The name keeps the /// term the active-learning literature uses; no averaging happens. /// /// Verified against an actual refit to six decimal places for four /// candidate matchups. /// /// # Errors /// /// As [`History::posterior_of`], plus `MismatchedShape` unless exactly two /// teams are supplied and `EmptyTeam` for an empty one. pub fn expected_variance_reduction( &self, teams: &[&[&K]], target: &[(&K, f64)], ) -> Result where K: std::fmt::Debug, { self.joint()?.expected_variance_reduction(teams, target) } /// Factorise the joint posterior once, to answer many questions against it. /// /// [`History::posterior_of`] and its neighbours each build and factorise /// the joint, use it once, and drop it. The factorisation is `O(n^3)` in /// the history's *appearances* and depends only on the fit, so a caller /// asking about every pair in a standings table, every cell in a grid, or /// every candidate in an active-learning sweep pays for the same /// factorisation once per question. /// /// A `Joint` pays it once. Each subsequent query is `O(n^2)` — one forward /// substitution — and returns exactly what the one-shot call would. /// /// ``` /// # use trueskill_tt::smallvec::smallvec; /// # use trueskill_tt::{Event, History, Member, Outcome, Team}; /// # let mut h = History::builder().score_sigma(1.0).build(); /// # let round = |x, y, sx, sy, t| Event { /// # time: t, /// # teams: smallvec![ /// # Team::with_members([Member::new(x)]), /// # Team::with_members([Member::new(y)]), /// # ], /// # outcome: Outcome::scores([sx, sy]), /// # }; /// # h.add_events(vec![ /// # round("a", "b", 3.0, 1.0, 1), /// # round("b", "c", 2.0, 1.0, 2), /// # ]).unwrap(); /// # h.converge().unwrap(); /// let joint = h.joint()?; /// for (a, b) in [("a", "b"), ("a", "c"), ("b", "c")] { /// let gap = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)])?; /// println!("{a} - {b}: {:.3} +/- {:.3}", gap.mu(), gap.sigma()); /// } /// # Ok::<(), trueskill_tt::InferenceError>(()) /// ``` /// /// The handle borrows the history, so the borrow checker enforces what a /// cache would otherwise have to invalidate: no events can be added and no /// refit can run while it is alive. Drop it to release the factorisation, /// which is `n^2` floats and is the largest thing this crate allocates. /// /// # Errors /// /// `JointUnavailable` if the history is empty, contains ranked events, or /// yields a precision matrix that is not positive-definite. pub fn joint(&self) -> Result, InferenceError> { if self.time_slices.is_empty() { return Err(InferenceError::JointUnavailable { reason: "the history has no events", }); } if !self.time_slices.iter().all(TimeSlice::all_scored) { return Err(InferenceError::JointUnavailable { reason: "the history contains ranked events, whose EP factors are \ not retained after convergence", }); } let TimeExpanded { lambda, latest, at_slice, width, } = self.time_expanded_joint(); let cholesky = crate::joint::Cholesky::factor(lambda, width).ok_or( InferenceError::JointUnavailable { reason: "the precision matrix is not positive-definite; the usual \ cause is a competitor with neither a proper prior nor any \ evidence, but an extreme prior or drift can also make the \ assembled matrix indefinite in floating point", }, )?; Ok(Joint { history: self, cholesky, latest, at_slice, width, }) } /// Predictive distribution of the score margin between two teams. /// /// Answers "what will the gap be, and how wide is that interval" for a /// scored matchup, composing the three things that make it uncertain: how /// unsure the model is about the competitors, their per-event performance /// noise, and the observation noise on the score itself. /// /// The interval widens as the model knows less. Measured on a fixture where /// one opponent has forty rounds and another has one, the margin's sigma /// goes from 2.48 to 3.38 — which is the property a caller most needs and /// the one a hand-fitted noise law tends to lose. /// /// # Why a margin rather than a score /// /// The model never sees an absolute score. Scored ingestion reduces each /// event to `score_a - score_b` before inference, so shifting every score /// in a history by a constant produces a bit-identical fit. There is /// therefore no information from which to predict what a competitor will /// *score*; only what the gap between two of them will be. Asking for an /// absolute score would return a number derived entirely from the prior, /// which is the kind of plausible constant this crate tries not to hand out. /// /// # Errors /// /// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam`, /// `UnknownKey` under [`UnknownKeys::Reject`](crate::UnknownKeys::Reject), /// and `JointUnavailable` if the history is empty or holds ranked events in /// *any* slice — not merely the latest one. pub fn predict_margin(&self, teams: &[&[&K]]) -> Result where K: std::fmt::Debug, { if teams.len() != 2 { return Err(InferenceError::MismatchedShape { kind: "predict_margin takes exactly 2 teams", expected: 2, got: teams.len(), }); } let mut terms: Vec<(&K, f64)> = Vec::new(); let mut performance_noise = 0.0; for (team_idx, team) in teams.iter().enumerate() { if team.is_empty() { return Err(InferenceError::EmptyTeam { team: team_idx }); } let sign = if team_idx == 0 { 1.0 } else { -1.0 }; for key in team.iter() { terms.push((*key, sign)); // Each member contributes its own performance noise to the // margin regardless of which side it is on. let beta = self .keys .get(*key) .map_or(self.beta, |index| self.competitors[index].rating.beta); performance_noise += beta * beta; } } let skill_gap = self.posterior_of(&terms)?; let variance = skill_gap.sigma().powi(2) + performance_noise + self.score_sigma.powi(2); Ok(Gaussian::from_mv(skill_gap.mu(), variance)) } /// 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. /// /// # 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` and `TooManyTeams` for the /// shape of the request, `GridTooCoarse` when the performance sigmas are /// too far apart to integrate on one grid, and anything inference returns /// for a hypothetical outcome. /// /// Every prediction reads skills through one gate, which adds two errors to /// all of them: `NonFiniteResult` if a skill has no usable mean or variance /// — the fit did not converge — and `InvalidParameter` if `beta` is zero /// and every skill is a point mass, leaving no performance distribution to /// predict from. 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`. /// /// Every prediction reads skills through one gate, which adds two errors to /// all of them: `NonFiniteResult` if a skill has no usable mean or variance /// — the fit did not converge — and `InvalidParameter` if `beta` is zero /// and every skill is a point mass, leaving no performance distribution to /// predict from. 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`. /// `GridTooCoarse` when the performance sigmas are too far apart to /// integrate on one grid. /// /// Every prediction reads skills through one gate, which adds two errors to /// all of them: `NonFiniteResult` if a skill has no usable mean or variance /// — the fit did not converge — and `InvalidParameter` if `beta` is zero /// and every skill is a point mass, leaving no performance distribution to /// predict from. 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. /// /// # 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 `MismatchedShape` if /// `ranks` does not have one entry per team. `GridTooCoarse` when the /// performance sigmas are too far apart to integrate on one grid. /// /// Every prediction reads skills through one gate, which adds two errors to /// all of them: `NonFiniteResult` if a skill has no usable mean or variance /// — the fit did not converge — and `InvalidParameter` if `beta` is zero /// and every skill is a point mass, leaving no performance distribution to /// predict from. 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)?; crate::predict::ranking_probability(&performances, &self.margins(&sizes), ranks) } /// Run the full forward+backward convergence loop to a fixed point. /// /// # Stopping short is an error /// /// Hitting `max_iter` without reaching `epsilon` returns `NotConverged`. /// /// It used to return `Ok` with `converged: false`, which was the worst /// available shape. A fit that stops short is *wrong by a little*: every /// posterior is finite, the ordering looks sensible, and nothing about the /// output says the numbers were still moving. Detection was opt-in, and /// `let _ = h.converge()` silently opted out — which is how a real defect /// hid in this crate's own test suite. /// /// The default `max_iter` is [`ITERATIONS`](crate::ITERATIONS), which is /// set high enough that reaching it means something is genuinely wrong /// rather than that the history is merely large. Raising the cap costs /// nothing when it is not needed, because the loop exits at `epsilon`. /// /// Use [`History::converge_partial`] when a capped, unconverged fit is /// what you actually want. /// /// # Errors /// /// `NotConverged` if the sweep hits `max_iter` with the step still above /// `epsilon`. /// /// `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. /// /// `InvalidParameter` if a competitor's drift model yields a negative or /// non-finite variance — which also covers a custom [`Drift`] /// implementation, the one case no constructor can check. pub fn converge(&mut self) -> Result { let report = self.converge_partial()?; if report.converged { Ok(report) } else { Err(InferenceError::NotConverged { iterations: report.iterations, final_step: report.final_step, epsilon: self.convergence.epsilon, }) } } /// As [`History::converge`], but a fit that stops at `max_iter` is /// returned rather than reported as an error. /// /// The report's `converged` flag says which happened. Use this when a /// deliberately capped sweep is the point — a cheap approximate fit, or a /// test that pins what a fixed number of iterations produces. Prefer /// `converge` everywhere else: an unconverged fit that nobody checks is /// indistinguishable from a converged one. /// /// # Errors /// /// `NonFiniteResult` if a sweep produces a NaN or infinite step. /// /// `InvalidParameter` if a competitor's drift model yields a negative or /// non-finite variance. Checked here, before any sweeping, so it applies to /// `converge` too. #[must_use = "this fit may have stopped at `max_iter` — check `converged`, \ or bind it to `_` to say you have decided not to"] pub fn converge_partial(&mut self) -> Result { use std::time::Instant; use smallvec::SmallVec; let opts = self.convergence; // Drift is the one model parameter with no boundary check, because // `HistoryBuilder::drift` is generic over `Drift` and cannot inspect // an arbitrary implementation. Validate what it actually produces // instead, which also covers a custom impl. // // `ConstantDrift` returns `elapsed * gamma * gamma`, so a negative // gamma is squared away: measured, `ConstantDrift::new(-0.0833)` gave // results **bit identical** to `+0.0833`, the same sign-absorption // defect already rejected for `sigma` and `beta`. A non-finite gamma // poisons every posterior derived from it. for slice in &self.time_slices { for (competitor, elapsed) in slice.appearances() { let drift = self.competitors[competitor] .rating .drift_variance_for_elapsed(elapsed); if !drift.is_finite() || drift < 0.0 { return Err(InferenceError::InvalidParameter { name: "drift variance", value: drift, }); } } } 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 event shape, for the same reason as the tie check // below: every ingestion route lands here. // // `run_chain` builds one diff link per adjacent pair of teams, so a // one-team event leaves it with an empty link vector and panics // indexing `links[1..]` — a reachable panic from safe API, in release. // An empty team is the quieter half: it contributes no performance, // so a malformed event yields a finite, plausible-looking posterior // for whoever it was matched against. // // Both errors already existed; they were only ever checked on the // prediction paths, which is why ingestion could still produce them. for teams in &composition { if teams.len() < 2 { return Err(InferenceError::NotEnoughTeams { got: teams.len() }); } for (team, members) in teams.iter().enumerate() { if members.is_empty() { return Err(InferenceError::EmptyTeam { team }); } } } // A non-finite outcome poisons the history rather than failing it: // `converge` does report `NonFiniteResult`, but a caller who reads // `current_skill` before converging is handed a NaN posterior with // nothing to say it is one. if let Some(results) = results.as_ref() { for (event_results, kind) in results.iter().zip(kinds.iter()) { let name = match kind { EventKind::Ranked => "rank", EventKind::Scored { .. } => "score", }; for value in event_results { if !value.is_finite() { return Err(InferenceError::InvalidParameter { name, value: *value, }); } } } } // A non-finite weight is not a weight. Measured, it behaves exactly as // `0.0` — the member contributes nothing — while `converge` reports // `converged: true` after one iteration with a step of `(0.0, 0.0)`. // So a NaN arriving from a division or a parse is indistinguishable // from a deliberate zero, and looks like a clean fit. // // Zero and negative weights stay accepted: both are expressible // choices about how much a member contributes, and // `tests/degenerate_inputs.rs` pins them deliberately. Only the values // that are not quantities at all are rejected. if let Some(weights) = weights.as_ref() { for team_weights in weights.iter().flatten() { for weight in team_weights { if !weight.is_finite() { return Err(InferenceError::InvalidParameter { name: "weight", value: *weight, }); } } } } // 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 }); } } } // Cross-batch conflict. The in-batch check upstream rejects one batch // that sets a field twice; `priors` is rebuilt per call, so without // this a *second* batch could quietly overwrite what a first one // declared, last-write-wins. // // That asymmetry cut against the invariant `tests/ingestion_equivalence.rs` // exists to protect: the same contradictory events errored when // batched and succeeded, order-dependently, when fed one at a time. // Checked before anything mutates, so a rejected batch leaves the // history untouched. // Sorted, not `HashMap` order. This loop returns on the FIRST conflict // it finds, so hash order decided *which* competitor the error blamed: // measured, 15 different competitors named across 40 runs on identical // input. The error fired every time — only its content was a lottery, // which makes it unreproducible and sends a reader after the wrong key. let mut conflict_scan: Vec = priors.keys().copied().collect(); conflict_scan.sort_unstable(); for competitor in &conflict_scan { let batch = priors[competitor]; let held = self.declared.get(competitor).copied().unwrap_or_default(); if let (Some(existing), Some(new)) = (held.prior, batch.prior) { if existing != new { return Err(InferenceError::ConflictingCompetitorConfig { competitor: competitor.get(), field: "prior", }); } } if let (Some(existing), Some(new)) = (held.drift_scale, batch.drift_scale) { if existing != new { return Err(InferenceError::ConflictingCompetitorConfig { competitor: competitor.get(), field: "drift_scale", }); } } } for (competitor, batch) in &priors { let entry = self.declared.entry(*competitor).or_default(); if batch.prior.is_some() { entry.prior = batch.prior; } if batch.drift_scale.is_some() { entry.drift_scale = batch.drift_scale; } } competitor::clean(self.competitors.values_mut(), true); let mut these_competitors = Vec::with_capacity(1024); for competitor in composition.iter().flatten().flatten() { if these_competitors.contains(competitor) { continue; } these_competitors.push(*competitor); // From `declared` rather than `priors`: a competitor configured by // `register` before any event has nothing in this batch's map. let config = self.declared.get(competitor).copied().unwrap_or_default(); if self.competitors.contains(*competitor) { // 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.competitors.get_mut(*competitor).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(*competitor) { 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.competitors.insert( *competitor, 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.competitors); } for competitor_idx in &these_competitors { if let Some(skill) = time_slice.skills.get_mut(*competitor_idx) { skill.elapsed = time_slice::compute_elapsed( self.competitors[*competitor_idx].last_time.as_ref(), &time_slice.time, ); let competitor = self.competitors.get_mut(*competitor_idx).unwrap(); competitor.last_time = Some(time_slice.time); competitor.message = Some(time_slice.forward_prior_out(competitor_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.competitors, ); for competitor_idx in time_slice.skills.keys() { let competitor = self.competitors.get_mut(competitor_idx).unwrap(); competitor.last_time = Some(t); competitor.message = Some(time_slice.forward_prior_out(&competitor_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.competitors, ); self.time_slices.insert(k, time_slice); let time_slice = &self.time_slices[k]; for competitor_idx in time_slice.skills.keys() { let competitor = self.competitors.get_mut(competitor_idx).unwrap(); competitor.last_time = Some(t); competitor.message = Some(time_slice.forward_prior_out(&competitor_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.competitors); for competitor_idx in &these_competitors { if let Some(skill) = time_slice.skills.get_mut(*competitor_idx) { skill.elapsed = time_slice::compute_elapsed( self.competitors[*competitor_idx].last_time.as_ref(), &time_slice.time, ); let competitor = self.competitors.get_mut(*competitor_idx).unwrap(); competitor.last_time = Some(time_slice.time); competitor.message = Some(time_slice.forward_prior_out(competitor_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. (Weights cannot mismatch here — they /// come one-per-`Member`; that check belongs to /// [`EventBuilder::weights`](crate::EventBuilder::weights), which builds /// them from a separate list.) /// - `NotEnoughTeams` for an event with fewer than two teams, and /// `EmptyTeam` for a team with no members. /// - `InvalidParameter` for a per-event `score_sigma` override that is not /// strictly positive, a non-finite score, rank or weight, or a /// `drift_scale` that is negative or non-finite. /// - `ConflictingCompetitorConfig` if one competitor is given two different /// values for `prior` or `drift_scale`, whether within one batch or /// across batches. /// - `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, score_sigma, } => { let resolved = score_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) } } /// Summarising rather than exhaustive. /// /// A `History` owns every competitor's skill at every time slice, so a derived /// `Debug` would print the entire fit — megabytes for a real history, and /// useless in a log. This prints the shape instead. Same reasoning as `Joint`'s, /// which omits its `n^2` factorisation. /// /// It exists at all because without it a consumer cannot `#[derive(Debug)]` on /// any struct holding a `History`, which is how both known consumers store it. impl, O: Observer, K: Eq + Hash + Clone> std::fmt::Debug for History { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("History") .field("competitors", &self.keys.len()) .field("events", &self.size) .field("time_slices", &self.time_slices.len()) .field("mu", &self.mu) .field("sigma", &self.sigma) .field("beta", &self.beta) .field("p_draw", &self.p_draw) .field("score_sigma", &self.score_sigma) .field("unknown_keys", &self.unknown_keys) .finish_non_exhaustive() } } /// A factorised joint posterior, reusable across many queries. /// /// Built by [`History::joint`]. Every question the joint answers — the width of /// a contrast, the covariance of two, how much a candidate matchup would /// sharpen either — is a bilinear form in the inverse precision matrix, and all /// of them share one factorisation. That factorisation is the whole cost: /// `O(n^3)` in the history's appearances to build, `O(n^2)` per question after. /// /// The handle borrows the history, so no refit can run and no events can be /// added while it is alive. That is what makes it correct without any /// invalidation logic: there is no window in which the factorisation could /// describe a fit that no longer exists. /// /// # What the cost actually scales in /// /// Not competitors, and not slices times competitors. One variable per /// *appearance* — a competitor per slice they appear in — minus every /// consecutive pair with no drift between them, which collapse to a single /// latent variable. /// /// That last clause dominates, and it is not obvious. A competitor whose drift /// is zero contributes **one** variable however long the history: whole-history /// `gamma = 0`, or `drift_scale = 0` on that competitor. So two fits over the /// same events and the same slices can differ in problem size by roughly the /// slice count, and in factorisation time by its cube. Measured by a consumer /// on a ~2,000-node model over 76 slices: /// /// ```text /// career fit (gamma = 0) 787 ms per solve /// drifting fit (gamma = 0.15) 6214 ms per solve /// ``` /// /// Choosing between a drifting and a drift-free configuration is therefore also /// choosing an 8x difference in query cost. [`Joint::variables`] reports the /// number that decides it, and can be read before committing to a batch of /// queries. /// /// Slices a competitor sits out cost nothing: an absence is not an appearance, /// so a competitor seen in the first and last of a hundred slices contributes /// two variables, not a hundred. #[must_use] pub struct Joint<'h, T: Time, D: Drift, O: Observer, K: Eq + Hash + Clone> { history: &'h History, cholesky: crate::joint::Cholesky, /// `(row, slice)` of each competitor's latest appearance. latest: HashMap, /// Row of each `(competitor, slice)` appearance. at_slice: HashMap<(Index, usize), usize>, /// Side length of the precision matrix. width: usize, } /// Deliberately does not print the factorisation, which is `n^2` floats and /// would make a `{:?}` of a large joint unreadable and slow. impl, O: Observer, K: Eq + Hash + Clone> std::fmt::Debug for Joint<'_, T, D, O, K> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Joint") .field("variables", &self.width) .finish_non_exhaustive() } } impl, O: Observer, K: Eq + Hash + Clone> Joint<'_, T, D, O, K> { /// Number of variables in the joint: the history's appearances, after /// collapsing consecutive pairs a competitor does not drift between. /// /// This is what the cost scales in — `O(n^3)` to factorise, `O(n^2)` per /// query — and it is neither the competitor count nor slices times /// competitors. A drift-free competitor contributes one variable however /// many slices they appear in; see the type docs for how large that /// difference gets. /// /// Worth reading before committing to a batch of queries: it is the one /// number that says whether a joint over this history is affordable. #[must_use] pub fn variables(&self) -> usize { self.width } /// Turn a resolved functional into its posterior. /// /// The variance is `|L^-1 c|^2` over the competitors the history knows, /// plus an independent prior variance for each competitor it does not — /// unseen competitors are uncorrelated with everything by construction. fn distribution(&self, resolved: &ResolvedTerms) -> Gaussian { let y = self.cholesky.whiten(&resolved.contrast); let prior_var = self.history.sigma * self.history.sigma; let variance = crate::joint::bilinear(&y, &y) + resolved .unseen .values() .map(|c| c * c * prior_var) .sum::(); Gaussian::from_mv(resolved.mean, variance) } /// Posterior of a linear combination of competitors' skills. /// /// Identical to [`History::posterior_of`], including which appearance each /// competitor is read at, without re-paying the factorisation. /// /// # Errors /// /// `UnknownKey` for a competitor the history has never seen. pub fn posterior_of(&self, terms: &[(&K, f64)]) -> Result where K: std::fmt::Debug, { let resolved = self .history .resolve_terms(terms, self.width, |index| self.latest.get(&index).copied())?; Ok(self.distribution(&resolved)) } /// Posterior of a linear combination, read as of `time`. /// /// Identical to [`History::posterior_of_at`] without re-paying the /// factorisation. /// /// # Errors /// /// `UnknownKey` for a competitor with no appearance at or before `time`. pub fn posterior_of_at(&self, time: T, terms: &[(&K, f64)]) -> Result where K: std::fmt::Debug, { let as_of = self.rows_as_of(time); let resolved = self .history .resolve_terms(terms, self.width, |index| as_of.get(&index).copied())?; Ok(self.distribution(&resolved)) } /// Latest appearance at or before `time`, per competitor. fn rows_as_of(&self, time: T) -> HashMap { let mut as_of: HashMap = HashMap::new(); for (slice_idx, slice) in self.history.time_slices.iter().enumerate() { if slice.time > time { break; } for (competitor, _) in slice.appearances() { if let Some(row) = self.at_slice.get(&(competitor, slice_idx)) { as_of.insert(competitor, (*row, slice_idx)); } } } as_of } /// How much observing this matchup would shrink the variance of `target`. /// /// Identical to [`History::expected_variance_reduction`] without re-paying /// the factorisation, which is the shape this call is normally used in: /// one target, a field of candidate matchups, one unchanged fit. /// /// # Errors /// /// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam` for /// an empty one, and `UnknownKey` for an unseen competitor. pub fn expected_variance_reduction( &self, teams: &[&[&K]], target: &[(&K, f64)], ) -> Result where K: std::fmt::Debug, { if teams.len() != 2 { return Err(InferenceError::MismatchedShape { kind: "expected_variance_reduction takes exactly 2 teams", expected: 2, got: teams.len(), }); } // The candidate matchup, expressed as the same kind of linear // functional as the target. let mut matchup: Vec<(&K, f64)> = Vec::new(); let mut noise = self.history.score_sigma * self.history.score_sigma; for (team_idx, team) in teams.iter().enumerate() { if team.is_empty() { return Err(InferenceError::EmptyTeam { team: team_idx }); } let sign = if team_idx == 0 { 1.0 } else { -1.0 }; for key in team.iter() { matchup.push((*key, sign)); let beta = self .history .keys .get(*key) .map_or(self.history.beta, |index| { self.history.competitors[index].rating.beta }); noise += beta * beta; } } let row_for = |index: Index| self.latest.get(&index).copied(); let target = self.history.resolve_terms(target, self.width, row_for)?; let matchup = self.history.resolve_terms(&matchup, self.width, row_for)?; let y_target = self.cholesky.whiten(&target.contrast); let y_matchup = self.cholesky.whiten(&matchup.contrast); let prior_var = self.history.sigma * self.history.sigma; // Competitors outside the history are independent, so they contribute // only where the same key appears in both functionals. let cross_unseen: f64 = target .unseen .iter() .map(|(k, tc)| tc * matchup.unseen.get(k).copied().unwrap_or(0.0) * prior_var) .sum(); let self_unseen: f64 = matchup.unseen.values().map(|c| c * c * prior_var).sum(); let cross = crate::joint::bilinear(&y_target, &y_matchup) + cross_unseen; let matchup_var = crate::joint::bilinear(&y_matchup, &y_matchup) + self_unseen; Ok(cross * cross / (noise + matchup_var)) } } #[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().key_type::().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::new(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.competitors, ), &[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::new(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::new(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::new(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").unwrap(); let lc_c = h.learning_curve("c").unwrap(); 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::new(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::new(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::new(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::new(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. `converge_partial` rather than // `converge`: stopping at the cap is the point here, and `converge` // now reports that as `NotConverged`. h.convergence = ConvergenceOptions { max_iter: 11, epsilon: EPSILON, alpha: 1.0, }; let _ = h.converge_partial().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::new(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::new(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::new(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").unwrap(); let lc_b = h.learning_curve("b").unwrap(); 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").unwrap(); let lc_b = h.learning_curve("b").unwrap(); 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::new(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 finite and 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); // A one-iteration cap is deliberate here, so the short fit is the // result rather than an error. let _ = h_capped.converge_partial().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("competitor 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("competitor 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("competitor 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 competitor {key:?}"); assert_eq!(a.1.tau(), b.1.tau(), "mismatch at competitor {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("competitor 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 competitor {key:?}"); assert_eq!(a.1.tau(), b.1.tau(), "mismatch at competitor {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("competitor 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("competitor missing"); for (a, b) in a_pts.iter().zip(b_pts.iter()) { assert_eq!(a.1.pi(), b.1.pi(), "mismatch at competitor {key:?}"); assert_eq!(a.1.tau(), b.1.tau(), "mismatch at competitor {key:?}"); } } } }