Six of fifteen variants carried a `&'static str` discriminator, about
thirty magic strings between them, and the only thing a caller could do
with one was print it. Four new enums replace them:
Parameter 13 variants, replacing 9 strings in InvalidParameter
Shape 4 variants, replacing 10 in MismatchedShape
OutcomeKind 2 variants, replacing WrongOutcomeKind's three fields
CompetitorField 2 variants, replacing ConflictingCompetitorConfig's
`InvalidProbability` folds into `InvalidParameter` as
`Parameter::PDraw`. It was a bespoke variant for one scalar while every
other scalar shared `InvalidParameter`, and it omitted the parameter
name — so the same parameter had two mechanisms.
`JointUnavailable { reason: &'static str }` splits into `EmptyHistory`,
`JointRequiresScoredEvents` and `NotPositiveDefinite`. The three are
conditions a caller branches on differently — add events, use
`predict_win_probabilities`, or reconsider the priors — and telling them
apart used to mean string-matching English. One test already proved the
distinction was load-bearing: the blanket conversion mapped the
empty-history case onto the ranked one and `an_empty_history_has_no_joint`
caught it immediately.
`NonFiniteResult` splits into `NonFiniteStep { context, step }` and
`NonFiniteSkill { mu, sigma }`. One `step: (f64, f64)` field was
carrying a sweep step from `converge` and a skill's own moments from a
prediction — two situations in one variant, and a field name that could
only be right for one of them.
`InvalidParameter { name: "beta with point-mass skills" }` becomes
`NoPerformanceVariance`. It was never a parameter out of range: both
values are individually valid and it is their combination that leaves
nothing varying.
Three `Display` impls did not meet the standard the others set, and the
typed data is what makes fixing them possible:
before drift variance is invalid: NaN
after drift variance must be finite and non-negative (got NaN)
before kinds: expected length 3, got 2
after the outcome describes a different number of teams than the
event has: expected 3, got 2
before Game::ranked: expected Outcome::Ranked, got Outcome::Scored
after expected Outcome::Ranked, got Outcome::Scored; call
Game::scored for a scored outcome
`Parameter::range()` states each parameter's actual bounds, which no
`&'static str` name could have. `error::message_tests` renders every one
and asserts each is a sentence rather than a label, and that the three
above now carry a range or a next step.
The four internal `MismatchedShape` kinds — `results`, `times`, `kinds`,
and the weights array — collapse to `Shape::Internal`, whose `Display`
says plainly that reaching it is a bug in this crate. They are checks on
`add_events_with_prior`'s own parallel arrays and are unreachable
through the public API; they stay checked rather than becoming
`debug_assert!`s, because release is where this crate's defects hide.
Closes #74.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
440 lines
16 KiB
Markdown
440 lines
16 KiB
Markdown
# 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<T: Time>: 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<i64, ConstantDrift> =
|
||
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<T>` for
|
||
every `T: Time`, so without it `T` is ambiguous.
|
||
|
||
### Custom drift
|
||
|
||
Implement `Drift<T>` 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<T: Time> Drift<T> 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<i64, SqrtDrift> =
|
||
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
|
||
<http://www.apache.org/licenses/LICENSE-2.0>)
|
||
- MIT license ([LICENSE-MIT](LICENSE-MIT) or
|
||
<http://opensource.org/licenses/MIT>)
|
||
|
||
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.
|