# TrueSkill - Through Time Bayesian skill rating over a time axis. Where plain TrueSkill gives each competitor one running estimate, TrueSkill Through Time treats a whole history as a single model and infers skill *at every point in time*. Evidence flows both directions: a result today sharpens the estimate of who someone was last year, so early estimates stop being frozen guesses and comparisons across eras become meaningful. A Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py). ## Install ```toml [dependencies] trueskill-tt = "0.8" ``` Optional features, both off by default: - `approx` — `approx`'s equality traits for `Gaussian`. Useful in tests. - `rayon` — parallelises the within-slice sweep and the per-slice passes of `learning_curves` / `log_evidence`. Results stay bit-identical regardless of worker count; `just determinism` asserts it at 1, 2, 4 and 8 threads. ## Quickstart Record results, converge, then read off skills. ```rust use trueskill_tt::History; let mut history = History::default(); history.record_winner(&"alice", &"bob", 1)?; history.record_winner(&"bob", &"carol", 2)?; history.record_winner(&"alice", &"carol", 3)?; history.converge()?; let alice = history.current_skill("alice").unwrap(); assert!(alice.mu() > 0.0, "alice won every game she played"); # Ok::<(), trueskill_tt::InferenceError>(()) ``` The third argument is the time. It is what makes this Through Time rather than plain TrueSkill: skill is inferred at each of those moments, not once at the end. `learning_curve` reads the whole trajectory back. ```rust # use trueskill_tt::History; # let mut history = History::default(); # history.record_winner(&"alice", &"bob", 1)?; # history.record_winner(&"bob", &"carol", 2)?; # history.record_winner(&"alice", &"carol", 3)?; # history.converge()?; // `None` means the key is unknown; `Some(vec![])` means known but unplayed. let curve = history.learning_curve("alice").unwrap(); for (time, skill) in &curve { println!("t={time}: {:.2} ± {:.2}", skill.mu(), skill.sigma()); } // Everyone's latest posterior in one pass — the leaderboard query. let latest = history.current_skills(); assert_eq!(latest.len(), 3); # Ok::<(), trueskill_tt::InferenceError>(()) ``` ## Teams, rankings and draws Anything beyond one-versus-one goes through the fluent event builder. An event is only recorded by the terminal `.commit()`. ```rust use trueskill_tt::History; let mut history = History::builder().p_draw(0.1).build(); history .event(1) .team(["alice", "bob"]) .team(["carol", "dave"]) .ranking([0, 1]) // lower is better; equal values are a tie .commit()?; history.converge()?; # Ok::<(), trueskill_tt::InferenceError>(()) ``` **A tie needs a positive `p_draw`.** A `p_draw` of zero asserts draws cannot happen, so a tied result has no representable likelihood and is rejected rather than fitted to something else: ```rust use trueskill_tt::{History, InferenceError}; let mut history = History::default(); // p_draw defaults to 0.0 let err = history.record_draw(&"alice", &"bob", 1).unwrap_err(); assert!(matches!(err, InferenceError::TieWithoutDrawProbability { .. })); ``` This also catches `Outcome::winner(w, n)` for three or more teams, which ties every loser. ## Which entry point? | You want to | Use | |---|---| | One match, two competitors | `record_winner` / `record_draw` | | Teams, explicit ranks, scores, per-member weights | `history.event(t)…commit()` | | A batch you already have as values | `add_events(iter)` | | Score a hypothetical with no history at all | `Game` | `Game` is the odd one out and worth being explicit about: it is a single match's factor graph, it does not participate in a `History`, and nothing it computes is remembered. Reach for it to evaluate a matchup in isolation; reach for `History` for everything that accumulates. ## `converge` is strict `converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the step still above `epsilon`, and `Err(NonFiniteStep)` if a sweep produces NaN. 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 high enough that reaching it means something is genuinely wrong rather than that the history is large; the loop exits at `epsilon` long before, so raising the cap costs nothing when it is not needed. Use `converge_partial` when a deliberately capped, unconverged fit is the point. Predictions are strict for the same reason: every `predict_*` method reads skills through one gate that refuses a NaN-poisoned fit, rather than returning a plausible number computed from it. ## Drift Skill drift models how a competitor's true skill can change between appearances. Each time they reappear after a gap, their skill uncertainty is widened by the drift model before the new evidence is incorporated. Drift is represented by the `Drift` trait (`src/drift.rs`), generic over the history's time type: ```text pub trait Drift: Copy + Debug + Send + Sync { fn variance_delta(&self, from: &T, to: &T) -> f64; fn variance_for_elapsed(&self, elapsed: i64) -> f64; } ``` Both methods return the amount to add to `σ²`, not to `σ`. `variance_delta` works from two timestamps; `variance_for_elapsed` takes an already-computed elapsed count, and is used on the paths that cache it. `Gaussian::forget` applies the result entirely in variance space — `from_mv(mu, variance() + variance_delta)` — taking no square root. That block is a quotation rather than a doctest. The custom-drift example below is compiled by CI, so it is what actually pins the signature. ### ConstantDrift The built-in `ConstantDrift` implements a linear random walk — skill uncertainty grows proportionally to time: ```text variance_delta = elapsed * γ² ``` This is the standard TrueSkill Through Time model. Pass a `ConstantDrift::new(gamma)` when constructing a `Rating`: ```rust use trueskill_tt::{ConstantDrift, Gaussian, Rating}; // gamma = 0.1 means skill can shift ~0.1 per time unit. let rating: Rating = Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, ConstantDrift::new(0.1)); assert_eq!(rating.drift().gamma(), 0.1); ``` The type annotation is load-bearing: `ConstantDrift` implements `Drift` for every `T: Time`, so without it `T` is ambiguous. ### Custom drift Implement `Drift` to express any other model. For example, a drift that saturates after a long absence, with uncertainty growing as the square root of elapsed time instead of linearly: ```rust use trueskill_tt::{Drift, Gaussian, History, Rating, Time}; #[derive(Clone, Copy, Debug)] struct SqrtDrift { gamma: f64, } impl Drift for SqrtDrift { fn variance_delta(&self, from: &T, to: &T) -> f64 { let elapsed = from.elapsed_to(to).max(0) as f64; elapsed.sqrt() * self.gamma * self.gamma } fn variance_for_elapsed(&self, elapsed: i64) -> f64 { (elapsed.max(0) as f64).sqrt() * self.gamma * self.gamma } } // On a single Rating: let rating: Rating = Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, SqrtDrift { gamma: 0.5 }); // Or for a whole History, via the builder: let history = History::builder().drift(SqrtDrift { gamma: 0.5 }).build(); assert_eq!(rating.beta(), 1.0); assert_eq!(history.log_evidence(), 0.0); ``` `HistoryBuilder::drift` is the only way to set a history's drift model; there is no `gamma()` shorthand. The default is `ConstantDrift::new(GAMMA)`. ### Per-competitor drift A `History` has one drift model, but individual competitors can scale it. `Member::with_drift_scale(s)` multiplies the drift *variance* that competitor accumulates, so `s` is in the same units as `gamma`: `ConstantDrift::new(g)` at scale `s` behaves exactly as `ConstantDrift::new(g * s)` would, for that competitor alone. `0.0` pins a competitor still. That is what makes a **fixed reference point** expressible in the same graph as moving competitors — a bot at a known strength, a rating floor, a course difficulty: ```rust use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team}; let mut h = History::builder().drift(ConstantDrift::new(0.1)).build(); h.add_events(vec![Event { time: 0, teams: [ Team::with_members([Member::new("player")]), // A course does not improve. Pin it, and the round's evidence // lands on the player instead of being split between the two. Team::with_members([Member::new("layout_7").with_drift_scale(0.0)]), ] .into_iter() .collect(), outcome: Outcome::winner(0, 2), }]) .unwrap(); h.converge().unwrap(); ``` Like `with_prior`, the scale is **competitor configuration, not a per-event value**: it applies to the competitor for the whole history, and it applies whenever it is supplied — including on a key the history already knows. Configuring one late still refits the whole history rather than taking effect only from that event onward, because `converge` refits from competitor state. Repeating the same value is inert; supplying two *different* values for one competitor within a single batch is `InferenceError::ConflictingCompetitorConfig`, since events in a batch have no order. The scale must be finite and non-negative; ingestion otherwise fails with `InferenceError::InvalidParameter`. The fluent `EventBuilder` reaches this too: `.team([...])` is the common case and leaves both unset, while `.members([...])` takes `Member` values directly, so `h.event(t).members([Member::new("layout_7").with_drift_scale(0.0)])` is equivalent to the typed shape above. ## Scored outcomes Use `Outcome::scores([...])` when you have continuous per-team scores rather than just ranks. Adjacent score margins flow into a `MarginFactor` that adds soft Gaussian evidence about the latent performance diff. Configure `HistoryBuilder::score_sigma(σ)` to control how much you trust the margins (smaller σ = more trust). ```rust use trueskill_tt::History; let mut h = History::builder().score_sigma(2.0).build(); h.event(1) .team(["alice"]) .team(["bob"]) .scores([21.0, 9.0]) .commit() .unwrap(); h.converge().unwrap(); ``` ## Prediction `predict_outcome` gives the full distribution over finishing orders. Each entry is a rank vector in the same shape `Outcome::ranking` takes — equal ranks mean a tie — so an outcome feeds straight back into inference. ```rust use trueskill_tt::History; let mut h = History::builder().p_draw(0.1).build(); h.record_winner(&"alice", &"bob", 1).unwrap(); h.converge().unwrap(); let p = h.predict_outcome(&[&[&"alice"], &[&"bob"]]).unwrap(); // Probabilities are exhaustive and disjoint, so they sum to one. assert!((p.total() - 1.0).abs() < 1e-6); let (best, likelihood) = p.most_likely().unwrap(); println!("most likely: {best:?} at {likelihood:.3}"); println!("draw: {:.3}", p.probability_of(&[0, 0])); ``` Supports any number of teams. Because the outcome space grows factorially, the full distribution is capped at `MAX_PREDICTED_TEAMS`; two cheaper entry points stay available at any size: - `predict_win_probabilities(teams)` — `P(team i finishes strictly first)`, quadratic in team count. - `predict_ranking(teams, ranks)` — one specific finishing order. Unknown keys are an error by default, not a silent omission: a team the history has never seen cannot produce a confident-looking probability. The error names the key, and every key must already be known — pre-filter with `current_skill` if your caller cannot guarantee that. If predicting for competitors you have never seen is the point rather than a mistake, say so once: ```rust use trueskill_tt::{History, UnknownKeys}; let h = History::builder().unknown_keys(UnknownKeys::Prior).build(); ``` An unknown competitor is then answered from the configured prior, which is the honest reading — you have no evidence about them — and correctly *widens* a team that contains one. There is deliberately no "skip the member" mode: a team's performance is the sum of its members, so dropping one would make the model more certain because it knows less. ### Asking about one competitor `Gaussian` answers tail questions directly, which is what a stopping rule needs: ```rust use trueskill_tt::History; let mut h = History::default(); h.record_winner(&"alice", &"bob", 1).unwrap(); h.converge().unwrap(); let skill = h.current_skill("alice").unwrap(); // "How sure am I that this is below the cutoff?" — a probability, not a // `mu + z * sigma` band whose confidence drifts as sigma changes. let _ = skill.probability_below(20.0); // Use this rather than `1.0 - probability_below(x)`: the complement cancels // away every digit in the upper tail, which is where a stopping rule lives. let _ = skill.probability_above(30.0); ``` ## Which match to play next `quality()` measures whether a matchup is *fair*. That is not the same as whether it is *informative*, and the two only coincide for two evenly matched competitors. When each observation costs something, ask `expected_information_gain` instead — the outcome-weighted divergence between what you believe now and what you would believe afterwards. ```rust use trueskill_tt::History; let mut h = History::default(); for t in 1..=10 { h.record_winner(&"veteran", &"regular", t).unwrap(); h.record_winner(&"regular", &"veteran", t + 100).unwrap(); } h.record_winner(&"veteran", &"newcomer", 500).unwrap(); h.converge().unwrap(); let settled = h.expected_information_gain(&[&[&"veteran"], &[&"regular"]]).unwrap(); let unknown = h.expected_information_gain(&[&[&"veteran"], &[&"newcomer"]]).unwrap(); // Playing the newcomer teaches you more than replaying a settled rivalry. assert!(unknown > settled); ``` The result is in nats, and is bounded by the entropy of the outcome: at most `ln 2 ≈ 0.693` for a two-way result, `ln 3` once draws are possible, `ln k` for `k` outcomes. A value near zero means you already know how it ends. This costs one full inference pass **per possible outcome**, so it is far more expensive than `quality()`. Scoring every pairing among `n` competitors is `O(n² × outcomes)` passes — shortlist with `quality()` or `predict_win_probabilities` first, then score only the shortlist. ## Other implementations - [ttt-scala](https://github.com/ankurdave/ttt-scala) - [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis) - [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl) - [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R) - [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf) - [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/) ## Status Every box on the old todo list is ticked, so it has been retired; open work lives in the issue tracker instead. The crate is in use and the API is still moving — breaking changes are batched into minor releases rather than dribbled out, and `CHANGELOG.md` records them. ## License Licensed under either of - Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) - MIT license ([LICENSE-MIT](LICENSE-MIT) or ) at your option. ### Contribution Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.