Four README code blocks no longer compiled: `Player` was renamed `Rating`
in T2, the `Drift` trait gained a `T: Time` parameter and a second method,
and two blocks were missing imports outright. The `Rating` example needed
more than a rename — with the binding unused, `T` is ambiguous because
`ConstantDrift` implements `Drift<T>` for every `T`, so it now carries an
explicit annotation.
Nothing compiled those blocks. `src/lib.rs` gains a `cfg(doctest)` struct
carrying `#[doc = include_str!("../README.md")]`, which turns every `rust`
block into a doctest without displacing the curated crate docs as the
front page. Verified it bites: reintroducing `Player` fails the build with
E0432 rather than shipping. Illustrative blocks are fenced `text` — note
that a bare fence defaults to `rust` under rustdoc, which is how the
`variance_delta = elapsed * γ²` formula became a compile error.
Prose fixes: README claimed `Gaussian::forget` takes a square root (it
works in variance space) and pointed at a `.gamma()` builder method that
does not exist. CLAUDE.md's data-flow diagram spliced the public ingestion
shape into the internal one — `Team` is not in that chain — listed
`cdf()`/`erfc()` as public when they are `pub(crate)` and private, and
called `SkillStore` public when only `CompetitorStore` escapes the crate.
Rustdoc fixes: `EventBuilder::scores_with_sigma` claimed a debug-assert
that `Outcome::scores_with_sigma` never had and whose own docs contradict;
rejection happens at ingestion as `InvalidParameter`. `event.rs` described
`add_events_with_prior` as replaced when it is still the ingestion
chokepoint. `factors.rs` advertised `Game::custom` without noting it is
`#[doc(hidden)]`. Internal T2/T4 milestone labels are dropped from public
items; the ones in the private `time_slice` module are left alone.
Closes #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014b6wy2q8rnFK8U8GPJVQNU
TrueSkill - Through Time
Rust port of TrueSkillThroughTime.py.
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
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(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(0.1));
assert_eq!(rating.drift().0, 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(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(g) at
scale s behaves exactly as ConstantDrift(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(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 captured at first
appearance — setting it on a key the history already knows has no effect. It
must be finite and non-negative; ingestion otherwise fails with
InferenceError::InvalidParameter.
Note that the fluent EventBuilder (h.event(t).team([...])) sets weights but
not drift_scale or prior; those need the typed Event / Team / Member
shape shown 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();
Todo
- Implement approx for Gaussian
- Add more tests from
TrueSkillThroughTime.jl - Generalise a time axis —
Timeis now a trait (Untimed,i64), not an enum - Add examples (
examples/atp.rs,examples/scored.rs) - Add Observer (
Observer/NullObserver) - Benchmark the inference loop (
benches/batch.rs,benches/history_converge.rs,benches/ingest.rs) - Cross-check
quality()against sublee/trueskill — N-group support works and is covered by invariants, but no reference values are asserted
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.