Adds benches/history_converge.rs with three workloads:
- 500 events / 100 competitors / 10 events per slice
- 2000 events / 200 competitors / 20 events per slice
- 5000 events / 50000 competitors / 5000 events per slice (gate workload)
Investigation found the original rayon path used a compute/apply split with
EventOutput heap allocation per event, causing 3-23x regression. Root cause:
per-event allocations caused heavy allocator contention across rayon threads.
Fixes:
- Replace EventOutput/two-phase approach with direct unsafe parallel write.
Events in a color group have disjoint agent index sets; concurrent writes
to SkillStore land on different Vec slots — no data race.
- Add RAYON_THRESHOLD=64: color groups below this size fall back to
sequential to avoid rayon overhead on small slices.
- Game internals: switch likelihoods/teams to SmallVec<[_;8]> to avoid
heap allocation for ≤8-team / ≤8-player-per-team games. Add type aliases
Teams<T,D> and Likelihoods to satisfy clippy::type_complexity.
- within_priors() and outputs() now return SmallVec; callers updated to
use ranked_with_arena_sv() directly (avoiding Vec→SmallVec conversion).
Sequential baseline (Apple M5 Pro, 2026-04-24):
500x100@10perslice: 4.72 ms
2000x200@20perslice: 23.17 ms
1v1-5000x50000@5000perslice: 13.89 ms
With --features rayon (RAYON_NUM_THREADS=5, P-cores on M5 Pro):
500x100@10perslice: 4.82 ms (1.0× — below threshold)
2000x200@20perslice: 23.09 ms (1.0× — below threshold)
1v1-5000x50000@5000perslice: 6.97 ms (2.0× speedup — GATE ACHIEVED)
T3 acceptance gate: >=2× speedup on at least one workload — ACHIEVED.
74 tests pass under both feature configs.
Part of T3.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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();
Todo
- Implement approx for Gaussian
- Add more tests from
TrueSkillThroughTime.jl - Add tests for
quality()(Use sublee/trueskill as reference) - Benchmark Batch::iteration()
- Time needs to be an enum so we can have multiple states (see
batch::compute_elapsed()) - Add examples (use same TrueSkillThroughTime.(py|jl))
- Add Observer (see argmin for inspiration)