logaritmiskandClaude Opus 5 1ac3b21db5 fix: enforce EventBuilder weight/team length in release
Part of #18. `EventBuilder::weights` guarded the length match with a
`debug_assert!`, so release builds accepted a mismatch, silently dropped the
weights, and ingested the event anyway. That is the exact shape #18 is about:
validation that exists only where it is least needed.

The setters return `Self` to keep the chain fluent, so they cannot return a
`Result`. The builder now records the first failure and `commit` returns it as
`MismatchedShape`. The weights are not applied on mismatch either, so a
partially-weighted team cannot reach the history by another route.

Two tests in tests/degenerate_inputs.rs, whose CI job runs in release — which
is the only place the old behaviour differed.

The second test needed strengthening before it was worth anything. As first
written it committed a ONE-team event, which ingestion rejects for an unrelated
reason, so it passed under a mutation that disabled the whole check. It now
uses two teams, so ingestion would otherwise succeed and the assertion is
actually load-bearing. Both tests were then mutation-proved together: disabling
the error path in `commit` fails both in release.

#18 stays open. The remaining debug_asserts live in `ranked_with_arena` and
`scored_with_arena`, and promoting those means threading `Result` up through
`Event::compute`, `TimeSlice::iteration`, `log_evidence` and `filtered_step` —
which lands on the public API as `log_evidence() -> Result<f64>` and
`filtered_learning_curve() -> Result<...>`. That is a trade-off about what the
query API should look like, not a mechanical change, so it is not mine to
decide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:53:37 +02:00
2026-03-23 14:21:23 +01: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
  • Generalise a time axis — Time is 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

License

Licensed under either of

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.

S
Description
No description provided
Readme
12 MiB
Languages
Rust 99.6%
Just 0.4%