745 ms -> 1.11 ms on the fixture #52 was opened about. The joint precision matrix is 0.19% dense at scale and gets sparser as the history grows. We allocated all n^2 entries — 31 MB at n = 1976, 128 MB at ustat's ~4000 appearances — filled 99.8% of it with zeros, and ran an O(n^3) factorisation over the whole thing. Two measurements shaped the fix, and the first killed the plan #52 proposed. **Ordering alone does nothing to a dense factorisation.** Its inner loops run over every k whether the entry is zero or not, so a permutation changes which entries are zero and not how many multiplications happen. A 700x700 banded matrix at 0.43% density: 30.196 ms in band order, 29.544 ms under a scramble that destroyed the band. Identical, as the flop count says it must be. #52's step 1 — "reorder with AMD, keep our own Cholesky, and measure" — could not have worked, and measuring said so before any of it was written. **Sparsity and AMD together are worth four orders of magnitude.** Symbolic factorisation on the n = 1976 fixture, against 2.572e9 dense flops: sparse in natural order needs 5.597e7 (46x), sparse under AMD needs 8.656e4 — 29,710x. AMD is worth 646x on top of sparsity and nothing without it. Natural order fills in badly for exactly the reason #52 predicted about bandwidth: nnz(L) is 292,437 against A's 7,504, because a competitor idle from slice 0 to slice 75 links across the whole matrix. Measured end to end, factorising through `History::joint`: n = 480 215 us (bench: 9.11 ms -> 167 us, 54x) n = 1976 1.112 ms (was ~745 ms, 670x) n = 7800 4.616 ms (dense would be 1.58e11 flops) Scaling is near-linear now rather than cubic: 16x the variables costs 21x the time, where dense would cost 4096x. The factorisation is the up-looking sparse Cholesky of Davis's *Direct Methods for Sparse Linear Systems*, written here rather than taken from a crate. The scouting in #52 still holds and got one addition: `feral` itself pulls `pulp`, so it has the same runtime CPU-dispatch problem that ruled out `faer` — results could differ between an AVX-512 host and an AVX2 one, the drift the libm-over-std decision was made to avoid. `sprs-ldl` is still LGPL and `nalgebra-sparse` still disclaims fill-reduction in its own docs. Only the ordering is a dependency: `feral-amd`, two crates, both `#![forbid(unsafe_code)]`. The matrix is accumulated into a `BTreeMap`, not a hash map: the iteration order becomes the summation order, and a hash map's varies per process. `tests/cross_process_determinism.rs` exists because that has bitten before. `whiten` returns its result in the permuted order and leaves it there — a dot product does not care, as long as both operands were permuted the same way — so `bilinear` is unchanged. Correctness: the existing analytic goldens are 2x2 and 3x3, too small to permute or fill in, so they could not have caught a symbolic-pass bug. `agrees_with_a_dense_reference_on_random_sparse_systems` checks every bilinear form against a deliberately naive dense factorisation that shares no code with the thing it is checking, on chain-plus-long-range matrices up to n = 60. Closes #52. 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.