Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2a7ade10c | ||
|
|
617bc07f6f | ||
|
|
1a88678384 | ||
|
|
5f46296671 | ||
|
|
2745fbb622 | ||
|
|
6d2573b92e | ||
|
|
1ac3b21db5 | ||
|
|
4e043364fd | ||
|
|
aff3fb948d | ||
|
|
06ed24b240 | ||
|
|
9b2c2b38c8 |
@@ -7,3 +7,4 @@
|
||||
NOTEPAD.md
|
||||
|
||||
/.claude
|
||||
proptest-regressions/
|
||||
|
||||
@@ -2,6 +2,39 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 0.3.0 - 2026-09-01
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- refactor!: make Competitor::message an Option, and compute_elapsed loud
|
||||
- refactor!: replace emptiness-as-sentinel with Option for results and weights
|
||||
- refactor!: remove ConvergenceReport::slices_skipped
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: enforce EventBuilder weight/team length in release
|
||||
|
||||
### Documentation
|
||||
|
||||
- docs: complete the public API documentation contract
|
||||
|
||||
### Features
|
||||
|
||||
- feat: allow drift to vary per competitor via Member::with_drift_scale
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- chore: ignore proptest regression seed files
|
||||
|
||||
### Performance
|
||||
|
||||
- perf: stop cloning inference inputs in OwnedGame and ingestion
|
||||
- perf: make the per-slice SkillStore compact instead of dense
|
||||
|
||||
### Testing
|
||||
|
||||
- test: add property-based tests, a shared finiteness helper, and boundary inputs
|
||||
|
||||
## 0.2.0 - 2026-08-27
|
||||
|
||||
### Breaking Changes
|
||||
@@ -36,6 +69,7 @@ All notable changes to this project will be documented in this file.
|
||||
- chore: target releases at the private kellnr registry
|
||||
- chore: keep the 48 MB ATP dataset out of the published crate
|
||||
- chore: dual-license MIT OR Apache-2.0
|
||||
- chore: Release trueskill-tt version 0.2.0
|
||||
|
||||
### Performance
|
||||
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "trueskill-tt"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
|
||||
@@ -62,9 +62,14 @@ rayon = ["dep:rayon"]
|
||||
criterion = "0.5"
|
||||
plotters = { version = "0.3", default-features = false, features = ["svg_backend", "all_elements", "all_series"] }
|
||||
plotters-backend = "0.3"
|
||||
proptest = "1.11.0"
|
||||
time = { version = "0.3", features = ["parsing"] }
|
||||
trueskill-tt = { path = ".", features = ["approx"] }
|
||||
|
||||
# Debug symbols in release are for `just flame` (cargo-flamegraph), which needs
|
||||
# them to symbolicate. Profile settings in a library are ignored by downstream
|
||||
# consumers, so these only affect local builds — this is deliberate, not an
|
||||
# oversight.
|
||||
[profile.release]
|
||||
debug = true
|
||||
|
||||
|
||||
@@ -71,6 +71,36 @@ let h = History::builder()
|
||||
.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
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ fn criterion_benchmark(criterion: &mut Criterion) {
|
||||
let kinds = vec![EventKind::Ranked; composition.len()];
|
||||
|
||||
let mut time_slice = TimeSlice::new(1, P_DRAW, ConvergenceOptions::default());
|
||||
time_slice.add_events(composition, results, weights, kinds, &agents);
|
||||
time_slice.add_events(composition, Some(results), Some(weights), kinds, &agents);
|
||||
|
||||
criterion.bench_function("Batch::iteration", |b| {
|
||||
b.iter(|| time_slice.iteration(0, &agents))
|
||||
|
||||
+23
-17
@@ -1,5 +1,4 @@
|
||||
use crate::{
|
||||
N_INF,
|
||||
drift::{ConstantDrift, Drift},
|
||||
gaussian::Gaussian,
|
||||
rating::Rating,
|
||||
@@ -13,7 +12,14 @@ use crate::{
|
||||
#[derive(Debug)]
|
||||
pub struct Competitor<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
||||
pub rating: Rating<T, D>,
|
||||
pub message: Gaussian,
|
||||
/// The forward message carried from this competitor's last appearance, or
|
||||
/// `None` before they have appeared anywhere.
|
||||
///
|
||||
/// Previously an improper `N_INF` served as the unset sentinel, which made
|
||||
/// "no message yet" indistinguishable from "a legitimately improper
|
||||
/// message" at the type level and required every reader to know the
|
||||
/// convention.
|
||||
pub message: Option<Gaussian>,
|
||||
pub last_time: Option<T>,
|
||||
}
|
||||
|
||||
@@ -21,14 +27,16 @@ impl<T: Time, D: Drift<T>> Competitor<T, D> {
|
||||
/// Compute the message received at time `now`, with drift accumulated
|
||||
/// from `self.last_time` (if any) to `now`.
|
||||
pub(crate) fn receive(&self, now: &T) -> Gaussian {
|
||||
if self.message != N_INF {
|
||||
let elapsed_variance = match &self.last_time {
|
||||
Some(last) => self.rating.drift.variance_delta(last, now),
|
||||
None => 0.0,
|
||||
};
|
||||
self.message.forget(elapsed_variance)
|
||||
} else {
|
||||
self.rating.prior
|
||||
match self.message {
|
||||
Some(message) => {
|
||||
let elapsed_variance = match &self.last_time {
|
||||
Some(last) => self.rating.drift_variance_delta(last, now),
|
||||
None => 0.0,
|
||||
};
|
||||
|
||||
message.forget(elapsed_variance)
|
||||
}
|
||||
None => self.rating.prior,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +45,9 @@ impl<T: Time, D: Drift<T>> Competitor<T, D> {
|
||||
/// Used in convergence sweeps where the elapsed was cached at slice-construction time
|
||||
/// and should not be recomputed from `last_time` (which may have shifted).
|
||||
pub(crate) fn receive_for_elapsed(&self, elapsed: i64) -> Gaussian {
|
||||
if self.message != N_INF {
|
||||
self.message
|
||||
.forget(self.rating.drift.variance_for_elapsed(elapsed))
|
||||
} else {
|
||||
self.rating.prior
|
||||
match self.message {
|
||||
Some(message) => message.forget(self.rating.drift_variance_for_elapsed(elapsed)),
|
||||
None => self.rating.prior,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +56,7 @@ impl Default for Competitor<i64, ConstantDrift> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
rating: Rating::default(),
|
||||
message: N_INF,
|
||||
message: None,
|
||||
last_time: None,
|
||||
}
|
||||
}
|
||||
@@ -63,7 +69,7 @@ where
|
||||
C: Iterator<Item = &'a mut Competitor<T, D>>,
|
||||
{
|
||||
for c in competitors {
|
||||
c.message = N_INF;
|
||||
c.message = None;
|
||||
if last_time {
|
||||
c.last_time = None;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ pub struct ConvergenceReport {
|
||||
pub log_evidence: f64,
|
||||
pub converged: bool,
|
||||
pub per_iteration_time: SmallVec<[Duration; 32]>,
|
||||
pub slices_skipped: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+36
-3
@@ -23,6 +23,7 @@ pub struct Team<K> {
|
||||
}
|
||||
|
||||
impl<K> Team<K> {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
members: SmallVec::new(),
|
||||
@@ -44,13 +45,20 @@ impl<K> Default for Team<K> {
|
||||
|
||||
/// One member of a team, identified by user key `K`.
|
||||
///
|
||||
/// `weight` defaults to 1.0; a per-event `prior` can override the competitor's
|
||||
/// current skill estimate for this event only.
|
||||
/// `weight` applies per event and defaults to 1.0.
|
||||
///
|
||||
/// `prior` and `drift_scale` are **competitor configuration**, not per-event
|
||||
/// values: both are captured when the competitor is first created and ignored
|
||||
/// on every later appearance. Setting either on a key the history already knows
|
||||
/// has no effect.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Member<K> {
|
||||
pub key: K,
|
||||
pub weight: f64,
|
||||
pub prior: Option<Gaussian>,
|
||||
/// Multiplier on the drift *variance* this competitor accumulates.
|
||||
/// `None` means 1.0.
|
||||
pub drift_scale: Option<f64>,
|
||||
}
|
||||
|
||||
impl<K> Member<K> {
|
||||
@@ -59,6 +67,7 @@ impl<K> Member<K> {
|
||||
key,
|
||||
weight: 1.0,
|
||||
prior: None,
|
||||
drift_scale: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +76,31 @@ impl<K> Member<K> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set this competitor's starting skill estimate.
|
||||
///
|
||||
/// Captured at the competitor's first appearance; see the type docs.
|
||||
pub fn with_prior(mut self, prior: Gaussian) -> Self {
|
||||
self.prior = Some(prior);
|
||||
self
|
||||
}
|
||||
|
||||
/// Scale how fast this competitor drifts, relative to the history's drift.
|
||||
///
|
||||
/// The scale multiplies the drift *variance*, so it is in the same units as
|
||||
/// `gamma`: `ConstantDrift(g)` at `scale = s` behaves exactly as
|
||||
/// `ConstantDrift(g * s)` would for this competitor alone.
|
||||
///
|
||||
/// `0.0` pins the competitor still — useful for a reference point that
|
||||
/// shares a scale with moving competitors but should not itself move: a bot
|
||||
/// at a known strength, a rating floor, a course difficulty.
|
||||
///
|
||||
/// Captured at the competitor's first appearance; see the type docs.
|
||||
/// Must be finite and non-negative, or ingestion fails with
|
||||
/// [`InferenceError::InvalidParameter`](crate::InferenceError::InvalidParameter).
|
||||
pub fn with_drift_scale(mut self, scale: f64) -> Self {
|
||||
self.drift_scale = Some(scale);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: a member is a user key with default weight 1.0 and no prior.
|
||||
@@ -91,15 +121,18 @@ mod tests {
|
||||
assert_eq!(m.key, "alice");
|
||||
assert_eq!(m.weight, 1.0);
|
||||
assert!(m.prior.is_none());
|
||||
assert!(m.drift_scale.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_builder_methods_chain() {
|
||||
let m = Member::new("alice")
|
||||
.with_weight(0.5)
|
||||
.with_prior(Gaussian::from_ms(20.0, 5.0));
|
||||
.with_prior(Gaussian::from_ms(20.0, 5.0))
|
||||
.with_drift_scale(0.0);
|
||||
assert_eq!(m.weight, 0.5);
|
||||
assert!(m.prior.is_some());
|
||||
assert_eq!(m.drift_scale, Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+40
-7
@@ -19,6 +19,14 @@ where
|
||||
history: &'h mut History<T, D, O, K>,
|
||||
event: Event<T, K>,
|
||||
current_team_idx: Option<usize>,
|
||||
/// First validation failure seen while building, surfaced by `commit`.
|
||||
///
|
||||
/// The setters return `Self` so the chain stays fluent; they cannot return
|
||||
/// a `Result` without breaking that. Recording the failure and reporting it
|
||||
/// at `commit` keeps the check enforced in release, where the previous
|
||||
/// `debug_assert!` was compiled out and a mismatched event was ingested
|
||||
/// silently.
|
||||
error: Option<InferenceError>,
|
||||
}
|
||||
|
||||
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K>
|
||||
@@ -37,6 +45,7 @@ where
|
||||
outcome: Outcome::Ranked(SmallVec::new()),
|
||||
},
|
||||
current_team_idx: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,22 +59,36 @@ where
|
||||
|
||||
/// Set per-member weights for the most recently added team.
|
||||
///
|
||||
/// Panics in debug builds if called before `.team(...)` or if the length
|
||||
/// doesn't match the team's member count.
|
||||
/// A length mismatch is recorded and returned by [`EventBuilder::commit`]
|
||||
/// as `InferenceError::MismatchedShape`, in both debug and release. The
|
||||
/// weights are not applied in that case, so a partially-weighted team
|
||||
/// cannot reach the history.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if called before any `.team(...)`.
|
||||
pub fn weights<I: IntoIterator<Item = f64>>(mut self, weights: I) -> Self {
|
||||
let idx = self
|
||||
.current_team_idx
|
||||
.expect(".weights(...) called before any .team(...)");
|
||||
|
||||
let ws: Vec<f64> = weights.into_iter().collect();
|
||||
let team = &mut self.event.teams[idx];
|
||||
debug_assert_eq!(
|
||||
ws.len(),
|
||||
team.members.len(),
|
||||
"weights length must match team size"
|
||||
);
|
||||
|
||||
if ws.len() != team.members.len() {
|
||||
self.error.get_or_insert(InferenceError::MismatchedShape {
|
||||
kind: "weights",
|
||||
expected: team.members.len(),
|
||||
got: ws.len(),
|
||||
});
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
for (m, w) in team.members.iter_mut().zip(ws) {
|
||||
m.weight = w;
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
@@ -103,7 +126,17 @@ where
|
||||
}
|
||||
|
||||
/// Commit the event to the history.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the first validation failure recorded while building — see
|
||||
/// [`EventBuilder::weights`] — otherwise forwards to
|
||||
/// [`History::add_events`] and returns its errors.
|
||||
pub fn commit(self) -> Result<(), InferenceError> {
|
||||
if let Some(error) = self.error {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
self.history.add_events(std::iter::once(self.event))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct MarginFactor {
|
||||
}
|
||||
|
||||
impl MarginFactor {
|
||||
#[must_use]
|
||||
pub fn new(diff: VarId, m_obs: f64, sigma: f64) -> Self {
|
||||
debug_assert!(sigma > 0.0, "score sigma must be positive");
|
||||
Self {
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct VarStore {
|
||||
}
|
||||
|
||||
impl VarStore {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
@@ -28,10 +29,12 @@ impl VarStore {
|
||||
self.marginals.clear();
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.marginals.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.marginals.is_empty()
|
||||
}
|
||||
@@ -42,6 +45,7 @@ impl VarStore {
|
||||
id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, id: VarId) -> Gaussian {
|
||||
self.marginals[id.0 as usize]
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ use crate::factor::{Factor, VarId, VarStore};
|
||||
/// On each propagation:
|
||||
/// - Reads marginals at `team_a` and `team_b` (which already incorporate any
|
||||
/// incoming messages from neighboring factors).
|
||||
/// - Computes `new_diff = team_a - team_b` (variance addition; see Gaussian::Sub).
|
||||
/// - Computes `new_diff = team_a - team_b` (variance addition; see `Gaussian::Sub`).
|
||||
/// - Writes the new marginal to `diff`.
|
||||
/// - Returns the delta against the previous diff value.
|
||||
///
|
||||
/// This factor does NOT store an outgoing message; the diff variable is
|
||||
/// effectively replaced on each propagation. The TruncFactor on the same diff
|
||||
/// effectively replaced on each propagation. The `TruncFactor` on the same diff
|
||||
/// var holds the EP-divide message that produces the cavity.
|
||||
#[derive(Debug)]
|
||||
pub struct RankDiffFactor {
|
||||
|
||||
+2
-1
@@ -15,13 +15,14 @@ pub struct TruncFactor {
|
||||
pub diff: VarId,
|
||||
pub margin: f64,
|
||||
pub tie: bool,
|
||||
/// Outgoing message to the diff variable (initial: N_INF, the EP identity).
|
||||
/// Outgoing message to the diff variable (initial: `N_INF`, the EP identity).
|
||||
pub(crate) msg: Gaussian,
|
||||
/// Cached evidence (linear, not log) computed from the cavity on first propagation.
|
||||
pub(crate) evidence_cached: Option<f64>,
|
||||
}
|
||||
|
||||
impl TruncFactor {
|
||||
#[must_use]
|
||||
pub fn new(diff: VarId, margin: f64, tie: bool) -> Self {
|
||||
Self {
|
||||
diff,
|
||||
|
||||
+38
-11
@@ -107,16 +107,13 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
convergence: crate::ConvergenceOptions,
|
||||
) -> Self {
|
||||
let mut arena = ScratchArena::new();
|
||||
let g = Game::ranked_with_arena(
|
||||
teams.clone(),
|
||||
&result,
|
||||
&weights,
|
||||
p_draw,
|
||||
convergence,
|
||||
&mut arena,
|
||||
);
|
||||
|
||||
// `Game` takes the teams by value and is dropped here, so take the vec
|
||||
// back out of it rather than handing it a clone.
|
||||
let g = Game::ranked_with_arena(teams, &result, &weights, p_draw, convergence, &mut arena);
|
||||
|
||||
Self {
|
||||
teams,
|
||||
teams: g.teams,
|
||||
likelihoods: g.likelihoods,
|
||||
log_evidence: g.log_evidence,
|
||||
}
|
||||
@@ -130,21 +127,24 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
convergence: crate::ConvergenceOptions,
|
||||
) -> Self {
|
||||
let mut arena = ScratchArena::new();
|
||||
|
||||
let g = Game::scored_with_arena(
|
||||
teams.clone(),
|
||||
teams,
|
||||
&scores,
|
||||
&weights,
|
||||
score_sigma,
|
||||
convergence,
|
||||
&mut arena,
|
||||
);
|
||||
|
||||
Self {
|
||||
teams,
|
||||
teams: g.teams,
|
||||
likelihoods: g.likelihoods,
|
||||
log_evidence: g.log_evidence,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
||||
self.likelihoods
|
||||
.iter()
|
||||
@@ -153,6 +153,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn log_evidence(&self) -> f64 {
|
||||
self.log_evidence
|
||||
}
|
||||
@@ -409,6 +410,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
self.likelihoods = likelihoods;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
||||
self.likelihoods
|
||||
.iter()
|
||||
@@ -422,12 +424,21 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn log_evidence(&self) -> f64 {
|
||||
self.log_evidence
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
/// # Errors
|
||||
///
|
||||
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`.
|
||||
/// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`.
|
||||
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
|
||||
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
|
||||
/// `p_draw` is zero: the truncation margin is then zero and the two-sided
|
||||
/// tie update evaluates `0/0`.
|
||||
pub fn ranked(
|
||||
teams: &[&[Rating<T, D>]],
|
||||
outcome: crate::Outcome,
|
||||
@@ -478,6 +489,12 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
))
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// - `InvalidParameter` if `options.score_sigma` is not strictly positive,
|
||||
/// or is NaN.
|
||||
/// - `MismatchedShape` if the outcome's score count differs from `teams.len()`.
|
||||
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Scored`.
|
||||
pub fn scored(
|
||||
teams: &[&[Rating<T, D>]],
|
||||
outcome: crate::Outcome,
|
||||
@@ -515,6 +532,12 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
))
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// Delegates to [`Game::ranked`] with default options, so it returns the
|
||||
/// same errors — in practice `WrongOutcomeKind` for a non-ranked outcome,
|
||||
/// or `TieWithoutDrawProbability` for a draw, since the default `p_draw`
|
||||
/// applies rather than one you chose.
|
||||
pub fn one_v_one(
|
||||
a: &Rating<T, D>,
|
||||
b: &Rating<T, D>,
|
||||
@@ -525,6 +548,10 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
Ok((post[0][0], post[1][0]))
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// Wraps each player in a one-member team and delegates to
|
||||
/// [`Game::ranked`], so it returns the same errors.
|
||||
pub fn free_for_all(
|
||||
players: &[&Rating<T, D>],
|
||||
outcome: crate::Outcome,
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct Gaussian {
|
||||
|
||||
impl Gaussian {
|
||||
/// Construct from mean and standard deviation.
|
||||
#[must_use]
|
||||
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
|
||||
if sigma == f64::INFINITY {
|
||||
Self { pi: 0.0, tau: 0.0 }
|
||||
@@ -64,16 +65,19 @@ impl Gaussian {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn pi(&self) -> f64 {
|
||||
self.pi
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn tau(&self) -> f64 {
|
||||
self.tau
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn mu(&self) -> f64 {
|
||||
// A non-positive precision is an improper (uninformative) Gaussian — its mean is
|
||||
// undefined. Treat it like `pi == 0` and return 0. EP message cancellation can land
|
||||
@@ -102,6 +106,7 @@ impl Gaussian {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn sigma(&self) -> f64 {
|
||||
// A non-positive precision is improper → infinite standard deviation. Guarding
|
||||
// `pi <= 0.0` (not just `== 0.0`) keeps `1.0 / pi.sqrt()` from returning NaN when EP
|
||||
@@ -145,6 +150,7 @@ impl Gaussian {
|
||||
/// Used by within-game inference to stabilise oscillating fixed-point
|
||||
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
|
||||
/// `alpha < 1.0` shrinks each per-step update.
|
||||
#[must_use]
|
||||
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
|
||||
Gaussian::from_natural(
|
||||
alpha * new.pi() + (1.0 - alpha) * self.pi(),
|
||||
|
||||
+177
-37
@@ -1,7 +1,7 @@
|
||||
use std::{borrow::Borrow, collections::HashMap, hash::Hash, marker::PhantomData};
|
||||
|
||||
use crate::{
|
||||
BETA, GAMMA, Index, MU, N_INF, P_DRAW, SIGMA,
|
||||
BETA, GAMMA, Index, MU, P_DRAW, SIGMA,
|
||||
competitor::{self, Competitor},
|
||||
convergence::{ConvergenceOptions, ConvergenceReport},
|
||||
drift::{ConstantDrift, Drift},
|
||||
@@ -198,6 +198,7 @@ impl Default for History<i64, ConstantDrift, NullObserver, &'static str> {
|
||||
}
|
||||
|
||||
impl History<i64, ConstantDrift, NullObserver, &'static str> {
|
||||
#[must_use]
|
||||
pub fn builder() -> HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str> {
|
||||
HistoryBuilder::default()
|
||||
}
|
||||
@@ -205,6 +206,7 @@ impl History<i64, ConstantDrift, NullObserver, &'static str> {
|
||||
|
||||
impl<K: Eq + Hash + Clone> History<i64, ConstantDrift, NullObserver, K> {
|
||||
/// Like `builder()` but uses a custom key type `K` instead of the default `&'static str`.
|
||||
#[must_use]
|
||||
pub fn builder_with_key() -> HistoryBuilder<i64, ConstantDrift, NullObserver, K> {
|
||||
HistoryBuilder {
|
||||
mu: MU,
|
||||
@@ -252,7 +254,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
for j in (0..self.time_slices.len() - 1).rev() {
|
||||
for agent in self.time_slices[j + 1].skills.keys() {
|
||||
self.agents.get_mut(agent).unwrap().message =
|
||||
self.time_slices[j + 1].backward_prior_out(&agent, &self.agents);
|
||||
Some(self.time_slices[j + 1].backward_prior_out(&agent, &self.agents));
|
||||
}
|
||||
|
||||
let old = self.time_slices[j].posteriors();
|
||||
@@ -271,7 +273,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
for j in 1..self.time_slices.len() {
|
||||
for agent in self.time_slices[j - 1].skills.keys() {
|
||||
self.agents.get_mut(agent).unwrap().message =
|
||||
self.time_slices[j - 1].forward_prior_out(&agent);
|
||||
Some(self.time_slices[j - 1].forward_prior_out(&agent));
|
||||
}
|
||||
|
||||
let old = self.time_slices[j].posteriors();
|
||||
@@ -552,7 +554,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
|
||||
/// 2-team win probability: returns `[P(team0 wins), P(team1 wins)]`.
|
||||
///
|
||||
/// Panics if `teams.len() != 2`. N-team support lands in T4.
|
||||
/// N-team support lands in T4.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `teams.len() != 2`.
|
||||
pub fn predict_outcome(&self, teams: &[&[&K]]) -> Vec<f64> {
|
||||
assert_eq!(teams.len(), 2, "predict_outcome T2: 2 teams only");
|
||||
let gather = |team: &[&K]| -> Gaussian {
|
||||
@@ -574,6 +580,15 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
|
||||
/// Run the full forward+backward convergence loop and return a summary.
|
||||
///
|
||||
/// Failing to reach `epsilon` within `max_iter` is not an error: the
|
||||
/// returned report carries `converged: false` and the final step.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has
|
||||
/// broken down at that point and further iterations cannot recover, so the
|
||||
/// loop stops rather than reporting a NaN step as convergence.
|
||||
pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> {
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -588,7 +603,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
log_evidence: 0.0,
|
||||
converged: true,
|
||||
per_iteration_time: SmallVec::new(),
|
||||
slices_skipped: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -627,7 +641,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
log_evidence,
|
||||
converged,
|
||||
per_iteration_time: per_iter,
|
||||
slices_skipped: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -635,18 +648,23 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
|
||||
pub(crate) fn add_events_with_prior(
|
||||
&mut self,
|
||||
composition: Vec<Vec<Vec<Index>>>,
|
||||
results: Vec<Vec<f64>>,
|
||||
mut composition: Vec<Vec<Vec<Index>>>,
|
||||
mut results: Option<Vec<Vec<f64>>>,
|
||||
times: Vec<T>,
|
||||
weights: Vec<Vec<Vec<f64>>>,
|
||||
mut weights: Option<Vec<Vec<Vec<f64>>>>,
|
||||
kinds: Vec<EventKind>,
|
||||
mut priors: HashMap<Index, Rating<T, D>>,
|
||||
) -> Result<(), InferenceError> {
|
||||
if !results.is_empty() && results.len() != composition.len() {
|
||||
if results
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.len() != composition.len())
|
||||
{
|
||||
let got = results.as_ref().map_or(0, Vec::len);
|
||||
|
||||
return Err(InferenceError::MismatchedShape {
|
||||
kind: "results",
|
||||
expected: composition.len(),
|
||||
got: results.len(),
|
||||
got,
|
||||
});
|
||||
}
|
||||
if times.len() != composition.len() {
|
||||
@@ -656,11 +674,16 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
got: times.len(),
|
||||
});
|
||||
}
|
||||
if !weights.is_empty() && weights.len() != composition.len() {
|
||||
if weights
|
||||
.as_ref()
|
||||
.is_some_and(|w| w.len() != composition.len())
|
||||
{
|
||||
let got = weights.as_ref().map_or(0, Vec::len);
|
||||
|
||||
return Err(InferenceError::MismatchedShape {
|
||||
kind: "weights",
|
||||
expected: composition.len(),
|
||||
got: weights.len(),
|
||||
got,
|
||||
});
|
||||
}
|
||||
if kinds.len() != composition.len() {
|
||||
@@ -675,7 +698,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
// including `record_draw`, which builds its results directly rather
|
||||
// than going through `Outcome`.
|
||||
if self.p_draw == 0.0 {
|
||||
for (event_results, kind) in results.iter().zip(kinds.iter()) {
|
||||
for (event_results, kind) in results.iter().flatten().zip(kinds.iter()) {
|
||||
if !matches!(kind, EventKind::Ranked) {
|
||||
continue;
|
||||
}
|
||||
@@ -708,7 +731,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
self.drift,
|
||||
)
|
||||
}),
|
||||
message: N_INF,
|
||||
message: None,
|
||||
last_time: None,
|
||||
},
|
||||
);
|
||||
@@ -718,6 +741,20 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let n = composition.len();
|
||||
let o = sort_time(×, false);
|
||||
|
||||
// The chunking loop below MOVES each event's data out of `composition`,
|
||||
// `results` and `weights` instead of cloning it. That is only sound
|
||||
// because `o` is a permutation, so every index is visited exactly once
|
||||
// — visiting one twice would silently yield an empty event rather than
|
||||
// failing.
|
||||
debug_assert!(
|
||||
{
|
||||
let mut seen = vec![false; n];
|
||||
o.iter()
|
||||
.all(|&idx| !std::mem::replace(&mut seen[idx], true))
|
||||
},
|
||||
"sort_time must return a permutation of 0..{n}"
|
||||
);
|
||||
|
||||
let mut i = 0;
|
||||
let mut k = 0;
|
||||
|
||||
@@ -746,7 +783,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let agent = self.agents.get_mut(*agent_idx).unwrap();
|
||||
|
||||
agent.last_time = Some(time_slice.time);
|
||||
agent.message = time_slice.forward_prior_out(agent_idx);
|
||||
agent.message = Some(time_slice.forward_prior_out(agent_idx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,20 +791,20 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
|
||||
let composition = (i..j)
|
||||
.map(|e| composition[o[e]].clone())
|
||||
.map(|e| std::mem::take(&mut composition[o[e]]))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let results = if results.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
(i..j).map(|e| results[o[e]].clone()).collect::<Vec<_>>()
|
||||
};
|
||||
let results = results.as_mut().map(|results| {
|
||||
(i..j)
|
||||
.map(|e| std::mem::take(&mut results[o[e]]))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let weights = if weights.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
(i..j).map(|e| weights[o[e]].clone()).collect::<Vec<_>>()
|
||||
};
|
||||
let weights = weights.as_mut().map(|weights| {
|
||||
(i..j)
|
||||
.map(|e| std::mem::take(&mut weights[o[e]]))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let kinds_chunk: Vec<EventKind> = (i..j).map(|e| kinds[o[e]]).collect();
|
||||
|
||||
@@ -779,7 +816,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let agent = self.agents.get_mut(agent_idx).unwrap();
|
||||
|
||||
agent.last_time = Some(t);
|
||||
agent.message = time_slice.forward_prior_out(&agent_idx);
|
||||
agent.message = Some(time_slice.forward_prior_out(&agent_idx));
|
||||
}
|
||||
|
||||
k += 1;
|
||||
@@ -795,7 +832,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let agent = self.agents.get_mut(agent_idx).unwrap();
|
||||
|
||||
agent.last_time = Some(t);
|
||||
agent.message = time_slice.forward_prior_out(&agent_idx);
|
||||
agent.message = Some(time_slice.forward_prior_out(&agent_idx));
|
||||
}
|
||||
|
||||
k += 1;
|
||||
@@ -819,7 +856,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let agent = self.agents.get_mut(*agent_idx).unwrap();
|
||||
|
||||
agent.last_time = Some(time_slice.time);
|
||||
agent.message = time_slice.forward_prior_out(agent_idx);
|
||||
agent.message = Some(time_slice.forward_prior_out(agent_idx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,6 +867,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a single two-competitor event that `winner` won.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Ingests through the same path as [`History::add_events`], so it returns
|
||||
/// the same errors. A two-team decisive outcome cannot tie, so
|
||||
/// `TieWithoutDrawProbability` is not reachable here.
|
||||
pub fn record_winner<Q>(&mut self, winner: &Q, loser: &Q, time: T) -> Result<(), InferenceError>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
@@ -839,14 +883,21 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let l = self.intern(loser);
|
||||
self.add_events_with_prior(
|
||||
vec![vec![vec![w], vec![l]]],
|
||||
vec![vec![1.0, 0.0]],
|
||||
Some(vec![vec![1.0, 0.0]]),
|
||||
vec![time],
|
||||
vec![],
|
||||
None,
|
||||
vec![EventKind::Ranked],
|
||||
HashMap::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Record a single two-competitor event that ended level.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Ingests through the same path as [`History::add_events`]. Note
|
||||
/// `TieWithoutDrawProbability` *is* reachable here: a draw needs a
|
||||
/// positive `p_draw`.
|
||||
pub fn record_draw<Q>(&mut self, a: &Q, b: &Q, time: T) -> Result<(), InferenceError>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
@@ -856,9 +907,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let b_idx = self.intern(b);
|
||||
self.add_events_with_prior(
|
||||
vec![vec![vec![a_idx], vec![b_idx]]],
|
||||
vec![vec![0.0, 0.0]],
|
||||
Some(vec![vec![0.0, 0.0]]),
|
||||
vec![time],
|
||||
vec![],
|
||||
None,
|
||||
vec![EventKind::Ranked],
|
||||
HashMap::new(),
|
||||
)
|
||||
@@ -870,6 +921,17 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
|
||||
/// Bulk-ingest typed events.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `MismatchedShape` if an event's outcome does not describe the same
|
||||
/// number of teams the event has, or if per-member weights do not match
|
||||
/// the team's membership.
|
||||
/// - `InvalidParameter` if a per-event `score_sigma` override is not
|
||||
/// strictly positive.
|
||||
/// - `TieWithoutDrawProbability` if an event ties two teams while the
|
||||
/// history's `p_draw` is zero. This includes `Outcome::winner(w, n)` for
|
||||
/// `n >= 3`, which ties every loser.
|
||||
pub fn add_events<I>(&mut self, events: I) -> Result<(), InferenceError>
|
||||
where
|
||||
I: IntoIterator<Item = crate::event::Event<T, K>>,
|
||||
@@ -906,8 +968,37 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let idx = self.keys.get_or_create(&member.key);
|
||||
team_indices.push(idx);
|
||||
team_weights.push(member.weight);
|
||||
if let Some(prior) = member.prior {
|
||||
priors.insert(idx, Rating::new(prior, self.beta, self.drift));
|
||||
|
||||
if let Some(scale) = member.drift_scale {
|
||||
// Squaring would make a negative scale behave as its
|
||||
// absolute value, so reject rather than silently
|
||||
// accept a sign the caller cannot have meant.
|
||||
if !scale.is_finite() || scale < 0.0 {
|
||||
return Err(InferenceError::InvalidParameter {
|
||||
name: "drift_scale",
|
||||
value: scale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// `prior` and `drift_scale` are competitor configuration,
|
||||
// captured here and consumed at competitor creation. Both
|
||||
// land in the same entry so a member may set either alone.
|
||||
if member.prior.is_some() || member.drift_scale.is_some() {
|
||||
let rating = priors.entry(idx).or_insert_with(|| {
|
||||
Rating::new(
|
||||
Gaussian::from_ms(self.mu, self.sigma),
|
||||
self.beta,
|
||||
self.drift,
|
||||
)
|
||||
});
|
||||
|
||||
if let Some(prior) = member.prior {
|
||||
rating.prior = prior;
|
||||
}
|
||||
if let Some(scale) = member.drift_scale {
|
||||
rating.drift_scale = scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
event_comp.push(team_indices);
|
||||
@@ -941,7 +1032,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
times.push(ev.time);
|
||||
}
|
||||
|
||||
self.add_events_with_prior(composition, results, times, weights, kinds, priors)
|
||||
let weights = if weights.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(weights)
|
||||
};
|
||||
|
||||
self.add_events_with_prior(composition, Some(results), times, weights, kinds, priors)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -956,6 +1053,49 @@ mod tests {
|
||||
arena::ScratchArena,
|
||||
};
|
||||
|
||||
/// #17: a slice's footprint must be O(competitors in the slice), not
|
||||
/// O(largest global index it touches). The store used to be a dense
|
||||
/// `Vec<Skill>` indexed by `Index.0`, so the same two-competitor games cost
|
||||
/// 20,000 slots per slice when the competitors sat at the top of a large
|
||||
/// roster. Measured end to end, peak RSS was 309 MB against 52 MB.
|
||||
#[test]
|
||||
fn per_slice_footprint_is_independent_of_index_magnitude() {
|
||||
fn total_skill_slots(high_indices: bool) -> usize {
|
||||
let mut h: History<i64, ConstantDrift, NullObserver, String> =
|
||||
History::builder_with_key().build();
|
||||
|
||||
for i in 0..2_000 {
|
||||
h.intern(&format!("k{i:05}"));
|
||||
}
|
||||
|
||||
let (a, b) = if high_indices {
|
||||
("k01998".to_string(), "k01999".to_string())
|
||||
} else {
|
||||
("k00000".to_string(), "k00001".to_string())
|
||||
};
|
||||
|
||||
for time in 1..=20i64 {
|
||||
h.record_winner(&a, &b, time).unwrap();
|
||||
}
|
||||
|
||||
h.time_slices
|
||||
.iter()
|
||||
.map(|ts| ts.skills.allocated_slots())
|
||||
.sum()
|
||||
}
|
||||
|
||||
let low = total_skill_slots(false);
|
||||
let high = total_skill_slots(true);
|
||||
|
||||
assert_eq!(low, high, "footprint must not depend on index magnitude");
|
||||
|
||||
// A dense store over a 2,000-key roster would allocate 20 x 2,000.
|
||||
assert!(
|
||||
high < 1_000,
|
||||
"20 slices of 2 competitors allocated {high} slots"
|
||||
);
|
||||
}
|
||||
|
||||
fn make_events_1v1(
|
||||
pairs: &[(&'static str, &'static str)],
|
||||
outcomes: &[Outcome],
|
||||
|
||||
@@ -25,6 +25,7 @@ impl<K> KeyTable<K>
|
||||
where
|
||||
K: Eq + Hash + Clone,
|
||||
{
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
forward: HashMap::new(),
|
||||
@@ -54,6 +55,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn key(&self, idx: Index) -> Option<&K> {
|
||||
self.reverse.get(idx.0)
|
||||
}
|
||||
@@ -62,10 +64,12 @@ where
|
||||
self.forward.keys()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.reverse.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.reverse.is_empty()
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
//! TrueSkill Through Time — Bayesian skill rating over a time axis.
|
||||
//! `TrueSkill` Through Time — Bayesian skill rating over a time axis.
|
||||
//!
|
||||
//! Where plain TrueSkill gives each competitor one running estimate, TrueSkill
|
||||
//! Where plain `TrueSkill` gives each competitor one running estimate, `TrueSkill`
|
||||
//! Through Time treats a whole history as a single model and infers skill *at
|
||||
//! every point in time*. Evidence flows both directions: a result today
|
||||
//! sharpens the estimate of who someone was last year, so early estimates stop
|
||||
@@ -361,6 +361,7 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
|
||||
/// Panics if fewer than two rating groups are supplied, or if any group is
|
||||
/// empty — match quality is a property of a contest between at least two
|
||||
/// non-empty sides.
|
||||
#[must_use]
|
||||
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
||||
assert!(
|
||||
rating_groups.len() >= 2,
|
||||
|
||||
@@ -29,7 +29,13 @@ pub enum Outcome {
|
||||
impl Outcome {
|
||||
/// `n`-team outcome where team `winner` won and everyone else tied for last.
|
||||
///
|
||||
/// Note this ties every loser, so for `n >= 3` it needs a positive
|
||||
/// `p_draw` — see `InferenceError::TieWithoutDrawProbability`.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `winner >= n`.
|
||||
#[must_use]
|
||||
pub fn winner(winner: u32, n: u32) -> Self {
|
||||
assert!(winner < n, "winner index {winner} out of range 0..{n}");
|
||||
let ranks: SmallVec<[u32; 4]> = (0..n).map(|i| if i == winner { 0 } else { 1 }).collect();
|
||||
@@ -37,6 +43,7 @@ impl Outcome {
|
||||
}
|
||||
|
||||
/// All `n` teams tied.
|
||||
#[must_use]
|
||||
pub fn draw(n: u32) -> Self {
|
||||
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
|
||||
}
|
||||
@@ -68,6 +75,7 @@ impl Outcome {
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn team_count(&self) -> usize {
|
||||
match self {
|
||||
Self::Ranked(r) => r.len(),
|
||||
|
||||
@@ -16,6 +16,9 @@ pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
||||
pub(crate) prior: Gaussian,
|
||||
pub(crate) beta: f64,
|
||||
pub(crate) drift: D,
|
||||
/// Multiplier on the drift *variance* this competitor accumulates; 1.0 is
|
||||
/// the neutral default. Set per competitor via `Member::with_drift_scale`.
|
||||
pub(crate) drift_scale: f64,
|
||||
pub(crate) _time: PhantomData<T>,
|
||||
}
|
||||
|
||||
@@ -25,10 +28,21 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
|
||||
prior,
|
||||
beta,
|
||||
drift,
|
||||
drift_scale: 1.0,
|
||||
_time: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale how fast this competitor drifts, relative to `drift`.
|
||||
///
|
||||
/// Multiplies the drift *variance*, so the scale is in the same units as
|
||||
/// `gamma`. `0.0` pins the competitor still.
|
||||
#[must_use]
|
||||
pub fn with_drift_scale(mut self, drift_scale: f64) -> Self {
|
||||
self.drift_scale = drift_scale;
|
||||
self
|
||||
}
|
||||
|
||||
/// The configured prior skill estimate.
|
||||
#[must_use]
|
||||
pub fn prior(&self) -> Gaussian {
|
||||
@@ -47,6 +61,28 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
|
||||
self.drift
|
||||
}
|
||||
|
||||
/// This competitor's multiplier on the drift variance; 1.0 is neutral.
|
||||
#[must_use]
|
||||
pub fn drift_scale(&self) -> f64 {
|
||||
self.drift_scale
|
||||
}
|
||||
|
||||
/// Drift variance accumulated over `from -> to`, scaled for this competitor.
|
||||
///
|
||||
/// The single place the scale is applied for a `Time`-typed span. Callers
|
||||
/// must go through this rather than `self.drift` directly, so a competitor's
|
||||
/// scale cannot be silently skipped.
|
||||
pub(crate) fn drift_variance_delta(&self, from: &T, to: &T) -> f64 {
|
||||
self.drift.variance_delta(from, to) * self.drift_scale * self.drift_scale
|
||||
}
|
||||
|
||||
/// Drift variance for a cached elapsed count, scaled for this competitor.
|
||||
///
|
||||
/// The counterpart of `drift_variance_delta` for the cached-elapsed paths.
|
||||
pub(crate) fn drift_variance_for_elapsed(&self, elapsed: i64) -> f64 {
|
||||
self.drift.variance_for_elapsed(elapsed) * self.drift_scale * self.drift_scale
|
||||
}
|
||||
|
||||
pub(crate) fn performance(&self) -> Gaussian {
|
||||
self.prior.forget(self.beta.powi(2))
|
||||
}
|
||||
@@ -58,6 +94,7 @@ impl Default for Rating<i64, ConstantDrift> {
|
||||
prior: Gaussian::default(),
|
||||
beta: BETA,
|
||||
drift: ConstantDrift(GAMMA),
|
||||
drift_scale: 1.0,
|
||||
_time: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
//! Schedule trait and built-in implementations.
|
||||
//!
|
||||
//! A schedule drives factor propagation to convergence. The default
|
||||
//! `EpsilonOrMax` performs one TeamSum sweep (setup) then alternating
|
||||
//! `EpsilonOrMax` performs one `TeamSum` sweep (setup) then alternating
|
||||
//! forward/backward sweeps over the iterating factors until the max
|
||||
//! delta drops below epsilon or `max` iterations is reached.
|
||||
|
||||
@@ -23,7 +23,7 @@ pub trait Schedule: Send + Sync {
|
||||
/// Default schedule: sweep forward then backward until step ≤ eps or iter == max.
|
||||
///
|
||||
/// Matches the existing `Game::likelihoods` loop bit-for-bit when given the
|
||||
/// same factor layout (TeamSums first, then alternating RankDiff/Trunc pairs).
|
||||
/// same factor layout (`TeamSums` first, then alternating RankDiff/Trunc pairs).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct EpsilonOrMax {
|
||||
pub eps: f64,
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::{Index, competitor::Competitor, drift::Drift, time::Time};
|
||||
|
||||
/// Dense Vec-backed store for competitor state in History.
|
||||
///
|
||||
/// Indexed directly by Index.0, eliminating HashMap hashing in the
|
||||
/// Indexed directly by Index.0, eliminating `HashMap` hashing in the
|
||||
/// forward/backward sweep. Uses `Vec<Option<Competitor<T, D>>>` so slots can be
|
||||
/// absent without an explicit present mask.
|
||||
#[derive(Debug)]
|
||||
@@ -21,6 +21,7 @@ impl<T: Time, D: Drift<T>> Default for CompetitorStore<T, D> {
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
@@ -39,6 +40,7 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
|
||||
self.competitors[idx.0] = Some(competitor);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, idx: Index) -> Option<&Competitor<T, D>> {
|
||||
self.competitors.get(idx.0).and_then(|slot| slot.as_ref())
|
||||
}
|
||||
@@ -49,14 +51,17 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
|
||||
.and_then(|slot| slot.as_mut())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn contains(&self, idx: Index) -> bool {
|
||||
self.get(idx).is_some()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.n_present
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.n_present == 0
|
||||
}
|
||||
|
||||
+118
-56
@@ -1,15 +1,27 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{Index, time_slice::Skill};
|
||||
|
||||
/// Dense Vec-backed store for per-agent skill state within a TimeSlice.
|
||||
/// Compact per-slice store for skill state, addressed by a slice-local slot.
|
||||
///
|
||||
/// Indexed directly by Index.0, eliminating HashMap hashing in the inner
|
||||
/// convergence loop. Uses a parallel `present` mask so iteration skips
|
||||
/// absent slots without incurring per-slot Option overhead in the hot path.
|
||||
/// `skills` holds one entry per competitor **in this slice**, so memory is
|
||||
/// O(competitors in the slice). It used to be a dense `Vec<Skill>` indexed by
|
||||
/// the global `Index.0`, which made a slice's footprint O(largest index it
|
||||
/// touches): a single 1v1 game between competitors 19998 and 19999 reserved
|
||||
/// 20,000 slots.
|
||||
///
|
||||
/// The dense layout existed to keep `HashMap` hashing out of the inner
|
||||
/// convergence loop, and that property is preserved. `slots` is consulted only
|
||||
/// while building a slice; every hot-path access goes through
|
||||
/// [`SkillStore::at`] / [`SkillStore::at_mut`] with a slot resolved once at
|
||||
/// ingestion and cached on the event's `Item`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SkillStore {
|
||||
skills: Vec<Skill>,
|
||||
present: Vec<bool>,
|
||||
n_present: usize,
|
||||
/// Slot -> global index, parallel to `skills`, so iteration can report the
|
||||
/// global index without a reverse lookup.
|
||||
indices: Vec<Index>,
|
||||
slots: HashMap<Index, u32>,
|
||||
}
|
||||
|
||||
impl SkillStore {
|
||||
@@ -17,73 +29,99 @@ impl SkillStore {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn ensure_capacity(&mut self, idx: usize) {
|
||||
if idx >= self.skills.len() {
|
||||
self.skills.resize_with(idx + 1, Skill::default);
|
||||
self.present.resize(idx + 1, false);
|
||||
}
|
||||
/// Resolve a global index to this slice's slot, if the competitor is here.
|
||||
///
|
||||
/// This hashes. Call it at ingestion and cache the result; do not call it
|
||||
/// from the convergence loop.
|
||||
pub fn slot_of(&self, idx: Index) -> Option<u32> {
|
||||
self.slots.get(&idx).copied()
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, idx: Index, skill: Skill) {
|
||||
self.ensure_capacity(idx.0);
|
||||
if !self.present[idx.0] {
|
||||
self.n_present += 1;
|
||||
/// Skill at a slot resolved earlier by [`SkillStore::slot_of`].
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `slot` is out of range, which means it came from a different
|
||||
/// slice's store.
|
||||
pub fn at(&self, slot: u32) -> &Skill {
|
||||
&self.skills[slot as usize]
|
||||
}
|
||||
|
||||
/// Mutable counterpart to [`SkillStore::at`].
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `slot` is out of range.
|
||||
pub fn at_mut(&mut self, slot: u32) -> &mut Skill {
|
||||
&mut self.skills[slot as usize]
|
||||
}
|
||||
|
||||
/// Insert or overwrite a competitor's skill, returning its slot.
|
||||
pub fn insert(&mut self, idx: Index, skill: Skill) -> u32 {
|
||||
match self.slots.get(&idx) {
|
||||
Some(&slot) => {
|
||||
self.skills[slot as usize] = skill;
|
||||
slot
|
||||
}
|
||||
None => {
|
||||
let slot = u32::try_from(self.skills.len())
|
||||
.expect("a time slice cannot hold more than u32::MAX competitors");
|
||||
|
||||
self.skills.push(skill);
|
||||
self.indices.push(idx);
|
||||
self.slots.insert(idx, slot);
|
||||
|
||||
slot
|
||||
}
|
||||
}
|
||||
self.skills[idx.0] = skill;
|
||||
self.present[idx.0] = true;
|
||||
}
|
||||
|
||||
pub fn get(&self, idx: Index) -> Option<&Skill> {
|
||||
if idx.0 < self.present.len() && self.present[idx.0] {
|
||||
Some(&self.skills[idx.0])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a slot is occupied. Test-only.
|
||||
#[cfg(test)]
|
||||
pub fn contains(&self, idx: Index) -> bool {
|
||||
idx.0 < self.present.len() && self.present[idx.0]
|
||||
}
|
||||
|
||||
/// Number of occupied slots. Test-only.
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.n_present
|
||||
self.slot_of(idx).map(|slot| self.at(slot))
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, idx: Index) -> Option<&mut Skill> {
|
||||
if idx.0 < self.present.len() && self.present[idx.0] {
|
||||
Some(&mut self.skills[idx.0])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
self.slot_of(idx)
|
||||
.map(|slot| &mut self.skills[slot as usize])
|
||||
}
|
||||
|
||||
/// Whether a competitor is present in this slice. Test-only.
|
||||
#[cfg(test)]
|
||||
pub fn contains(&self, idx: Index) -> bool {
|
||||
self.slots.contains_key(&idx)
|
||||
}
|
||||
|
||||
/// Number of competitors in this slice. Test-only.
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.skills.len()
|
||||
}
|
||||
|
||||
/// Slots actually allocated — the quantity #17 is about, and NOT the same
|
||||
/// as `len` for every possible implementation.
|
||||
///
|
||||
/// A store indexed by the global `Index` must report `max_index + 1` here
|
||||
/// while reporting the true competitor count from `len`, which is exactly
|
||||
/// how the original defect hid. Tests that mean to pin the footprint must
|
||||
/// assert on this.
|
||||
#[cfg(test)]
|
||||
pub fn allocated_slots(&self) -> usize {
|
||||
self.skills.len()
|
||||
}
|
||||
|
||||
/// Iterate in slot order — the order competitors were first seen in this
|
||||
/// slice. Deterministic for a given event order, which is what the
|
||||
/// cross-thread determinism test relies on.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (Index, &Skill)> {
|
||||
self.present.iter().enumerate().filter_map(|(i, &p)| {
|
||||
if p {
|
||||
Some((Index(i), &self.skills[i]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
self.indices.iter().copied().zip(self.skills.iter())
|
||||
}
|
||||
|
||||
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Skill)> {
|
||||
self.skills
|
||||
.iter_mut()
|
||||
.zip(self.present.iter())
|
||||
.enumerate()
|
||||
.filter_map(|(i, (s, &p))| if p { Some((Index(i), s)) } else { None })
|
||||
self.indices.iter().copied().zip(self.skills.iter_mut())
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> impl Iterator<Item = Index> + '_ {
|
||||
self.present
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &p)| if p { Some(Index(i)) } else { None })
|
||||
self.indices.iter().copied()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +147,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_skips_absent_slots() {
|
||||
fn iter_reports_global_indices() {
|
||||
let mut store = SkillStore::new();
|
||||
store.insert(Index(0), Skill::default());
|
||||
store.insert(Index(5), Skill::default());
|
||||
@@ -124,4 +162,28 @@ mod tests {
|
||||
store.insert(Index(2), Skill::default());
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
|
||||
/// The defect in #17: a slice holding two competitors must cost the same
|
||||
/// whether their indices are small or large.
|
||||
#[test]
|
||||
fn footprint_is_independent_of_index_magnitude() {
|
||||
let mut low = SkillStore::new();
|
||||
low.insert(Index(0), Skill::default());
|
||||
low.insert(Index(1), Skill::default());
|
||||
|
||||
let mut high = SkillStore::new();
|
||||
high.insert(Index(19_998), Skill::default());
|
||||
high.insert(Index(19_999), Skill::default());
|
||||
|
||||
assert_eq!(low.len(), high.len());
|
||||
assert_eq!(low.skills.capacity(), high.skills.capacity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_survives_reinsert() {
|
||||
let mut store = SkillStore::new();
|
||||
let first = store.insert(Index(7), Skill::default());
|
||||
let again = store.insert(Index(7), Skill::default());
|
||||
assert_eq!(first, again);
|
||||
}
|
||||
}
|
||||
|
||||
+86
-35
@@ -51,6 +51,13 @@ pub enum EventKind {
|
||||
#[derive(Clone, Debug)]
|
||||
struct Item {
|
||||
agent: Index,
|
||||
/// This competitor's slot in the owning slice's `SkillStore`, resolved
|
||||
/// once at ingestion.
|
||||
///
|
||||
/// The convergence loop reaches skills through this rather than through
|
||||
/// `agent`, which is what keeps `HashMap` hashing out of the hot path now
|
||||
/// that the store is compact rather than indexed by the global `Index`.
|
||||
slot: u32,
|
||||
likelihood: Gaussian,
|
||||
}
|
||||
|
||||
@@ -62,12 +69,13 @@ impl Item {
|
||||
agents: &CompetitorStore<T, D>,
|
||||
) -> Rating<T, D> {
|
||||
let r = &agents[self.agent].rating;
|
||||
let skill = skills.get(self.agent).unwrap();
|
||||
let skill = skills.at(self.slot);
|
||||
|
||||
if forward {
|
||||
Rating::new(skill.forward, r.beta, r.drift)
|
||||
Rating::new(skill.forward, r.beta, r.drift).with_drift_scale(r.drift_scale)
|
||||
} else {
|
||||
Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift)
|
||||
.with_drift_scale(r.drift_scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,9 +165,9 @@ impl Event {
|
||||
for (t, team) in self.teams.iter_mut().enumerate() {
|
||||
for (i, item) in team.items.iter_mut().enumerate() {
|
||||
let fresh = update.likelihoods[t][i];
|
||||
let old_likelihood = skills.get(item.agent).unwrap().likelihood;
|
||||
let old_likelihood = skills.at(item.slot).likelihood;
|
||||
let new_likelihood = (old_likelihood / item.likelihood) * fresh;
|
||||
skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
|
||||
skills.at_mut(item.slot).likelihood = new_likelihood;
|
||||
item.likelihood = fresh;
|
||||
}
|
||||
}
|
||||
@@ -277,8 +285,8 @@ impl<T: Time> TimeSlice<T> {
|
||||
pub fn add_events<D: Drift<T>>(
|
||||
&mut self,
|
||||
composition: Vec<Vec<Vec<Index>>>,
|
||||
results: Vec<Vec<f64>>,
|
||||
weights: Vec<Vec<Vec<f64>>>,
|
||||
results: Option<Vec<Vec<f64>>>,
|
||||
weights: Option<Vec<Vec<Vec<f64>>>>,
|
||||
kinds: Vec<EventKind>,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
) {
|
||||
@@ -297,14 +305,16 @@ impl<T: Time> TimeSlice<T> {
|
||||
for idx in this_agent {
|
||||
let elapsed = compute_elapsed(agents[*idx].last_time.as_ref(), &self.time);
|
||||
|
||||
let forward = agents[*idx].receive(&self.time);
|
||||
|
||||
if let Some(skill) = self.skills.get_mut(*idx) {
|
||||
skill.elapsed = elapsed;
|
||||
skill.forward = agents[*idx].receive(&self.time);
|
||||
skill.forward = forward;
|
||||
} else {
|
||||
self.skills.insert(
|
||||
*idx,
|
||||
Skill {
|
||||
forward: agents[*idx].receive(&self.time),
|
||||
forward,
|
||||
backward: N_INF,
|
||||
likelihood: N_INF,
|
||||
elapsed,
|
||||
@@ -313,6 +323,8 @@ impl<T: Time> TimeSlice<T> {
|
||||
}
|
||||
}
|
||||
|
||||
let skills = &self.skills;
|
||||
|
||||
let events = composition.iter().enumerate().map(|(e, event)| {
|
||||
let teams = event
|
||||
.iter()
|
||||
@@ -322,28 +334,32 @@ impl<T: Time> TimeSlice<T> {
|
||||
.iter()
|
||||
.map(|&agent| Item {
|
||||
agent,
|
||||
// Every participant was inserted into `skills`
|
||||
// just above, so the slot always resolves.
|
||||
slot: skills
|
||||
.slot_of(agent)
|
||||
.expect("participant must be present in the slice store"),
|
||||
likelihood: N_INF,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Team {
|
||||
items,
|
||||
output: if results.is_empty() {
|
||||
(event.len() - (t + 1)) as f64
|
||||
} else {
|
||||
results[e][t]
|
||||
output: match &results {
|
||||
Some(results) => results[e][t],
|
||||
// No explicit result: rank by position, first team best.
|
||||
None => (event.len() - (t + 1)) as f64,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let weights = if weights.is_empty() {
|
||||
teams
|
||||
let weights = match &weights {
|
||||
Some(weights) => weights[e].clone(),
|
||||
None => teams
|
||||
.iter()
|
||||
.map(|team| vec![1.0; team.items.len()])
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
weights[e].clone()
|
||||
.collect::<Vec<_>>(),
|
||||
};
|
||||
|
||||
Event {
|
||||
@@ -370,6 +386,13 @@ impl<T: Time> TimeSlice<T> {
|
||||
.collect::<HashMap<_, _>>()
|
||||
}
|
||||
|
||||
/// Sweep this slice's events once, starting at index `from`.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if an event references a competitor with no entry in this
|
||||
/// slice's skill store. `add_events` inserts one for every participant, so
|
||||
/// this cannot happen for slices built through the public API.
|
||||
pub fn iteration<D: Drift<T>>(&mut self, from: usize, agents: &CompetitorStore<T, D>) {
|
||||
if from == 0 && self.color_groups_dirty {
|
||||
self.recompute_color_groups();
|
||||
@@ -402,10 +425,10 @@ impl<T: Time> TimeSlice<T> {
|
||||
|
||||
for (t, team) in event.teams.iter_mut().enumerate() {
|
||||
for (i, item) in team.items.iter_mut().enumerate() {
|
||||
let old_likelihood = self.skills.get(item.agent).unwrap().likelihood;
|
||||
let old_likelihood = self.skills.at(item.slot).likelihood;
|
||||
let new_likelihood =
|
||||
(old_likelihood / item.likelihood) * g.likelihoods[t][i];
|
||||
self.skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
|
||||
self.skills.at_mut(item.slot).likelihood = new_likelihood;
|
||||
item.likelihood = g.likelihoods[t][i];
|
||||
}
|
||||
}
|
||||
@@ -567,14 +590,13 @@ impl<T: Time> TimeSlice<T> {
|
||||
n.forget(
|
||||
agents[*agent]
|
||||
.rating
|
||||
.drift
|
||||
.variance_for_elapsed(skill.elapsed),
|
||||
.drift_variance_for_elapsed(skill.elapsed),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
||||
for (agent, skill) in self.skills.iter_mut() {
|
||||
skill.backward = agents[agent].message;
|
||||
skill.backward = agents[agent].message.unwrap_or(N_INF);
|
||||
}
|
||||
self.iteration(0, agents);
|
||||
}
|
||||
@@ -623,11 +645,11 @@ impl<T: Time> TimeSlice<T> {
|
||||
let rating = &agents[agent].rating;
|
||||
|
||||
let forward = match incoming.get(&agent) {
|
||||
Some(message) => message.forget(rating.drift.variance_for_elapsed(skill.elapsed)),
|
||||
Some(message) => message.forget(rating.drift_variance_for_elapsed(skill.elapsed)),
|
||||
None => rating.prior,
|
||||
};
|
||||
|
||||
scratch.skills.insert(
|
||||
let slot = scratch.skills.insert(
|
||||
agent,
|
||||
Skill {
|
||||
forward,
|
||||
@@ -636,6 +658,17 @@ impl<T: Time> TimeSlice<T> {
|
||||
elapsed: skill.elapsed,
|
||||
},
|
||||
);
|
||||
|
||||
// The cloned events carry slots resolved against the REAL store, so
|
||||
// the scratch must assign the same ones. It does because `iter()`
|
||||
// yields slot order and `insert` allocates slots in call order —
|
||||
// but that is a coupling between two types, so pin it here rather
|
||||
// than leave it to be rediscovered after it breaks.
|
||||
debug_assert_eq!(
|
||||
Some(slot),
|
||||
self.skills.slot_of(agent),
|
||||
"scratch slot must match the real slice's slot for {agent:?}"
|
||||
);
|
||||
}
|
||||
|
||||
scratch.iterate_to_convergence(agents);
|
||||
@@ -754,8 +787,26 @@ impl<T: Time> TimeSlice<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Elapsed time from a competitor's previous appearance to `current`.
|
||||
///
|
||||
/// A negative elapsed means slices are being visited out of time order, which
|
||||
/// would make drift *reduce* uncertainty. Release builds clamp to zero so a
|
||||
/// bad timestamp degrades to "no drift" rather than corrupting the posterior;
|
||||
/// debug builds trip instead, because reaching here is a bug in slice ordering
|
||||
/// rather than something callers can cause with ordinary data.
|
||||
pub(crate) fn compute_elapsed<T: Time>(last: Option<&T>, current: &T) -> i64 {
|
||||
last.map(|l| l.elapsed_to(current).max(0)).unwrap_or(0)
|
||||
let Some(last) = last else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let elapsed = last.elapsed_to(current);
|
||||
|
||||
debug_assert!(
|
||||
elapsed >= 0,
|
||||
"negative elapsed ({elapsed}) — slices visited out of time order"
|
||||
);
|
||||
|
||||
elapsed.max(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -803,8 +854,8 @@ mod tests {
|
||||
vec![vec![c], vec![d]],
|
||||
vec![vec![e], vec![f]],
|
||||
],
|
||||
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
|
||||
vec![],
|
||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||
None,
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
);
|
||||
@@ -880,8 +931,8 @@ mod tests {
|
||||
vec![vec![a], vec![c]],
|
||||
vec![vec![b], vec![c]],
|
||||
],
|
||||
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
|
||||
vec![],
|
||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||
None,
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
);
|
||||
@@ -960,8 +1011,8 @@ mod tests {
|
||||
vec![vec![a], vec![c]],
|
||||
vec![vec![b], vec![c]],
|
||||
],
|
||||
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
|
||||
vec![],
|
||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||
None,
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
);
|
||||
@@ -992,8 +1043,8 @@ mod tests {
|
||||
vec![vec![a], vec![c]],
|
||||
vec![vec![b], vec![c]],
|
||||
],
|
||||
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
|
||||
vec![],
|
||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||
None,
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
);
|
||||
@@ -1063,8 +1114,8 @@ mod tests {
|
||||
vec![vec![c], vec![d]],
|
||||
vec![vec![a], vec![c]],
|
||||
],
|
||||
vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]],
|
||||
vec![],
|
||||
Some(vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]),
|
||||
None,
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
);
|
||||
|
||||
@@ -247,3 +247,46 @@ fn fluent_event_builder_scores() {
|
||||
let b = h.current_skill(&"bob").unwrap();
|
||||
assert!(a.mu() > b.mu());
|
||||
}
|
||||
|
||||
/// Every field of `ConvergenceReport` must carry real information.
|
||||
///
|
||||
/// `slices_skipped` was public, hardcoded to `0`, and reported a plausible
|
||||
/// value for a feature that never existed — the same shape as the inert
|
||||
/// `online` flag in #19. It was removed in #33. This pins the remaining fields
|
||||
/// so the next always-constant member has to survive an assertion rather than
|
||||
/// just a reviewer's attention.
|
||||
#[test]
|
||||
fn every_convergence_report_field_is_populated() {
|
||||
let mut h = History::builder().build();
|
||||
|
||||
for time in 1..=6i64 {
|
||||
h.record_winner(&"a", &"b", time).unwrap();
|
||||
}
|
||||
|
||||
let report = h.converge().unwrap();
|
||||
|
||||
assert!(
|
||||
report.iterations > 0,
|
||||
"iterations is zero on a real converge"
|
||||
);
|
||||
|
||||
assert!(report.converged, "fixture must converge");
|
||||
|
||||
assert!(
|
||||
report.final_step.0.is_finite() && report.final_step.1.is_finite(),
|
||||
"final_step is not finite: {:?}",
|
||||
report.final_step
|
||||
);
|
||||
|
||||
assert!(
|
||||
report.log_evidence.is_finite() && report.log_evidence < 0.0,
|
||||
"log_evidence is not a finite negative log probability: {}",
|
||||
report.log_evidence
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
report.per_iteration_time.len(),
|
||||
report.iterations,
|
||||
"per_iteration_time must carry one duration per iteration"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Helpers shared across the integration suites.
|
||||
//!
|
||||
//! Each integration file is its own binary, so `mod common;` compiles a copy
|
||||
//! per suite. Anything unused in a given suite would warn, hence the
|
||||
//! `#![allow(dead_code)]`.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use trueskill_tt::Gaussian;
|
||||
|
||||
/// A posterior must be finite with a strictly positive sigma.
|
||||
///
|
||||
/// A non-finite posterior is the failure mode this crate is most prone to —
|
||||
/// EP breaking down produces NaN rather than an error — and a zero or negative
|
||||
/// sigma means the precision went non-positive, which `Gaussian::sigma` reports
|
||||
/// as improper rather than trapping.
|
||||
pub fn assert_finite(g: Gaussian, what: &str) {
|
||||
assert!(
|
||||
g.mu().is_finite(),
|
||||
"{what}: mu is not finite (mu={}, sigma={})",
|
||||
g.mu(),
|
||||
g.sigma()
|
||||
);
|
||||
|
||||
assert!(
|
||||
g.sigma().is_finite() && g.sigma() > 0.0,
|
||||
"{what}: sigma must be finite and positive (mu={}, sigma={})",
|
||||
g.mu(),
|
||||
g.sigma()
|
||||
);
|
||||
}
|
||||
|
||||
/// Every point on every learning curve must be finite.
|
||||
pub fn assert_curve_finite(curve: &[(i64, Gaussian)], who: &str) {
|
||||
for (time, g) in curve {
|
||||
assert_finite(*g, &format!("{who} at t={time}"));
|
||||
}
|
||||
}
|
||||
+160
-9
@@ -3,6 +3,9 @@
|
||||
//! These run in both debug and release: the defects they pin were all
|
||||
//! guarded only by `debug_assert!`, so a debug-only suite never saw them.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::assert_finite;
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError,
|
||||
NullObserver, Outcome, Rating,
|
||||
@@ -18,15 +21,6 @@ fn rating() -> R {
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_finite(g: Gaussian, what: &str) {
|
||||
assert!(
|
||||
g.mu().is_finite() && g.sigma().is_finite(),
|
||||
"{what} must be finite, got mu={} sigma={}",
|
||||
g.mu(),
|
||||
g.sigma()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_draw_without_draw_probability_is_rejected() {
|
||||
let mut h = History::default();
|
||||
@@ -141,6 +135,54 @@ fn converge_on_an_empty_history_with_owned_keys() {
|
||||
assert!(report.converged);
|
||||
}
|
||||
|
||||
/// A weights/team length mismatch used to be a `debug_assert!`, so release
|
||||
/// builds ingested the event with the weights silently unapplied. This file's
|
||||
/// CI job runs in release too, which is the point of pinning it here.
|
||||
#[test]
|
||||
fn event_builder_rejects_a_weights_length_mismatch() {
|
||||
let mut h = History::default();
|
||||
|
||||
let err = h
|
||||
.event(1)
|
||||
.team(["a"])
|
||||
.weights([1.0, 2.0])
|
||||
.team(["b"])
|
||||
.winner(0)
|
||||
.commit()
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::MismatchedShape {
|
||||
kind: "weights",
|
||||
expected: 1,
|
||||
got: 2,
|
||||
}
|
||||
),
|
||||
"expected a weights MismatchedShape, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The mismatch must not be applied even partially — a half-weighted team
|
||||
/// reaching the history would be worse than the error.
|
||||
#[test]
|
||||
fn event_builder_weights_mismatch_leaves_the_history_untouched() {
|
||||
let mut h = History::default();
|
||||
|
||||
// Two teams, so ingestion would otherwise succeed — a one-team event is
|
||||
// rejected for an unrelated reason and would pass this vacuously.
|
||||
let _ = h
|
||||
.event(1)
|
||||
.team(["a"])
|
||||
.weights([1.0, 2.0])
|
||||
.team(["b"])
|
||||
.winner(0)
|
||||
.commit();
|
||||
|
||||
assert!(h.learning_curve("a").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_event_stream_then_converge() {
|
||||
let mut h = History::default();
|
||||
@@ -270,3 +312,112 @@ fn empty_history_has_no_filtered_estimates() {
|
||||
|
||||
assert!(history.filtered_learning_curve("nobody").is_empty());
|
||||
}
|
||||
|
||||
// --- Boundary inputs (#26) ----------------------------------------------
|
||||
|
||||
fn tight() -> ConvergenceOptions {
|
||||
ConvergenceOptions {
|
||||
max_iter: 2_000,
|
||||
epsilon: 1e-12,
|
||||
..ConvergenceOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_curve_finite(h: &History, keys: &[&str], what: &str) {
|
||||
for key in keys {
|
||||
for (time, g) in h.learning_curve(*key) {
|
||||
assert!(
|
||||
g.mu().is_finite() && g.sigma().is_finite(),
|
||||
"{what}: non-finite posterior for {key} at t={time} (mu={} sigma={})",
|
||||
g.mu(),
|
||||
g.sigma()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A zero weight reaches `(m - performance.exclude(..)) * (1.0 / w)`, i.e. a
|
||||
/// division by zero. The commit is accepted today, so this pins that the
|
||||
/// resulting posterior is still finite rather than quietly NaN.
|
||||
#[test]
|
||||
fn zero_weight_does_not_produce_a_non_finite_posterior() {
|
||||
let mut h = History::builder().build();
|
||||
|
||||
h.event(1)
|
||||
.team(["a"])
|
||||
.weights([0.0])
|
||||
.team(["b"])
|
||||
.winner(0)
|
||||
.commit()
|
||||
.expect("a zero weight is accepted today; update this test if that changes");
|
||||
|
||||
h.converge().unwrap();
|
||||
|
||||
assert_curve_finite(&h, &["a", "b"], "zero weight");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_weight_does_not_produce_a_non_finite_posterior() {
|
||||
let mut h = History::builder().build();
|
||||
|
||||
h.event(1)
|
||||
.team(["a"])
|
||||
.weights([-1.0])
|
||||
.team(["b"])
|
||||
.winner(0)
|
||||
.commit()
|
||||
.expect("a negative weight is accepted today; update this test if that changes");
|
||||
|
||||
h.converge().unwrap();
|
||||
|
||||
assert_curve_finite(&h, &["a", "b"], "negative weight");
|
||||
}
|
||||
|
||||
/// Events supplied newest-first must land in the same slices as oldest-first:
|
||||
/// ingestion sorts by time rather than trusting arrival order.
|
||||
#[test]
|
||||
fn out_of_order_timestamps_converge_to_the_same_answer() {
|
||||
fn build(descending: bool) -> History {
|
||||
let mut h = History::builder().convergence(tight()).build();
|
||||
|
||||
let mut times: Vec<i64> = (1..=6).collect();
|
||||
if descending {
|
||||
times.reverse();
|
||||
}
|
||||
|
||||
for time in times {
|
||||
h.record_winner(&"a", &"b", time).unwrap();
|
||||
}
|
||||
|
||||
h.converge().unwrap();
|
||||
h
|
||||
}
|
||||
|
||||
let ascending = build(false);
|
||||
let descending = build(true);
|
||||
|
||||
let one = ascending.current_skill("a").unwrap();
|
||||
let other = descending.current_skill("a").unwrap();
|
||||
|
||||
assert!(
|
||||
(one.mu() - other.mu()).abs() < 1e-8 && (one.sigma() - other.sigma()).abs() < 1e-8,
|
||||
"arrival order changed the answer: ascending mu={} sigma={}, descending mu={} sigma={}",
|
||||
one.mu(),
|
||||
one.sigma(),
|
||||
other.mu(),
|
||||
other.sigma()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_beta_and_sigma_stay_finite() {
|
||||
for (beta, sigma) in [(1e-6, 1e-6), (1e6, 1e6), (1e-6, 1e6), (1e6, 1e-6)] {
|
||||
let mut h = History::builder().beta(beta).sigma(sigma).build();
|
||||
|
||||
h.record_winner(&"a", &"b", 1).unwrap();
|
||||
h.record_winner(&"a", &"b", 2).unwrap();
|
||||
h.converge().unwrap();
|
||||
|
||||
assert_curve_finite(&h, &["a", "b"], &format!("beta={beta} sigma={sigma}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Per-competitor drift scaling via `Member::with_drift_scale`.
|
||||
//!
|
||||
//! The scale multiplies the *variance* the history's `Drift` contributes for
|
||||
//! that competitor, so `scale` is in the same units as `gamma`:
|
||||
//! `ConstantDrift(g)` at `scale = s` behaves as `ConstantDrift(g * s)` would.
|
||||
//! `scale = 0.0` pins a competitor still — an anchor, a rating floor, a course
|
||||
//! difficulty — while everyone around them keeps drifting.
|
||||
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member,
|
||||
NullObserver, Outcome, Team,
|
||||
};
|
||||
|
||||
type Fit = History<i64, ConstantDrift, NullObserver, &'static str>;
|
||||
|
||||
const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
|
||||
max_iter: 64,
|
||||
epsilon: 1e-9,
|
||||
alpha: 1.0,
|
||||
};
|
||||
|
||||
/// Two events separated by a long gap, so drift has room to matter.
|
||||
fn distant_pair(anchor_scale: Option<f64>) -> Vec<Event<i64, &'static str>> {
|
||||
let anchor = |s: Option<f64>| match s {
|
||||
Some(scale) => Member::new("anchor").with_drift_scale(scale),
|
||||
None => Member::new("anchor"),
|
||||
};
|
||||
|
||||
vec![
|
||||
Event {
|
||||
time: 0,
|
||||
teams: smallvec![
|
||||
Team::with_members([anchor(anchor_scale)]),
|
||||
Team::with_members([Member::new("player")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
},
|
||||
Event {
|
||||
time: 1000,
|
||||
teams: smallvec![
|
||||
Team::with_members([anchor(anchor_scale)]),
|
||||
Team::with_members([Member::new("player")]),
|
||||
],
|
||||
outcome: Outcome::winner(1, 2),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn fit(events: Vec<Event<i64, &'static str>>, gamma: f64) -> Fit {
|
||||
let mut h = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.0)
|
||||
.drift(ConstantDrift(gamma))
|
||||
.convergence(CONVERGENCE)
|
||||
.build();
|
||||
|
||||
h.add_events(events).unwrap();
|
||||
h.converge().unwrap();
|
||||
h
|
||||
}
|
||||
|
||||
fn curve(h: &Fit, key: &str) -> Vec<(i64, Gaussian)> {
|
||||
let mut c = h.learning_curves().remove(key).expect("key in curves");
|
||||
c.sort_by_key(|(t, _)| *t);
|
||||
c
|
||||
}
|
||||
|
||||
/// A competitor at `scale = 0.0` is one latent skill observed twice, so the
|
||||
/// posterior is the same distribution at both times — and strictly tighter
|
||||
/// than the same competitor left to drift.
|
||||
#[test]
|
||||
fn zero_scale_pins_a_competitor_still() {
|
||||
let pinned = fit(distant_pair(Some(0.0)), 25.0 / 300.0);
|
||||
let drifting = fit(distant_pair(None), 25.0 / 300.0);
|
||||
|
||||
let pinned_curve = curve(&pinned, "anchor");
|
||||
assert_eq!(pinned_curve.len(), 2);
|
||||
|
||||
let (t0, first) = pinned_curve[0];
|
||||
let (t1, second) = pinned_curve[1];
|
||||
assert_eq!((t0, t1), (0, 1000));
|
||||
|
||||
assert!(
|
||||
(first.sigma() - second.sigma()).abs() < 1e-9,
|
||||
"a pinned competitor's uncertainty must not move between t=0 and t=1000: \
|
||||
{} vs {}",
|
||||
first.sigma(),
|
||||
second.sigma()
|
||||
);
|
||||
assert!(
|
||||
(first.mu() - second.mu()).abs() < 1e-9,
|
||||
"a pinned competitor's mean must not move: {} vs {}",
|
||||
first.mu(),
|
||||
second.mu()
|
||||
);
|
||||
|
||||
let drifting_curve = curve(&drifting, "anchor");
|
||||
assert!(
|
||||
drifting_curve[0].1.sigma() > first.sigma() + 1e-6,
|
||||
"drift must leave the anchor less certain than pinning does: {} vs {}",
|
||||
drifting_curve[0].1.sigma(),
|
||||
first.sigma()
|
||||
);
|
||||
}
|
||||
|
||||
/// The scale is composable with `gamma`: scaling every competitor by `s` is
|
||||
/// exactly the same fit as scaling the history's drift by `s`.
|
||||
#[test]
|
||||
fn scale_is_equivalent_to_scaling_gamma() {
|
||||
let scaled: Vec<Event<i64, &'static str>> = vec![
|
||||
Event {
|
||||
time: 0,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a").with_drift_scale(0.5)]),
|
||||
Team::with_members([Member::new("b").with_drift_scale(0.5)]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
},
|
||||
Event {
|
||||
time: 400,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("b").with_drift_scale(0.5)]),
|
||||
Team::with_members([Member::new("a").with_drift_scale(0.5)]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
},
|
||||
];
|
||||
|
||||
let plain: Vec<Event<i64, &'static str>> = vec![
|
||||
Event {
|
||||
time: 0,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a")]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
},
|
||||
Event {
|
||||
time: 400,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("b")]),
|
||||
Team::with_members([Member::new("a")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
},
|
||||
];
|
||||
|
||||
let by_scale = fit(scaled, 0.3);
|
||||
let by_gamma = fit(plain, 0.15);
|
||||
|
||||
for key in ["a", "b"] {
|
||||
let lhs = curve(&by_scale, key);
|
||||
let rhs = curve(&by_gamma, key);
|
||||
assert_eq!(lhs.len(), rhs.len());
|
||||
|
||||
for ((t_l, g_l), (t_r, g_r)) in lhs.iter().zip(rhs.iter()) {
|
||||
assert_eq!(t_l, t_r);
|
||||
assert!(
|
||||
(g_l.mu() - g_r.mu()).abs() < 1e-9 && (g_l.sigma() - g_r.sigma()).abs() < 1e-9,
|
||||
"ConstantDrift(0.3) at scale 0.5 must equal ConstantDrift(0.15) for {key} at \
|
||||
t={t_l}: ({}, {}) vs ({}, {})",
|
||||
g_l.mu(),
|
||||
g_l.sigma(),
|
||||
g_r.mu(),
|
||||
g_r.sigma()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `None` means 1.0: an explicit unit scale changes nothing.
|
||||
#[test]
|
||||
fn unset_scale_matches_an_explicit_unit_scale() {
|
||||
let implicit = fit(distant_pair(None), 25.0 / 300.0);
|
||||
let explicit = fit(distant_pair(Some(1.0)), 25.0 / 300.0);
|
||||
|
||||
for key in ["anchor", "player"] {
|
||||
let lhs = curve(&implicit, key);
|
||||
let rhs = curve(&explicit, key);
|
||||
assert_eq!(lhs.len(), rhs.len());
|
||||
|
||||
for ((t_l, g_l), (t_r, g_r)) in lhs.iter().zip(rhs.iter()) {
|
||||
assert_eq!(t_l, t_r);
|
||||
assert_eq!(
|
||||
(g_l.mu(), g_l.sigma()),
|
||||
(g_r.mu(), g_r.sigma()),
|
||||
"an explicit scale of 1.0 must be bit-identical to leaving it unset, \
|
||||
for {key} at t={t_l}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The use case from the issue: a static difficulty alongside drifting players,
|
||||
/// in one graph. The anchor must hold still without absorbing drift through its
|
||||
/// neighbours, and everything must stay finite.
|
||||
#[test]
|
||||
fn mixed_static_and_drifting_graph_converges() {
|
||||
let mut events: Vec<Event<i64, &'static str>> = Vec::new();
|
||||
let players = ["p0", "p1", "p2"];
|
||||
|
||||
for (i, p) in players.iter().cycle().take(9).enumerate() {
|
||||
events.push(Event {
|
||||
time: (i as i64) * 100,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(*p)]),
|
||||
Team::with_members([Member::new("layout").with_drift_scale(0.0)]),
|
||||
],
|
||||
outcome: Outcome::winner((i % 2) as u32, 2),
|
||||
});
|
||||
}
|
||||
|
||||
let mut h = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.convergence(CONVERGENCE)
|
||||
.build();
|
||||
|
||||
h.add_events(events).unwrap();
|
||||
let report = h.converge().unwrap();
|
||||
assert!(report.converged, "mixed graph must converge: {report:?}");
|
||||
|
||||
let curves = h.learning_curves();
|
||||
for (key, points) in &curves {
|
||||
for (t, g) in points {
|
||||
assert!(
|
||||
g.mu().is_finite() && g.sigma().is_finite() && g.sigma() > 0.0,
|
||||
"{key} at t={t} is not a usable posterior: mu={}, sigma={}",
|
||||
g.mu(),
|
||||
g.sigma()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let layout = curve(&h, "layout");
|
||||
assert_eq!(layout.len(), 9);
|
||||
let (_, first) = layout[0];
|
||||
for (t, g) in &layout {
|
||||
assert!(
|
||||
(g.sigma() - first.sigma()).abs() < 1e-9,
|
||||
"a static layout must not accumulate uncertainty; t={t} has sigma {} vs {}",
|
||||
g.sigma(),
|
||||
first.sigma()
|
||||
);
|
||||
}
|
||||
|
||||
let p0 = curve(&h, "p0");
|
||||
assert!(
|
||||
p0.last().unwrap().1.sigma() > 0.0,
|
||||
"a drifting player should still have a proper posterior"
|
||||
);
|
||||
}
|
||||
|
||||
fn reject(scale: f64) -> InferenceError {
|
||||
let mut h = History::builder()
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.build();
|
||||
|
||||
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||
time: 0,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a").with_drift_scale(scale)]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
}];
|
||||
|
||||
h.add_events(events)
|
||||
.expect_err("an out-of-range drift_scale must be rejected")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_scale_is_rejected() {
|
||||
assert_eq!(
|
||||
reject(-1.0),
|
||||
InferenceError::InvalidParameter {
|
||||
name: "drift_scale",
|
||||
value: -1.0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_scale_is_rejected() {
|
||||
for scale in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
assert!(
|
||||
matches!(
|
||||
reject(scale),
|
||||
InferenceError::InvalidParameter {
|
||||
name: "drift_scale",
|
||||
..
|
||||
}
|
||||
),
|
||||
"a drift_scale of {scale} must be rejected as an invalid parameter"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The scale must reach the filtering pass too, not just `converge()`.
|
||||
/// `filtered_learning_curves` runs its own drift application, so a pinned
|
||||
/// competitor has to stay pinned there as well.
|
||||
#[test]
|
||||
fn zero_scale_pins_a_competitor_in_the_filtered_pass() {
|
||||
let pinned = fit(distant_pair(Some(0.0)), 25.0 / 300.0);
|
||||
let drifting = fit(distant_pair(None), 25.0 / 300.0);
|
||||
|
||||
let filtered = |h: &Fit| -> Vec<(i64, Gaussian)> {
|
||||
let mut c = h
|
||||
.filtered_learning_curves()
|
||||
.remove("anchor")
|
||||
.expect("anchor in filtered curves");
|
||||
c.sort_by_key(|(t, _)| *t);
|
||||
c
|
||||
};
|
||||
|
||||
let pinned_curve = filtered(&pinned);
|
||||
let drifting_curve = filtered(&drifting);
|
||||
assert_eq!(pinned_curve.len(), 2);
|
||||
assert_eq!(drifting_curve.len(), 2);
|
||||
|
||||
assert!(
|
||||
pinned_curve[1].1.sigma() < pinned_curve[0].1.sigma(),
|
||||
"a pinned competitor's filtered uncertainty must shrink with a second \
|
||||
observation, not be re-inflated by drift: {} then {}",
|
||||
pinned_curve[0].1.sigma(),
|
||||
pinned_curve[1].1.sigma()
|
||||
);
|
||||
|
||||
assert!(
|
||||
pinned_curve[1].1.sigma() < drifting_curve[1].1.sigma() - 1e-6,
|
||||
"pinning must leave the filtered estimate tighter than drifting does: \
|
||||
{} vs {}",
|
||||
pinned_curve[1].1.sigma(),
|
||||
drifting_curve[1].1.sigma()
|
||||
);
|
||||
}
|
||||
|
||||
/// `drift_scale` is competitor configuration captured at first appearance, the
|
||||
/// same as `prior` — a later `with_drift_scale` on a key the history already
|
||||
/// knows is ignored. This guards that decision rather than driving it: the
|
||||
/// behaviour falls out of where the capture happens, and the point of the test
|
||||
/// is that moving the capture would be a visible break, not a silent one.
|
||||
#[test]
|
||||
fn drift_scale_is_ignored_after_first_appearance() {
|
||||
let mut late = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.convergence(CONVERGENCE)
|
||||
.build();
|
||||
|
||||
// First batch creates "anchor" with the default scale.
|
||||
late.add_events(vec![Event {
|
||||
time: 0,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("anchor")]),
|
||||
Team::with_members([Member::new("player")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
// Second batch asks for a pin. Too late: the competitor already exists.
|
||||
late.add_events(vec![Event {
|
||||
time: 1000,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("anchor").with_drift_scale(0.0)]),
|
||||
Team::with_members([Member::new("player")]),
|
||||
],
|
||||
outcome: Outcome::winner(1, 2),
|
||||
}])
|
||||
.unwrap();
|
||||
late.converge().unwrap();
|
||||
|
||||
let ignored = curve(&late, "anchor");
|
||||
let drifting = curve(&fit(distant_pair(None), 25.0 / 300.0), "anchor");
|
||||
|
||||
for ((t_l, g_l), (t_r, g_r)) in ignored.iter().zip(drifting.iter()) {
|
||||
assert_eq!(t_l, t_r);
|
||||
assert!(
|
||||
(g_l.sigma() - g_r.sigma()).abs() < 1e-9,
|
||||
"a scale set after first appearance must be ignored, leaving the fit \
|
||||
identical to one that never set it: t={t_l}, {} vs {}",
|
||||
g_l.sigma(),
|
||||
g_r.sigma()
|
||||
);
|
||||
}
|
||||
|
||||
let pinned = curve(&fit(distant_pair(Some(0.0)), 25.0 / 300.0), "anchor");
|
||||
assert!(
|
||||
(ignored[1].1.sigma() - pinned[1].1.sigma()).abs() > 1e-6,
|
||||
"sanity: the pinned fit must actually differ, or the assertion above is vacuous"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Property-based tests over generated histories.
|
||||
//!
|
||||
//! The golden suite pins exact values against the Python/Julia reference on a
|
||||
//! handful of fixtures. These pin *invariants* over inputs nobody wrote by
|
||||
//! hand, which is where the defects this crate has actually shipped were
|
||||
//! hiding: a linear evidence product that underflowed only past ~1000 teams,
|
||||
//! and a batching path no golden exercised because every golden ingests in one
|
||||
//! call.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::assert_finite;
|
||||
use proptest::prelude::*;
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||
|
||||
/// Distinct competitors, so no event pits someone against themselves.
|
||||
fn pairs() -> impl Strategy<Value = Vec<(usize, usize)>> {
|
||||
prop::collection::vec((0usize..8, 0usize..8), 1..24)
|
||||
.prop_map(|v| v.into_iter().filter(|(a, b)| a != b).collect::<Vec<_>>())
|
||||
.prop_filter("needs at least one valid pair", |v| !v.is_empty())
|
||||
}
|
||||
|
||||
const KEYS: [&str; 8] = ["a", "b", "c", "d", "e", "f", "g", "h"];
|
||||
|
||||
fn history_from(games: &[(usize, usize)]) -> History {
|
||||
let mut h = History::builder()
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 200,
|
||||
epsilon: 1e-10,
|
||||
..ConvergenceOptions::default()
|
||||
})
|
||||
.build();
|
||||
|
||||
let events: Vec<Event<i64, &'static str>> = games
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &(a, b))| Event {
|
||||
time: i as i64 + 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(KEYS[a])]),
|
||||
Team::with_members([Member::new(KEYS[b])]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
})
|
||||
.collect();
|
||||
|
||||
h.add_events(events).unwrap();
|
||||
|
||||
h
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(48))]
|
||||
|
||||
/// Whatever the schedule of games, convergence must not produce NaN or an
|
||||
/// improper posterior. `converge` returns `NonFiniteResult` rather than
|
||||
/// silently reporting a NaN step as converged, so a break shows up here as
|
||||
/// either an Err or a non-finite curve point.
|
||||
#[test]
|
||||
fn converged_posteriors_are_always_finite(games in pairs()) {
|
||||
let mut h = history_from(&games);
|
||||
|
||||
h.converge().unwrap();
|
||||
|
||||
for key in KEYS {
|
||||
for (time, g) in h.learning_curve(key) {
|
||||
assert_finite(g, &format!("{key} at t={time}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Log-evidence is a log probability: finite, and never above zero.
|
||||
///
|
||||
/// The linear-product implementation this replaced underflowed to zero on
|
||||
/// long chains, making `ln(0)` = -inf — finite-ness is the property that
|
||||
/// would have caught it.
|
||||
#[test]
|
||||
fn log_evidence_is_a_finite_log_probability(games in pairs()) {
|
||||
let mut h = history_from(&games);
|
||||
|
||||
h.converge().unwrap();
|
||||
|
||||
let batch = h.log_evidence();
|
||||
let filtered = h.filtered_log_evidence();
|
||||
|
||||
prop_assert!(batch.is_finite(), "batch log-evidence {batch} is not finite");
|
||||
prop_assert!(batch <= 0.0, "batch log-evidence {batch} exceeds zero");
|
||||
prop_assert!(filtered.is_finite(), "filtered log-evidence {filtered} is not finite");
|
||||
prop_assert!(filtered <= 0.0, "filtered log-evidence {filtered} exceeds zero");
|
||||
}
|
||||
|
||||
/// Filtered estimates must not depend on whether `converge` has run — the
|
||||
/// property the whole forward-only design rests on.
|
||||
#[test]
|
||||
fn filtered_evidence_is_invariant_to_convergence(games in pairs()) {
|
||||
let mut h = history_from(&games);
|
||||
|
||||
let before = h.filtered_log_evidence();
|
||||
|
||||
h.converge().unwrap();
|
||||
|
||||
let after = h.filtered_log_evidence();
|
||||
|
||||
prop_assert!(
|
||||
(before - after).abs() < 1e-8,
|
||||
"filtered evidence moved across converge(): {before} -> {after}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Ingesting the same games one at a time must reach the same fixed point
|
||||
/// as ingesting them in one call.
|
||||
#[test]
|
||||
fn ingestion_order_does_not_change_the_answer(games in pairs()) {
|
||||
let batched = {
|
||||
let mut h = history_from(&games);
|
||||
h.converge().unwrap();
|
||||
h
|
||||
};
|
||||
|
||||
let incremental = {
|
||||
let mut h = History::builder()
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 200,
|
||||
epsilon: 1e-10,
|
||||
..ConvergenceOptions::default()
|
||||
})
|
||||
.build();
|
||||
|
||||
for (i, &(a, b)) in games.iter().enumerate() {
|
||||
h.add_events([Event {
|
||||
time: i as i64 + 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(KEYS[a])]),
|
||||
Team::with_members([Member::new(KEYS[b])]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
}])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
h.converge().unwrap();
|
||||
h
|
||||
};
|
||||
|
||||
for key in KEYS {
|
||||
let one = batched.current_skill(key);
|
||||
let other = incremental.current_skill(key);
|
||||
|
||||
match (one, other) {
|
||||
(Some(one), Some(other)) => {
|
||||
prop_assert!(
|
||||
(one.mu() - other.mu()).abs() < 1e-6
|
||||
&& (one.sigma() - other.sigma()).abs() < 1e-6,
|
||||
"{key}: batched mu={} sigma={}, incremental mu={} sigma={}",
|
||||
one.mu(),
|
||||
one.sigma(),
|
||||
other.mu(),
|
||||
other.sigma()
|
||||
);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => prop_assert!(false, "{key} present in only one history"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user