Drift was a property of the History, so every competitor drifted at the same rate and a fixed reference point could not share a graph with moving competitors. A bot at a known strength, a rating floor, a course difficulty — all of them drifted along with the players. Member::with_drift_scale(s) multiplies the drift *variance* a 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. A scalar rather than a per-competitor Drift keeps History's single D type parameter untouched and stays Copy. 0.0 pins a competitor still. The scale lives on Rating, beside the drift it scales, and is applied only through Rating::drift_variance_delta / drift_variance_for_elapsed. Making those the sole entry points means a caller cannot reach the raw drift and silently skip a competitor's scale — the filtered pass was exactly that bug during development, caught because its test was written before the wiring. Like with_prior, the scale is competitor configuration captured at first appearance rather than a per-event override; a competitor that is static is static, and a scale that changed between events would make the skill trajectory hard to interpret. Member's docs claimed prior was a per-event override, which the code has never done — corrected here. A negative scale is rejected rather than squared into its absolute value, and a non-finite one rejected outright, both as InvalidParameter. None means 1.0, so no existing call site changes and no existing fit moves. Adding a public field to Member does break struct-literal construction downstream. Closes #34 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014b6wy2q8rnFK8U8GPJVQNU
151 lines
5.3 KiB
Markdown
151 lines
5.3 KiB
Markdown
# TrueSkill - Through Time
|
||
|
||
Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
|
||
|
||
## Other implementations
|
||
|
||
- [ttt-scala](https://github.com/ankurdave/ttt-scala)
|
||
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis)
|
||
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
|
||
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R)
|
||
- [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf)
|
||
- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/)
|
||
|
||
## 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:
|
||
|
||
```rust
|
||
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`:
|
||
|
||
```rust
|
||
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):
|
||
|
||
```rust
|
||
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()`:
|
||
|
||
```rust
|
||
let h = History::builder()
|
||
.drift(SqrtDrift { gamma: 0.5 })
|
||
.build();
|
||
```
|
||
|
||
### 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:
|
||
|
||
```rust
|
||
let events = vec![Event {
|
||
time: 0,
|
||
teams: smallvec![
|
||
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)]),
|
||
],
|
||
outcome: Outcome::winner(0, 2),
|
||
}];
|
||
```
|
||
|
||
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`.
|
||
|
||
## 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).
|
||
|
||
```rust
|
||
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
|
||
|
||
- [x] Implement approx for Gaussian
|
||
- [x] Add more tests from `TrueSkillThroughTime.jl`
|
||
- [x] Generalise a time axis — `Time` is now a trait (`Untimed`, `i64`), not an enum
|
||
- [x] Add examples (`examples/atp.rs`, `examples/scored.rs`)
|
||
- [x] Add Observer (`Observer` / `NullObserver`)
|
||
- [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`)
|
||
- [ ] Cross-check `quality()` against [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) — 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](LICENSE-APACHE) or
|
||
<http://www.apache.org/licenses/LICENSE-2.0>)
|
||
- MIT license ([LICENSE-MIT](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.
|