logaritmiskandClaude Opus 5 06b6a68499 fix(rayon): remove the aliasing unsafe from the parallel sweep
The parallel color-group sweep passed a `*mut SkillStore` through a `usize`
and cast it back inside the rayon closure, so every worker materialised its
own `&mut SkillStore` to the same store. Two live `&mut` to one object is an
aliasing violation whatever the workers subsequently touch — `&mut` carries
`noalias` down to LLVM — and laundering the pointer through `usize` also
discarded provenance. The existing SAFETY comment argued element
disjointness, which is true and is why nothing miscompiled in practice, but
it is not the property the aliasing rules ask about.

Events in a color group touch disjoint agents, so none can observe another's
writes. That makes the sweep separable rather than merely safe-in-practice:
`Event::compute` runs inference over shared `&self.skills` with no mutation,
and `Event::apply` folds the results in afterwards in index order. No
`unsafe`, no aliasing argument, and the apply order does not depend on which
worker finished first, so results stay bit-identical across thread counts.

The crate now contains no `unsafe` at all, locked in with
`#![forbid(unsafe_code)]`.

Splitting compute from apply also removes the duplicated sweep body: the
`from > 0` branch of `TimeSlice::iteration` was a verbatim copy of
`iteration_direct`, and both now share one implementation.

Cost, measured on the three `history_converge` workloads (sequential vs
parallel, this machine):

    500x100@10perslice     4.02ms -> 4.21ms
    2000x200@20perslice   19.70ms -> 19.76ms
    1v1-5000x50000        11.75ms -> 10.46ms

The deferred apply gives back part of the parallel win on the only workload
where rayon ever helped (1.12x here, against the 1.3x T3 reported), and the
sequential path is unchanged. Trading a fraction of a 1.3x speedup on one
pathological shape for the removal of undefined behaviour is the right side
of that bargain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:54:34 +02:00
2026-03-23 14:21:23 +01:00
2026-04-23 20:24:10 +02:00

TrueSkill - Through Time

Rust port of TrueSkillThroughTime.py.

Other implementations

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
  • 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)
S
Description
No description provided
Readme
12 MiB
Languages
Rust 99.6%
Just 0.4%