`HistoryBuilder::online(true)` is inert: it flips a flag that reaches `Item::within_prior`, which reads `Skill.online` — a field initialised to `N_INF` and assigned nowhere. So `log_evidence()` under that setting reports `n * ln(0.5)`, every game scored as a coin flip. The number is finite and plausible, which is why nothing caught it. Issue #19 proposed populating the field during the forward pass. That does not work, and the reason shapes the whole design. `new_forward_info` sets `skill.forward` from the previous slice's `forward_prior_out`, which is `skill.forward * skill.likelihood`; `History::iteration` alternates backward and forward sweeps, so from the second iteration onward that likelihood has already absorbed backward information. After `converge()`, `skill.forward` is a smoothed quantity — and so is anything written from it. The same reasoning condemns the neighbouring `forward: bool` flag, which is a filtering quantity only on a history that was never converged. That is why the test at history.rs:1183 can assert the two evidences are equal. Left alone here; recorded as a follow-up. The design is a read-only forward-only pass instead: walk slices in time order carrying their own forward messages, and per slice build a scratch clone whose `backward` is `N_INF`, then run the unmodified production sweep on it. Reusing `iterate_to_convergence` rather than reimplementing inference means a competitor playing twice at one time is handled by the same within-slice EP that `converge()` uses, instead of being approximated the way today's evidence paths approximate it. Nothing is stored on `Skill`, which drops 16 bytes and helps #17 regardless. Three methods ship — `filtered_log_evidence`, `filtered_learning_curves`, `filtered_learning_curve` — all taking `&self`. The second consumer is ustat, whose learning curves start already collapsed to sigma 0.9-1.6 against a prior of 6.0 because every point is smoothed; the filtered view cannot be reconstructed from the public API today except by O(n^2) refits. The red test brackets the issue's own fixture strictly between 5*ln(0.5) and the batch evidence, so neither "still inert" nor "accidentally smoothed" passes. The invariant that would have caught this bug class is that filtered results are identical before and after `converge()` — exactly what a stored field cannot give. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
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 player's true skill can change between appearances. Each time a player reappears 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:
pub trait Drift: Copy + Debug {
fn variance_delta(&self, elapsed: i64) -> f64;
}
variance_delta returns the amount to add to σ² given the elapsed time since the player last played. Internally, Gaussian::forget uses this to compute the new sigma: σ_new = sqrt(σ² + variance_delta).
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. Use it by passing a ConstantDrift(gamma) when constructing a Player:
use trueskill_tt::{Player, Gaussian, drift::ConstantDrift};
// gamma = 0.1 means skill can shift ~0.1 per time unit
let player = Player::new(Gaussian::from_ms(0.0, 6.0), 1.0, ConstantDrift(0.1));
Custom drift
Implement Drift to express any other model. For example, a drift that saturates after a long absence (uncertainty grows with the square root of elapsed time instead of linearly):
use trueskill_tt::drift::Drift;
#[derive(Clone, Copy, Debug)]
struct SqrtDrift {
gamma: f64,
}
impl Drift for SqrtDrift {
fn variance_delta(&self, elapsed: i64) -> f64 {
(elapsed as f64).sqrt() * self.gamma * self.gamma
}
}
let player = Player::new(Gaussian::from_ms(0.0, 6.0), 1.0, SqrtDrift { gamma: 0.5 });
To use a custom drift type with History, use the .drift() builder method instead of .gamma():
let h = History::builder()
.drift(SqrtDrift { gamma: 0.5 })
.build();
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, Outcome};
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