`posterior_of`, `posterior_of_at` and `expected_variance_reduction`
existed twice: once on `Joint`, and once on `History` as one-shot
wrappers whose whole body was `self.joint()?.<same>(..)`.
The wrappers re-factorised on every call — their own docs said so,
warning the reader to take a `Joint` instead — and they were what
smuggled the scored-only precondition onto the flat surface. A user
following the quickstart builds a ranked history, sees `posterior_of` in
the method list, and it never works. `h.joint()?.posterior_of(..)` is
one call longer and tells the truth: you need a joint, and a joint needs
a scored history.
That leaves three tiers instead of a flat surface with a hidden
precondition: `History` fits and reads, `predict_*` forecasts, `Joint`
answers exact joint questions.
`predict_margin` was itself calling `self.posterior_of`; it goes through
`self.joint()?` directly now.
The `Joint` methods' docs referred back to the wrappers for their real
content ("Identical to `History::posterior_of`, without re-paying the
factorisation"), so they now carry it: what a linear functional means,
which appearance each competitor is read at, and why
`expected_variance_reduction` belongs on the handle.
`tests/joint_handle.rs` had three tests comparing the wrapper against
the handle. That comparison is gone, but the property behind it is not —
they now compare a *reused* joint against a *fresh* one per question,
which is the actual correctness claim behind caching the factorisation
(#51), without the wrapper in the middle.
Closes #78.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
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.
Install
[dependencies]
trueskill-tt = "0.8"
Optional features, both off by default:
approx—approx's equality traits forGaussian. Useful in tests.rayon— parallelises the within-slice sweep and the per-slice passes oflearning_curves/log_evidence. Results stay bit-identical regardless of worker count;just determinismasserts it at 1, 2, 4 and 8 threads.
Quickstart
Record results, converge, then read off skills.
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.
# 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().
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:
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(NonFiniteResult) 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:
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:
variance_delta = elapsed * γ²
This is the standard TrueSkill Through Time model. Pass a ConstantDrift::new(gamma)
when constructing a Rating:
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:
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:
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).
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.
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:
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:
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.
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
- ChessAnalysis #F
- TrueSkillThroughTime.jl
- TrueSkillThroughTime.R
- TrueSkill Through Time: Revisiting the History of Chess
- TrueSkill Through Time. The full scientific documentation
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 or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (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.