A systematic scan for precision defects, following the tail-precision work in7341669. Three findings; the first is a correctness bug in a released version. 1. `erfc_inv`'s initial guess had the wrong sign. Numerical Recipes' `inverfc` uses -0.70711 as the leading coefficient; this used +FRAC_1_SQRT_2. Since `rational - t` is negative, that put Newton on the mirror image of the root, and three fixed iterations could not cross back. Measured against exact standard-normal quantiles: p_draw old rel err new rel err 0.50 1.46e-7 8.40e-8 0.90 1.02e-1 8.63e-9 0.95 3.06e-1 1.91e-8 0.99 8.05e-1 5.89e-9 `compute_margin` inherited it, so the draw margin was wrong for any `p_draw` above about 0.6 and *non-monotone* above 0.9 — it ran 0.674, 1.476, 0.503, 0.982 as p_draw went 0.5, 0.9, 0.99, 0.999. A history configured for a 0.99 draw rate was being fitted at 0.385. Note it was slightly wrong everywhere, not only in the tail. 2. `MarginFactor` computed a density and clamped it. `pdf` underflows past ~38 sigma, so `ln` of the clamped zero reported -708 nats however far out the score actually was: 4292 nats adrift at 100 sigma, and unbounded beyond. This is the same defect as the one fixed in `TruncFactor`, one file over, on the scored-outcome path. 3. `TruncFactor` still bottomed out past ~38 sigma even after7341669removed the cancellation, because the linear probability itself underflows there. 2 and 3 are fixed the same way: factors cache a *log* evidence, built from new `ln_pdf`, `ln_sf` and `ln_interval` helpers that factor the shared exponential out analytically via the `erfcx` added earlier. Nothing underflows, at any separation. One golden moved. `test_1vs1vs1` runs at `p_draw = 0.5`, so it goes through `compute_margin`; its 1e-6-place values shifted. Verified as movement *toward* analytic truth by comparing both the old and new inverse against exact quantiles, per the goldens policy in CLAUDE.md — not re-baselined on faith. Two test tolerances are asserted at 1e-6 rather than tighter because above x = 2 `erfcx` uses a continued fraction accurate to ~1e-15 while `erfc` carries ~1e-7, so the log path is the more accurate of the two and they part company at `erfc`'s error. That floor is tracked in #41. Refs #41 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
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();
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, not a silent omission: a team the history has never seen cannot produce a confident-looking probability.
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::builder().build();
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.
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) - N-team
predict_outcomewith draw mass, andexpected_information_gain - 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.