Compare commits
29
Commits
v0.7.0
...
0ab56248bb
@@ -2,12 +2,51 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 0.8.0 - 2026-09-08
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- feat!: make a short fit an error and raise the default iteration cap
|
||||
- feat!: validate mu, sigma and beta on HistoryBuilder
|
||||
- feat!: add History::register and History::rating, and reject config conflicts across batches
|
||||
- fix!: reject non-finite weights at ingestion
|
||||
- fix!: reject malformed games at the Game boundary too
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: reject malformed events at the ingestion boundary
|
||||
|
||||
### Documentation
|
||||
|
||||
- docs: record the rayon opt-in deviation in spec section 6
|
||||
- docs: state what the joint's cost actually scales in
|
||||
|
||||
### Features
|
||||
|
||||
- feat: add EventBuilder::members for per-member configuration
|
||||
|
||||
### Other (unconventional)
|
||||
|
||||
- Merge branch 'fix/ingestion-shape'
|
||||
- Merge branch 'feat/convergence-strictness'
|
||||
- Merge branch 'fix/non-finite-weights'
|
||||
- Merge branch 'test/close-coverage-gaps'
|
||||
- Merge branch 'fix/game-boundary'
|
||||
|
||||
### Testing
|
||||
|
||||
- test: cover non-finite results and color-group disjointness
|
||||
|
||||
## 0.7.0 - 2026-09-08
|
||||
|
||||
### Features
|
||||
|
||||
- feat: factorise the joint once with History::joint
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- chore: Release trueskill-tt version 0.7.0
|
||||
|
||||
### Other (unconventional)
|
||||
|
||||
- Merge branch 'feat/joint-handle'
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "trueskill-tt"
|
||||
version = "0.7.0"
|
||||
version = "0.8.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"
|
||||
|
||||
@@ -45,7 +45,7 @@ grows proportionally to time:
|
||||
variance_delta = elapsed * γ²
|
||||
```
|
||||
|
||||
This is the standard TrueSkill Through Time model. Pass a `ConstantDrift(gamma)`
|
||||
This is the standard TrueSkill Through Time model. Pass a `ConstantDrift::new(gamma)`
|
||||
when constructing a `Rating`:
|
||||
|
||||
```rust
|
||||
@@ -53,9 +53,9 @@ use trueskill_tt::{ConstantDrift, Gaussian, Rating};
|
||||
|
||||
// gamma = 0.1 means skill can shift ~0.1 per time unit.
|
||||
let rating: Rating<i64, ConstantDrift> =
|
||||
Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, ConstantDrift(0.1));
|
||||
Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, ConstantDrift::new(0.1));
|
||||
|
||||
assert_eq!(rating.drift().0, 0.1);
|
||||
assert_eq!(rating.drift().gamma(), 0.1);
|
||||
```
|
||||
|
||||
The type annotation is load-bearing: `ConstantDrift` implements `Drift<T>` for
|
||||
@@ -98,14 +98,14 @@ assert_eq!(history.log_evidence(), 0.0);
|
||||
```
|
||||
|
||||
`HistoryBuilder::drift` is the only way to set a history's drift model; there is
|
||||
no `gamma()` shorthand. The default is `ConstantDrift(GAMMA)`.
|
||||
no `gamma()` shorthand. The default is `ConstantDrift::new(GAMMA)`.
|
||||
|
||||
### 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
|
||||
accumulates, so `s` is in the same units as `gamma`: `ConstantDrift::new(g)` at
|
||||
scale `s` behaves exactly as `ConstantDrift::new(g * s)` would, for that competitor
|
||||
alone.
|
||||
|
||||
`0.0` pins a competitor still. That is what makes a **fixed reference point**
|
||||
@@ -115,7 +115,7 @@ strength, a rating floor, a course difficulty:
|
||||
```rust
|
||||
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
|
||||
|
||||
let mut h = History::builder().drift(ConstantDrift(0.1)).build();
|
||||
let mut h = History::builder().drift(ConstantDrift::new(0.1)).build();
|
||||
|
||||
h.add_events(vec![Event {
|
||||
time: 0,
|
||||
@@ -134,14 +134,20 @@ h.add_events(vec![Event {
|
||||
h.converge().unwrap();
|
||||
```
|
||||
|
||||
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`.
|
||||
Like `with_prior`, the scale is **competitor configuration, not a per-event
|
||||
value**: it applies to the competitor for the whole history, and it applies
|
||||
whenever it is supplied — including on a key the history already knows.
|
||||
Configuring one late still refits the whole history rather than taking effect
|
||||
only from that event onward, because `converge` refits from competitor state.
|
||||
Repeating the same value is inert; supplying two *different* values for one
|
||||
competitor within a single batch is `InferenceError::ConflictingCompetitorConfig`,
|
||||
since events in a batch have no order. The scale must be finite and
|
||||
non-negative; ingestion otherwise fails with `InferenceError::InvalidParameter`.
|
||||
|
||||
Note that the fluent `EventBuilder` (`h.event(t).team([...])`) sets weights but
|
||||
not `drift_scale` or `prior`; those need the typed `Event` / `Team` / `Member`
|
||||
shape shown above.
|
||||
The fluent `EventBuilder` reaches this too: `.team([...])` is the common case
|
||||
and leaves both unset, while `.members([...])` takes `Member` values directly,
|
||||
so `h.event(t).members([Member::new("layout_7").with_drift_scale(0.0)])` is
|
||||
equivalent to the typed shape above.
|
||||
|
||||
## Scored outcomes
|
||||
|
||||
|
||||
+5
-1
@@ -17,7 +17,11 @@ fn criterion_benchmark(criterion: &mut Criterion) {
|
||||
agents.insert(
|
||||
agent,
|
||||
Competitor {
|
||||
rating: Rating::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)),
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(MU, SIGMA),
|
||||
BETA,
|
||||
ConstantDrift::new(GAMMA),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
@@ -47,7 +47,7 @@ fn build_history_1v1(
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ fn fitted() -> History<i64, ConstantDrift, trueskill_tt::NullObserver, String> {
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift(0.05))
|
||||
.drift(ConstantDrift::new(0.05))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-10,
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ fn bench_scored_history(c: &mut Criterion) {
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(0.03))
|
||||
.drift(ConstantDrift::new(0.03))
|
||||
.score_sigma(2.0)
|
||||
.build();
|
||||
|
||||
|
||||
@@ -500,6 +500,26 @@ All public traits (`Time`, `Drift`, `Observer`, `Factor`, `Schedule`) require `S
|
||||
|
||||
`rayon` as default-on feature; with `default-features = false`, parallel paths fall back to sequential iterators behind `cfg(feature = "rayon")`.
|
||||
|
||||
> **Not implemented. Deliberate deviation, decided 2026-09-08 (issue #5).**
|
||||
>
|
||||
> `rayon` ships **opt-in**: `Cargo.toml` has no `default = [...]` key. The
|
||||
> measured speedups are 1.0x on realistic workloads and 1.3x on a pathological
|
||||
> one (issue #4), because typical slices hold too few events to amortize
|
||||
> rayon's task-spawn overhead. Default-on would hand every downstream user a
|
||||
> thread pool and a dependency for approximately no gain.
|
||||
>
|
||||
> This section made the trade conditional on cross-slice dirty-bit skipping
|
||||
> landing and changing the parallel story. It did not land: #4 was closed on
|
||||
> 2026-08-27 by removing the inert `ConvergenceReport::slices_skipped` field
|
||||
> rather than by implementing the mechanism, so the re-measurement this was
|
||||
> waiting on will not arrive.
|
||||
>
|
||||
> The "Trade-offs" note below also cited an `unsafe` concurrent-write path
|
||||
> through `SkillStore` as a cost of default-on. That cost does not exist: the
|
||||
> crate is `#![forbid(unsafe_code)]`, and the compute/apply split on the
|
||||
> internal `Event` is what lets a color group run in parallel without it. The
|
||||
> case for opt-in rests on the measurements alone.
|
||||
|
||||
### Expected speedup ballpark
|
||||
|
||||
For 1000 players, 60 events/slice × 1000 slices, 30 convergence iterations:
|
||||
@@ -521,7 +541,7 @@ These are pre-implementation estimates. Each tier validates with criterion.
|
||||
- Color-group parallelism requires up-front graph coloring at ingestion. Cost: linear in events, run once per `add_events`. Cheap.
|
||||
- Default = asynchronous EP (preserves current semantics). Synchronous opt-in only.
|
||||
- Cross-slice sweep stays sequential; no speculative parallel sweeps.
|
||||
- Rayon default-on but feature-gated.
|
||||
- Rayon default-on but feature-gated. **Superseded — shipped opt-in; see the deviation note in Section 6.**
|
||||
|
||||
### Open question
|
||||
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ fn main() {
|
||||
|
||||
let mut hist: History<i64, _, _, String> = History::builder_with_key()
|
||||
.sigma(1.6)
|
||||
.drift(ConstantDrift(0.036))
|
||||
.drift(ConstantDrift::new(0.036))
|
||||
.convergence(trueskill_tt::ConvergenceOptions {
|
||||
// This history needs 30 sweeps to reach the epsilon below. It was
|
||||
// capped at 10 until the `#[must_use]` on `ConvergenceReport`
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ fn main() {
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(0.03))
|
||||
.drift(ConstantDrift::new(0.03))
|
||||
.score_sigma(2.0) // tune to data; smaller = trust margins more
|
||||
.build();
|
||||
|
||||
|
||||
+38
-3
@@ -47,7 +47,38 @@ fn kl_divergence(q: Gaussian, p: Gaussian) -> f64 {
|
||||
}
|
||||
|
||||
let mean_gap = q.mu() - p.mu();
|
||||
0.5 * (libm::log(var_p / var_q) + (var_q + mean_gap * mean_gap) / var_p - 1.0)
|
||||
|
||||
// Algebraically `0.5 * (ln(var_p/var_q) + (var_q + gap^2)/var_p - 1)`, but
|
||||
// written so that neither term can go negative.
|
||||
//
|
||||
// The direct form cancels against its `- 1.0` for two near-identical
|
||||
// distributions and returns a *negative* divergence — measured, 762 082 of
|
||||
// 3 000 000 near-identical pairs, worst `-5.55e-17`, which is exactly one
|
||||
// ULP of the 1.0. It also loses the answer entirely where it is small:
|
||||
// at `var_q/var_p - 1 = 1e-9` the direct form gives `0.0` where the true
|
||||
// value is `2.5e-19`.
|
||||
//
|
||||
// With `u = var_q/var_p - 1` the variance part is `0.5 * (u - ln(1+u))`,
|
||||
// which is non-negative for every `u > -1`, and the mean part is a square
|
||||
// over a positive variance. Non-negativity is then structural rather than
|
||||
// incidental.
|
||||
let u = var_q / var_p - 1.0;
|
||||
0.5 * u_minus_ln1p(u) + mean_gap * mean_gap / (2.0 * var_p)
|
||||
}
|
||||
|
||||
/// `u - ln(1 + u)`, without the cancellation that spelling invites.
|
||||
///
|
||||
/// Both terms are approximately `u` for small `u`, so the subtraction loses
|
||||
/// everything just where the result matters. The Taylor series
|
||||
/// `u^2/2 - u^3/3 + u^4/4 - ...` is exact in that regime and manifestly
|
||||
/// non-negative, since `u^2/2` dominates.
|
||||
fn u_minus_ln1p(u: f64) -> f64 {
|
||||
if u.abs() < 1e-4 {
|
||||
let u2 = u * u;
|
||||
u2 * (0.5 - u / 3.0 + u2 / 4.0)
|
||||
} else {
|
||||
u - libm::log1p(u)
|
||||
}
|
||||
}
|
||||
|
||||
/// Expected information gain of a hypothetical matchup, in nats.
|
||||
@@ -146,7 +177,7 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
|
||||
|
||||
let mut gain = 0.0;
|
||||
|
||||
for (ranks, probability) in predict::outcome_distribution(&performances, &margins) {
|
||||
for (ranks, probability) in predict::outcome_distribution(&performances, &margins)? {
|
||||
if probability <= NEGLIGIBLE {
|
||||
continue;
|
||||
}
|
||||
@@ -177,7 +208,11 @@ mod tests {
|
||||
type R = Rating<i64, ConstantDrift>;
|
||||
|
||||
fn rating(mu: f64, sigma: f64) -> R {
|
||||
R::new(Gaussian::from_ms(mu, sigma), BETA, ConstantDrift(GAMMA))
|
||||
R::new(
|
||||
Gaussian::from_ms(mu, sigma),
|
||||
BETA,
|
||||
ConstantDrift::new(GAMMA),
|
||||
)
|
||||
}
|
||||
|
||||
fn options(p_draw: f64) -> GameOptions {
|
||||
|
||||
@@ -191,3 +191,121 @@ mod tests {
|
||||
assert_eq!(cg.total_events(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod properties {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The property the whole parallel sweep rests on: two events sharing a
|
||||
/// competitor must never land in the same color, because a color group is
|
||||
/// run concurrently and two events touching one competitor would race.
|
||||
///
|
||||
/// Hand-written cases cover the shapes someone thought of. This covers the
|
||||
/// ones nobody did — the correctness of `sweep_color_groups` depends on it
|
||||
/// holding for every input, not for five.
|
||||
fn check(events: &[Vec<usize>]) {
|
||||
let groups = color_greedy(events.len(), |ev| {
|
||||
events[ev]
|
||||
.iter()
|
||||
.copied()
|
||||
.map(Index::from)
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
// Disjointness *between events* within a color. Deduplicated per
|
||||
// event, because one event legitimately naming a competitor twice is
|
||||
// not a collision — `color_greedy` collects each event's members into
|
||||
// a set for exactly that reason.
|
||||
for color in 0..groups.n_colors() {
|
||||
let mut seen: HashSet<usize> = HashSet::new();
|
||||
for &ev in &groups.groups[color] {
|
||||
let members: HashSet<usize> = events[ev].iter().copied().collect();
|
||||
for competitor in members {
|
||||
assert!(
|
||||
seen.insert(competitor),
|
||||
"competitor {competitor} shared by two events in color {color}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every event is assigned exactly once. Without this, a partition that
|
||||
// dropped events would satisfy disjointness trivially.
|
||||
let mut assigned: Vec<usize> = groups.groups.iter().flatten().copied().collect();
|
||||
assigned.sort_unstable();
|
||||
assert_eq!(assigned, (0..events.len()).collect::<Vec<_>>());
|
||||
assert_eq!(groups.total_events(), events.len());
|
||||
|
||||
// No empty colors: one would waste a sweep and make `n_colors`
|
||||
// misleading.
|
||||
for (color, group) in groups.groups.iter().enumerate() {
|
||||
assert!(!group.is_empty(), "color {color} is empty");
|
||||
}
|
||||
|
||||
// Contiguity is not a property of `color_greedy` — it holds only after
|
||||
// `recompute_color_groups` reorders the events so each color occupies
|
||||
// one range. What must always hold is that the reorder is *possible*:
|
||||
// relabelling events in group order yields contiguous groups. The
|
||||
// parallel sweep slices `&mut` sub-ranges from those, so if this ever
|
||||
// failed the reorder would produce overlapping ranges.
|
||||
let mut next = 0usize;
|
||||
let relabelled: Vec<Vec<usize>> = groups
|
||||
.groups
|
||||
.iter()
|
||||
.map(|group| {
|
||||
group
|
||||
.iter()
|
||||
.map(|_| {
|
||||
let i = next;
|
||||
next += 1;
|
||||
i
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
assert!(ColorGroups { groups: relabelled }.groups_are_contiguous());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(512))]
|
||||
|
||||
/// Small competitor pool, so collisions are common and colors are
|
||||
/// forced to multiply.
|
||||
#[test]
|
||||
fn colors_are_disjoint_on_a_dense_pool(
|
||||
events in prop::collection::vec(
|
||||
prop::collection::vec(0usize..6, 1..4),
|
||||
0..20,
|
||||
)
|
||||
) {
|
||||
check(&events);
|
||||
}
|
||||
|
||||
/// Wide pool, so most events are independent and land in one color.
|
||||
#[test]
|
||||
fn colors_are_disjoint_on_a_sparse_pool(
|
||||
events in prop::collection::vec(
|
||||
prop::collection::vec(0usize..200, 1..6),
|
||||
0..30,
|
||||
)
|
||||
) {
|
||||
check(&events);
|
||||
}
|
||||
|
||||
/// Repeated competitors within one event must not confuse the
|
||||
/// member-set bookkeeping.
|
||||
#[test]
|
||||
fn colors_are_disjoint_with_repeated_members(
|
||||
events in prop::collection::vec(
|
||||
prop::collection::vec(0usize..3, 1..8),
|
||||
0..15,
|
||||
)
|
||||
) {
|
||||
check(&events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -62,10 +62,17 @@ impl Default for ConvergenceOptions {
|
||||
}
|
||||
|
||||
/// Post-hoc summary of a `History::converge` call.
|
||||
///
|
||||
/// From [`History::converge`](crate::History::converge) this always describes a
|
||||
/// converged fit — stopping at `max_iter` is
|
||||
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged) there.
|
||||
/// From [`History::converge_partial`](crate::History::converge_partial) it may
|
||||
/// not be, and `converged` is what says so.
|
||||
#[derive(Clone, Debug)]
|
||||
#[must_use = "a ConvergenceReport carries `converged`, and a fit that stopped \
|
||||
at `max_iter` is wrong by a little rather than loudly broken — \
|
||||
check it, or bind it to `_` to say you have decided not to"]
|
||||
#[must_use = "from `converge_partial` this may describe a fit that stopped at \
|
||||
`max_iter`, which is wrong by a little rather than loudly \
|
||||
broken — check `converged`, or bind it to `_` to say you have \
|
||||
decided not to"]
|
||||
pub struct ConvergenceReport {
|
||||
pub iterations: usize,
|
||||
pub final_step: (f64, f64),
|
||||
|
||||
+51
-1
@@ -21,8 +21,58 @@ pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
|
||||
///
|
||||
/// For `Time = i64`: variance added is `(to - from) * gamma^2`.
|
||||
/// For `Time = Untimed`: elapsed is always 0, so drift is always 0.
|
||||
///
|
||||
/// # Why the field is private
|
||||
///
|
||||
/// `gamma` enters only as `gamma * gamma`, so a negative value is squared away:
|
||||
/// measured against the old public-field form, `ConstantDrift(-0.0833)` produced
|
||||
/// results **bit identical** to `ConstantDrift(0.0833)`. The sign was neither
|
||||
/// rejected nor honoured — it vanished. That is the same sign-absorption `HistoryBuilder::sigma`,
|
||||
/// `HistoryBuilder::beta`, `Gaussian::from_ms` and `Rating::new` all reject.
|
||||
///
|
||||
/// It could not be checked while the field was a public tuple position, because
|
||||
/// there was no constructor to intercept. Validating inside
|
||||
/// `variance_for_elapsed` would have been worse: it runs inside the sweep, so a
|
||||
/// construction-time mistake would panic mid-inference — and `Gaussian::from_ms`
|
||||
/// is a worked example of why that is the wrong place for a guard, where
|
||||
/// rejecting NaN turned the `NonFiniteResult` reporting path into a crash.
|
||||
///
|
||||
/// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value
|
||||
/// back with [`ConstantDrift::gamma`].
|
||||
///
|
||||
/// A non-finite gamma is caught a second time regardless:
|
||||
/// `History::converge` validates the drift variance each competitor actually
|
||||
/// accumulates, which also covers a custom [`Drift`] implementation.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ConstantDrift(pub f64);
|
||||
pub struct ConstantDrift(f64);
|
||||
|
||||
impl ConstantDrift {
|
||||
/// Drift of `gamma` standard deviations per unit time.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics unless `gamma` is finite and non-negative.
|
||||
///
|
||||
/// The field is private and this is the only constructor precisely so that
|
||||
/// there is somewhere to check. While it was a public tuple field there was
|
||||
/// nothing to intercept, and a negative gamma was silently squared away —
|
||||
/// see the type docs.
|
||||
#[must_use]
|
||||
pub fn new(gamma: f64) -> Self {
|
||||
assert!(
|
||||
gamma.is_finite() && gamma >= 0.0,
|
||||
"gamma must be finite and non-negative (got {gamma}); it is only ever \
|
||||
squared, so a negative value would silently behave as its absolute value"
|
||||
);
|
||||
Self(gamma)
|
||||
}
|
||||
|
||||
/// Standard deviations of drift accumulated per unit time.
|
||||
#[must_use]
|
||||
pub fn gamma(&self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Time> Drift<T> for ConstantDrift {
|
||||
fn variance_delta(&self, from: &T, to: &T) -> f64 {
|
||||
|
||||
@@ -64,6 +64,24 @@ pub enum InferenceError {
|
||||
/// result has no representable likelihood. Configure a positive `p_draw`
|
||||
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
|
||||
TieWithoutDrawProbability { teams: (usize, usize) },
|
||||
/// The convergence sweep hit `max_iter` with the step still above
|
||||
/// `epsilon`.
|
||||
///
|
||||
/// A fit that stops short is wrong by a little, which is the worst
|
||||
/// available failure: every rating is finite, the ordering looks sensible,
|
||||
/// and nothing in the numbers says they were still moving. Reported rather
|
||||
/// than returned as a flag on an `Ok`, because a flag has to be checked
|
||||
/// and `let _ = h.converge()` is the natural way not to.
|
||||
///
|
||||
/// Either the history needs more iterations — raise `max_iter` — or it is
|
||||
/// oscillating rather than converging, in which case `alpha < 1.0` damps
|
||||
/// the within-game EP loop. [`History::converge_partial`](crate::History::converge_partial)
|
||||
/// returns the short fit instead when that is genuinely what is wanted.
|
||||
NotConverged {
|
||||
iterations: usize,
|
||||
final_step: (f64, f64),
|
||||
epsilon: f64,
|
||||
},
|
||||
/// Inference produced a non-finite value (NaN or infinity).
|
||||
///
|
||||
/// Indicates numerical breakdown; the resulting skills are meaningless
|
||||
@@ -100,8 +118,41 @@ pub enum InferenceError {
|
||||
member: usize,
|
||||
key: String,
|
||||
},
|
||||
/// `History::register` was called for a competitor that already exists.
|
||||
///
|
||||
/// Registration states a competitor's configuration before anything has
|
||||
/// been observed about them, so a competitor that already exists has
|
||||
/// already been configured — by an earlier `register`, or by an event that
|
||||
/// created them. Silently overwriting would reintroduce exactly the
|
||||
/// order-dependence registration exists to remove.
|
||||
///
|
||||
/// To change an existing competitor's configuration, supply it on an event
|
||||
/// through `Member`; that refits the whole history.
|
||||
AlreadyRegistered { key: String },
|
||||
/// A prediction was given a team with no members.
|
||||
EmptyTeam { team: usize },
|
||||
/// The prediction grid cannot resolve the narrowest feature in the matchup.
|
||||
///
|
||||
/// `predict_outcome` and `predict_ranking` integrate every team's density
|
||||
/// on one shared grid, whose resolution is set by the narrowest sigma (or a
|
||||
/// narrower draw margin). When the widest and narrowest are far enough
|
||||
/// apart, resolving the narrow one across the wide one's support needs more
|
||||
/// nodes than the grid is allowed to hold.
|
||||
///
|
||||
/// Reported rather than clamped. Clamping is what this replaced, and it
|
||||
/// returned probabilities greater than one — measured, a `P` of 2.79 and a
|
||||
/// `Prediction::total()` of 5.41 — because the trapezoid rule stops
|
||||
/// resolving a density once the step exceeds roughly 1.7 of its sigma.
|
||||
///
|
||||
/// `predict_win_probabilities` answers the same matchup through adaptive
|
||||
/// quadrature and is accurate here; use it when only the per-team win
|
||||
/// probabilities are needed.
|
||||
GridTooCoarse {
|
||||
/// Nodes required to resolve the narrowest feature.
|
||||
needed: usize,
|
||||
/// Nodes the grid may hold.
|
||||
max: usize,
|
||||
},
|
||||
/// A joint posterior was requested where one cannot be formed exactly.
|
||||
JointUnavailable { reason: &'static str },
|
||||
/// Fewer than two teams were supplied to a prediction.
|
||||
@@ -144,6 +195,18 @@ impl fmt::Display for InferenceError {
|
||||
teams.0, teams.1
|
||||
)
|
||||
}
|
||||
Self::NotConverged {
|
||||
iterations,
|
||||
final_step,
|
||||
epsilon,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"did not converge in {iterations} iterations: final step {final_step:?} \
|
||||
is still above epsilon {epsilon}; raise max_iter, or damp with \
|
||||
alpha < 1.0 if it is oscillating"
|
||||
)
|
||||
}
|
||||
Self::NonFiniteResult { context, step } => {
|
||||
write!(
|
||||
f,
|
||||
@@ -167,9 +230,26 @@ impl fmt::Display for InferenceError {
|
||||
with `lookup` or `current_skill` if that is not guaranteed)"
|
||||
)
|
||||
}
|
||||
Self::AlreadyRegistered { key } => {
|
||||
write!(
|
||||
f,
|
||||
"competitor {key} is already registered; registration states \
|
||||
configuration before anything is observed, so re-registering \
|
||||
would silently overwrite it"
|
||||
)
|
||||
}
|
||||
Self::EmptyTeam { team } => {
|
||||
write!(f, "team {team} has no members")
|
||||
}
|
||||
Self::GridTooCoarse { needed, max } => {
|
||||
write!(
|
||||
f,
|
||||
"the prediction grid needs {needed} nodes to resolve the narrowest \
|
||||
team's density across the widest team's support, but may hold only \
|
||||
{max}; the sigmas in this matchup are too far apart to integrate on \
|
||||
one grid. Use predict_win_probabilities, which is accurate here"
|
||||
)
|
||||
}
|
||||
Self::JointUnavailable { reason } => {
|
||||
write!(f, "no exact joint posterior is available: {reason}")
|
||||
}
|
||||
|
||||
+7
-4
@@ -88,7 +88,9 @@ impl<K> Member<K> {
|
||||
|
||||
/// Set this competitor's starting skill estimate.
|
||||
///
|
||||
/// Captured at the competitor's first appearance; see the type docs.
|
||||
/// Competitor configuration, not a per-event value: it applies for the
|
||||
/// whole history and applies whenever it is supplied, including on a key
|
||||
/// the history already knows. See the type docs.
|
||||
pub fn with_prior(mut self, prior: Gaussian) -> Self {
|
||||
self.prior = Some(prior);
|
||||
self
|
||||
@@ -97,14 +99,15 @@ impl<K> Member<K> {
|
||||
/// 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.
|
||||
/// `gamma`: `ConstantDrift::new(g)` at `scale = s` behaves exactly as
|
||||
/// `ConstantDrift::new(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.
|
||||
/// Applies for the whole history and whenever it is supplied, including on
|
||||
/// a key the history already knows; 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 {
|
||||
|
||||
@@ -50,6 +50,8 @@ where
|
||||
}
|
||||
|
||||
/// Add a team by its member keys (weight 1.0 each, no prior overrides).
|
||||
///
|
||||
/// Use [`EventBuilder::members`] to set `prior` or `drift_scale`.
|
||||
pub fn team<I: IntoIterator<Item = K>>(mut self, keys: I) -> Self {
|
||||
let members: SmallVec<[Member<K>; 4]> = keys.into_iter().map(Member::new).collect();
|
||||
self.event.teams.push(Team { members });
|
||||
@@ -57,6 +59,40 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a team from fully-specified [`Member`] values.
|
||||
///
|
||||
/// [`EventBuilder::team`] is the common case and builds members with
|
||||
/// `Member::new`, which leaves `prior` and `drift_scale` unset. This is the
|
||||
/// escape hatch for when they matter:
|
||||
///
|
||||
/// ```
|
||||
/// # use trueskill_tt::{Gaussian, History, Member};
|
||||
/// # let mut h = History::builder().build();
|
||||
/// h.event(0)
|
||||
/// .team(["player"])
|
||||
/// .members([Member::new("layout_7")
|
||||
/// .with_drift_scale(0.0)
|
||||
/// .with_prior(Gaussian::from_ms(0.0, 1.0))])
|
||||
/// .ranking([0, 1])
|
||||
/// .commit()?;
|
||||
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||
/// ```
|
||||
///
|
||||
/// One method rather than a `priors` and a `drift_scales` setter beside
|
||||
/// `weights`: those would have to grow a parallel array — and a parallel
|
||||
/// length check — every time `Member` gains a field, and each one would be
|
||||
/// a new way to get the lengths wrong. `Member`'s own builder already
|
||||
/// expresses all of it.
|
||||
///
|
||||
/// `prior` and `drift_scale` are competitor configuration rather than
|
||||
/// per-event values; see [`Member`] for what that means for a key the
|
||||
/// history already knows.
|
||||
pub fn members<I: IntoIterator<Item = Member<K>>>(mut self, members: I) -> Self {
|
||||
self.event.teams.push(Team::with_members(members));
|
||||
self.current_team_idx = Some(self.event.teams.len() - 1);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set per-member weights for the most recently added team.
|
||||
///
|
||||
/// A length mismatch is recorded and returned by [`EventBuilder::commit`]
|
||||
|
||||
@@ -81,7 +81,7 @@ fn cavity_log_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
|
||||
// `hypot`, not `sqrt(a^2 + b^2)`: squaring overflows to infinity above a
|
||||
// sigma of ~1.3e154 and flushes to zero below ~1.5e-154, and `Gaussian`'s
|
||||
// constructors are public so a caller can reach both.
|
||||
let combined_sigma = cavity.sigma().hypot(sigma);
|
||||
let combined_sigma = libm::hypot(cavity.sigma(), sigma);
|
||||
let value = ln_pdf(m_obs, cavity.mu(), combined_sigma);
|
||||
|
||||
// A degenerate cavity (infinite sigma) is the only way to reach a
|
||||
@@ -89,7 +89,7 @@ fn cavity_log_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
|
||||
if value.is_finite() {
|
||||
value
|
||||
} else {
|
||||
f64::MIN_POSITIVE.ln()
|
||||
libm::log(f64::MIN_POSITIVE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -95,7 +95,7 @@ fn cavity_log_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 {
|
||||
if value.is_finite() {
|
||||
value
|
||||
} else {
|
||||
f64::MIN_POSITIVE.ln()
|
||||
libm::log(f64::MIN_POSITIVE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ mod tests {
|
||||
let approx = -0.5 * z * z - z.ln() - (2.0 * std::f64::consts::PI).sqrt().ln();
|
||||
|
||||
assert!(
|
||||
got < f64::MIN_POSITIVE.ln(),
|
||||
got < libm::log(f64::MIN_POSITIVE),
|
||||
"mu={mu}: {got} is still stuck on the old clamp floor"
|
||||
);
|
||||
assert!(
|
||||
|
||||
+102
-39
@@ -431,6 +431,29 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
/// Reject the team shapes inference cannot represent.
|
||||
///
|
||||
/// `run_chain` builds one diff link per adjacent pair of teams, so fewer
|
||||
/// than two teams leaves it indexing `links[1..]` on an empty vector — a
|
||||
/// panic, in release, from safe API. An empty team is the quiet half: it
|
||||
/// contributes no performance, so a malformed game returns a finite,
|
||||
/// plausible-looking posterior for whoever it was matched against.
|
||||
///
|
||||
/// `History` validates the same two things at its own ingestion
|
||||
/// chokepoint. `Game` is a separate public entry point that does not pass
|
||||
/// through it, so it needs its own check rather than inheriting one.
|
||||
fn validate_teams(teams: &[&[Rating<T, D>]]) -> Result<(), crate::InferenceError> {
|
||||
if teams.len() < 2 {
|
||||
return Err(crate::InferenceError::NotEnoughTeams { got: teams.len() });
|
||||
}
|
||||
for (team, members) in teams.iter().enumerate() {
|
||||
if members.is_empty() {
|
||||
return Err(crate::InferenceError::EmptyTeam { team });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// - `InvalidParameter` if `options.convergence` is out of range — an
|
||||
@@ -442,12 +465,15 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
/// - `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`.
|
||||
/// - `NotEnoughTeams` for fewer than two teams, and `EmptyTeam` for a team
|
||||
/// with no members.
|
||||
pub fn ranked(
|
||||
teams: &[&[Rating<T, D>]],
|
||||
outcome: crate::Outcome,
|
||||
options: &GameOptions,
|
||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||
options.convergence.validate()?;
|
||||
Self::validate_teams(teams)?;
|
||||
if !(0.0..1.0).contains(&options.p_draw) {
|
||||
return Err(crate::InferenceError::InvalidProbability {
|
||||
value: options.p_draw,
|
||||
@@ -499,12 +525,15 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
/// or is NaN, or if `options.convergence` is out of range.
|
||||
/// - `MismatchedShape` if the outcome's score count differs from `teams.len()`.
|
||||
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Scored`.
|
||||
/// - `NotEnoughTeams` for fewer than two teams, `EmptyTeam` for a team with
|
||||
/// no members, and `InvalidParameter` for a non-finite score.
|
||||
pub fn scored(
|
||||
teams: &[&[Rating<T, D>]],
|
||||
outcome: crate::Outcome,
|
||||
options: &GameOptions,
|
||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||
options.convergence.validate()?;
|
||||
Self::validate_teams(teams)?;
|
||||
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
|
||||
return Err(crate::InferenceError::InvalidParameter {
|
||||
name: "score_sigma",
|
||||
@@ -526,6 +555,16 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
got: "Outcome::Ranked",
|
||||
})?
|
||||
.to_vec();
|
||||
// A non-finite score poisons the chain rather than failing it. Ranks
|
||||
// need no equivalent: they are `u32`.
|
||||
for value in &scores {
|
||||
if !value.is_finite() {
|
||||
return Err(crate::InferenceError::InvalidParameter {
|
||||
name: "score",
|
||||
value: *value,
|
||||
});
|
||||
}
|
||||
}
|
||||
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
|
||||
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
|
||||
Ok(OwnedGame::new_scored(
|
||||
@@ -584,12 +623,12 @@ mod tests {
|
||||
let t_a = R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
let t_b = R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
@@ -612,12 +651,12 @@ mod tests {
|
||||
let t_a = R::new(
|
||||
Gaussian::from_ms(29.0, 1.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(GAMMA),
|
||||
ConstantDrift::new(GAMMA),
|
||||
);
|
||||
let t_b = R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(GAMMA),
|
||||
ConstantDrift::new(GAMMA),
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
@@ -637,8 +676,16 @@ mod tests {
|
||||
assert_ulps_eq!(a, Gaussian::from_ms(28.896475, 0.996604), epsilon = 1e-6);
|
||||
assert_ulps_eq!(b, Gaussian::from_ms(32.189211, 6.062063), epsilon = 1e-6);
|
||||
|
||||
let t_a = R::new(Gaussian::from_ms(1.139, 0.531), 1.0, ConstantDrift(0.2125));
|
||||
let t_b = R::new(Gaussian::from_ms(15.568, 0.51), 1.0, ConstantDrift(0.2125));
|
||||
let t_a = R::new(
|
||||
Gaussian::from_ms(1.139, 0.531),
|
||||
1.0,
|
||||
ConstantDrift::new(0.2125),
|
||||
);
|
||||
let t_b = R::new(
|
||||
Gaussian::from_ms(15.568, 0.51),
|
||||
1.0,
|
||||
ConstantDrift::new(0.2125),
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(
|
||||
@@ -660,17 +707,17 @@ mod tests {
|
||||
vec![R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
)],
|
||||
vec![R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
)],
|
||||
vec![R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
)],
|
||||
];
|
||||
|
||||
@@ -740,12 +787,12 @@ mod tests {
|
||||
let t_a = R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
let t_b = R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
@@ -772,12 +819,12 @@ mod tests {
|
||||
let t_a = R::new(
|
||||
Gaussian::from_ms(25.0, 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
let t_b = R::new(
|
||||
Gaussian::from_ms(29.0, 2.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
@@ -803,17 +850,17 @@ mod tests {
|
||||
let t_a = R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
let t_b = R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
let t_c = R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||
@@ -840,17 +887,17 @@ mod tests {
|
||||
let t_a = R::new(
|
||||
Gaussian::from_ms(25.0, 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
let t_b = R::new(
|
||||
Gaussian::from_ms(25.0, 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
let t_c = R::new(
|
||||
Gaussian::from_ms(29.0, 2.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||
@@ -879,29 +926,29 @@ mod tests {
|
||||
R::new(
|
||||
Gaussian::from_ms(12.0, 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
),
|
||||
R::new(
|
||||
Gaussian::from_ms(18.0, 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
),
|
||||
];
|
||||
let t_b = vec![R::new(
|
||||
Gaussian::from_ms(30.0, 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
)];
|
||||
let t_c = vec![
|
||||
R::new(
|
||||
Gaussian::from_ms(14.0, 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
),
|
||||
R::new(
|
||||
Gaussian::from_ms(16., 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -931,12 +978,12 @@ mod tests {
|
||||
let t_a = vec![R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(0.0),
|
||||
ConstantDrift::new(0.0),
|
||||
)];
|
||||
let t_b = vec![R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(0.0),
|
||||
ConstantDrift::new(0.0),
|
||||
)];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
@@ -1014,8 +1061,16 @@ mod tests {
|
||||
let w_a = vec![1.0];
|
||||
let w_b = vec![0.0];
|
||||
|
||||
let t_a = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
|
||||
let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
|
||||
let t_a = vec![R::new(
|
||||
Gaussian::from_ms(2.0, 6.0),
|
||||
1.0,
|
||||
ConstantDrift::new(0.0),
|
||||
)];
|
||||
let t_b = vec![R::new(
|
||||
Gaussian::from_ms(2.0, 6.0),
|
||||
1.0,
|
||||
ConstantDrift::new(0.0),
|
||||
)];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::ranked_with_arena(
|
||||
@@ -1042,8 +1097,16 @@ mod tests {
|
||||
let w_a = vec![1.0];
|
||||
let w_b = vec![-1.0];
|
||||
|
||||
let t_a = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
|
||||
let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
|
||||
let t_a = vec![R::new(
|
||||
Gaussian::from_ms(2.0, 6.0),
|
||||
1.0,
|
||||
ConstantDrift::new(0.0),
|
||||
)];
|
||||
let t_b = vec![R::new(
|
||||
Gaussian::from_ms(2.0, 6.0),
|
||||
1.0,
|
||||
ConstantDrift::new(0.0),
|
||||
)];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::ranked_with_arena(
|
||||
@@ -1086,7 +1149,7 @@ mod tests {
|
||||
let prior = R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
let teams = vec![vec![prior], vec![prior]];
|
||||
let result = vec![10.0, 0.0]; // a beat b by 10
|
||||
@@ -1136,7 +1199,7 @@ mod tests {
|
||||
let prior = R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
let opts = GameOptions {
|
||||
score_sigma: 1.0,
|
||||
@@ -1152,7 +1215,7 @@ mod tests {
|
||||
let prior = R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
let err = Game::scored(
|
||||
&[&[prior], &[prior]],
|
||||
@@ -1171,7 +1234,7 @@ mod tests {
|
||||
let prior = R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
);
|
||||
let opts = GameOptions {
|
||||
score_sigma: 0.0,
|
||||
@@ -1198,12 +1261,12 @@ mod tests {
|
||||
R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(0.0),
|
||||
ConstantDrift::new(0.0),
|
||||
),
|
||||
R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(0.0),
|
||||
ConstantDrift::new(0.0),
|
||||
),
|
||||
];
|
||||
let w_a = vec![0.4, 0.8];
|
||||
@@ -1212,12 +1275,12 @@ mod tests {
|
||||
R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(0.0),
|
||||
ConstantDrift::new(0.0),
|
||||
),
|
||||
R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(0.0),
|
||||
ConstantDrift::new(0.0),
|
||||
),
|
||||
];
|
||||
let w_b = vec![0.9, 0.6];
|
||||
@@ -1331,7 +1394,7 @@ mod tests {
|
||||
vec![R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(0.0),
|
||||
ConstantDrift::new(0.0),
|
||||
)],
|
||||
],
|
||||
&[1.0, 0.0],
|
||||
|
||||
@@ -18,8 +18,44 @@ pub struct Gaussian {
|
||||
|
||||
impl Gaussian {
|
||||
/// Construct from mean and standard deviation.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a
|
||||
/// broken fit produces one, and `converge` reports that as
|
||||
/// `NonFiniteResult` rather than panicking mid-inference.
|
||||
///
|
||||
/// A negative sigma used to be accepted and returned results **bit
|
||||
/// identical** to its absolute value, because sigma only ever enters as
|
||||
/// `sigma * sigma`. The sign was not rejected and not honoured; it simply
|
||||
/// vanished. That is the same defect `HistoryBuilder::sigma`,
|
||||
/// `HistoryBuilder::beta` and `Member::with_drift_scale` already reject.
|
||||
///
|
||||
/// # Very small sigma
|
||||
///
|
||||
/// `pi = 1 / sigma^2` leaves `f64`'s range below about `1.5e-154`, and
|
||||
/// `tau = mu * pi` overflows sooner still — at a threshold that depends on
|
||||
/// `mu`, so there is a band where `pi` is finite and only `tau` is not.
|
||||
/// Both land on the same point-mass representation the `sigma == 0.0`
|
||||
/// branch produces, and a point mass with a non-zero mean has `mu() = NaN`,
|
||||
/// because `tau / pi` is `inf / inf`.
|
||||
///
|
||||
/// This is not rejected, because `approx` legitimately produces a very
|
||||
/// small truncated sigma and inference must not panic. It is worth knowing
|
||||
/// that such a `Gaussian` is not equal to itself, so two identical
|
||||
/// declarations of one can be reported as conflicting.
|
||||
#[must_use]
|
||||
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
|
||||
// NaN is admitted on purpose. A broken fit legitimately produces a NaN
|
||||
// sigma — `sqrt` of a negative truncated variance — and the design is
|
||||
// to propagate that to `converge`'s `NonFiniteResult` guard, not to
|
||||
// panic inside inference. Rejecting it here turned that reporting path
|
||||
// into a crash, which two tests caught immediately.
|
||||
assert!(
|
||||
sigma >= 0.0 || sigma.is_nan(),
|
||||
"sigma must not be negative; it is only ever squared, so a negative \
|
||||
value would silently behave as its absolute value"
|
||||
);
|
||||
if sigma == f64::INFINITY {
|
||||
Self { pi: 0.0, tau: 0.0 }
|
||||
} else if sigma == 0.0 {
|
||||
@@ -120,7 +156,25 @@ impl Gaussian {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far this Gaussian moved from `other`, as `(|d mu|, |d sigma|)`.
|
||||
///
|
||||
/// Identical messages have not moved, whatever their parameters, and that
|
||||
/// case is answered in natural space before touching `mu()`/`sigma()`. An
|
||||
/// improper message has `pi == 0`, so `sigma()` is infinite — and
|
||||
/// `inf - inf` is NaN, a NaN *change* for a message that did not change at
|
||||
/// all. (`mu()` is guarded and returns 0.0 here, so the mean component was
|
||||
/// never the problem; the sigma component alone produced `(0.0, NaN)`.)
|
||||
///
|
||||
/// That is reachable in ordinary inference: once a pairing is more than
|
||||
/// about nine cavity-sigma apart the truncation is a no-op, `trunc / cavity`
|
||||
/// is exactly the identity message, and the chain compares one identity
|
||||
/// against another. Before this guard that produced `(0.0, NaN)`, which
|
||||
/// silently disabled the sigma half of the convergence test.
|
||||
pub(crate) fn delta(&self, other: Gaussian) -> (f64, f64) {
|
||||
if self.pi == other.pi && self.tau == other.tau {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
|
||||
(
|
||||
(self.mu() - other.mu()).abs(),
|
||||
(self.sigma() - other.sigma()).abs(),
|
||||
@@ -256,6 +310,42 @@ impl ops::Div<Gaussian> for Gaussian {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A message that did not change must report no change, even when it is
|
||||
/// improper. `mu()` of an improper Gaussian is `0/0 = NaN` and `sigma()` is
|
||||
/// infinite, so the mean/sigma form reported `(NaN, NaN)` for two identical
|
||||
/// identity messages — which silently disabled the sigma half of the
|
||||
/// convergence test in `run_chain`.
|
||||
#[test]
|
||||
fn delta_of_two_identical_improper_messages_is_zero() {
|
||||
let improper = crate::N_INF;
|
||||
// `mu()` is guarded and returns 0.0 for an improper Gaussian, so the
|
||||
// mean component was always fine. The NaN came from the sigma
|
||||
// component alone: `inf - inf`. The pre-fix value was `(0.0, NaN)`.
|
||||
assert!(improper.sigma().is_infinite(), "premise: sigma is infinite");
|
||||
assert_eq!(improper.mu(), 0.0, "premise: mu is guarded, not NaN");
|
||||
assert!(
|
||||
(improper.sigma() - improper.sigma()).is_nan(),
|
||||
"premise: the unguarded sigma difference is NaN"
|
||||
);
|
||||
assert_eq!(improper.delta(improper), (0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_of_identical_proper_messages_is_zero() {
|
||||
let g = Gaussian::from_ms(25.0, 8.0);
|
||||
assert_eq!(g.delta(g), (0.0, 0.0));
|
||||
}
|
||||
|
||||
/// The shortcut must not swallow a real difference.
|
||||
#[test]
|
||||
fn delta_still_measures_a_real_move() {
|
||||
let a = Gaussian::from_ms(25.0, 8.0);
|
||||
let b = Gaussian::from_ms(26.0, 9.0);
|
||||
let (dmu, dsigma) = a.delta(b);
|
||||
assert!((dmu - 1.0).abs() < 1e-12, "{dmu}");
|
||||
assert!((dsigma - 1.0).abs() < 1e-12, "{dsigma}");
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
+470
-40
@@ -1,4 +1,9 @@
|
||||
use std::{borrow::Borrow, collections::HashMap, hash::Hash, marker::PhantomData};
|
||||
use std::{
|
||||
borrow::Borrow,
|
||||
collections::{BTreeMap, HashMap},
|
||||
hash::Hash,
|
||||
marker::PhantomData,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
BETA, GAMMA, Index, MU, P_DRAW, SIGMA,
|
||||
@@ -6,6 +11,7 @@ use crate::{
|
||||
convergence::{ConvergenceOptions, ConvergenceReport},
|
||||
drift::{ConstantDrift, Drift},
|
||||
error::InferenceError,
|
||||
event::Member,
|
||||
gaussian::Gaussian,
|
||||
key_table::KeyTable,
|
||||
observer::{NullObserver, Observer},
|
||||
@@ -39,17 +45,55 @@ pub struct HistoryBuilder<
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<T, D, O, K> {
|
||||
/// Prior mean skill.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `mu` is not finite. A non-finite prior mean poisons every
|
||||
/// posterior derived from it: `converge` reports `NonFiniteResult`, but a
|
||||
/// caller who reads `current_skill` first is handed `tau: NaN`.
|
||||
pub fn mu(mut self, mu: f64) -> Self {
|
||||
assert!(mu.is_finite(), "mu must be finite (got {mu})");
|
||||
self.mu = mu;
|
||||
self
|
||||
}
|
||||
|
||||
/// Prior standard deviation.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics unless `sigma` is finite and strictly positive.
|
||||
///
|
||||
/// Zero and infinity both give a prior precision that is not a number, and
|
||||
/// the whole fit comes back NaN. A *negative* sigma is the quieter half:
|
||||
/// it is only ever squared, so `-8.33` produces bit-identical results to
|
||||
/// `8.33` — a sign the caller cannot have meant, silently ignored.
|
||||
pub fn sigma(mut self, sigma: f64) -> Self {
|
||||
assert!(
|
||||
sigma.is_finite() && sigma > 0.0,
|
||||
"sigma must be finite and positive (got {sigma})"
|
||||
);
|
||||
self.sigma = sigma;
|
||||
self
|
||||
}
|
||||
|
||||
/// Per-event performance noise.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics unless `beta` is finite and non-negative.
|
||||
///
|
||||
/// Zero is allowed and meaningful — performance is then exactly skill, and
|
||||
/// the fit differs measurably from a positive `beta` rather than
|
||||
/// degenerating. Negative is rejected for the same reason as a negative
|
||||
/// `sigma` or `Member::with_drift_scale`: `beta` enters only as `beta^2`,
|
||||
/// so a negative value behaves as its absolute value and the sign is lost
|
||||
/// without comment.
|
||||
pub fn beta(mut self, beta: f64) -> Self {
|
||||
assert!(
|
||||
beta.is_finite() && beta >= 0.0,
|
||||
"beta must be finite and non-negative (got {beta})"
|
||||
);
|
||||
self.beta = beta;
|
||||
self
|
||||
}
|
||||
@@ -95,8 +139,8 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
||||
/// Panics if `score_sigma` is not strictly positive.
|
||||
pub fn score_sigma(mut self, score_sigma: f64) -> Self {
|
||||
assert!(
|
||||
score_sigma > 0.0,
|
||||
"score_sigma must be positive (got {score_sigma})"
|
||||
score_sigma.is_finite() && score_sigma > 0.0,
|
||||
"score_sigma must be finite and positive (got {score_sigma})"
|
||||
);
|
||||
self.score_sigma = score_sigma;
|
||||
self
|
||||
@@ -169,6 +213,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
||||
convergence: self.convergence,
|
||||
observer: self.observer,
|
||||
unknown_keys: self.unknown_keys,
|
||||
declared: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,7 +224,7 @@ impl Default for HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str>
|
||||
mu: MU,
|
||||
sigma: SIGMA,
|
||||
beta: BETA,
|
||||
drift: ConstantDrift(GAMMA),
|
||||
drift: ConstantDrift::new(GAMMA),
|
||||
p_draw: P_DRAW,
|
||||
score_sigma: 1.0,
|
||||
convergence: ConvergenceOptions::default(),
|
||||
@@ -221,7 +266,15 @@ struct ResolvedTerms {
|
||||
contrast: Vec<f64>,
|
||||
/// Coefficients of competitors the slice has never seen, keyed by their
|
||||
/// rendering. Independent of everything in the slice by construction.
|
||||
unseen: HashMap<String, f64>,
|
||||
///
|
||||
/// A `BTreeMap` rather than a `HashMap`, and that is load-bearing. These
|
||||
/// coefficients are summed, addition is not associative, and Rust seeds its
|
||||
/// default hasher per process — so iterating a `HashMap` here made
|
||||
/// `posterior_of` return different bits run to run on identical input.
|
||||
/// Measured over 40 processes: two distinct sigma bit patterns, and five
|
||||
/// distinct values from `expected_variance_reduction` spanning ~7 ULP.
|
||||
/// Ordered iteration makes the sum reproducible.
|
||||
unseen: BTreeMap<String, f64>,
|
||||
mean: f64,
|
||||
}
|
||||
|
||||
@@ -276,6 +329,12 @@ pub struct History<
|
||||
convergence: ConvergenceOptions,
|
||||
observer: O,
|
||||
unknown_keys: crate::UnknownKeys,
|
||||
/// Competitor configuration explicitly declared so far, by whichever route.
|
||||
///
|
||||
/// Kept separate from the applied `Rating` because a `Rating` cannot say
|
||||
/// whether a value was *chosen* or inherited from the history defaults,
|
||||
/// and that is exactly the distinction a conflict check needs.
|
||||
declared: HashMap<Index, CompetitorConfig>,
|
||||
}
|
||||
|
||||
impl Default for History<i64, ConstantDrift, NullObserver, &'static str> {
|
||||
@@ -299,7 +358,7 @@ impl<K: Eq + Hash + Clone> History<i64, ConstantDrift, NullObserver, K> {
|
||||
mu: MU,
|
||||
sigma: SIGMA,
|
||||
beta: BETA,
|
||||
drift: ConstantDrift(GAMMA),
|
||||
drift: ConstantDrift::new(GAMMA),
|
||||
p_draw: P_DRAW,
|
||||
score_sigma: 1.0,
|
||||
convergence: ConvergenceOptions::default(),
|
||||
@@ -455,6 +514,111 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
|
||||
/// Skill estimate at the latest time slice the competitor appears in.
|
||||
/// Configure a competitor before anything has been observed about them.
|
||||
///
|
||||
/// The configuration a competitor needs is often a property of the domain
|
||||
/// rather than of any one event — "every layout is static", "this bot sits
|
||||
/// at a known strength". Stating it per-event means every ingestion path
|
||||
/// has to remember it, and the fluent and two-argument paths could not
|
||||
/// state it at all.
|
||||
///
|
||||
/// ```
|
||||
/// # use trueskill_tt::{History, Member};
|
||||
/// let mut h = History::builder().build();
|
||||
/// h.register(Member::new("layout_7").with_drift_scale(0.0))?;
|
||||
///
|
||||
/// // Reaches a competitor first seen through any route, including the
|
||||
/// // two-argument one, which cannot carry configuration itself.
|
||||
/// h.record_winner(&"player", &"layout_7", 1)?;
|
||||
/// assert_eq!(h.rating(&"layout_7").unwrap().drift_scale(), 0.0);
|
||||
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||
/// ```
|
||||
///
|
||||
/// The competitor exists from this point on, with no appearances, so
|
||||
/// [`History::rating`] can read back what was actually stored — the
|
||||
/// diagnostic that was previously missing entirely.
|
||||
///
|
||||
/// `weight` is per-event and has no meaning here, so a `Member` carrying a
|
||||
/// non-default one is rejected rather than silently ignored.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `AlreadyRegistered` if the competitor already exists, whether from an
|
||||
/// earlier `register` or from an event. `InvalidParameter` for a `weight`
|
||||
/// other than 1.0, or a `drift_scale` that is negative or non-finite.
|
||||
pub fn register(&mut self, member: Member<K>) -> Result<(), InferenceError>
|
||||
where
|
||||
K: std::fmt::Debug,
|
||||
{
|
||||
if member.weight != 1.0 {
|
||||
return Err(InferenceError::InvalidParameter {
|
||||
name: "weight",
|
||||
value: member.weight,
|
||||
});
|
||||
}
|
||||
if let Some(scale) = member.drift_scale {
|
||||
if !scale.is_finite() || scale < 0.0 {
|
||||
return Err(InferenceError::InvalidParameter {
|
||||
name: "drift_scale",
|
||||
value: scale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let key = format!("{:?}", member.key);
|
||||
let idx = self.keys.get_or_create(&member.key);
|
||||
if self.agents.contains(idx) {
|
||||
return Err(InferenceError::AlreadyRegistered { key });
|
||||
}
|
||||
|
||||
let mut rating = 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;
|
||||
}
|
||||
|
||||
self.declared.insert(
|
||||
idx,
|
||||
CompetitorConfig {
|
||||
prior: member.prior,
|
||||
drift_scale: member.drift_scale,
|
||||
},
|
||||
);
|
||||
self.agents.insert(
|
||||
idx,
|
||||
Competitor {
|
||||
rating,
|
||||
message: None,
|
||||
last_time: None,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The configuration in force for a competitor, or `None` if the history
|
||||
/// has never seen them.
|
||||
///
|
||||
/// Reads back what was actually stored, which is what makes a
|
||||
/// configuration mistake detectable from outside the crate. Every other
|
||||
/// accessor returns what inference *inferred*; this returns what it was
|
||||
/// told.
|
||||
#[must_use]
|
||||
pub fn rating<Q>(&self, key: &Q) -> Option<Rating<T, D>>
|
||||
where
|
||||
K: std::borrow::Borrow<Q>,
|
||||
Q: std::hash::Hash + Eq + ?Sized,
|
||||
{
|
||||
let idx = self.keys.get(key)?;
|
||||
self.agents.contains(idx).then(|| self.agents[idx].rating)
|
||||
}
|
||||
|
||||
pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian>
|
||||
where
|
||||
K: std::borrow::Borrow<Q>,
|
||||
@@ -796,6 +960,40 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
///
|
||||
/// Returns the matrix, each competitor's row at its latest appearance, and
|
||||
/// the row at each `(competitor, slice)` for time-addressed queries.
|
||||
/// Drift below which two consecutive appearances are one latent variable.
|
||||
///
|
||||
/// The rule used to be `drift <= 0.0` exactly, and everything above it got an
|
||||
/// explicit `1.0 / drift` precision. That is a representation the matrix cannot
|
||||
/// hold: at `drift = 1e-16` the entry is `1e16`, and `1e16 + 0.28` rounds back
|
||||
/// to `1e16`, so the prior and the event contrasts are annihilated in the
|
||||
/// stored `f64` before the factorisation ever runs. Measured, `drift_scale =
|
||||
/// 1e-10` returned a posterior variance **12 000x too small** — a 111x
|
||||
/// overconfident interval — as `Ok`, and the band just above it returned a
|
||||
/// misleading `JointUnavailable`.
|
||||
///
|
||||
/// Solved exactly in high precision the same system is perfectly well
|
||||
/// conditioned: it converges smoothly onto the collapsed value and is flat from
|
||||
/// `1e-16` down to `1e-40`. So this is a representation problem, not a
|
||||
/// conditioning one, and scaling cannot fix it — symmetric (Jacobi)
|
||||
/// equilibration was measured **30x worse**, because the information is already
|
||||
/// gone from the assembled matrix by the time a solver sees it.
|
||||
///
|
||||
/// The threshold balances the two errors that trade off here. Ignoring a real
|
||||
/// drift costs roughly `drift / V`; representing one costs roughly
|
||||
/// `EPSILON * V / drift`, since `1 / drift` swamps the other precisions in the
|
||||
/// row. They cross at `drift ~ V * sqrt(EPSILON)`, which is what this returns.
|
||||
/// `V` is the competitor's own prior variance, so the threshold follows the
|
||||
/// scale each competitor is actually measured on.
|
||||
///
|
||||
/// Ordinary drift is far above this and is unaffected: the crate's default
|
||||
/// `gamma = 25/300` accumulates `0.0069` per unit time against a threshold of
|
||||
/// `1.0e-6` at the default prior.
|
||||
fn collapse_threshold(prior_variance: f64) -> f64 {
|
||||
// sqrt(f64::EPSILON), as a const rather than a runtime sqrt.
|
||||
const SQRT_EPSILON: f64 = 1.490_116_119_384_765_6e-8;
|
||||
prior_variance * SQRT_EPSILON
|
||||
}
|
||||
|
||||
fn time_expanded_joint(&self) -> TimeExpanded {
|
||||
let mut latest: HashMap<Index, (usize, usize)> = HashMap::new();
|
||||
let mut at_slice: HashMap<(Index, usize), usize> = HashMap::new();
|
||||
@@ -818,8 +1016,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
Some(&prev) => {
|
||||
let drift = rating.drift_variance_for_elapsed(elapsed);
|
||||
if drift <= 0.0 {
|
||||
// No drift: the same latent skill, not two.
|
||||
if drift <= Self::collapse_threshold(rating.prior.variance()) {
|
||||
// No drift, or too little to represent: the same
|
||||
// latent skill, not two. See `collapse_threshold`.
|
||||
prev
|
||||
} else {
|
||||
let row = n;
|
||||
@@ -881,7 +1080,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
K: std::fmt::Debug,
|
||||
{
|
||||
let mut contrast = vec![0.0; width];
|
||||
let mut unseen: HashMap<String, f64> = HashMap::new();
|
||||
let mut unseen: BTreeMap<String, f64> = BTreeMap::new();
|
||||
let mut mean = 0.0;
|
||||
|
||||
for (member, (key, coefficient)) in terms.iter().enumerate() {
|
||||
@@ -958,6 +1157,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// [`Joint`] with [`History::joint`] instead — the answers are identical,
|
||||
/// and only the first one pays.
|
||||
///
|
||||
/// # Cost
|
||||
///
|
||||
/// A dense solve over the history's *appearances*, not its competitors. A
|
||||
/// drift-free competitor collapses to a single variable however long the
|
||||
/// history, so the same events can differ enormously in cost depending on
|
||||
/// the drift configuration — see [`Joint`], which also amortises this
|
||||
/// across many questions.
|
||||
///
|
||||
/// # Limitations
|
||||
///
|
||||
/// Exact only for a history whose events are all scored, because a scored
|
||||
@@ -1119,8 +1326,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
|
||||
let cholesky = crate::joint::Cholesky::factor(lambda, width).ok_or(
|
||||
InferenceError::JointUnavailable {
|
||||
reason: "the precision matrix is not positive-definite, which means \
|
||||
a competitor has neither a proper prior nor any evidence",
|
||||
reason: "the precision matrix is not positive-definite; the usual \
|
||||
cause is a competitor with neither a proper prior nor any \
|
||||
evidence, but an extreme prior or drift can also make the \
|
||||
assembled matrix indefinite in floating point",
|
||||
},
|
||||
)?;
|
||||
|
||||
@@ -1343,7 +1552,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
Ok(Prediction::new(crate::predict::outcome_distribution(
|
||||
&performances,
|
||||
&self.margins(&sizes),
|
||||
)))
|
||||
)?))
|
||||
}
|
||||
|
||||
/// Probability of one specific finishing order.
|
||||
@@ -1383,30 +1592,95 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
|
||||
let (performances, sizes) = self.performances(teams)?;
|
||||
Ok(crate::predict::ranking_probability(
|
||||
&performances,
|
||||
&self.margins(&sizes),
|
||||
ranks,
|
||||
))
|
||||
crate::predict::ranking_probability(&performances, &self.margins(&sizes), ranks)
|
||||
}
|
||||
|
||||
/// Run the full forward+backward convergence loop and return a summary.
|
||||
/// Run the full forward+backward convergence loop to a fixed point.
|
||||
///
|
||||
/// Failing to reach `epsilon` within `max_iter` is not an error: the
|
||||
/// returned report carries `converged: false` and the final step.
|
||||
/// # Stopping short is an error
|
||||
///
|
||||
/// Hitting `max_iter` without reaching `epsilon` returns `NotConverged`.
|
||||
///
|
||||
/// It used to return `Ok` with `converged: false`, which was the worst
|
||||
/// available shape. A fit that stops short is *wrong by a little*: every
|
||||
/// rating is finite, the ordering looks sensible, and nothing about the
|
||||
/// output says the numbers were still moving. Detection was opt-in, and
|
||||
/// `let _ = h.converge()` silently opted out — which is how a real defect
|
||||
/// hid in this crate's own test suite.
|
||||
///
|
||||
/// The default `max_iter` is [`ITERATIONS`](crate::ITERATIONS), which is
|
||||
/// set high enough that reaching it means something is genuinely wrong
|
||||
/// rather than that the history is merely large. Raising the cap costs
|
||||
/// nothing when it is not needed, because the loop exits at `epsilon`.
|
||||
///
|
||||
/// Use [`History::converge_partial`] when a capped, unconverged fit is
|
||||
/// what you actually want.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `NotConverged` if the sweep hits `max_iter` with the step still above
|
||||
/// `epsilon`.
|
||||
///
|
||||
/// `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> {
|
||||
let report = self.converge_partial()?;
|
||||
|
||||
if report.converged {
|
||||
Ok(report)
|
||||
} else {
|
||||
Err(InferenceError::NotConverged {
|
||||
iterations: report.iterations,
|
||||
final_step: report.final_step,
|
||||
epsilon: self.convergence.epsilon,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// As [`History::converge`], but a fit that stops at `max_iter` is
|
||||
/// returned rather than reported as an error.
|
||||
///
|
||||
/// The report's `converged` flag says which happened. Use this when a
|
||||
/// deliberately capped sweep is the point — a cheap approximate fit, or a
|
||||
/// test that pins what a fixed number of iterations produces. Prefer
|
||||
/// `converge` everywhere else: an unconverged fit that nobody checks is
|
||||
/// indistinguishable from a converged one.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `NonFiniteResult` if a sweep produces a NaN or infinite step.
|
||||
pub fn converge_partial(&mut self) -> Result<ConvergenceReport, InferenceError> {
|
||||
use std::time::Instant;
|
||||
|
||||
use smallvec::SmallVec;
|
||||
|
||||
let opts = self.convergence;
|
||||
|
||||
// Drift is the one model parameter with no boundary check, because
|
||||
// `HistoryBuilder::drift` is generic over `Drift<T>` and cannot inspect
|
||||
// an arbitrary implementation. Validate what it actually produces
|
||||
// instead, which also covers a custom impl.
|
||||
//
|
||||
// `ConstantDrift` returns `elapsed * gamma * gamma`, so a negative
|
||||
// gamma is squared away: measured, `ConstantDrift::new(-0.0833)` gave
|
||||
// results **bit identical** to `+0.0833`, the same sign-absorption
|
||||
// defect already rejected for `sigma` and `beta`. A non-finite gamma
|
||||
// poisons every posterior derived from it.
|
||||
for slice in &self.time_slices {
|
||||
for (agent, elapsed) in slice.appearances() {
|
||||
let drift = self.agents[agent]
|
||||
.rating
|
||||
.drift_variance_for_elapsed(elapsed);
|
||||
if !drift.is_finite() || drift < 0.0 {
|
||||
return Err(InferenceError::InvalidParameter {
|
||||
name: "drift variance",
|
||||
value: drift,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.time_slices.is_empty() {
|
||||
return Ok(ConvergenceReport {
|
||||
iterations: 0,
|
||||
@@ -1505,6 +1779,73 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
});
|
||||
}
|
||||
|
||||
// Chokepoint for event shape, for the same reason as the tie check
|
||||
// below: every ingestion route lands here.
|
||||
//
|
||||
// `run_chain` builds one diff link per adjacent pair of teams, so a
|
||||
// one-team event leaves it with an empty link vector and panics
|
||||
// indexing `links[1..]` — a reachable panic from safe API, in release.
|
||||
// An empty team is the quieter half: it contributes no performance,
|
||||
// so a malformed event yields a finite, plausible-looking posterior
|
||||
// for whoever it was matched against.
|
||||
//
|
||||
// Both errors already existed; they were only ever checked on the
|
||||
// prediction paths, which is why ingestion could still produce them.
|
||||
for teams in &composition {
|
||||
if teams.len() < 2 {
|
||||
return Err(InferenceError::NotEnoughTeams { got: teams.len() });
|
||||
}
|
||||
for (team, members) in teams.iter().enumerate() {
|
||||
if members.is_empty() {
|
||||
return Err(InferenceError::EmptyTeam { team });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A non-finite outcome poisons the history rather than failing it:
|
||||
// `converge` does report `NonFiniteResult`, but a caller who reads
|
||||
// `current_skill` before converging is handed a NaN posterior with
|
||||
// nothing to say it is one.
|
||||
if let Some(results) = results.as_ref() {
|
||||
for (event_results, kind) in results.iter().zip(kinds.iter()) {
|
||||
let name = match kind {
|
||||
EventKind::Ranked => "rank",
|
||||
EventKind::Scored { .. } => "score",
|
||||
};
|
||||
for value in event_results {
|
||||
if !value.is_finite() {
|
||||
return Err(InferenceError::InvalidParameter {
|
||||
name,
|
||||
value: *value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A non-finite weight is not a weight. Measured, it behaves exactly as
|
||||
// `0.0` — the member contributes nothing — while `converge` reports
|
||||
// `converged: true` after one iteration with a step of `(0.0, 0.0)`.
|
||||
// So a NaN arriving from a division or a parse is indistinguishable
|
||||
// from a deliberate zero, and looks like a clean fit.
|
||||
//
|
||||
// Zero and negative weights stay accepted: both are expressible
|
||||
// choices about how much a member contributes, and
|
||||
// `tests/degenerate_inputs.rs` pins them deliberately. Only the values
|
||||
// that are not quantities at all are rejected.
|
||||
if let Some(weights) = weights.as_ref() {
|
||||
for team_weights in weights.iter().flatten() {
|
||||
for weight in team_weights {
|
||||
if !weight.is_finite() {
|
||||
return Err(InferenceError::InvalidParameter {
|
||||
name: "weight",
|
||||
value: *weight,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chokepoint for tie validation: every ingestion route lands here,
|
||||
// including `record_draw`, which builds its results directly rather
|
||||
// than going through `Outcome`.
|
||||
@@ -1520,6 +1861,56 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-batch conflict. The in-batch check upstream rejects one batch
|
||||
// that sets a field twice; `priors` is rebuilt per call, so without
|
||||
// this a *second* batch could quietly overwrite what a first one
|
||||
// declared, last-write-wins.
|
||||
//
|
||||
// That asymmetry cut against the invariant `tests/ingestion_equivalence.rs`
|
||||
// exists to protect: the same contradictory events errored when
|
||||
// batched and succeeded, order-dependently, when fed one at a time.
|
||||
// Checked before anything mutates, so a rejected batch leaves the
|
||||
// history untouched.
|
||||
// Sorted, not `HashMap` order. This loop returns on the FIRST conflict
|
||||
// it finds, so hash order decided *which* competitor the error blamed:
|
||||
// measured, 15 different competitors named across 40 runs on identical
|
||||
// input. The error fired every time — only its content was a lottery,
|
||||
// which makes it unreproducible and sends a reader after the wrong key.
|
||||
let mut conflict_scan: Vec<Index> = priors.keys().copied().collect();
|
||||
conflict_scan.sort_unstable();
|
||||
|
||||
for agent in &conflict_scan {
|
||||
let batch = priors[agent];
|
||||
let held = self.declared.get(agent).copied().unwrap_or_default();
|
||||
|
||||
if let (Some(existing), Some(new)) = (held.prior, batch.prior) {
|
||||
if existing != new {
|
||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||
competitor: agent.get(),
|
||||
field: "prior",
|
||||
});
|
||||
}
|
||||
}
|
||||
if let (Some(existing), Some(new)) = (held.drift_scale, batch.drift_scale) {
|
||||
if existing != new {
|
||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||
competitor: agent.get(),
|
||||
field: "drift_scale",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (agent, batch) in &priors {
|
||||
let entry = self.declared.entry(*agent).or_default();
|
||||
if batch.prior.is_some() {
|
||||
entry.prior = batch.prior;
|
||||
}
|
||||
if batch.drift_scale.is_some() {
|
||||
entry.drift_scale = batch.drift_scale;
|
||||
}
|
||||
}
|
||||
|
||||
competitor::clean(self.agents.values_mut(), true);
|
||||
|
||||
let mut this_agent = Vec::with_capacity(1024);
|
||||
@@ -1531,7 +1922,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
|
||||
this_agent.push(*agent);
|
||||
|
||||
let config = priors.get(agent).copied().unwrap_or_default();
|
||||
// From `declared` rather than `priors`: a competitor configured by
|
||||
// `register` before any event has nothing in this batch's map.
|
||||
let config = self.declared.get(agent).copied().unwrap_or_default();
|
||||
|
||||
if self.agents.contains(*agent) {
|
||||
// Seeding a competitor the history already knows. This used to
|
||||
@@ -1922,6 +2315,34 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// added while it is alive. That is what makes it correct without any
|
||||
/// invalidation logic: there is no window in which the factorisation could
|
||||
/// describe a fit that no longer exists.
|
||||
///
|
||||
/// # What the cost actually scales in
|
||||
///
|
||||
/// Not competitors, and not slices times competitors. One variable per
|
||||
/// *appearance* — a competitor per slice they appear in — minus every
|
||||
/// consecutive pair with no drift between them, which collapse to a single
|
||||
/// latent variable.
|
||||
///
|
||||
/// That last clause dominates, and it is not obvious. A competitor whose drift
|
||||
/// is zero contributes **one** variable however long the history: whole-history
|
||||
/// `gamma = 0`, or `drift_scale = 0` on that competitor. So two fits over the
|
||||
/// same events and the same slices can differ in problem size by roughly the
|
||||
/// slice count, and in factorisation time by its cube. Measured by a consumer
|
||||
/// on a ~2,000-node model over 76 slices:
|
||||
///
|
||||
/// ```text
|
||||
/// career fit (gamma = 0) 787 ms per solve
|
||||
/// drifting fit (gamma = 0.15) 6214 ms per solve
|
||||
/// ```
|
||||
///
|
||||
/// Choosing between a drifting and a drift-free configuration is therefore also
|
||||
/// choosing an 8x difference in query cost. [`Joint::variables`] reports the
|
||||
/// number that decides it, and can be read before committing to a batch of
|
||||
/// queries.
|
||||
///
|
||||
/// Slices a competitor sits out cost nothing: an absence is not an appearance,
|
||||
/// so a competitor seen in the first and last of a hundred slices contributes
|
||||
/// two variables, not a hundred.
|
||||
pub struct Joint<'h, T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> {
|
||||
history: &'h History<T, D, O, K>,
|
||||
cholesky: crate::joint::Cholesky,
|
||||
@@ -1949,9 +2370,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
|
||||
/// Number of variables in the joint: the history's appearances, after
|
||||
/// collapsing consecutive pairs a competitor does not drift between.
|
||||
///
|
||||
/// This is what the cost scales in, and it is not the competitor count — a
|
||||
/// competitor contributes one variable per slice it appears in. Worth
|
||||
/// checking before asking for a joint over a long history.
|
||||
/// This is what the cost scales in — `O(n^3)` to factorise, `O(n^2)` per
|
||||
/// query — and it is neither the competitor count nor slices times
|
||||
/// competitors. A drift-free competitor contributes one variable however
|
||||
/// many slices they appear in; see the type docs for how large that
|
||||
/// difference gets.
|
||||
///
|
||||
/// Worth reading before committing to a batch of queries: it is the one
|
||||
/// number that says whether a joint over this history is affordable.
|
||||
#[must_use]
|
||||
pub fn variables(&self) -> usize {
|
||||
self.width
|
||||
@@ -2180,7 +2606,7 @@ mod tests {
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(0.15 * 25.0 / 3.0))
|
||||
.drift(ConstantDrift::new(0.15 * 25.0 / 3.0))
|
||||
.build();
|
||||
|
||||
let events = make_events_1v1(
|
||||
@@ -2245,7 +2671,7 @@ mod tests {
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(0.15 * 25.0 / 3.0))
|
||||
.drift(ConstantDrift::new(0.15 * 25.0 / 3.0))
|
||||
.build();
|
||||
|
||||
let events = make_events_1v1(
|
||||
@@ -2290,7 +2716,7 @@ mod tests {
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||
.build();
|
||||
|
||||
let events = make_events_1v1(
|
||||
@@ -2338,7 +2764,7 @@ mod tests {
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||
.build();
|
||||
|
||||
let events = make_events_1v1(
|
||||
@@ -2380,7 +2806,7 @@ mod tests {
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||
.build();
|
||||
|
||||
let events = make_events_1v1(
|
||||
@@ -2425,7 +2851,7 @@ mod tests {
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.build();
|
||||
|
||||
let events: Vec<Event<i64, &'static str>> = vec![
|
||||
@@ -2531,7 +2957,7 @@ mod tests {
|
||||
.mu(0.0)
|
||||
.sigma(2.0)
|
||||
.beta(1.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.build();
|
||||
|
||||
let events = make_events_1v1(
|
||||
@@ -2628,7 +3054,7 @@ mod tests {
|
||||
.mu(0.0)
|
||||
.sigma(2.0)
|
||||
.beta(1.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.build();
|
||||
|
||||
let events = make_events_1v1(
|
||||
@@ -2755,13 +3181,15 @@ mod tests {
|
||||
epsilon = 1e-6
|
||||
);
|
||||
|
||||
// run exactly 11 iterations (old test used convergence(11, ...))
|
||||
// Run exactly 11 iterations. `converge_partial` rather than
|
||||
// `converge`: stopping at the cap is the point here, and `converge`
|
||||
// now reports that as `NotConverged`.
|
||||
h.convergence = ConvergenceOptions {
|
||||
max_iter: 11,
|
||||
epsilon: EPSILON,
|
||||
alpha: 1.0,
|
||||
};
|
||||
let _ = h.converge().unwrap();
|
||||
let _ = h.converge_partial().unwrap();
|
||||
|
||||
let loocv_approx_2 = h.log_evidence_internal(false, &[]).exp().sqrt();
|
||||
|
||||
@@ -2799,7 +3227,7 @@ mod tests {
|
||||
.mu(0.0)
|
||||
.sigma(2.0)
|
||||
.beta(1.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.build();
|
||||
|
||||
let events = make_events_1v1(
|
||||
@@ -2902,7 +3330,7 @@ mod tests {
|
||||
.mu(0.0)
|
||||
.sigma(2.0)
|
||||
.beta(1.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.build();
|
||||
|
||||
let events = make_events_1v1(
|
||||
@@ -3006,7 +3434,7 @@ mod tests {
|
||||
.mu(2.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.build();
|
||||
|
||||
// empty results in old API = team 0 wins: a wins event 1, b wins event 2
|
||||
@@ -3073,7 +3501,7 @@ mod tests {
|
||||
.mu(0.0)
|
||||
.sigma(2.0)
|
||||
.beta(1.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
@@ -3100,7 +3528,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "score_sigma must be positive")]
|
||||
#[should_panic(expected = "score_sigma must be finite and positive")]
|
||||
fn history_builder_rejects_zero_score_sigma() {
|
||||
let _ = History::builder().score_sigma(0.0).build();
|
||||
}
|
||||
@@ -3128,7 +3556,9 @@ mod tests {
|
||||
})
|
||||
.build();
|
||||
events_for(&mut h_capped);
|
||||
let _ = h_capped.converge().unwrap();
|
||||
// A one-iteration cap is deliberate here, so the short fit is the
|
||||
// result rather than an error.
|
||||
let _ = h_capped.converge_partial().unwrap();
|
||||
|
||||
let mut h_full: History<i64, _, _, &'static str> = History::builder().build();
|
||||
events_for(&mut h_full);
|
||||
|
||||
+292
-28
@@ -158,22 +158,43 @@ pub const P_DRAW: f64 = 0.0;
|
||||
pub const EPSILON: f64 = 1e-6;
|
||||
/// Default cap on convergence sweeps.
|
||||
///
|
||||
/// **This is a floor, not a recommendation.** It is adequate for small
|
||||
/// histories and is quickly outgrown: a history of 400 events over 100
|
||||
/// competitors already stops here with a final step of ~7e-3 against the 1e-6
|
||||
/// default tolerance — four orders of magnitude short — and a dense joint model
|
||||
/// of ~2,000 nodes over ~3,300 events has been measured needing 76 to 161.
|
||||
/// **A runaway guard, not a budget.** The sweep exits as soon as the step falls
|
||||
/// below `epsilon`, so the cap is never reached by a history that converges and
|
||||
/// raising it costs nothing. Measured on a history that needs four sweeps:
|
||||
///
|
||||
/// Overrunning it is not an error, and deliberately so: `converge` returns a
|
||||
/// [`ConvergenceReport`] whose `converged` flag says what happened. But a fit
|
||||
/// that stopped short is *wrong by a little*, which is the worst available
|
||||
/// failure — every rating is finite and ordered sensibly, and nothing in the
|
||||
/// numbers themselves says they were still moving. Read the report; the type is
|
||||
/// `#[must_use]` for that reason.
|
||||
/// ```text
|
||||
/// max_iter 30: 4 iterations, 129.9 us
|
||||
/// max_iter 100_000: 4 iterations, 131.9 us
|
||||
/// ```
|
||||
///
|
||||
/// Raise it via [`ConvergenceOptions`]. Convergence cost is roughly linear in
|
||||
/// the cap, and for anything but a toy the extra sweeps are milliseconds.
|
||||
pub const ITERATIONS: usize = 30;
|
||||
/// This was `30` until it was measured, and 30 truncated ordinary healthy
|
||||
/// histories: 160 events over 100 competitors already needs 42. Because a short
|
||||
/// fit is finite and sensibly ordered, that was invisible.
|
||||
///
|
||||
/// # Why it is not scaled to the history
|
||||
///
|
||||
/// The obvious improvement — pick the cap from the node or event count — does
|
||||
/// not work, because iteration count is driven by how *loopy* the graph is
|
||||
/// rather than how big it is. At a fixed 320 events over 40 slices, varying
|
||||
/// only the number of competitors sharing them:
|
||||
///
|
||||
/// ```text
|
||||
/// competitors appearances each iterations
|
||||
/// 3 213 2_789
|
||||
/// 10 64 1_068
|
||||
/// 50 12.8 206
|
||||
/// 100 6.4 90
|
||||
/// 400 1.6 2
|
||||
/// ```
|
||||
///
|
||||
/// Three orders of magnitude apart on identical event and slice counts. Any
|
||||
/// formula in those two numbers would be badly wrong on some real shape, so the
|
||||
/// cap is a single value set high enough that reaching it means the fit is
|
||||
/// oscillating rather than merely large.
|
||||
///
|
||||
/// Reaching it is [`InferenceError::NotConverged`]. See
|
||||
/// [`History::converge`](crate::History::converge).
|
||||
pub const ITERATIONS: usize = 10_000;
|
||||
|
||||
/// Largest team count `History::predict_outcome` will enumerate.
|
||||
///
|
||||
@@ -197,6 +218,12 @@ const HALF_LINE_WINDOW: f64 = 10.0;
|
||||
/// four-term series is good to ~1e-10 by here, so the two are at their closest
|
||||
/// agreement around this point. Below it the subtraction is exact; above it the
|
||||
/// series is.
|
||||
/// `alpha / width` past which the tie branch's `v^2 - u` has lost too many
|
||||
/// digits to trust, and the narrow-window form takes over.
|
||||
///
|
||||
/// The subtraction retains about `(width / alpha)^2 / EPSILON` of its
|
||||
/// precision, so this is the ratio at which that falls below roughly 1e-6.
|
||||
const NARROW_WINDOW_RATIO: f64 = 2.0e4;
|
||||
const ASYMPTOTIC_MILLS_ALPHA: f64 = 100.0;
|
||||
|
||||
pub const N01: Gaussian = Gaussian::from_ms(0.0, 1.0);
|
||||
@@ -455,10 +482,72 @@ fn pdf(x: f64, mu: f64, sigma: f64) -> f64 {
|
||||
fn half_line_truncation(alpha: f64) -> (f64, f64) {
|
||||
let inv = alpha.recip();
|
||||
let inv_sq = inv * inv;
|
||||
let gap = inv * (1.0 - inv_sq * (2.0 - inv_sq * (10.0 - 74.0 * inv_sq)));
|
||||
let b = 2.0 - inv_sq * (10.0 - 74.0 * inv_sq);
|
||||
let gap = inv * (1.0 - inv_sq * b);
|
||||
let v = alpha + gap;
|
||||
|
||||
(v, v * gap)
|
||||
// Returns `1 - w`, not `w`, and that is the whole point of this shape.
|
||||
//
|
||||
// `w` tends to 1 out here, so a caller forming `1 - w` loses about
|
||||
// `log10(alpha^2)` digits: measured against the exact truncated variance,
|
||||
// `1 - w` came back with 8.9e-5 relative error at alpha = 1e6 and **0.0**
|
||||
// from alpha = 1e8 — where the true value is 1e-16 and perfectly
|
||||
// representable. `sigma * (1 - w).sqrt()` was then exactly zero, and
|
||||
// `from_ms(mu, 0.0)` is a point mass whose `mu()` is `inf/inf = NaN`.
|
||||
//
|
||||
// Expanding `1 - v*gap` symbolically removes the subtraction: with
|
||||
// `alpha*gap = 1 - inv^2*b`, the leading ones cancel on paper instead of in
|
||||
// floating point, leaving `inv^2` times a bracket that tends to 1. Measured
|
||||
// exact — 0.0 relative error — from alpha = 1e3 to 1e8.
|
||||
let one_minus_w = inv_sq
|
||||
* ((1.0 - inv_sq * (10.0 - 74.0 * inv_sq)) + 2.0 * inv_sq * b - inv_sq * inv_sq * b * b);
|
||||
|
||||
(v, one_minus_w)
|
||||
}
|
||||
|
||||
/// Truncation to a *narrow* window `[alpha, alpha + d]`, as `(v, 1 - w)`.
|
||||
///
|
||||
/// The tie branch forms `w` from `v^2 - u`, and both grow as `alpha^2` while
|
||||
/// their difference stays `O(1)`. Far enough into the tail that subtraction has
|
||||
/// nothing left: measured at `alpha = 1e6` with a window of `1e-6` it kept four
|
||||
/// significant digits and returned `1 - w = -2.4e-4` where the truth is
|
||||
/// `+2.8e-13`, so `sqrt` of it was NaN. One step earlier it was quietly wrong
|
||||
/// instead — `1 - w = 1.0` exactly, a truncation reported as a no-op, where the
|
||||
/// truth was `5e-17`.
|
||||
///
|
||||
/// The existing half-line escape hatch does not cover it, because that keys on
|
||||
/// `alpha * d >= HALF_LINE_WINDOW` — how many window-widths from the mean the
|
||||
/// window sits — and a *narrow* window fails that however deep it is.
|
||||
///
|
||||
/// Over a narrow window the density is `exp(-t*s - s^2 d^2 / 2)` in
|
||||
/// `x = alpha + s*d`, with `t = alpha * d`. Dropping the `d^2` term leaves a
|
||||
/// truncated exponential on `[0, 1]`, whose mean and variance are closed forms.
|
||||
/// So `v = alpha + d*m(t)` and `1 - w = d^2 * V(t)`, with no subtraction of
|
||||
/// large quantities anywhere.
|
||||
///
|
||||
/// Measured against high-precision quadrature over `alpha` in `[1e2, 1e9]`:
|
||||
/// `v` exact to 4e-10 or better, `1 - w` to 4e-10 across the region this is
|
||||
/// used in.
|
||||
fn narrow_window_truncation(alpha: f64, d: f64) -> (f64, f64) {
|
||||
let t = alpha * d;
|
||||
|
||||
// `m` and `V` are the mean and variance of a truncated exponential on
|
||||
// [0, 1] with rate `t`, both of which cancel as `t -> 0`. The series is
|
||||
// their limit (1/2 and 1/12, a uniform window) with the leading correction.
|
||||
let (m, v_s) = if t < 1e-3 {
|
||||
(
|
||||
0.5 - t / 12.0 + t * t * t / 720.0,
|
||||
1.0 / 12.0 - t * t / 240.0,
|
||||
)
|
||||
} else {
|
||||
let em1 = libm::expm1(t);
|
||||
(
|
||||
1.0 / t - 1.0 / em1,
|
||||
1.0 / (t * t) - (em1 + 1.0) / (em1 * em1),
|
||||
)
|
||||
};
|
||||
|
||||
(alpha + d * m, d * d * v_s)
|
||||
}
|
||||
|
||||
fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
||||
@@ -486,7 +575,7 @@ fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
||||
(v, v - alpha)
|
||||
};
|
||||
|
||||
(v, v * gap)
|
||||
(v, 1.0 - v * gap)
|
||||
} else {
|
||||
// v is odd in mu and w is even, so fold to mu <= 0. Both truncation
|
||||
// points then sit in the upper tail, where the scaled form applies.
|
||||
@@ -502,9 +591,22 @@ fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
||||
// Once the window sits many of its own widths into the tail it is
|
||||
// indistinguishable from a half-line, so the asymptotic covers it with
|
||||
// no subtraction at all.
|
||||
if alpha >= ASYMPTOTIC_MILLS_ALPHA && alpha * (beta - alpha) >= HALF_LINE_WINDOW {
|
||||
let (v, w) = half_line_truncation(alpha);
|
||||
return (if flipped { -v } else { v }, w);
|
||||
let width = beta - alpha;
|
||||
|
||||
if alpha >= ASYMPTOTIC_MILLS_ALPHA && alpha * width >= HALF_LINE_WINDOW {
|
||||
let (v, one_minus_w) = half_line_truncation(alpha);
|
||||
return (if flipped { -v } else { v }, one_minus_w);
|
||||
}
|
||||
|
||||
// A narrow window deep in the tail: too narrow for the half-line above,
|
||||
// too deep for the subtraction below. The direct form keeps roughly
|
||||
// `1 / (alpha/width)^2` of its digits, so the crossover is on that
|
||||
// ratio rather than on either quantity alone — and the approximation is
|
||||
// most accurate exactly where the subtraction is worst, since both
|
||||
// improve as the window narrows.
|
||||
if alpha > 0.0 && alpha > NARROW_WINDOW_RATIO * width {
|
||||
let (v, one_minus_w) = narrow_window_truncation(alpha, width);
|
||||
return (if flipped { -v } else { v }, one_minus_w);
|
||||
}
|
||||
|
||||
let (v, u) = if alpha > 0.0 {
|
||||
@@ -527,17 +629,23 @@ fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
||||
)
|
||||
};
|
||||
|
||||
let w = -(u - v.powi(2));
|
||||
// `1 - w` where `w = v^2 - u`. Both `v^2` and `u` grow as alpha^2 while
|
||||
// their difference stays O(1), so this subtraction is the one place the
|
||||
// tie branch can still lose everything — see the escape hatch above,
|
||||
// which is what keeps the far tail away from it.
|
||||
let one_minus_w = 1.0 + u - v.powi(2);
|
||||
|
||||
(if flipped { -v } else { v }, w)
|
||||
(if flipped { -v } else { v }, one_minus_w)
|
||||
}
|
||||
}
|
||||
|
||||
fn trunc(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
||||
let (v, w) = v_w(mu, sigma, margin, tie);
|
||||
// `v_w` returns `1 - w` rather than `w`: forming the difference here is
|
||||
// what destroyed the truncated variance in the far tail.
|
||||
let (v, one_minus_w) = v_w(mu, sigma, margin, tie);
|
||||
|
||||
let mu_trunc = mu + sigma * v;
|
||||
let sigma_trunc = sigma * (1.0 - w).sqrt();
|
||||
let sigma_trunc = sigma * one_minus_w.sqrt();
|
||||
|
||||
(mu_trunc, sigma_trunc)
|
||||
}
|
||||
@@ -548,13 +656,34 @@ pub(crate) fn approx(n: Gaussian, margin: f64, tie: bool) -> Gaussian {
|
||||
Gaussian::from_ms(mu, sigma)
|
||||
}
|
||||
|
||||
/// Componentwise maximum that **propagates** NaN rather than dropping it.
|
||||
///
|
||||
/// Every caller folds this as `tuple_max(accumulator, new)`. A plain `>`
|
||||
/// comparison is false against NaN, so a NaN accumulator would be replaced by
|
||||
/// the next finite delta and the breakdown would vanish — leaving `step_is_finite`
|
||||
/// to pass on a fit that is already NaN. Because the fold runs over a `HashMap`,
|
||||
/// whether that happened depended on per-process hash order: measured, a NaN fit
|
||||
/// was reported as `converged: true` in 16 of 30 runs on identical input.
|
||||
///
|
||||
/// `f64::max` is not a substitute: it also ignores NaN by design, which is the
|
||||
/// same defect wearing a standard-library name.
|
||||
pub(crate) fn tuple_max(v1: (f64, f64), v2: (f64, f64)) -> (f64, f64) {
|
||||
(
|
||||
if v1.0 > v2.0 { v1.0 } else { v2.0 },
|
||||
if v1.1 > v2.1 { v1.1 } else { v2.1 },
|
||||
max_propagating_nan(v1.0, v2.0),
|
||||
max_propagating_nan(v1.1, v2.1),
|
||||
)
|
||||
}
|
||||
|
||||
fn max_propagating_nan(a: f64, b: f64) -> f64 {
|
||||
if a.is_nan() || b.is_nan() {
|
||||
f64::NAN
|
||||
} else if a > b {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tuple_gt(t: (f64, f64), e: f64) -> bool {
|
||||
t.0 > e || t.1 > e
|
||||
}
|
||||
@@ -631,6 +760,13 @@ 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.
|
||||
///
|
||||
/// Also panics with "cannot invert a singular matrix" when every rating has
|
||||
/// zero sigma *and* `beta` is zero. Nothing is then uncertain, so there is no
|
||||
/// distribution to take the quality of; `Gaussian::from_ms(mu, 0.0)` is a point
|
||||
/// mass and its `mu()` is not even well defined. Documented rather than
|
||||
/// converted, because the input has no meaningful answer rather than an
|
||||
/// awkward one.
|
||||
#[must_use]
|
||||
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
||||
assert!(
|
||||
@@ -696,13 +832,141 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
||||
let end = &rotated_a_matrix * &mean_matrix;
|
||||
|
||||
let e_arg = (-0.5 * &start * &middle.inverse() * &end).determinant();
|
||||
let s_arg = ata.determinant() / middle.determinant();
|
||||
|
||||
libm::exp(e_arg) * s_arg.sqrt()
|
||||
// `sqrt(det(ata) / det(middle))`, taken in log space. Both determinants are
|
||||
// products of `k - 1` diagonal entries, so they leave `f64`'s range long
|
||||
// before their ratio does: measured at the crate defaults, 150 groups was
|
||||
// correct at `8.45e-53`, 200 returned `0`, and 250 returned `NaN` where the
|
||||
// true value is `9.51e-88`. With a small beta it is sharper still — at
|
||||
// `sigma = beta = 1e-3`, 60 groups returned `NaN` against a true `1.32e-9`.
|
||||
//
|
||||
// The ratio is what the answer needs and it is representable throughout, so
|
||||
// the intermediates are the only thing that ever overflowed.
|
||||
let ln_s_arg = ata.ln_abs_determinant() - middle.ln_abs_determinant();
|
||||
|
||||
libm::exp(e_arg + 0.5 * ln_s_arg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The truncated variance must stay a variance across every branch, and
|
||||
/// the branches must agree where they meet.
|
||||
///
|
||||
/// `v_w` now has three regimes for a tie — half-line, narrow-window, and
|
||||
/// the direct subtraction — and a misplaced crossover between them is the
|
||||
/// failure mode this guards. A jump at a boundary is visible here even
|
||||
/// though the absolute values are not pinned.
|
||||
#[test]
|
||||
fn truncated_variance_is_continuous_across_the_tie_branches() {
|
||||
for &alpha in &[50.0, 99.0, 100.0, 101.0, 1e3, 1e5, 1e6] {
|
||||
// Sweep the window width across NARROW_WINDOW_RATIO and the
|
||||
// half-line threshold, which sit at different widths per alpha.
|
||||
let mut previous: Option<(f64, f64)> = None;
|
||||
let mut width = alpha / (NARROW_WINDOW_RATIO * 100.0);
|
||||
while width < 40.0 / alpha {
|
||||
// mu = 0 puts the window at [-margin, margin]; shift it out to
|
||||
// `alpha` by moving the mean instead.
|
||||
let margin = width * 0.5;
|
||||
let mu = -(alpha + width * 0.5);
|
||||
let (v, one_minus_w) = v_w(mu, 1.0, margin, true);
|
||||
|
||||
assert!(v.is_finite(), "alpha {alpha}, width {width:e}: v = {v}");
|
||||
assert!(
|
||||
one_minus_w.is_finite() && one_minus_w > 0.0 && one_minus_w <= 1.0,
|
||||
"alpha {alpha}, width {width:e}: 1 - w = {one_minus_w:e} is not a variance"
|
||||
);
|
||||
|
||||
if let Some((pv, pw)) = previous {
|
||||
// Consecutive widths differ by 2x, so the moments may not
|
||||
// differ by more than a small multiple of that.
|
||||
assert!(
|
||||
one_minus_w / pw < 32.0 && pw / one_minus_w < 32.0,
|
||||
"alpha {alpha}: 1 - w jumped from {pw:e} to {one_minus_w:e} \
|
||||
at width {width:e} — a branch boundary is misplaced"
|
||||
);
|
||||
assert!(
|
||||
(v - pv).abs() <= 8.0 * width.max(1e-12) + 1e-9 * v.abs(),
|
||||
"alpha {alpha}: v jumped from {pv} to {v} at width {width:e}"
|
||||
);
|
||||
}
|
||||
previous = Some((v, one_minus_w));
|
||||
width *= 2.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The narrow-window form against high-precision quadrature.
|
||||
///
|
||||
/// These are the inputs where the direct `v^2 - u` subtraction had four
|
||||
/// significant digits left and returned a negative variance.
|
||||
#[test]
|
||||
fn narrow_window_truncation_matches_quadrature() {
|
||||
for &(alpha, d, expect_v, expect_w) in &[
|
||||
(1e6, 2e-6, 1_000_000.000_000_687, 2.759_383_390_335_666e-13),
|
||||
(1e4, 1e-6, 10_000.000_000_499_167, 8.333_291_666_831_727e-14),
|
||||
(
|
||||
1e3,
|
||||
1e-5,
|
||||
1_000.000_004_991_666_6,
|
||||
8.333_291_666_803_818e-12,
|
||||
),
|
||||
] {
|
||||
let (v, one_minus_w) = narrow_window_truncation(alpha, d);
|
||||
assert!(
|
||||
((v - expect_v) / expect_v).abs() < 1e-12,
|
||||
"alpha {alpha:e}: v = {v}, want {expect_v}"
|
||||
);
|
||||
assert!(
|
||||
((one_minus_w - expect_w) / expect_w).abs() < 1e-8,
|
||||
"alpha {alpha:e}: 1 - w = {one_minus_w:e}, want {expect_w:e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A NaN must survive the fold from ANY position, not only the last.
|
||||
///
|
||||
/// The fold runs over a `HashMap`, so "last" is per-process hash order. The
|
||||
/// end-to-end symptom was a NaN fit reported as `converged: true` in 16 of
|
||||
/// 30 runs on identical input; these three cases are the deterministic form
|
||||
/// of that, so a regression cannot hide behind a lucky seed.
|
||||
#[test]
|
||||
fn tuple_max_propagates_a_nan_from_any_position() {
|
||||
let nan = (f64::NAN, f64::NAN);
|
||||
let small = (1e-9, 1e-9);
|
||||
let big = (1e-3, 1e-3);
|
||||
|
||||
// NaN last.
|
||||
let step = tuple_max(tuple_max(big, small), nan);
|
||||
assert!(!step_is_finite(step), "NaN last: {step:?}");
|
||||
|
||||
// NaN middle.
|
||||
let step = tuple_max(tuple_max(big, nan), small);
|
||||
assert!(!step_is_finite(step), "NaN middle: {step:?}");
|
||||
|
||||
// NaN first — the case a plain `>` comparison drops.
|
||||
let step = tuple_max(tuple_max(nan, big), small);
|
||||
assert!(!step_is_finite(step), "NaN first: {step:?}");
|
||||
}
|
||||
|
||||
/// `f64::max` would pass the test above's first two cases and fail the
|
||||
/// third, so pin that it is not what we use.
|
||||
#[test]
|
||||
fn tuple_max_is_not_f64_max() {
|
||||
assert!(
|
||||
f64::max(f64::NAN, 1.0) == 1.0,
|
||||
"premise: f64::max drops NaN"
|
||||
);
|
||||
let (a, _) = tuple_max((f64::NAN, 0.0), (1.0, 0.0));
|
||||
assert!(a.is_nan(), "tuple_max must not drop what f64::max drops");
|
||||
}
|
||||
|
||||
/// Ordinary values are unaffected.
|
||||
#[test]
|
||||
fn tuple_max_still_takes_the_larger_component() {
|
||||
assert_eq!(tuple_max((1.0, 5.0), (3.0, 2.0)), (3.0, 5.0));
|
||||
assert_eq!(tuple_max((3.0, 2.0), (1.0, 5.0)), (3.0, 5.0));
|
||||
}
|
||||
|
||||
use ::approx::assert_ulps_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -91,6 +91,29 @@ impl Lu {
|
||||
det
|
||||
}
|
||||
|
||||
/// `ln |det|`, accumulated term by term rather than multiplied out.
|
||||
///
|
||||
/// The determinant of an `n x n` Gram matrix is a product of `n` diagonal
|
||||
/// entries, so it leaves `f64`'s range long before the quantities built
|
||||
/// from it do. `quality()` only ever wants a *ratio* of two determinants,
|
||||
/// and that ratio is perfectly representable while the determinants
|
||||
/// themselves are not — measured, at 250 rating groups both overflow and
|
||||
/// the ratio came back `NaN` where the true answer is `9.51e-88`.
|
||||
///
|
||||
/// Returns `-inf` for a singular matrix, so `exp` of it is zero.
|
||||
fn ln_abs_determinant(&self) -> f64 {
|
||||
if self.sign == 0.0 {
|
||||
return f64::NEG_INFINITY;
|
||||
}
|
||||
|
||||
let mut acc = 0.0;
|
||||
for i in 0..self.n {
|
||||
acc += libm::log(self.lu[i * self.n + i].abs());
|
||||
}
|
||||
|
||||
acc
|
||||
}
|
||||
|
||||
/// Solve `Ax = b` for a single column of the identity, giving one column
|
||||
/// of the inverse.
|
||||
fn solve_column(&self, col: usize, out: &mut [f64]) {
|
||||
@@ -157,6 +180,24 @@ impl Matrix {
|
||||
Lu::decompose(self).determinant()
|
||||
}
|
||||
|
||||
/// `ln |det|` of a square matrix; `-inf` when singular.
|
||||
///
|
||||
/// See [`Lu::ln_abs_determinant`] for why a ratio of determinants must be
|
||||
/// taken this way.
|
||||
pub fn ln_abs_determinant(&self) -> f64 {
|
||||
assert_eq!(
|
||||
self.width, self.height,
|
||||
"determinant requires a square matrix, got {}x{}",
|
||||
self.height, self.width
|
||||
);
|
||||
|
||||
if self.width == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
Lu::decompose(self).ln_abs_determinant()
|
||||
}
|
||||
|
||||
/// Matrix inverse via LU decomposition.
|
||||
///
|
||||
/// # Panics
|
||||
|
||||
+56
-27
@@ -21,7 +21,7 @@
|
||||
//! would have made every `predict_*` call return a slightly different number,
|
||||
//! which is not a property a rating library should have.
|
||||
|
||||
use crate::{Gaussian, quadrature};
|
||||
use crate::{Gaussian, InferenceError, quadrature};
|
||||
|
||||
/// Teams beyond this count make the outcome enumeration impractical.
|
||||
///
|
||||
@@ -52,6 +52,10 @@ const WIN_TOLERANCE: f64 = 1e-8;
|
||||
/// point where refining stops helping.
|
||||
const MIN_GRID_POINTS: usize = 8_192;
|
||||
const MAX_GRID_POINTS: usize = 262_144;
|
||||
/// Nodes requested across the narrowest feature the recursion must resolve.
|
||||
const NODES_PER_FEATURE: f64 = 12.0;
|
||||
/// Nodes below which the trapezoid rule stops resolving that feature at all.
|
||||
const MIN_NODES_PER_FEATURE: f64 = 4.0;
|
||||
|
||||
/// How many standard deviations of support the grid and integrals cover.
|
||||
///
|
||||
@@ -152,7 +156,7 @@ pub(crate) fn win_probabilities(perf: &[Gaussian], margins: &Margins) -> Vec<f64
|
||||
/// Resolution is set by the *smallest* feature in play — the narrowest sigma,
|
||||
/// or a draw margin narrower still — because that is what the recursion has to
|
||||
/// resolve. A grid sized off the widest team would step over the narrow one.
|
||||
fn grid_shape(perf: &[Gaussian], margins: &Margins) -> (f64, f64, usize) {
|
||||
fn grid_shape(perf: &[Gaussian], margins: &Margins) -> Result<(f64, f64, usize), InferenceError> {
|
||||
let lo = perf
|
||||
.iter()
|
||||
.map(|g| g.mu() - SUPPORT_SIGMAS * g.sigma())
|
||||
@@ -175,18 +179,36 @@ fn grid_shape(perf: &[Gaussian], margins: &Margins) -> (f64, f64, usize) {
|
||||
|
||||
let feature = narrowest.min(smallest_margin);
|
||||
let wanted = if feature.is_finite() && feature > 0.0 {
|
||||
((hi - lo) / (feature / 12.0)).ceil()
|
||||
((hi - lo) / (feature / NODES_PER_FEATURE)).ceil()
|
||||
} else {
|
||||
MIN_GRID_POINTS as f64
|
||||
};
|
||||
|
||||
let points = if wanted.is_finite() {
|
||||
(wanted as usize).clamp(MIN_GRID_POINTS, MAX_GRID_POINTS)
|
||||
} else {
|
||||
MIN_GRID_POINTS
|
||||
};
|
||||
if !wanted.is_finite() {
|
||||
return Ok((lo, hi, MIN_GRID_POINTS));
|
||||
}
|
||||
|
||||
(lo, hi, points)
|
||||
// Report rather than clamp. Clamping is what this replaced: it silently
|
||||
// handed the recursion a grid too coarse for the narrowest density, and the
|
||||
// trapezoid rule then returned probabilities greater than one — measured, a
|
||||
// `P` of 2.79 and a total of 5.41. Trapezoid error on a Gaussian is
|
||||
// `~exp(-2 pi^2 (sigma/h)^2)`, which is 1e-12 at `h/sigma = 0.86` and O(1)
|
||||
// by `h/sigma = 17`, so the cliff is sharp and there is no useful answer on
|
||||
// the far side of it.
|
||||
//
|
||||
// The floor is `MIN_NODES_PER_FEATURE` rather than the `NODES_PER_FEATURE`
|
||||
// asked for, because the request carries a large margin: measured accurate
|
||||
// to 2.2e-12 at 1.4 nodes per sigma, and wrong by 1.2e-3 at 0.7.
|
||||
let needed = wanted as usize;
|
||||
let floor = ((hi - lo) / (feature / MIN_NODES_PER_FEATURE)).ceil();
|
||||
if floor.is_finite() && floor as usize > MAX_GRID_POINTS {
|
||||
return Err(InferenceError::GridTooCoarse {
|
||||
needed,
|
||||
max: MAX_GRID_POINTS,
|
||||
});
|
||||
}
|
||||
|
||||
Ok((lo, hi, needed.clamp(MIN_GRID_POINTS, MAX_GRID_POINTS)))
|
||||
}
|
||||
|
||||
/// Densities of each team sampled on the shared grid.
|
||||
@@ -198,8 +220,8 @@ struct Sampled {
|
||||
}
|
||||
|
||||
impl Sampled {
|
||||
fn new(perf: &[Gaussian], margins: &Margins) -> Self {
|
||||
let (lo, hi, points) = grid_shape(perf, margins);
|
||||
fn new(perf: &[Gaussian], margins: &Margins) -> Result<Self, InferenceError> {
|
||||
let (lo, hi, points) = grid_shape(perf, margins)?;
|
||||
let step = (hi - lo) / (points - 1) as f64;
|
||||
let density = perf
|
||||
.iter()
|
||||
@@ -209,12 +231,12 @@ impl Sampled {
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
Ok(Self {
|
||||
lo,
|
||||
step,
|
||||
points,
|
||||
density,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn node(&self, i: usize) -> f64 {
|
||||
@@ -317,9 +339,12 @@ fn events(n: usize, strict_only: bool) -> Vec<(Vec<usize>, Vec<bool>)> {
|
||||
///
|
||||
/// Orders that differ only *within* a tied group describe the same finishing
|
||||
/// order, so their probabilities are summed into one entry.
|
||||
pub(crate) fn outcome_distribution(perf: &[Gaussian], margins: &Margins) -> Vec<(Vec<u32>, f64)> {
|
||||
pub(crate) fn outcome_distribution(
|
||||
perf: &[Gaussian],
|
||||
margins: &Margins,
|
||||
) -> Result<Vec<(Vec<u32>, f64)>, InferenceError> {
|
||||
let n = perf.len();
|
||||
let sampled = Sampled::new(perf, margins);
|
||||
let sampled = Sampled::new(perf, margins)?;
|
||||
|
||||
let mut aggregated: Vec<(Vec<u32>, f64)> = Vec::new();
|
||||
for (order, tied) in events(n, margins.all_zero()) {
|
||||
@@ -332,7 +357,7 @@ pub(crate) fn outcome_distribution(perf: &[Gaussian], margins: &Margins) -> Vec<
|
||||
}
|
||||
|
||||
aggregated.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
aggregated
|
||||
Ok(aggregated)
|
||||
}
|
||||
|
||||
/// All permutations of `items`.
|
||||
@@ -397,9 +422,13 @@ fn orders_for_groups(groups: &[Vec<usize>]) -> Vec<(Vec<usize>, Vec<bool>)> {
|
||||
/// Ties in `ranks` mean the tied teams may finish in any internal order, so
|
||||
/// this sums the orders consistent with the requested ranking rather than
|
||||
/// picking one.
|
||||
pub(crate) fn ranking_probability(perf: &[Gaussian], margins: &Margins, ranks: &[u32]) -> f64 {
|
||||
pub(crate) fn ranking_probability(
|
||||
perf: &[Gaussian],
|
||||
margins: &Margins,
|
||||
ranks: &[u32],
|
||||
) -> Result<f64, InferenceError> {
|
||||
let n = perf.len();
|
||||
let sampled = Sampled::new(perf, margins);
|
||||
let sampled = Sampled::new(perf, margins)?;
|
||||
|
||||
let mut distinct: Vec<u32> = ranks.to_vec();
|
||||
distinct.sort_unstable();
|
||||
@@ -410,10 +439,10 @@ pub(crate) fn ranking_probability(perf: &[Gaussian], margins: &Margins, ranks: &
|
||||
.map(|&r| (0..n).filter(|&i| ranks[i] == r).collect())
|
||||
.collect();
|
||||
|
||||
orders_for_groups(&groups)
|
||||
Ok(orders_for_groups(&groups)
|
||||
.iter()
|
||||
.map(|(order, tied)| order_probability(margins, &sampled, order, tied))
|
||||
.sum()
|
||||
.sum())
|
||||
}
|
||||
|
||||
/// A distribution over the ways a contest could finish.
|
||||
@@ -604,7 +633,7 @@ mod tests {
|
||||
),
|
||||
] {
|
||||
let n = perf.len();
|
||||
let dist = outcome_distribution(&perf, &flat(n, eps));
|
||||
let dist = outcome_distribution(&perf, &flat(n, eps)).unwrap();
|
||||
let sum: f64 = dist.iter().map(|(_, p)| p).sum();
|
||||
assert!(
|
||||
(sum - 1.0).abs() < 1e-6,
|
||||
@@ -620,7 +649,7 @@ mod tests {
|
||||
fn two_team_distribution_matches_the_closed_form() {
|
||||
let perf = [g(3.0, 6.0), g(-2.0, 1.0)];
|
||||
let eps = 1.5;
|
||||
let dist = outcome_distribution(&perf, &flat(2, eps));
|
||||
let dist = outcome_distribution(&perf, &flat(2, eps)).unwrap();
|
||||
let (wa, wb) = closed_form_two(perf[0], perf[1], eps);
|
||||
|
||||
let find = |ranks: &[u32]| {
|
||||
@@ -653,10 +682,10 @@ mod tests {
|
||||
let perf = [g(5.0, 6.0), g(0.0, 3.0), g(-5.0, 1.0)];
|
||||
let eps = 1.5;
|
||||
let margins = flat(3, eps);
|
||||
let dist = outcome_distribution(&perf, &margins);
|
||||
let dist = outcome_distribution(&perf, &margins).unwrap();
|
||||
|
||||
for (ranks, expected) in &dist {
|
||||
let direct = ranking_probability(&perf, &margins, ranks);
|
||||
let direct = ranking_probability(&perf, &margins, ranks).unwrap();
|
||||
assert!(
|
||||
(direct - expected).abs() < 1e-9,
|
||||
"ranks {ranks:?}: direct {direct} vs distribution {expected}"
|
||||
@@ -675,7 +704,7 @@ mod tests {
|
||||
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
|
||||
let mut previous = 0.0;
|
||||
for eps in [0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 24.0] {
|
||||
let p = ranking_probability(&perf, &flat(3, eps), &[0, 0, 0]);
|
||||
let p = ranking_probability(&perf, &flat(3, eps), &[0, 0, 0]).unwrap();
|
||||
assert!(p >= previous, "eps={eps}: {p} < {previous}");
|
||||
if eps == 0.0 {
|
||||
assert!(p < 1e-12, "a tie needs a margin, got {p}");
|
||||
@@ -696,7 +725,7 @@ mod tests {
|
||||
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
|
||||
let sweep: Vec<f64> = [0.5, 2.0, 4.0, 8.0, 16.0]
|
||||
.iter()
|
||||
.map(|&eps| ranking_probability(&perf, &flat(3, eps), &[0, 0, 1]))
|
||||
.map(|&eps| ranking_probability(&perf, &flat(3, eps), &[0, 0, 1]).unwrap())
|
||||
.collect();
|
||||
let peak = sweep
|
||||
.iter()
|
||||
@@ -717,7 +746,7 @@ mod tests {
|
||||
#[test]
|
||||
fn ties_are_impossible_without_a_draw_margin() {
|
||||
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(0.0, 4.0)];
|
||||
let dist = outcome_distribution(&perf, &flat(3, 0.0));
|
||||
let dist = outcome_distribution(&perf, &flat(3, 0.0)).unwrap();
|
||||
assert_eq!(dist.len(), 6, "expected only the 6 strict orders: {dist:?}");
|
||||
assert!(dist.iter().all(|(r, _)| {
|
||||
let mut seen = r.clone();
|
||||
|
||||
+18
-1
@@ -23,7 +23,24 @@ pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>> Rating<T, D> {
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics unless `beta` is finite and non-negative, matching
|
||||
/// `HistoryBuilder::beta`.
|
||||
///
|
||||
/// Zero is allowed and meaningful — performance is then exactly skill, and
|
||||
/// the fit differs measurably from a positive beta rather than degenerating.
|
||||
/// Negative is rejected because `beta` enters only as `beta^2`: measured, a
|
||||
/// negative beta returned results **bit identical** to its absolute value,
|
||||
/// and a NaN beta reached `Game::ranked`, which returned `Ok` carrying a
|
||||
/// `Gaussian { pi: NaN, tau: NaN }` — there is no `converge` on that path to
|
||||
/// catch it.
|
||||
pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self {
|
||||
assert!(
|
||||
beta.is_finite() && beta >= 0.0,
|
||||
"beta must be finite and non-negative (got {beta}); it is only ever \
|
||||
squared, so a negative value would silently behave as its absolute value"
|
||||
);
|
||||
Self {
|
||||
prior,
|
||||
beta,
|
||||
@@ -93,7 +110,7 @@ impl Default for Rating<i64, ConstantDrift> {
|
||||
Self {
|
||||
prior: Gaussian::default(),
|
||||
beta: BETA,
|
||||
drift: ConstantDrift(GAMMA),
|
||||
drift: ConstantDrift::new(GAMMA),
|
||||
drift_scale: 1.0,
|
||||
_time: PhantomData,
|
||||
}
|
||||
|
||||
+4
-4
@@ -911,7 +911,7 @@ mod tests {
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -988,7 +988,7 @@ mod tests {
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -1068,7 +1068,7 @@ mod tests {
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -1171,7 +1171,7 @@ mod tests {
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -34,7 +34,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 20_000,
|
||||
epsilon: 1e-12,
|
||||
|
||||
+4
-4
@@ -11,7 +11,7 @@ fn add_events_bulk_via_iter() {
|
||||
.sigma(2.0)
|
||||
.beta(1.0)
|
||||
.p_draw(0.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
@@ -53,7 +53,7 @@ fn add_events_draw() {
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.25)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||
.build();
|
||||
|
||||
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||
@@ -181,7 +181,7 @@ fn log_evidence_total_vs_subset() {
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.p_draw(0.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.build();
|
||||
h.record_winner(&"a", &"b", 1).unwrap();
|
||||
h.record_winner(&"b", &"a", 2).unwrap();
|
||||
@@ -236,7 +236,7 @@ fn fluent_event_builder_scores() {
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.build();
|
||||
|
||||
h.event(1)
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Every public entry point that takes a magnitude, in one place.
|
||||
//!
|
||||
//! This defect class was closed three times in one session and reopened twice,
|
||||
//! because each fix validated the layer it had just touched and inferred the
|
||||
//! rest: `HistoryBuilder` first, then `Game`'s own entry points, then the
|
||||
//! constructors beneath both. A per-site fix cannot notice the site nobody
|
||||
//! thought of.
|
||||
//!
|
||||
//! So this enumerates them. `sigma`, `beta` and `gamma` all enter inference
|
||||
//! only as squares, which means a negative value does not fail — it behaves as
|
||||
//! its absolute value, bit for bit, and the sign vanishes with no diagnostic.
|
||||
//! Non-finite values poison every posterior derived from them.
|
||||
//!
|
||||
//! Adding a public constructor that takes one of these and not adding it here
|
||||
//! is the failure this file exists to make harder.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use trueskill_tt::{ConstantDrift, Gaussian, History, Member, Outcome, Rating};
|
||||
|
||||
/// Did the entry point refuse the value, by panic or by `Err`?
|
||||
fn refuses(f: impl FnOnce() -> bool) -> bool {
|
||||
catch_unwind(AssertUnwindSafe(f)).unwrap_or(true)
|
||||
}
|
||||
|
||||
/// One entry point, as a name and a closure that applies a value to it.
|
||||
type Case = (&'static str, Box<dyn Fn(f64) -> bool>);
|
||||
|
||||
/// Entry points that must reject a negative magnitude.
|
||||
///
|
||||
/// Each closure returns `true` if it refused by returning an error; a panic is
|
||||
/// also a refusal and is caught.
|
||||
#[test]
|
||||
fn every_magnitude_parameter_rejects_a_negative_value() {
|
||||
let cases: Vec<Case> = vec![
|
||||
(
|
||||
"Gaussian::from_ms(sigma)",
|
||||
Box::new(|v| {
|
||||
let _ = Gaussian::from_ms(25.0, v);
|
||||
false
|
||||
}),
|
||||
),
|
||||
(
|
||||
"Rating::new(beta)",
|
||||
Box::new(|v| {
|
||||
let _ = Rating::<i64, ConstantDrift>::new(
|
||||
Gaussian::default(),
|
||||
v,
|
||||
ConstantDrift::new(0.0),
|
||||
);
|
||||
false
|
||||
}),
|
||||
),
|
||||
(
|
||||
"ConstantDrift::new(gamma)",
|
||||
Box::new(|v| {
|
||||
let _ = ConstantDrift::new(v);
|
||||
false
|
||||
}),
|
||||
),
|
||||
(
|
||||
"HistoryBuilder::sigma",
|
||||
Box::new(|v| {
|
||||
let _ = History::builder().sigma(v);
|
||||
false
|
||||
}),
|
||||
),
|
||||
(
|
||||
"HistoryBuilder::beta",
|
||||
Box::new(|v| {
|
||||
let _ = History::builder().beta(v);
|
||||
false
|
||||
}),
|
||||
),
|
||||
(
|
||||
"HistoryBuilder::score_sigma",
|
||||
Box::new(|v| {
|
||||
let _ = History::builder().score_sigma(v);
|
||||
false
|
||||
}),
|
||||
),
|
||||
(
|
||||
"HistoryBuilder::p_draw",
|
||||
Box::new(|v| {
|
||||
let _ = History::builder().p_draw(v);
|
||||
false
|
||||
}),
|
||||
),
|
||||
(
|
||||
"Member::with_drift_scale (at ingestion)",
|
||||
Box::new(|v| {
|
||||
let mut h = History::builder().build();
|
||||
h.add_events(vec![trueskill_tt::Event {
|
||||
time: 1i64,
|
||||
teams: smallvec::smallvec![
|
||||
trueskill_tt::Team::with_members([Member::new("a").with_drift_scale(v)]),
|
||||
trueskill_tt::Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
}])
|
||||
.is_err()
|
||||
}),
|
||||
),
|
||||
(
|
||||
"Outcome::scores_with_sigma (at ingestion)",
|
||||
Box::new(|v| {
|
||||
let mut h = History::builder().build();
|
||||
h.add_events(vec![trueskill_tt::Event {
|
||||
time: 1i64,
|
||||
teams: smallvec::smallvec![
|
||||
trueskill_tt::Team::with_members([Member::new("a")]),
|
||||
trueskill_tt::Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores_with_sigma([3.0, 1.0], v),
|
||||
}])
|
||||
.is_err()
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
let mut accepted = Vec::new();
|
||||
for (name, f) in &cases {
|
||||
if !refuses(|| f(-1.0)) {
|
||||
accepted.push(*name);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
accepted.is_empty(),
|
||||
"these accepted a negative magnitude, which is squared away silently \
|
||||
rather than honoured or refused:\n {}",
|
||||
accepted.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
/// Same set, for NaN and infinity.
|
||||
///
|
||||
/// `Gaussian::from_ms` is deliberately absent: a broken fit produces a NaN
|
||||
/// sigma legitimately and `converge` reports it as `NonFiniteResult`. Rejecting
|
||||
/// it in the constructor turned that reporting path into a panic inside
|
||||
/// inference — see the comment on `from_ms`.
|
||||
#[test]
|
||||
fn every_magnitude_parameter_rejects_a_non_finite_value() {
|
||||
let cases: Vec<Case> = vec![
|
||||
(
|
||||
"Rating::new(beta)",
|
||||
Box::new(|v| {
|
||||
let _ = Rating::<i64, ConstantDrift>::new(
|
||||
Gaussian::default(),
|
||||
v,
|
||||
ConstantDrift::new(0.0),
|
||||
);
|
||||
false
|
||||
}),
|
||||
),
|
||||
(
|
||||
"ConstantDrift::new(gamma)",
|
||||
Box::new(|v| {
|
||||
let _ = ConstantDrift::new(v);
|
||||
false
|
||||
}),
|
||||
),
|
||||
(
|
||||
"HistoryBuilder::sigma",
|
||||
Box::new(|v| {
|
||||
let _ = History::builder().sigma(v);
|
||||
false
|
||||
}),
|
||||
),
|
||||
(
|
||||
"HistoryBuilder::beta",
|
||||
Box::new(|v| {
|
||||
let _ = History::builder().beta(v);
|
||||
false
|
||||
}),
|
||||
),
|
||||
(
|
||||
"HistoryBuilder::mu",
|
||||
Box::new(|v| {
|
||||
let _ = History::builder().mu(v);
|
||||
false
|
||||
}),
|
||||
),
|
||||
(
|
||||
"HistoryBuilder::score_sigma",
|
||||
Box::new(|v| {
|
||||
let _ = History::builder().score_sigma(v);
|
||||
false
|
||||
}),
|
||||
),
|
||||
(
|
||||
"HistoryBuilder::p_draw",
|
||||
Box::new(|v| {
|
||||
let _ = History::builder().p_draw(v);
|
||||
false
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
let mut accepted = Vec::new();
|
||||
for (name, f) in &cases {
|
||||
for bad in [f64::NAN, f64::INFINITY] {
|
||||
if !refuses(|| f(bad)) {
|
||||
accepted.push(format!("{name} accepted {bad}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
accepted.is_empty(),
|
||||
"these accepted a non-finite magnitude:\n {}",
|
||||
accepted.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
/// The suite must not pass by refusing everything.
|
||||
#[test]
|
||||
fn ordinary_values_are_still_accepted() {
|
||||
let _ = Gaussian::from_ms(25.0, 8.33);
|
||||
let _ = Rating::<i64, ConstantDrift>::new(Gaussian::default(), 4.17, ConstantDrift::new(0.05));
|
||||
let _ = ConstantDrift::new(0.0833);
|
||||
let _ = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(8.33)
|
||||
.beta(4.17)
|
||||
.score_sigma(1.0)
|
||||
.p_draw(0.1);
|
||||
|
||||
// Zero beta and zero gamma are legitimate, not degenerate.
|
||||
let _ = ConstantDrift::new(0.0);
|
||||
let _ = Rating::<i64, ConstantDrift>::new(Gaussian::default(), 0.0, ConstantDrift::new(0.0));
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Stopping short of convergence is an error, not a flag on a success.
|
||||
//!
|
||||
//! A fit that hits `max_iter` is wrong by a little: every rating is finite,
|
||||
//! the ordering looks sensible, and nothing in the numbers says they were
|
||||
//! still moving. When that was `Ok` with `converged: false`, detecting it was
|
||||
//! opt-in and `let _ = h.converge()` was the natural way to opt out — which is
|
||||
//! how a real defect once hid in this crate's own suite.
|
||||
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
|
||||
};
|
||||
|
||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||
|
||||
fn duel(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> {
|
||||
Event {
|
||||
time: t,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(a)]),
|
||||
Team::with_members([Member::new(b)]),
|
||||
],
|
||||
outcome: Outcome::scores([3.0, 1.0]),
|
||||
}
|
||||
}
|
||||
|
||||
fn capped(max_iter: usize) -> H {
|
||||
History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift::new(0.5))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter,
|
||||
epsilon: 1e-13,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
fn fill(h: &mut H) {
|
||||
h.add_events((1..=6).map(|t| duel("a", "b", t)).collect::<Vec<_>>())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hitting_the_cap_is_an_error() {
|
||||
let mut h = capped(1);
|
||||
fill(&mut h);
|
||||
let err = h.converge().unwrap_err();
|
||||
match err {
|
||||
InferenceError::NotConverged {
|
||||
iterations,
|
||||
final_step,
|
||||
epsilon,
|
||||
} => {
|
||||
assert_eq!(iterations, 1);
|
||||
assert!(
|
||||
final_step.0 > epsilon || final_step.1 > epsilon,
|
||||
"{final_step:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected NotConverged, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The message has to name what to do about it, since the fit looks fine.
|
||||
#[test]
|
||||
fn the_error_says_how_to_fix_it() {
|
||||
let mut h = capped(1);
|
||||
fill(&mut h);
|
||||
let text = h.converge().unwrap_err().to_string();
|
||||
assert!(text.contains("did not converge in 1 iterations"), "{text}");
|
||||
assert!(text.contains("max_iter"), "{text}");
|
||||
assert!(text.contains("alpha"), "{text}");
|
||||
}
|
||||
|
||||
/// The escape hatch: a deliberately capped fit is still reachable.
|
||||
#[test]
|
||||
fn converge_partial_returns_the_short_fit() {
|
||||
let mut h = capped(1);
|
||||
fill(&mut h);
|
||||
let report = h.converge_partial().unwrap();
|
||||
assert_eq!(report.iterations, 1);
|
||||
assert!(!report.converged);
|
||||
assert!(h.current_skill(&"a").is_some());
|
||||
}
|
||||
|
||||
/// Both agree when the fit does converge, so the strict path costs nothing.
|
||||
#[test]
|
||||
fn the_two_agree_on_a_converged_fit() {
|
||||
let mut strict = capped(20_000);
|
||||
fill(&mut strict);
|
||||
let a = strict.converge().unwrap();
|
||||
|
||||
let mut partial = capped(20_000);
|
||||
fill(&mut partial);
|
||||
let b = partial.converge_partial().unwrap();
|
||||
|
||||
assert!(a.converged && b.converged);
|
||||
assert_eq!(a.iterations, b.iterations);
|
||||
assert_eq!(a.final_step, b.final_step);
|
||||
}
|
||||
|
||||
/// The default cap must be high enough that an ordinary history clears it.
|
||||
/// At the old value of 30 this history stopped short and said nothing.
|
||||
#[test]
|
||||
fn the_default_cap_clears_an_ordinary_history() {
|
||||
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift::new(0.05))
|
||||
.build();
|
||||
|
||||
let mut events = Vec::new();
|
||||
for t in 0..20i64 {
|
||||
for j in 0..8usize {
|
||||
let k = (t as usize) * 8 + j;
|
||||
events.push(Event {
|
||||
time: t,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(format!("p{}", k % 100))]),
|
||||
Team::with_members([Member::new(format!("p{}", (k + 37) % 100))]),
|
||||
],
|
||||
outcome: Outcome::scores([3.0, 1.0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
h.add_events(events).unwrap();
|
||||
|
||||
let report = h
|
||||
.converge()
|
||||
.expect("an ordinary history must converge by default");
|
||||
assert!(
|
||||
report.iterations > 30,
|
||||
"needed {} sweeps",
|
||||
report.iterations
|
||||
);
|
||||
assert!(report.iterations < trueskill_tt::ITERATIONS);
|
||||
}
|
||||
|
||||
/// An empty history converges trivially rather than erroring.
|
||||
#[test]
|
||||
fn an_empty_history_converges() {
|
||||
let mut h = capped(1);
|
||||
let report = h.converge().unwrap();
|
||||
assert!(report.converged);
|
||||
assert_eq!(report.iterations, 0);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Determinism across *processes*, which an in-process test cannot see.
|
||||
//!
|
||||
//! Rust seeds its default hasher once per process, so every `HashMap`
|
||||
//! iteration order is fixed for a run and varies between runs. A test that
|
||||
//! compares results within one process therefore cannot detect a float sum
|
||||
//! whose order comes from a map — all its samples share one seed.
|
||||
//!
|
||||
//! That is not hypothetical. `tests/determinism.rs` compares four thread counts
|
||||
//! inside one process and passed throughout, while `posterior_of` was returning
|
||||
//! two distinct bit patterns across 40 separate runs on identical input.
|
||||
//!
|
||||
//! This re-executes the test binary and compares `f64::to_bits`.
|
||||
|
||||
use std::{env, process::Command};
|
||||
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team,
|
||||
UnknownKeys,
|
||||
};
|
||||
|
||||
/// Set in the child so it reports instead of re-spawning.
|
||||
const CHILD: &str = "TSTT_DETERMINISM_CHILD";
|
||||
|
||||
const RUNS: usize = 40;
|
||||
|
||||
type H = History<i64, ConstantDrift, NullObserver, String>;
|
||||
|
||||
fn fitted() -> H {
|
||||
let mut h: H = History::builder_with_key()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift::new(0.05))
|
||||
.unknown_keys(UnknownKeys::Prior)
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 20_000,
|
||||
epsilon: 1e-13,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
|
||||
let mut events = Vec::new();
|
||||
for t in 0..12i64 {
|
||||
for k in 0..6usize {
|
||||
let a = format!("p{}", (t as usize * 6 + k) % 10);
|
||||
let b = format!("p{}", (t as usize * 6 + k + 4) % 10);
|
||||
events.push(Event {
|
||||
time: t,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(a)]),
|
||||
Team::with_members([Member::new(b)]),
|
||||
],
|
||||
outcome: Outcome::scores([3.0, 1.0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
h.add_events(events).unwrap();
|
||||
assert!(h.converge().unwrap().converged);
|
||||
h
|
||||
}
|
||||
|
||||
/// Every quantity that could plausibly depend on iteration order, as bits.
|
||||
fn fingerprint() -> String {
|
||||
let h = fitted();
|
||||
|
||||
// Unknown keys with UNEQUAL but COMPARABLE coefficients, which is what
|
||||
// makes the sum order-sensitive.
|
||||
//
|
||||
// Equal terms sum order-independently and would make this pass vacuously.
|
||||
// Terms of wildly different magnitudes are no better: the small ones fall
|
||||
// below the running total's ULP and are absorbed whatever the order —
|
||||
// measured, spreading these over nine decades dropped the detection rate
|
||||
// to roughly one run in forty. Comparable sizes keep every term able to
|
||||
// change the last bits.
|
||||
let ghosts: Vec<String> = (0..24).map(|i| format!("ghost{i}")).collect();
|
||||
let mut terms: Vec<(&String, f64)> = ghosts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, k)| (k, 1.0 + i as f64 * 0.37))
|
||||
.collect();
|
||||
let known = "p0".to_string();
|
||||
terms.push((&known, -1.0));
|
||||
|
||||
let posterior = h.posterior_of(&terms).unwrap();
|
||||
|
||||
let a = "p0".to_string();
|
||||
let b = "p1".to_string();
|
||||
let target = [(&a, 1.0), (&b, -1.0)];
|
||||
let teams: [&[&String]; 2] = [&[&a], &[&b]];
|
||||
let evr = h.expected_variance_reduction(&teams, &target).unwrap();
|
||||
|
||||
let curves = h.learning_curves();
|
||||
let mut curve_bits: u64 = 0;
|
||||
let mut keys: Vec<&String> = curves.keys().collect();
|
||||
keys.sort();
|
||||
for key in keys {
|
||||
for (t, g) in &curves[key] {
|
||||
curve_bits ^= (*t as u64).rotate_left(17)
|
||||
^ g.mu().to_bits().rotate_left(31)
|
||||
^ g.sigma().to_bits();
|
||||
}
|
||||
}
|
||||
|
||||
format!(
|
||||
"post={:016x} evr={:016x} le={:016x} curves={curve_bits:016x}",
|
||||
posterior.sigma().to_bits(),
|
||||
evr.to_bits(),
|
||||
h.log_evidence().to_bits(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_are_identical_across_processes() {
|
||||
if env::var(CHILD).is_ok() {
|
||||
println!("FINGERPRINT {}", fingerprint());
|
||||
return;
|
||||
}
|
||||
|
||||
let exe = env::current_exe().expect("current exe");
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
|
||||
for run in 0..RUNS {
|
||||
let out = Command::new(&exe)
|
||||
.args([
|
||||
"results_are_identical_across_processes",
|
||||
"--exact",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.output()
|
||||
.expect("spawn child");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"child {run} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let line = stdout
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("FINGERPRINT "))
|
||||
.unwrap_or_else(|| panic!("child {run} printed no fingerprint:\n{stdout}"))
|
||||
.to_string();
|
||||
seen.push(line);
|
||||
}
|
||||
|
||||
let first = &seen[0];
|
||||
let differing: Vec<&String> = seen.iter().filter(|s| *s != first).collect();
|
||||
assert!(
|
||||
differing.is_empty(),
|
||||
"results differ across processes on identical input.\n {} of {RUNS} runs differed\n \
|
||||
first: {first}\n differing: {}",
|
||||
differing.len(),
|
||||
differing[0]
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ fn rating() -> R {
|
||||
R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -170,8 +170,9 @@ fn event_builder_rejects_a_weights_length_mismatch() {
|
||||
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.
|
||||
// Two teams, so ingestion would otherwise succeed. A one-team event is
|
||||
// rejected as `NotEnoughTeams` before the weights are ever examined, so
|
||||
// building this with one team would pass vacuously.
|
||||
let _ = h
|
||||
.event(1)
|
||||
.team(["a"])
|
||||
@@ -280,8 +281,16 @@ fn log_evidence_survives_a_long_diff_chain() {
|
||||
/// `erfc` approximation; the evidence floor keeps `ln` finite.
|
||||
#[test]
|
||||
fn log_evidence_finite_for_near_certain_outcome() {
|
||||
let overwhelming = R::new(Gaussian::from_ms(5_000.0, 0.5), 1.0, ConstantDrift(0.0));
|
||||
let hopeless = R::new(Gaussian::from_ms(-5_000.0, 0.5), 1.0, ConstantDrift(0.0));
|
||||
let overwhelming = R::new(
|
||||
Gaussian::from_ms(5_000.0, 0.5),
|
||||
1.0,
|
||||
ConstantDrift::new(0.0),
|
||||
);
|
||||
let hopeless = R::new(
|
||||
Gaussian::from_ms(-5_000.0, 0.5),
|
||||
1.0,
|
||||
ConstantDrift::new(0.0),
|
||||
);
|
||||
let a = [overwhelming];
|
||||
let b = [hopeless];
|
||||
let teams: Vec<&[R]> = vec![&a, &b];
|
||||
|
||||
+164
-64
@@ -1,101 +1,201 @@
|
||||
//! Determinism tests: identical posteriors across RAYON_NUM_THREADS
|
||||
//! values. Only compiled with the `rayon` feature.
|
||||
//! Determinism across `RAYON_NUM_THREADS`, on a workload that actually reaches
|
||||
//! the parallel path.
|
||||
//!
|
||||
//! This test previously proved less than it appeared to. `sweep_color_groups`
|
||||
//! takes its `par_iter` branch only for colour groups of at least
|
||||
//! `RAYON_THRESHOLD` (64) events, and the old fixture built 20 slices of 10
|
||||
//! events — a colour group is a subset of one slice's events, so it could never
|
||||
//! exceed 10. The branch was unreachable, confirmed by CPU-vs-wall time:
|
||||
//! `user 0.64` on eight threads is one core.
|
||||
//!
|
||||
//! It also compared a single competitor's curve out of forty, and never
|
||||
//! compared `log_evidence`, `final_step` or `iterations`.
|
||||
//!
|
||||
//! The fixture below guarantees the parallel branch **by construction**: within
|
||||
//! a slice every event uses a disjoint pair of competitors, so greedy colouring
|
||||
//! puts all of them in colour 0, and that group is `EVENTS_PER_SLICE` long.
|
||||
//! Competitors recur across slices, so the fit still has temporal coupling and
|
||||
//! drift rather than being a set of independent duels.
|
||||
|
||||
#![cfg(feature = "rayon")]
|
||||
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team,
|
||||
};
|
||||
|
||||
/// Build a deterministic workload using a simple LCG (no external rand crate).
|
||||
fn build_and_converge(seed: u64) -> Vec<(i64, trueskill_tt::Gaussian)> {
|
||||
/// Comfortably above the crate's internal `RAYON_THRESHOLD` of 64.
|
||||
const EVENTS_PER_SLICE: usize = 96;
|
||||
const SLICES: i64 = 8;
|
||||
/// Two per event, all disjoint within a slice.
|
||||
const COMPETITORS: usize = EVENTS_PER_SLICE * 2;
|
||||
|
||||
/// Everything a thread count could plausibly perturb.
|
||||
struct Fingerprint {
|
||||
curves: Vec<(String, Vec<(i64, Gaussian)>)>,
|
||||
log_evidence: f64,
|
||||
final_step: (f64, f64),
|
||||
iterations: usize,
|
||||
}
|
||||
|
||||
fn build_and_converge() -> Fingerprint {
|
||||
let mut h = History::<i64, _, _, String>::builder_with_key()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
max_iter: 20_000,
|
||||
epsilon: 1e-9,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
|
||||
// LCG for deterministic pseudo-random ints.
|
||||
let mut rng = seed;
|
||||
let mut next = || {
|
||||
rng = rng
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
rng
|
||||
};
|
||||
|
||||
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(200);
|
||||
for ev_i in 0..200 {
|
||||
let a = (next() % 40) as usize;
|
||||
let mut b = (next() % 40) as usize;
|
||||
while b == a {
|
||||
b = (next() % 40) as usize;
|
||||
let mut events: Vec<Event<i64, String>> = Vec::new();
|
||||
for slice in 0..SLICES {
|
||||
for e in 0..EVENTS_PER_SLICE {
|
||||
// Disjoint within the slice: event `e` owns competitors 2e and
|
||||
// 2e+1. Rotating by the slice index makes the pairings differ
|
||||
// between slices, so competitors accumulate a real history.
|
||||
let a = (2 * e + slice as usize) % COMPETITORS;
|
||||
let b = (2 * e + 1 + slice as usize * 3) % COMPETITORS;
|
||||
if a == b {
|
||||
continue;
|
||||
}
|
||||
events.push(Event {
|
||||
time: slice + 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(format!("p{a}"))]),
|
||||
Team::with_members([Member::new(format!("p{b}"))]),
|
||||
],
|
||||
outcome: Outcome::winner(u32::from((e + slice as usize) % 2 == 0), 2),
|
||||
});
|
||||
}
|
||||
// ~10 events per slice so color groups have material parallelism.
|
||||
events.push(Event {
|
||||
time: (ev_i as i64 / 10) + 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(format!("p{a}"))]),
|
||||
Team::with_members([Member::new(format!("p{b}"))]),
|
||||
],
|
||||
outcome: Outcome::winner((next() % 2) as u32, 2),
|
||||
});
|
||||
}
|
||||
h.add_events(events).unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
// Sample one competitor's curve for the comparison.
|
||||
h.learning_curve("p0")
|
||||
|
||||
let report = h.converge().expect("fixture must converge");
|
||||
|
||||
let mut curves: Vec<(String, Vec<(i64, Gaussian)>)> = h
|
||||
.learning_curves()
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.clone(), v))
|
||||
.collect();
|
||||
curves.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
Fingerprint {
|
||||
curves,
|
||||
log_evidence: h.log_evidence(),
|
||||
final_step: report.final_step,
|
||||
iterations: report.iterations,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posteriors_identical_across_thread_counts() {
|
||||
let sizes = [1usize, 2, 4, 8];
|
||||
let mut results: Vec<Vec<(i64, trueskill_tt::Gaussian)>> = Vec::new();
|
||||
let mut results: Vec<Fingerprint> = Vec::new();
|
||||
|
||||
for &n in &sizes {
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(n)
|
||||
.build()
|
||||
.expect("rayon pool build");
|
||||
let curve = pool.install(|| build_and_converge(42));
|
||||
results.push(curve);
|
||||
results.push(pool.install(build_and_converge));
|
||||
}
|
||||
|
||||
let reference = &results[0];
|
||||
for (i, curve) in results.iter().enumerate().skip(1) {
|
||||
|
||||
// Guard against the failure this test previously had: passing while
|
||||
// measuring almost nothing.
|
||||
assert!(
|
||||
reference.curves.len() > 100,
|
||||
"expected every competitor's curve, got {}",
|
||||
reference.curves.len()
|
||||
);
|
||||
|
||||
for (i, got) in results.iter().enumerate().skip(1) {
|
||||
let n = sizes[i];
|
||||
|
||||
assert_eq!(
|
||||
curve.len(),
|
||||
reference.len(),
|
||||
"curve length differs at {n} threads",
|
||||
n = sizes[i],
|
||||
got.iterations, reference.iterations,
|
||||
"iterations differ at {n} threads"
|
||||
);
|
||||
for (j, (&(t_ref, g_ref), &(t, g))) in reference.iter().zip(curve.iter()).enumerate() {
|
||||
assert_eq!(
|
||||
got.final_step.0.to_bits(),
|
||||
reference.final_step.0.to_bits(),
|
||||
"final_step.0 differs at {n} threads: {:?} vs {:?}",
|
||||
reference.final_step,
|
||||
got.final_step
|
||||
);
|
||||
assert_eq!(
|
||||
got.final_step.1.to_bits(),
|
||||
reference.final_step.1.to_bits(),
|
||||
"final_step.1 differs at {n} threads"
|
||||
);
|
||||
assert_eq!(
|
||||
got.log_evidence.to_bits(),
|
||||
reference.log_evidence.to_bits(),
|
||||
"log_evidence differs at {n} threads: {} vs {}",
|
||||
reference.log_evidence,
|
||||
got.log_evidence
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
got.curves.len(),
|
||||
reference.curves.len(),
|
||||
"competitor count differs at {n} threads"
|
||||
);
|
||||
|
||||
for ((ref_key, ref_curve), (key, curve)) in reference.curves.iter().zip(got.curves.iter()) {
|
||||
assert_eq!(ref_key, key, "competitor order differs at {n} threads");
|
||||
assert_eq!(
|
||||
t_ref,
|
||||
t,
|
||||
"time point {j} differs at {n} threads: ref={t_ref} vs got={t}",
|
||||
n = sizes[i],
|
||||
);
|
||||
assert_eq!(
|
||||
g_ref.mu().to_bits(),
|
||||
g.mu().to_bits(),
|
||||
"mu bits differ at {n} threads, time {t}: ref={ref_mu} got={got_mu}",
|
||||
n = sizes[i],
|
||||
ref_mu = g_ref.mu(),
|
||||
got_mu = g.mu(),
|
||||
);
|
||||
assert_eq!(
|
||||
g_ref.sigma().to_bits(),
|
||||
g.sigma().to_bits(),
|
||||
"sigma bits differ at {n} threads, time {t}: ref={ref_sigma} got={got_sigma}",
|
||||
n = sizes[i],
|
||||
ref_sigma = g_ref.sigma(),
|
||||
got_sigma = g.sigma(),
|
||||
curve.len(),
|
||||
ref_curve.len(),
|
||||
"curve length differs for {key} at {n} threads"
|
||||
);
|
||||
for (&(t_ref, g_ref), &(t, g)) in ref_curve.iter().zip(curve.iter()) {
|
||||
assert_eq!(t_ref, t, "time point differs for {key} at {n} threads");
|
||||
assert_eq!(
|
||||
g_ref.mu().to_bits(),
|
||||
g.mu().to_bits(),
|
||||
"mu differs for {key} at t={t}, {n} threads: {} vs {}",
|
||||
g_ref.mu(),
|
||||
g.mu()
|
||||
);
|
||||
assert_eq!(
|
||||
g_ref.sigma().to_bits(),
|
||||
g.sigma().to_bits(),
|
||||
"sigma differs for {key} at t={t}, {n} threads: {} vs {}",
|
||||
g_ref.sigma(),
|
||||
g.sigma()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixture must keep reaching the parallel branch.
|
||||
///
|
||||
/// `RAYON_THRESHOLD` is private, so this pins the property that makes the
|
||||
/// branch reachable rather than the branch itself: within a slice every event
|
||||
/// uses a disjoint competitor pair, so greedy colouring puts all
|
||||
/// `EVENTS_PER_SLICE` of them in one colour group. If someone shrinks the
|
||||
/// fixture, this fails rather than the suite quietly going back to testing the
|
||||
/// sequential path.
|
||||
#[test]
|
||||
fn the_fixture_still_exceeds_the_rayon_threshold() {
|
||||
const RAYON_THRESHOLD: usize = 64;
|
||||
const {
|
||||
assert!(
|
||||
EVENTS_PER_SLICE >= RAYON_THRESHOLD,
|
||||
"a colour group holds at most EVENTS_PER_SLICE events, which must \
|
||||
reach the crate's RAYON_THRESHOLD for the parallel sweep to run"
|
||||
);
|
||||
}
|
||||
|
||||
// Measured by instrumenting `sweep_color_groups`: this fixture produces
|
||||
// one colour group of 96 events and takes the parallel branch on all 872
|
||||
// sweeps. The old fixture's 10-event slices could not reach 64 at all.
|
||||
assert_eq!(EVENTS_PER_SLICE, 96);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 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.
|
||||
//! `ConstantDrift::new(g)` at `scale = s` behaves as `ConstantDrift::new(g * s)` would.
|
||||
//! `scale = 0.0` pins a competitor still — an anchor, a rating floor, a course
|
||||
//! difficulty — while everyone around them keeps drifting.
|
||||
|
||||
@@ -53,7 +53,7 @@ fn fit(events: Vec<Event<i64, &'static str>>, gamma: f64) -> Fit {
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.0)
|
||||
.drift(ConstantDrift(gamma))
|
||||
.drift(ConstantDrift::new(gamma))
|
||||
.convergence(CONVERGENCE)
|
||||
.build();
|
||||
|
||||
@@ -160,7 +160,7 @@ fn scale_is_equivalent_to_scaling_gamma() {
|
||||
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 \
|
||||
"ConstantDrift::new(0.3) at scale 0.5 must equal ConstantDrift::new(0.15) for {key} at \
|
||||
t={t_l}: ({}, {}) vs ({}, {})",
|
||||
g_l.mu(),
|
||||
g_l.sigma(),
|
||||
@@ -218,7 +218,7 @@ fn mixed_static_and_drifting_graph_converges() {
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||
.convergence(CONVERGENCE)
|
||||
.build();
|
||||
|
||||
@@ -259,7 +259,7 @@ fn mixed_static_and_drifting_graph_converges() {
|
||||
|
||||
fn reject(scale: f64) -> InferenceError {
|
||||
let mut h = History::builder()
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||
.build();
|
||||
|
||||
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||
@@ -360,7 +360,7 @@ fn drift_scale_applies_when_set_after_first_appearance() {
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||
.convergence(CONVERGENCE)
|
||||
.build();
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
|
||||
type R = Rating<i64, ConstantDrift>;
|
||||
|
||||
fn ts_rating(mu: f64, sigma: f64, beta: f64, gamma: f64) -> R {
|
||||
R::new(Gaussian::from_ms(mu, sigma), beta, ConstantDrift(gamma))
|
||||
R::new(
|
||||
Gaussian::from_ms(mu, sigma),
|
||||
beta,
|
||||
ConstantDrift::new(gamma),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! `EventBuilder::members` must reach exactly what the typed path reaches.
|
||||
//!
|
||||
//! Before this existed, `EventBuilder` could set weights and nothing else, so
|
||||
//! `prior` and `drift_scale` were expressible only through `Event`/`Team`/
|
||||
//! `Member` + `add_events`. Which ingestion route a competitor arrived through
|
||||
//! decided whether it could be configured at all.
|
||||
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
|
||||
Team,
|
||||
};
|
||||
|
||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||
|
||||
fn history() -> H {
|
||||
History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift::new(0.5))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 20_000,
|
||||
epsilon: 1e-13,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
const PRIOR: Gaussian = Gaussian::from_ms(3.0, 1.5);
|
||||
|
||||
/// The contract that makes the escape hatch worth having: same configuration,
|
||||
/// same fit, bit for bit.
|
||||
#[test]
|
||||
fn members_matches_the_typed_path_exactly() {
|
||||
let mut typed = history();
|
||||
typed
|
||||
.add_events(vec![Event {
|
||||
time: 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("player")]),
|
||||
Team::with_members([Member::new("layout_7")
|
||||
.with_drift_scale(0.0)
|
||||
.with_prior(PRIOR)]),
|
||||
],
|
||||
outcome: Outcome::scores([5.0, 2.0]),
|
||||
}])
|
||||
.unwrap();
|
||||
assert!(typed.converge().unwrap().converged);
|
||||
|
||||
let mut fluent = history();
|
||||
fluent
|
||||
.event(1)
|
||||
.team(["player"])
|
||||
.members([Member::new("layout_7")
|
||||
.with_drift_scale(0.0)
|
||||
.with_prior(PRIOR)])
|
||||
.scores([5.0, 2.0])
|
||||
.commit()
|
||||
.unwrap();
|
||||
assert!(fluent.converge().unwrap().converged);
|
||||
|
||||
for key in ["player", "layout_7"] {
|
||||
let a = typed.current_skill(&key).unwrap();
|
||||
let b = fluent.current_skill(&key).unwrap();
|
||||
assert_eq!(a.pi(), b.pi(), "{key} pi");
|
||||
assert_eq!(a.tau(), b.tau(), "{key} tau");
|
||||
}
|
||||
}
|
||||
|
||||
/// The configuration has to actually take effect, not merely round-trip: a
|
||||
/// competitor pinned with `drift_scale = 0.0` must not move across slices,
|
||||
/// where an unpinned one does.
|
||||
///
|
||||
/// The comparison is against a control rather than against a fixed epsilon.
|
||||
/// Pinned marginals are not bit-identical across slices — each slice combines
|
||||
/// its own forward and backward messages, so the arithmetic order differs and
|
||||
/// the last bit moves. What "pinned" promises is that no drift variance
|
||||
/// accumulates, and the control is what makes that measurable.
|
||||
#[test]
|
||||
fn a_drift_scale_set_through_members_is_applied() {
|
||||
fn spread(h: &H, key: &'static str) -> f64 {
|
||||
let curve = h.learning_curve(&key);
|
||||
assert!(curve.len() >= 2, "{key}: expected several appearances");
|
||||
let (lo, hi) = curve.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (_, g)| {
|
||||
(lo.min(g.sigma()), hi.max(g.sigma()))
|
||||
});
|
||||
(hi - lo) / hi
|
||||
}
|
||||
|
||||
let mut h = history();
|
||||
for t in 1..=4 {
|
||||
h.event(t)
|
||||
.team(["player"])
|
||||
.members([Member::new("pinned").with_drift_scale(0.0)])
|
||||
.scores([5.0, 2.0])
|
||||
.commit()
|
||||
.unwrap();
|
||||
// Same shape, no pinning: the control.
|
||||
h.event(t)
|
||||
.team(["rival"])
|
||||
.team(["drifting"])
|
||||
.scores([5.0, 2.0])
|
||||
.commit()
|
||||
.unwrap();
|
||||
}
|
||||
assert!(h.converge().unwrap().converged);
|
||||
|
||||
let pinned = spread(&h, "pinned");
|
||||
let drifting = spread(&h, "drifting");
|
||||
assert!(pinned < 1e-9, "pinned competitor moved: {pinned:e}");
|
||||
assert!(
|
||||
drifting > 1e-3,
|
||||
"control did not move, so the test proves nothing: {drifting:e}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `weights` still applies to a team added through `members`, and still
|
||||
/// records a mismatch rather than partially applying it.
|
||||
#[test]
|
||||
fn weights_still_guards_a_members_team() {
|
||||
let mut h = history();
|
||||
let err = h
|
||||
.event(1)
|
||||
.team(["a"])
|
||||
.members([Member::new("b"), Member::new("c")])
|
||||
.weights([1.0])
|
||||
.winner(0)
|
||||
.commit()
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::MismatchedShape {
|
||||
kind: "weights",
|
||||
expected: 2,
|
||||
got: 1
|
||||
}
|
||||
),
|
||||
"{err:?}"
|
||||
);
|
||||
assert!(h.current_skill(&"b").is_none(), "nothing may reach history");
|
||||
}
|
||||
|
||||
/// An invalid `drift_scale` surfaces from `commit`, not from a panic and not
|
||||
/// silently.
|
||||
#[test]
|
||||
fn an_invalid_drift_scale_surfaces_from_commit() {
|
||||
for bad in [-1.0, f64::NAN, f64::INFINITY] {
|
||||
let mut h = history();
|
||||
let err = h
|
||||
.event(1)
|
||||
.team(["a"])
|
||||
.members([Member::new("b").with_drift_scale(bad)])
|
||||
.winner(0)
|
||||
.commit()
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
name: "drift_scale",
|
||||
..
|
||||
}
|
||||
),
|
||||
"{bad}: {err:?}"
|
||||
);
|
||||
assert!(h.current_skill(&"b").is_none(), "{bad} reached the history");
|
||||
}
|
||||
}
|
||||
|
||||
/// `members` and `team` compose in either order.
|
||||
#[test]
|
||||
fn members_and_team_interleave() {
|
||||
let mut h = history();
|
||||
h.event(1)
|
||||
.members([Member::new("a").with_prior(PRIOR)])
|
||||
.team(["b"])
|
||||
.scores([3.0, 1.0])
|
||||
.commit()
|
||||
.unwrap();
|
||||
h.event(2)
|
||||
.team(["b"])
|
||||
.members([Member::new("c").with_prior(PRIOR)])
|
||||
.scores([2.0, 4.0])
|
||||
.commit()
|
||||
.unwrap();
|
||||
assert!(h.converge().unwrap().converged);
|
||||
for key in ["a", "b", "c"] {
|
||||
assert!(h.current_skill(&key).is_some(), "{key} missing");
|
||||
}
|
||||
}
|
||||
+115
-3
@@ -8,7 +8,7 @@ fn default_rating() -> R {
|
||||
R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
ConstantDrift::new(25.0 / 300.0),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ fn game_one_v_one_shortcut() {
|
||||
|
||||
#[test]
|
||||
fn game_ranked_rejects_bad_p_draw() {
|
||||
let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0));
|
||||
let a = R::new(Gaussian::default(), 1.0, ConstantDrift::new(0.0));
|
||||
let err = Game::<i64, _>::ranked(
|
||||
&[&[a], &[a]],
|
||||
Outcome::winner(0, 2),
|
||||
@@ -56,7 +56,7 @@ fn game_ranked_rejects_bad_p_draw() {
|
||||
|
||||
#[test]
|
||||
fn game_ranked_rejects_mismatched_ranks() {
|
||||
let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0));
|
||||
let a = R::new(Gaussian::default(), 1.0, ConstantDrift::new(0.0));
|
||||
let err = Game::<i64, _>::ranked(
|
||||
&[&[a], &[a]],
|
||||
Outcome::ranking([0, 1, 2]),
|
||||
@@ -138,3 +138,115 @@ fn one_v_one_honours_convergence_options() {
|
||||
let (a_post, _) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options).unwrap();
|
||||
assert!(a_post.mu() > 25.0);
|
||||
}
|
||||
|
||||
/// `Game` is a public entry point that does not pass through `History`'s
|
||||
/// ingestion chokepoint, so it needs its own boundary — and did not have one.
|
||||
///
|
||||
/// A one-team game panicked at `src/game.rs:317` with "range start index 1 out
|
||||
/// of range for slice of length 0", in release, from safe API. This is the
|
||||
/// same defect `tests/ingestion_shape.rs` covers for `History`; fixing that
|
||||
/// path left this one open, because they share no validation.
|
||||
mod malformed_games {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_one_team_ranked_game_is_an_error_not_a_panic() {
|
||||
let a = default_rating();
|
||||
let err = Game::<i64, _>::ranked(&[&[a]], Outcome::winner(0, 1), &GameOptions::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_team_scored_game_is_an_error_not_a_panic() {
|
||||
let a = default_rating();
|
||||
let err = Game::<i64, _>::scored(
|
||||
&[&[a]],
|
||||
Outcome::scores([1.0]),
|
||||
&GameOptions {
|
||||
score_sigma: 1.0,
|
||||
..GameOptions::default()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_team_game_is_an_error() {
|
||||
let err =
|
||||
Game::<i64, ConstantDrift>::ranked(&[], Outcome::ranking([]), &GameOptions::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 0 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The quiet half: an empty team contributed no performance, so the game
|
||||
/// returned a finite posterior for its opponent as though it had won one.
|
||||
#[test]
|
||||
fn an_empty_team_is_an_error() {
|
||||
let a = default_rating();
|
||||
let err =
|
||||
Game::<i64, _>::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::EmptyTeam { team: 0 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_finite_score_is_an_error() {
|
||||
let a = default_rating();
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let err = Game::<i64, _>::scored(
|
||||
&[&[a], &[a]],
|
||||
Outcome::scores([bad, 1.0]),
|
||||
&GameOptions {
|
||||
score_sigma: 1.0,
|
||||
..GameOptions::default()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
|
||||
"{bad}: {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `free_for_all` and `one_v_one` build their teams internally, so they
|
||||
/// must keep working — the check must not catch well-formed games.
|
||||
#[test]
|
||||
fn well_formed_games_are_untouched() {
|
||||
let a = default_rating();
|
||||
assert!(
|
||||
Game::<i64, _>::ranked(
|
||||
&[&[a], &[a]],
|
||||
Outcome::winner(0, 2),
|
||||
&GameOptions::default()
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
Game::<i64, _>::free_for_all(
|
||||
&[&a, &a, &a],
|
||||
Outcome::ranking([0, 1, 2]),
|
||||
&GameOptions::default()
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
Game::<i64, _>::one_v_one(&a, &a, Outcome::winner(0, 2), &GameOptions::default())
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Malformed events must be rejected at the ingestion boundary.
|
||||
//!
|
||||
//! Every case here was reachable from safe public API in a release build. Two
|
||||
//! of them are the two shapes this crate's defects keep taking: a panic from
|
||||
//! deep inside inference, and a finite, plausible-looking posterior computed
|
||||
//! from an event that should never have been accepted.
|
||||
//!
|
||||
//! `InferenceError::NotEnoughTeams` and `EmptyTeam` already existed when these
|
||||
//! were found — they were checked on the prediction paths and nowhere else, so
|
||||
//! ingestion could still manufacture the states they describe.
|
||||
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
|
||||
|
||||
type Ev = Event<i64, &'static str>;
|
||||
|
||||
fn history() -> History<i64, trueskill_tt::ConstantDrift, trueskill_tt::NullObserver, &'static str>
|
||||
{
|
||||
History::builder().score_sigma(1.0).build()
|
||||
}
|
||||
|
||||
fn teams(names: &[&[&'static str]]) -> smallvec::SmallVec<[Team<&'static str>; 4]> {
|
||||
names
|
||||
.iter()
|
||||
.map(|team| Team::with_members(team.iter().map(|k| Member::new(*k))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The regression this file exists for: `run_chain` builds one diff link per
|
||||
/// adjacent pair of teams, so a one-team event left it indexing `links[1..]`
|
||||
/// on an empty vector and panicked — in release, from `History::add_events`.
|
||||
#[test]
|
||||
fn a_one_team_event_is_an_error_not_a_panic() {
|
||||
let mut h = history();
|
||||
let err = h
|
||||
.add_events(vec![Ev {
|
||||
time: 1,
|
||||
teams: teams(&[&["a"]]),
|
||||
outcome: Outcome::winner(0, 1),
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_team_event_is_an_error() {
|
||||
let mut h = history();
|
||||
let err = h
|
||||
.add_events(vec![Ev {
|
||||
time: 1,
|
||||
teams: smallvec![],
|
||||
outcome: Outcome::ranking([]),
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 0 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The quiet half. An empty team contributes no performance, so before this
|
||||
/// was rejected the event converged and handed back a finite posterior for its
|
||||
/// opponent — a plausible constant computed from nothing.
|
||||
#[test]
|
||||
fn an_empty_team_is_an_error_rather_than_a_free_win() {
|
||||
let mut h = history();
|
||||
let err = h
|
||||
.add_events(vec![Ev {
|
||||
time: 1,
|
||||
teams: teams(&[&[], &["b"]]),
|
||||
outcome: Outcome::winner(0, 2),
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::EmptyTeam { team: 0 }),
|
||||
"{err:?}"
|
||||
);
|
||||
// Nothing was recorded, so the history is still empty.
|
||||
assert!(h.current_skill(&"b").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_team_is_reported_by_position() {
|
||||
let mut h = history();
|
||||
let err = h
|
||||
.add_events(vec![Ev {
|
||||
time: 1,
|
||||
teams: teams(&[&["a"], &[]]),
|
||||
outcome: Outcome::winner(0, 2),
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::EmptyTeam { team: 1 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A NaN score used to ingest cleanly. `converge` reported `NonFiniteResult`,
|
||||
/// but a caller who read `current_skill` first was handed `tau: NaN` with
|
||||
/// nothing to say so.
|
||||
#[test]
|
||||
fn a_non_finite_score_is_rejected_at_ingestion() {
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let mut h = history();
|
||||
let err = h
|
||||
.add_events(vec![Ev {
|
||||
time: 1,
|
||||
teams: teams(&[&["a"], &["b"]]),
|
||||
outcome: Outcome::scores([bad, 0.0]),
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
|
||||
"{bad}: {err:?}"
|
||||
);
|
||||
assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway");
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-finite weight behaved exactly as `0.0` — the member contributed
|
||||
/// nothing — while `converge` reported `converged: true` after one iteration
|
||||
/// with a step of `(0.0, 0.0)`. So a NaN arriving from a division or a parse
|
||||
/// was indistinguishable from a deliberate zero, and looked like a clean fit.
|
||||
#[test]
|
||||
fn a_non_finite_weight_is_rejected_at_ingestion() {
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let mut h = history();
|
||||
let err = h
|
||||
.event(1)
|
||||
.team(["a"])
|
||||
.weights([bad])
|
||||
.team(["b"])
|
||||
.winner(0)
|
||||
.commit()
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
|
||||
"{bad}: {err:?}"
|
||||
);
|
||||
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero and negative weights are expressible choices about how much a member
|
||||
/// contributes, not malformed input, and `tests/degenerate_inputs.rs` pins
|
||||
/// their behaviour deliberately. Rejecting non-finite values must not catch
|
||||
/// them too.
|
||||
#[test]
|
||||
fn zero_and_negative_weights_still_ingest() {
|
||||
for w in [0.0, -1.0, 0.5] {
|
||||
let mut h = history();
|
||||
h.event(1)
|
||||
.team(["a"])
|
||||
.weights([w])
|
||||
.team(["b"])
|
||||
.winner(0)
|
||||
.commit()
|
||||
.unwrap_or_else(|e| panic!("weight {w} should ingest: {e:?}"));
|
||||
assert!(h.current_skill(&"a").is_some(), "weight {w}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The fluent builder routes through the same chokepoint, so it inherits the
|
||||
/// checks rather than needing its own.
|
||||
#[test]
|
||||
fn the_event_builder_inherits_the_shape_checks() {
|
||||
let mut h = history();
|
||||
let err = h.event(1).team(["a"]).winner(0).commit().unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A well-formed event is untouched by any of this.
|
||||
#[test]
|
||||
fn a_well_formed_event_still_ingests() {
|
||||
let mut h = history();
|
||||
h.add_events(vec![Ev {
|
||||
time: 1,
|
||||
teams: teams(&[&["a"], &["b"]]),
|
||||
outcome: Outcome::scores([3.0, 1.0]),
|
||||
}])
|
||||
.unwrap();
|
||||
assert!(h.converge().unwrap().converged);
|
||||
assert!(h.current_skill(&"a").unwrap().mu() > h.current_skill(&"b").unwrap().mu());
|
||||
}
|
||||
+121
-2
@@ -41,7 +41,7 @@ fn history(unknown: UnknownKeys) -> H {
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift(0.5))
|
||||
.drift(ConstantDrift::new(0.5))
|
||||
.unknown_keys(unknown)
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 20_000,
|
||||
@@ -141,6 +141,53 @@ fn variables_counts_appearances_not_competitors() {
|
||||
assert_eq!(joint.variables(), 12);
|
||||
}
|
||||
|
||||
/// How much the collapse is worth, which is the part a caller has to plan
|
||||
/// around: a drift-free competitor contributes **one** variable however long
|
||||
/// the history, so the same events at `gamma = 0` and `gamma > 0` differ by
|
||||
/// roughly the slice count in problem size — and by its cube in solve time.
|
||||
///
|
||||
/// Reported by a consumer as an 8x difference in solve time on a ~2,000-node,
|
||||
/// 76-slice model (787 ms career against 6,214 ms drifting). This pins the
|
||||
/// mechanism behind that so a change to the collapse rule cannot quietly
|
||||
/// remove it.
|
||||
#[test]
|
||||
fn drift_free_competitors_shrink_the_joint_by_the_slice_count() {
|
||||
fn variables(gamma: f64) -> usize {
|
||||
let mut h = History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift::new(gamma))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 20_000,
|
||||
epsilon: 1e-13,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
h.add_events(
|
||||
(1..=10)
|
||||
.map(|t| duel("a", "b", t, 5.0, 2.0))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
h.joint().unwrap().variables()
|
||||
}
|
||||
|
||||
let drifting = variables(0.5);
|
||||
let career = variables(0.0);
|
||||
|
||||
// Two competitors over ten slices: twenty appearances, or two variables.
|
||||
assert_eq!(drifting, 20);
|
||||
assert_eq!(career, 2);
|
||||
assert_eq!(
|
||||
drifting / career,
|
||||
10,
|
||||
"collapse should track the slice count"
|
||||
);
|
||||
}
|
||||
|
||||
/// With `drift = 0` consecutive appearances are the same latent variable, so
|
||||
/// the joint is smaller than the appearance count.
|
||||
#[test]
|
||||
@@ -150,7 +197,7 @@ fn pinned_competitors_collapse_consecutive_appearances() {
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 20_000,
|
||||
epsilon: 1e-13,
|
||||
@@ -218,3 +265,75 @@ fn unseen_competitors_match_the_one_shot_path() {
|
||||
assert_eq!(one_shot.pi(), cached.pi());
|
||||
assert_eq!(one_shot.tau(), cached.tau());
|
||||
}
|
||||
|
||||
/// A drift too small to represent must collapse, not corrupt the matrix.
|
||||
///
|
||||
/// The collapse rule used to fire only at `drift <= 0.0` exactly. Anything
|
||||
/// smaller-but-positive got an explicit `1.0 / drift` precision, and at
|
||||
/// `drift = 1e-16` that entry is `1e16` — so `1e16 + 0.28` rounds back to
|
||||
/// `1e16` and the prior and contrasts are annihilated in the stored `f64`.
|
||||
///
|
||||
/// Measured before the fix, at `drift_scale = 1e-10` this returned a variance
|
||||
/// **12 000x too small** (a 111x overconfident interval) as `Ok`, with a band
|
||||
/// just above it returning a misleading `JointUnavailable`.
|
||||
#[test]
|
||||
fn a_drift_too_small_to_represent_collapses_rather_than_corrupting() {
|
||||
fn variance(scale: f64) -> f64 {
|
||||
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift::new(0.5))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 20_000,
|
||||
epsilon: 1e-13,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
let mut events = Vec::new();
|
||||
for t in 0..15i64 {
|
||||
for k in 0..4usize {
|
||||
let x = format!("p{}", (t as usize * 4 + k) % 8);
|
||||
let y = format!("p{}", (t as usize * 4 + k + 3) % 8);
|
||||
events.push(Event {
|
||||
time: t,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(x).with_drift_scale(scale)]),
|
||||
Team::with_members([Member::new(y).with_drift_scale(scale)]),
|
||||
],
|
||||
outcome: Outcome::scores([3.0, 1.0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
h.add_events(events).unwrap();
|
||||
assert!(h.converge().unwrap().converged);
|
||||
let (a, b) = ("p0".to_string(), "p1".to_string());
|
||||
let joint = h
|
||||
.joint()
|
||||
.expect("a tiny drift must not make the joint unavailable");
|
||||
let g = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).unwrap();
|
||||
g.sigma() * g.sigma()
|
||||
}
|
||||
|
||||
let collapsed = variance(0.0);
|
||||
|
||||
// Below the threshold every scale must reach the collapsed answer exactly,
|
||||
// and none may error.
|
||||
for scale in [1e-3, 1e-4, 1e-6, 1e-8, 1e-10, 1e-12] {
|
||||
let v = variance(scale);
|
||||
assert_eq!(
|
||||
v.to_bits(),
|
||||
collapsed.to_bits(),
|
||||
"drift_scale {scale:e}: {v} vs collapsed {collapsed}"
|
||||
);
|
||||
}
|
||||
|
||||
// Above it, real drift is still modelled — otherwise this test would pass
|
||||
// by collapsing everything.
|
||||
let drifting = variance(1e-2);
|
||||
assert!(
|
||||
(drifting - collapsed).abs() / collapsed > 1e-5,
|
||||
"a drift of 1e-2 must still move the answer: {drifting} vs {collapsed}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ fn nan_after_fit(players: usize) -> usize {
|
||||
let mut h: History<i64, ConstantDrift, NullObserver, String> = History::builder_with_key()
|
||||
.beta(1.0)
|
||||
.sigma(6.0)
|
||||
.drift(ConstantDrift(0.1))
|
||||
.drift(ConstantDrift::new(0.1))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: ITERATIONS,
|
||||
epsilon: EPSILON,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! The libm rule, enforced rather than asserted in prose.
|
||||
//!
|
||||
//! CLAUDE.md requires transcendentals to go through `libm`, not `std`:
|
||||
//!
|
||||
//! > IEEE 754 pins the basic operations and `sqrt` but says nothing about
|
||||
//! > `exp`/`log`/`erf`, and `std` delegates to the *system* math library —
|
||||
//! > measured, `f64::exp` and `libm::exp` disagree on 9.7% of inputs by one
|
||||
//! > ULP. Since inference is an iterative fixed point, one ULP can change an
|
||||
//! > iteration count.
|
||||
//!
|
||||
//! The rule was stated clearly and still violated in three production sites,
|
||||
//! one of them `hypot` on the path of every scored event — whose measured
|
||||
//! divergence, 12.1%, is *higher* than the `exp` figure the rule cites as its
|
||||
//! own justification. Prose is evidently not enough, so this is a test.
|
||||
//!
|
||||
//! Tests may use either, which the crate documents, so `#[cfg(test)]` blocks
|
||||
//! are excluded.
|
||||
|
||||
use std::{fs, path::Path};
|
||||
|
||||
/// Method-call spellings that reach the system math library.
|
||||
///
|
||||
/// `sqrt` is deliberately absent: IEEE 754 specifies it exactly, so `std` and
|
||||
/// `libm` cannot disagree. `abs`, `recip`, `powi` and `mul_add` are likewise
|
||||
/// exact or specified.
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
"exp", "exp2", "exp_m1", "ln", "ln_1p", "log", "log2", "log10", "powf", "sin", "cos", "tan",
|
||||
"asin", "acos", "atan", "atan2", "sinh", "cosh", "tanh", "hypot", "cbrt", "erf", "erfc",
|
||||
];
|
||||
|
||||
/// Strip `#[cfg(test)]` items by brace matching, plus comments and string
|
||||
/// literals, so a mention in prose is not mistaken for a call.
|
||||
fn production_code(source: &str) -> String {
|
||||
let mut out = String::with_capacity(source.len());
|
||||
let bytes: Vec<char> = source.chars().collect();
|
||||
let mut i = 0;
|
||||
|
||||
while i < bytes.len() {
|
||||
let rest: String = bytes[i..].iter().take(16).collect();
|
||||
|
||||
if rest.starts_with("#[cfg(test)]") {
|
||||
// Skip to the opening brace of the guarded item, then past its
|
||||
// matching close.
|
||||
let mut j = i;
|
||||
while j < bytes.len() && bytes[j] != '{' {
|
||||
j += 1;
|
||||
}
|
||||
let mut depth = 0usize;
|
||||
while j < bytes.len() {
|
||||
match bytes[j] {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
if rest.starts_with("//") {
|
||||
while i < bytes.len() && bytes[i] != '\n' {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if rest.starts_with("/*") {
|
||||
i += 2;
|
||||
while i + 1 < bytes.len() && !(bytes[i] == '*' && bytes[i + 1] == '/') {
|
||||
i += 1;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if bytes[i] == '"' {
|
||||
i += 1;
|
||||
while i < bytes.len() && bytes[i] != '"' {
|
||||
if bytes[i] == '\\' {
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn rust_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in fs::read_dir(dir).expect("read src") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_code_never_calls_a_std_transcendental() {
|
||||
let mut files = Vec::new();
|
||||
rust_files(Path::new("src"), &mut files);
|
||||
assert!(files.len() > 10, "expected to find the crate's sources");
|
||||
|
||||
let mut offences = Vec::new();
|
||||
|
||||
for path in &files {
|
||||
let source = fs::read_to_string(path).expect("read source");
|
||||
let code = production_code(&source);
|
||||
|
||||
for (n, line) in code.lines().enumerate() {
|
||||
for name in FORBIDDEN {
|
||||
let needle = format!(".{name}(");
|
||||
if line.contains(&needle) {
|
||||
offences.push(format!("{}:{}: {}", path.display(), n + 1, line.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
offences.is_empty(),
|
||||
"production code must call libm, not std, for transcendentals \
|
||||
(`sqrt` is exempt — IEEE 754 specifies it):\n{}",
|
||||
offences.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// The stripper has to actually strip, or the test above passes vacuously.
|
||||
#[test]
|
||||
fn the_test_module_stripper_works() {
|
||||
let source = r#"
|
||||
fn production() { let _ = libm::exp(1.0); }
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn allowed() { let x = 1.0f64.exp(); }
|
||||
}
|
||||
|
||||
fn also_production() {}
|
||||
"#;
|
||||
let code = production_code(source);
|
||||
assert!(
|
||||
code.contains("also_production"),
|
||||
"stripped too much: {code}"
|
||||
);
|
||||
assert!(
|
||||
!code.contains(".exp()"),
|
||||
"failed to strip cfg(test): {code}"
|
||||
);
|
||||
}
|
||||
|
||||
/// And it must not strip a doc comment's worth of prose into oblivion, nor
|
||||
/// mistake prose for a call.
|
||||
#[test]
|
||||
fn prose_is_not_mistaken_for_a_call() {
|
||||
let source = "/// Uses `x.exp()` in the docs.\nfn f() { let _ = libm::exp(1.0); }\n";
|
||||
let code = production_code(source);
|
||||
assert!(!code.contains(".exp()"), "doc comment leaked: {code}");
|
||||
assert!(code.contains("libm::exp"), "stripped real code: {code}");
|
||||
}
|
||||
@@ -140,7 +140,7 @@ fn fitted(
|
||||
.sigma(SIGMA0)
|
||||
.beta(BETA)
|
||||
.score_sigma(SCORE_SIGMA)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 20_000,
|
||||
epsilon: 1e-13,
|
||||
@@ -324,7 +324,7 @@ fn cost_scaling() {
|
||||
let names: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
|
||||
let mut h: History<i64, _, _, String> = History::builder_with_key()
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 200,
|
||||
epsilon: 1e-8,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Inference must report numerical breakdown rather than call it convergence.
|
||||
//!
|
||||
//! The boundary rejects inputs that are *not numbers*, but finite inputs can
|
||||
//! still overflow during inference — `beta.powi(2)` at 1e300 is infinite, and
|
||||
//! infinity minus infinity is NaN. `NonFiniteResult` is the guard for that, and
|
||||
//! it matters because the alternative is silent: NaN fails every comparison, so
|
||||
//! a naive `step < epsilon` check reads a NaN step as *converged*.
|
||||
//!
|
||||
//! That is why the crate has `step_converged` / `step_is_finite` rather than
|
||||
//! `!tuple_gt(..)`. These tests pin the guard from outside.
|
||||
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, Event, Gaussian, History, InferenceError, Member, Outcome, Team,
|
||||
};
|
||||
|
||||
fn scored_fit(
|
||||
sigma: f64,
|
||||
beta: f64,
|
||||
score_sigma: f64,
|
||||
scores: [f64; 2],
|
||||
) -> Result<bool, InferenceError> {
|
||||
let mut h = History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(sigma)
|
||||
.beta(beta)
|
||||
.score_sigma(score_sigma)
|
||||
.build();
|
||||
h.add_events(vec![Event {
|
||||
time: 1i64,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a")]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores(scores),
|
||||
}])?;
|
||||
h.converge().map(|r| r.converged)
|
||||
}
|
||||
|
||||
/// Every one of these is built from finite, individually legal parameters. The
|
||||
/// overflow happens inside inference, which is exactly the case the boundary
|
||||
/// checks cannot catch.
|
||||
///
|
||||
/// Matched rather than merely `is_err()`: an assertion that only checks "some
|
||||
/// error" would keep passing if these started failing at the boundary for an
|
||||
/// unrelated reason, and would then be testing nothing.
|
||||
#[test]
|
||||
fn overflow_during_inference_is_reported_not_hidden() {
|
||||
let cases: [(&str, f64, f64, f64, [f64; 2]); 5] = [
|
||||
("huge sigma", 1e300, 1.0, 1.0, [3.0, 1.0]),
|
||||
("huge beta", 6.0, 1e300, 1.0, [3.0, 1.0]),
|
||||
("tiny sigma", 1e-300, 1.0, 1.0, [3.0, 1.0]),
|
||||
("tiny score_sigma", 6.0, 1.0, 1e-300, [3.0, 1.0]),
|
||||
("huge scores", 6.0, 1.0, 1.0, [1e308, -1e308]),
|
||||
];
|
||||
|
||||
for (name, sigma, beta, score_sigma, scores) in cases {
|
||||
match scored_fit(sigma, beta, score_sigma, scores) {
|
||||
Err(InferenceError::NonFiniteResult { context, step }) => {
|
||||
assert_eq!(context, "History::converge", "{name}");
|
||||
assert!(
|
||||
!step.0.is_finite() || !step.1.is_finite(),
|
||||
"{name}: reported NonFiniteResult with a finite step {step:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("{name}: expected NonFiniteResult, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The trap the invariant exists for: NaN fails every comparison, so a naive
|
||||
/// `step < epsilon` test reads a NaN step as converged. A breakdown must never
|
||||
/// come back as a successful fit.
|
||||
#[test]
|
||||
fn a_broken_fit_is_never_reported_as_converged() {
|
||||
let mut h = History::builder().build();
|
||||
h.add_events(vec![Event {
|
||||
time: 1i64,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(1e300, 1e-300))]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
let err = h.converge().unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NonFiniteResult { .. }),
|
||||
"a breakdown must not be reported as convergence: {err:?}"
|
||||
);
|
||||
|
||||
// `converge_partial` must not launder it into an `Ok` either — the
|
||||
// permissive path is permissive about *stopping short*, not about NaN.
|
||||
let mut h2 = History::builder().build();
|
||||
h2.add_events(vec![Event {
|
||||
time: 1i64,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(1e300, 1e-300))]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
}])
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
h2.converge_partial().unwrap_err(),
|
||||
InferenceError::NonFiniteResult { .. }
|
||||
));
|
||||
}
|
||||
|
||||
/// The neighbouring case, so the tests above cannot pass by the fit simply
|
||||
/// always failing: ordinary extreme-but-workable parameters still converge.
|
||||
#[test]
|
||||
fn merely_extreme_parameters_still_converge() {
|
||||
assert!(scored_fit(1e6, 1.0, 1.0, [3.0, 1.0]).unwrap());
|
||||
assert!(scored_fit(1e-6, 1.0, 1.0, [3.0, 1.0]).unwrap());
|
||||
assert!(scored_fit(6.0, 1.0, 1e6, [3.0, 1.0]).unwrap());
|
||||
assert!(scored_fit(6.0, 1.0, 1.0, [1e150, -1e150]).unwrap());
|
||||
}
|
||||
|
||||
/// A NaN in one competitor must not be masked by a healthy competitor reduced
|
||||
/// after it.
|
||||
///
|
||||
/// The convergence step is a fold over a `HashMap`, so which competitor is
|
||||
/// reduced last is per-process hash order. Before the fix, `tuple_max` dropped
|
||||
/// a NaN accumulator in favour of the next finite delta and this returned
|
||||
/// `Ok(converged: true)` with a NaN posterior in **16 of 30 runs** on identical
|
||||
/// input. Deterministic now, but note this test can only ever sample one hash
|
||||
/// order per run — the ordering guarantee itself is pinned by
|
||||
/// `tuple_max_propagates_a_nan_from_any_position` in the crate's unit tests.
|
||||
#[test]
|
||||
fn a_nan_competitor_is_not_masked_by_a_healthy_one() {
|
||||
let mut h = History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.p_draw(0.1)
|
||||
.build();
|
||||
h.add_events(vec![
|
||||
Event {
|
||||
time: 1i64,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(0.0, 1e-200))]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
},
|
||||
// A healthy pair in the same slice, to be reduced alongside the NaN.
|
||||
Event {
|
||||
time: 1i64,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("c")]),
|
||||
Team::with_members([Member::new("d")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let err = h
|
||||
.converge()
|
||||
.expect_err("a NaN fit must never be reported as converged");
|
||||
assert!(
|
||||
matches!(err, InferenceError::NonFiniteResult { .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A tie observed with a narrow draw margin between far-apart competitors must
|
||||
/// produce a fit, not NaN skills.
|
||||
///
|
||||
/// The tie branch forms the truncated variance from `v^2 - u`, and both grow as
|
||||
/// `alpha^2` while their difference stays `O(1)`. Deep enough into the tail
|
||||
/// that subtraction had four digits left: measured, it returned `1 - w`
|
||||
/// negative and `sqrt` of it was NaN. The half-line escape hatch did not cover
|
||||
/// it, because that keys on how many window-widths from the mean the window
|
||||
/// sits and a narrow window fails that however deep it is.
|
||||
///
|
||||
/// These parameters are ordinary for a precise-scoring domain, and the
|
||||
/// neighbouring wider-margin case always worked — so this was a cliff, not
|
||||
/// "extreme inputs break".
|
||||
#[test]
|
||||
fn a_narrow_draw_margin_far_into_the_tail_still_fits() {
|
||||
for (beta, p_draw, sd, gap) in [
|
||||
(1e-2, 1e-8, 1e-2, 10.0),
|
||||
(1e-3, 1e-9, 1e-3, 1.0),
|
||||
(1e-4, 1e-12, 1e-4, 1.0),
|
||||
] {
|
||||
let mut h = History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(sd)
|
||||
.beta(beta)
|
||||
.p_draw(p_draw)
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.build();
|
||||
h.add_events(vec![Event {
|
||||
time: 1i64,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(0.0, sd))]),
|
||||
Team::with_members([Member::new("b").with_prior(Gaussian::from_ms(gap, sd))]),
|
||||
],
|
||||
outcome: Outcome::draw(2),
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
let report = h
|
||||
.converge()
|
||||
.unwrap_or_else(|e| panic!("beta {beta:e}, p_draw {p_draw:e}: {e:?}"));
|
||||
assert!(report.converged);
|
||||
|
||||
let skill = h.current_skill(&"a").unwrap();
|
||||
assert!(
|
||||
skill.mu().is_finite() && skill.sigma().is_finite() && skill.sigma() > 0.0,
|
||||
"beta {beta:e}, p_draw {p_draw:e}: {skill:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ fn builder(
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.unknown_keys(policy)
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 5_000,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Bounds that any correct implementation must satisfy, swept rather than
|
||||
//! spot-checked.
|
||||
//!
|
||||
//! The crate's docs call the `ln k` ceiling "the sharpest available test of an
|
||||
//! implementation", and record that an early prototype returned 4.77 nats. It
|
||||
//! was violated again — 3.237828 nats against `ln 2` — because the existing
|
||||
//! check sampled one fixture and the violation lives in a specific regime: a
|
||||
//! large ratio between the widest and narrowest performance sigma, where the
|
||||
//! shared prediction grid could not resolve the narrow density and returned
|
||||
//! probabilities greater than one.
|
||||
//!
|
||||
//! A single fixture cannot defend a bound like this. A sweep can.
|
||||
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, GameOptions, Gaussian, InferenceError, Rating, expected_information_gain,
|
||||
};
|
||||
|
||||
type R = Rating<i64, ConstantDrift>;
|
||||
|
||||
/// How many random matchups the ceiling sweep draws.
|
||||
///
|
||||
/// Scaled by build profile rather than fixed. Each sample runs a full inference
|
||||
/// pass per outcome, and that is about **19x** faster in release — measured,
|
||||
/// 20 000 samples take 12.1s released against 23s for 2 000 in debug. `just
|
||||
/// test` runs three debug feature combinations and one release one, so a fixed
|
||||
/// count pays the slow price three times and the fast one once, which is
|
||||
/// exactly backwards.
|
||||
///
|
||||
/// The debug run is here to prove the sweep still compiles and holds on a small
|
||||
/// sample; the release run is the one that actually searches. The violation
|
||||
/// this guards was found at a rate near 1.8%, so even the debug count expects
|
||||
/// tens of hits in the regime.
|
||||
#[cfg(debug_assertions)]
|
||||
const SAMPLES: usize = 1_000;
|
||||
#[cfg(not(debug_assertions))]
|
||||
const SAMPLES: usize = 50_000;
|
||||
|
||||
/// Deterministic LCG, so a failure is reproducible from the printed seed.
|
||||
struct Lcg(u64);
|
||||
|
||||
impl Lcg {
|
||||
fn next_f64(&mut self) -> f64 {
|
||||
self.0 = self
|
||||
.0
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
// Top 53 bits to [0, 1).
|
||||
((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
|
||||
}
|
||||
|
||||
fn in_range(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
lo + (hi - lo) * self.next_f64()
|
||||
}
|
||||
|
||||
/// Log-uniform, so the sweep spends its samples across magnitudes rather
|
||||
/// than crowding the top of the range — the violations live at small sigma.
|
||||
fn log_uniform(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
let t = self.next_f64();
|
||||
(lo.ln() + t * (hi.ln() - lo.ln())).exp()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn information_gain_never_exceeds_the_entropy_of_the_outcome() {
|
||||
let mut rng = Lcg(0x5eed_1234_abcd_ef01);
|
||||
let ceiling = 2.0_f64.ln();
|
||||
let mut evaluated = 0usize;
|
||||
let mut refused = 0usize;
|
||||
|
||||
for i in 0..SAMPLES {
|
||||
let mu_a = rng.in_range(-100.0, 100.0);
|
||||
let mu_b = rng.in_range(-100.0, 100.0);
|
||||
let sigma_a = rng.log_uniform(1e-4, 1e2);
|
||||
let sigma_b = rng.log_uniform(1e-4, 1e2);
|
||||
let beta = rng.log_uniform(1e-4, 1e1);
|
||||
|
||||
let a = R::new(
|
||||
Gaussian::from_ms(mu_a, sigma_a),
|
||||
beta,
|
||||
ConstantDrift::new(0.0),
|
||||
);
|
||||
let b = R::new(
|
||||
Gaussian::from_ms(mu_b, sigma_b),
|
||||
beta,
|
||||
ConstantDrift::new(0.0),
|
||||
);
|
||||
let options = GameOptions {
|
||||
p_draw: 0.0,
|
||||
..GameOptions::default()
|
||||
};
|
||||
|
||||
match expected_information_gain(&[&[a], &[b]], &options) {
|
||||
Ok(gain) => {
|
||||
evaluated += 1;
|
||||
assert!(
|
||||
gain.is_finite(),
|
||||
"sample {i}: non-finite gain {gain} \
|
||||
(mu {mu_a}, {mu_b}; sigma {sigma_a:e}, {sigma_b:e}; beta {beta:e})"
|
||||
);
|
||||
assert!(
|
||||
gain >= 0.0,
|
||||
"sample {i}: negative gain {gain} \
|
||||
(mu {mu_a}, {mu_b}; sigma {sigma_a:e}, {sigma_b:e}; beta {beta:e})"
|
||||
);
|
||||
assert!(
|
||||
gain <= ceiling + 1e-9,
|
||||
"sample {i}: gain {gain} exceeds ln 2 = {ceiling} \
|
||||
(mu {mu_a}, {mu_b}; sigma {sigma_a:e}, {sigma_b:e}; beta {beta:e})"
|
||||
);
|
||||
}
|
||||
// Refusing to answer is acceptable; answering wrongly is not.
|
||||
Err(InferenceError::GridTooCoarse { .. }) => refused += 1,
|
||||
Err(e) => panic!("sample {i}: unexpected error {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// The sweep must actually exercise the function, not pass by refusing
|
||||
// everything.
|
||||
assert!(
|
||||
evaluated * 2 > SAMPLES,
|
||||
"only {evaluated} of {SAMPLES} samples were evaluated ({refused} refused); \
|
||||
the sweep is no longer testing anything"
|
||||
);
|
||||
// And it must still reach the regime where the ceiling was violated —
|
||||
// large sigma ratios, which is exactly where the grid now refuses. Without
|
||||
// this the sweep could drift into only-easy inputs and stop being a guard.
|
||||
assert!(
|
||||
refused > 0,
|
||||
"no sample reached the coarse-grid regime; the sweep no longer covers \
|
||||
the case that produced 3.24 nats"
|
||||
);
|
||||
}
|
||||
|
||||
/// The regime that produced 3.237828 nats, pinned exactly.
|
||||
#[test]
|
||||
fn the_known_ceiling_violation_no_longer_answers_wrongly() {
|
||||
let a = R::new(
|
||||
Gaussian::from_ms(9.577_887_112_129_012, 0.000_132_507_526_585_134_38),
|
||||
0.000_307_235_559_013_096_2,
|
||||
ConstantDrift::new(0.0),
|
||||
);
|
||||
let b = R::new(
|
||||
Gaussian::from_ms(-14.114_932_828_525_696, 91.586_690_140_921_16),
|
||||
0.000_307_235_559_013_096_2,
|
||||
ConstantDrift::new(0.0),
|
||||
);
|
||||
let options = GameOptions {
|
||||
p_draw: 0.0,
|
||||
..GameOptions::default()
|
||||
};
|
||||
|
||||
match expected_information_gain(&[&[a], &[b]], &options) {
|
||||
Ok(gain) => assert!(
|
||||
gain <= 2.0_f64.ln() + 1e-9,
|
||||
"returned {gain}, over the ln 2 ceiling"
|
||||
),
|
||||
Err(InferenceError::GridTooCoarse { needed, max }) => {
|
||||
assert!(needed > max, "needed {needed} should exceed max {max}");
|
||||
}
|
||||
Err(e) => panic!("unexpected error {e:?}"),
|
||||
}
|
||||
}
|
||||
@@ -164,3 +164,52 @@ fn quality_matches_the_reference_implementation() {
|
||||
let refs: Vec<&[Gaussian]> = five.iter().map(Vec::as_slice).collect();
|
||||
assert!((quality(&refs, beta) - 0.040).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// `quality()` used to compute `det(ata) / det(middle)` in linear space. Both
|
||||
/// are products of `k - 1` diagonal entries, so they leave `f64`'s range long
|
||||
/// before their ratio does — and the ratio is the only thing the answer needs.
|
||||
///
|
||||
/// Measured before the fix: at the crate defaults 150 groups was correct, 200
|
||||
/// returned `0`, and 250 returned `NaN` where the truth is `9.51e-88`. With a
|
||||
/// small beta it bit sooner — `sigma = beta = 1e-3` returned `NaN` at 60 groups
|
||||
/// against a true `1.32e-9`, a value that is entirely ordinary.
|
||||
///
|
||||
/// For `k` single-member groups with equal means the answer has a closed form,
|
||||
/// `(beta / sqrt(beta^2 + sigma^2))^(k-1)`, so this checks against arithmetic
|
||||
/// rather than against a recorded output.
|
||||
#[test]
|
||||
fn quality_matches_its_closed_form_past_the_overflow_point() {
|
||||
for (sigma, beta) in [(25.0 / 3.0, 25.0 / 6.0), (1e-3, 1e-3), (50.0, 25.0 / 6.0)] {
|
||||
let rating = vec![Gaussian::from_ms(25.0, sigma)];
|
||||
for k in [2usize, 50, 60, 150, 200, 250, 300] {
|
||||
let groups: Vec<&[Gaussian]> = (0..k).map(|_| rating.as_slice()).collect();
|
||||
let got = quality(&groups, beta);
|
||||
let expected = (beta / (beta * beta + sigma * sigma).sqrt()).powi(k as i32 - 1);
|
||||
|
||||
assert!(
|
||||
got.is_finite(),
|
||||
"sigma {sigma}, beta {beta}, {k} groups: got {got}"
|
||||
);
|
||||
// Subnormal results have no relative precision left to check.
|
||||
if expected > f64::MIN_POSITIVE {
|
||||
let rel = ((got - expected) / expected).abs();
|
||||
assert!(
|
||||
rel < 1e-11,
|
||||
"sigma {sigma}, beta {beta}, {k} groups: got {got:e}, \
|
||||
closed form {expected:e}, rel {rel:e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The overflow was in the intermediates, never in the answer: every value
|
||||
/// above is an ordinary float. This pins the specific case that returned `NaN`
|
||||
/// where the true answer is nine orders of magnitude inside the normal range.
|
||||
#[test]
|
||||
fn a_small_beta_does_not_overflow_at_sixty_groups() {
|
||||
let rating = vec![Gaussian::from_ms(25.0, 1e-3)];
|
||||
let groups: Vec<&[Gaussian]> = (0..60).map(|_| rating.as_slice()).collect();
|
||||
let got = quality(&groups, 1e-3);
|
||||
assert!((got - 1.317_089e-9).abs() / 1.317_089e-9 < 1e-6, "{got:e}");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ fn record_winner_builds_history() {
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
@@ -43,7 +43,7 @@ fn record_draw_with_p_draw_set() {
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||
.p_draw(0.25)
|
||||
.build();
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Configuring a competitor before anything is observed about them.
|
||||
//!
|
||||
//! The configuration a competitor needs is usually a property of the domain —
|
||||
//! "every layout is static" — not of whichever event happens to mention them
|
||||
//! first. Stating it per-event meant every ingestion path had to remember it,
|
||||
//! and two of the four paths could not state it at all.
|
||||
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
|
||||
Team,
|
||||
};
|
||||
|
||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||
|
||||
const PINNED: Gaussian = Gaussian::from_ms(2.0, 0.5);
|
||||
|
||||
fn history() -> H {
|
||||
History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift::new(0.5))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 20_000,
|
||||
epsilon: 1e-13,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
fn duel(
|
||||
a: &'static str,
|
||||
b: &'static str,
|
||||
t: i64,
|
||||
m: Option<Member<&'static str>>,
|
||||
) -> Event<i64, &'static str> {
|
||||
Event {
|
||||
time: t,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(a)]),
|
||||
Team::with_members([m.unwrap_or_else(|| Member::new(b))]),
|
||||
],
|
||||
outcome: Outcome::scores([5.0, 2.0]),
|
||||
}
|
||||
}
|
||||
|
||||
fn skills(h: &H) -> Vec<(&'static str, Gaussian)> {
|
||||
["player", "layout"]
|
||||
.into_iter()
|
||||
.map(|k| (k, h.current_skill(&k).unwrap()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The headline contract.
|
||||
#[test]
|
||||
fn registering_matches_configuring_on_the_first_event() {
|
||||
let configured = {
|
||||
let mut h = history();
|
||||
h.add_events(vec![
|
||||
duel(
|
||||
"player",
|
||||
"layout",
|
||||
1,
|
||||
Some(
|
||||
Member::new("layout")
|
||||
.with_drift_scale(0.0)
|
||||
.with_prior(PINNED),
|
||||
),
|
||||
),
|
||||
duel("player", "layout", 2, None),
|
||||
])
|
||||
.unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
h
|
||||
};
|
||||
|
||||
let registered = {
|
||||
let mut h = history();
|
||||
h.register(
|
||||
Member::new("layout")
|
||||
.with_drift_scale(0.0)
|
||||
.with_prior(PINNED),
|
||||
)
|
||||
.unwrap();
|
||||
h.add_events(vec![
|
||||
duel("player", "layout", 1, None),
|
||||
duel("player", "layout", 2, None),
|
||||
])
|
||||
.unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
h
|
||||
};
|
||||
|
||||
for ((k, a), (_, b)) in skills(&configured).into_iter().zip(skills(®istered)) {
|
||||
assert_eq!(a.pi(), b.pi(), "{k} pi");
|
||||
assert_eq!(a.tau(), b.tau(), "{k} tau");
|
||||
}
|
||||
}
|
||||
|
||||
/// The case `EventBuilder` and the typed path cannot reach: a competitor whose
|
||||
/// first appearance arrives through the two-argument convenience route.
|
||||
#[test]
|
||||
fn registration_reaches_a_competitor_first_seen_through_record_winner() {
|
||||
let mut h = history();
|
||||
h.register(
|
||||
Member::new("layout")
|
||||
.with_drift_scale(0.0)
|
||||
.with_prior(PINNED),
|
||||
)
|
||||
.unwrap();
|
||||
h.record_winner(&"player", &"layout", 1).unwrap();
|
||||
h.record_winner(&"player", &"layout", 2).unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
|
||||
let rating = h.rating(&"layout").unwrap();
|
||||
assert_eq!(rating.drift_scale(), 0.0);
|
||||
assert_eq!(rating.prior().mu(), PINNED.mu());
|
||||
|
||||
// Pinned means pinned: no drift across the two slices.
|
||||
let curve = h.learning_curve(&"layout");
|
||||
assert!(curve.len() >= 2);
|
||||
let widest = curve
|
||||
.iter()
|
||||
.map(|(_, g)| g.sigma())
|
||||
.fold(f64::MIN, f64::max);
|
||||
let narrowest = curve
|
||||
.iter()
|
||||
.map(|(_, g)| g.sigma())
|
||||
.fold(f64::MAX, f64::min);
|
||||
assert!(
|
||||
(widest - narrowest) / widest < 1e-9,
|
||||
"{narrowest} .. {widest}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registering_a_known_competitor_is_an_error() {
|
||||
let mut h = history();
|
||||
h.record_winner(&"player", &"layout", 1).unwrap();
|
||||
let err = h.register(Member::new("layout")).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::AlreadyRegistered { .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registering_twice_is_an_error() {
|
||||
let mut h = history();
|
||||
h.register(Member::new("layout").with_drift_scale(0.0))
|
||||
.unwrap();
|
||||
let err = h
|
||||
.register(Member::new("layout").with_drift_scale(1.0))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::AlreadyRegistered { .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
// The first registration stands.
|
||||
assert_eq!(h.rating(&"layout").unwrap().drift_scale(), 0.0);
|
||||
}
|
||||
|
||||
/// `weight` is per-event and meaningless here, so it is rejected rather than
|
||||
/// dropped — dropping it silently is the defect class this whole area keeps
|
||||
/// producing.
|
||||
#[test]
|
||||
fn a_weight_on_a_registration_is_rejected() {
|
||||
let mut h = history();
|
||||
let err = h
|
||||
.register(Member::new("layout").with_weight(0.5))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_drift_scale_on_a_registration_is_rejected() {
|
||||
for bad in [-1.0, f64::NAN, f64::INFINITY] {
|
||||
let mut h = history();
|
||||
let err = h
|
||||
.register(Member::new("layout").with_drift_scale(bad))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
name: "drift_scale",
|
||||
..
|
||||
}
|
||||
),
|
||||
"{bad}: {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registration makes the fit independent of the order events arrive in,
|
||||
/// which is what the per-event shape could not guarantee.
|
||||
#[test]
|
||||
fn registration_makes_the_fit_order_independent() {
|
||||
let build = |reversed: bool| {
|
||||
let mut h = history();
|
||||
h.register(
|
||||
Member::new("layout")
|
||||
.with_drift_scale(0.0)
|
||||
.with_prior(PINNED),
|
||||
)
|
||||
.unwrap();
|
||||
let mut events = vec![
|
||||
duel("player", "layout", 1, None),
|
||||
duel("player", "layout", 2, None),
|
||||
duel("player", "layout", 3, None),
|
||||
];
|
||||
if reversed {
|
||||
events.reverse();
|
||||
}
|
||||
h.add_events(events).unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
h
|
||||
};
|
||||
|
||||
let forward = build(false);
|
||||
let backward = build(true);
|
||||
for ((k, a), (_, b)) in skills(&forward).into_iter().zip(skills(&backward)) {
|
||||
assert_eq!(a.pi(), b.pi(), "{k} pi");
|
||||
assert_eq!(a.tau(), b.tau(), "{k} tau");
|
||||
}
|
||||
}
|
||||
|
||||
/// `rating` is the read-back that made a configuration mistake detectable from
|
||||
/// outside the crate at all. Every other accessor reports what inference
|
||||
/// inferred; this reports what it was told.
|
||||
#[test]
|
||||
fn rating_reads_back_what_was_stored() {
|
||||
let mut h = history();
|
||||
assert!(h.rating(&"nobody").is_none());
|
||||
|
||||
h.register(
|
||||
Member::new("layout")
|
||||
.with_drift_scale(0.25)
|
||||
.with_prior(PINNED),
|
||||
)
|
||||
.unwrap();
|
||||
let r = h.rating(&"layout").unwrap();
|
||||
assert_eq!(r.drift_scale(), 0.25);
|
||||
assert_eq!(r.prior().pi(), PINNED.pi());
|
||||
assert_eq!(r.prior().tau(), PINNED.tau());
|
||||
|
||||
// A competitor created by an event reports the history defaults.
|
||||
h.record_winner(&"player", &"layout", 1).unwrap();
|
||||
assert_eq!(h.rating(&"player").unwrap().drift_scale(), 1.0);
|
||||
}
|
||||
|
||||
/// The decision this issue turned on: two different values for one competitor
|
||||
/// are an error whether they arrive in one batch or two.
|
||||
///
|
||||
/// Last-write-wins across batches cut against the invariant
|
||||
/// `tests/ingestion_equivalence.rs` protects — the same contradictory events
|
||||
/// errored when batched and succeeded, order-dependently, one at a time.
|
||||
mod conflicting_configuration {
|
||||
use super::*;
|
||||
|
||||
fn seed(scale: f64) -> Event<i64, &'static str> {
|
||||
duel(
|
||||
"player",
|
||||
"layout",
|
||||
1,
|
||||
Some(Member::new("layout").with_drift_scale(scale)),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn within_one_batch_is_an_error() {
|
||||
let mut h = history();
|
||||
let err = h.add_events(vec![seed(0.0), seed(1.0)]).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::ConflictingCompetitorConfig {
|
||||
field: "drift_scale",
|
||||
..
|
||||
}
|
||||
),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn across_two_batches_is_also_an_error() {
|
||||
let mut h = history();
|
||||
h.add_events(vec![seed(0.0)]).unwrap();
|
||||
let err = h.add_events(vec![seed(1.0)]).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::ConflictingCompetitorConfig {
|
||||
field: "drift_scale",
|
||||
..
|
||||
}
|
||||
),
|
||||
"{err:?}"
|
||||
);
|
||||
// Rejected before anything mutates: the first declaration stands.
|
||||
assert_eq!(h.rating(&"layout").unwrap().drift_scale(), 0.0);
|
||||
}
|
||||
|
||||
/// Repeating the *same* value stays inert, which is the expected shape
|
||||
/// when the configuration is a property of the domain.
|
||||
#[test]
|
||||
fn repeating_the_same_value_is_inert() {
|
||||
let mut h = history();
|
||||
h.add_events(vec![seed(0.0)]).unwrap();
|
||||
h.add_events(vec![seed(0.0)]).unwrap();
|
||||
assert_eq!(h.rating(&"layout").unwrap().drift_scale(), 0.0);
|
||||
}
|
||||
|
||||
/// A registration and a later event that agree are fine; one that
|
||||
/// disagrees is the same error.
|
||||
#[test]
|
||||
fn a_registration_conflicts_with_a_later_event() {
|
||||
let mut h = history();
|
||||
h.register(Member::new("layout").with_drift_scale(0.0))
|
||||
.unwrap();
|
||||
h.add_events(vec![seed(0.0)]).unwrap();
|
||||
|
||||
let mut h2 = history();
|
||||
h2.register(Member::new("layout").with_drift_scale(0.0))
|
||||
.unwrap();
|
||||
let err = h2.add_events(vec![seed(1.0)]).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::ConflictingCompetitorConfig { .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -9,7 +9,7 @@ fn scored_two_team_one_event_pulls_winner_up() {
|
||||
.mu(0.0)
|
||||
.sigma(2.0)
|
||||
.beta(1.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.score_sigma(1.0)
|
||||
.build();
|
||||
|
||||
@@ -46,7 +46,7 @@ fn scored_zero_margin_treats_as_tie() {
|
||||
.mu(0.0)
|
||||
.sigma(2.0)
|
||||
.beta(1.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.score_sigma(1.0)
|
||||
.build();
|
||||
|
||||
@@ -88,7 +88,7 @@ fn scored_three_team_partial_order() {
|
||||
.mu(0.0)
|
||||
.sigma(2.0)
|
||||
.beta(1.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.score_sigma(1.0)
|
||||
.build();
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ fn history(gamma: f64) -> H {
|
||||
.sigma(SIGMA0)
|
||||
.beta(BETA)
|
||||
.score_sigma(SCORE_SIGMA)
|
||||
.drift(ConstantDrift(gamma))
|
||||
.drift(ConstantDrift::new(gamma))
|
||||
.unknown_keys(UnknownKeys::Reject)
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 20_000,
|
||||
|
||||
+190
-1
@@ -22,7 +22,7 @@ fn rating() -> R {
|
||||
R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(0.0),
|
||||
ConstantDrift::new(0.0),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -184,3 +184,192 @@ fn ingestion_rejects_weights_that_do_not_match_their_team() {
|
||||
"got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `mu`, `sigma` and `beta` were the last unvalidated setters on
|
||||
/// `HistoryBuilder`, next to `p_draw`, `score_sigma` and `convergence`, which
|
||||
/// all assert eagerly.
|
||||
///
|
||||
/// Two of the rejected values are the quiet kind. A negative `sigma` or `beta`
|
||||
/// enters inference only as its square, so it produced bit-identical results
|
||||
/// to the positive value — the sign was dropped without comment.
|
||||
mod builder_parameters {
|
||||
use trueskill_tt::History;
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "mu must be finite")]
|
||||
fn a_non_finite_mu_is_rejected() {
|
||||
let _ = History::builder().mu(f64::NAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "sigma must be finite and positive")]
|
||||
fn a_zero_sigma_is_rejected() {
|
||||
let _ = History::builder().sigma(0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "sigma must be finite and positive")]
|
||||
fn a_negative_sigma_is_rejected() {
|
||||
let _ = History::builder().sigma(-8.33);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "sigma must be finite and positive")]
|
||||
fn an_infinite_sigma_is_rejected() {
|
||||
let _ = History::builder().sigma(f64::INFINITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "beta must be finite and non-negative")]
|
||||
fn a_negative_beta_is_rejected() {
|
||||
let _ = History::builder().beta(-4.17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "beta must be finite and non-negative")]
|
||||
fn a_non_finite_beta_is_rejected() {
|
||||
let _ = History::builder().beta(f64::NAN);
|
||||
}
|
||||
|
||||
/// Zero beta is deliberately allowed: performance is then exactly skill.
|
||||
/// It has to reach a different fit than a positive beta, or "allowed"
|
||||
/// would just mean "not checked".
|
||||
#[test]
|
||||
fn a_zero_beta_is_allowed_and_changes_the_fit() {
|
||||
let fit = |beta: f64| {
|
||||
let mut h = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(beta)
|
||||
.build();
|
||||
h.record_winner(&"a", &"b", 1).unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
h.current_skill(&"a").unwrap()
|
||||
};
|
||||
let zero = fit(0.0);
|
||||
let positive = fit(25.0 / 6.0);
|
||||
assert!(zero.pi().is_finite() && zero.pi() > 0.0);
|
||||
assert!(
|
||||
(zero.pi() - positive.pi()).abs() > 1e-6,
|
||||
"zero beta must not merely be ignored: {zero:?} vs {positive:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The constructors below `HistoryBuilder`, which 0.8.0's validation did not
|
||||
/// reach.
|
||||
///
|
||||
/// `sigma`, `beta` and `gamma` all enter inference only as squares, so a
|
||||
/// negative value behaves as its absolute value and the sign vanishes without
|
||||
/// comment. Measured before these guards: `from_ms(25.0, -8.33)` and
|
||||
/// `Rating::new(_, -4.17, _)` returned results bit identical to their positive
|
||||
/// counterparts, and `Rating::new(_, NaN, _)` reached `Game::ranked`, which
|
||||
/// returned `Ok` carrying `Gaussian { pi: NaN, tau: NaN }`.
|
||||
mod constructor_parameters {
|
||||
use trueskill_tt::{ConstantDrift, Gaussian, History, InferenceError, Rating};
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "sigma must not be negative")]
|
||||
fn a_negative_sigma_is_rejected_by_from_ms() {
|
||||
let _ = Gaussian::from_ms(25.0, -8.33);
|
||||
}
|
||||
|
||||
/// NaN must pass, and that is deliberate: a broken fit produces a NaN
|
||||
/// sigma and `converge` reports it as `NonFiniteResult`. Rejecting it here
|
||||
/// would turn reporting into a panic inside inference.
|
||||
#[test]
|
||||
fn a_nan_sigma_passes_through_from_ms() {
|
||||
let g = Gaussian::from_ms(25.0, f64::NAN);
|
||||
assert!(g.sigma().is_nan() || g.pi().is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "beta must be finite and non-negative")]
|
||||
fn a_negative_beta_is_rejected_by_rating_new() {
|
||||
let _ =
|
||||
Rating::<i64, ConstantDrift>::new(Gaussian::default(), -4.17, ConstantDrift::new(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "beta must be finite and non-negative")]
|
||||
fn a_nan_beta_is_rejected_by_rating_new() {
|
||||
let _ = Rating::<i64, ConstantDrift>::new(
|
||||
Gaussian::default(),
|
||||
f64::NAN,
|
||||
ConstantDrift::new(0.0),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_beta_is_accepted_by_rating_new() {
|
||||
let _ =
|
||||
Rating::<i64, ConstantDrift>::new(Gaussian::default(), 0.0, ConstantDrift::new(0.0));
|
||||
}
|
||||
|
||||
/// `ConstantDrift` rejects at construction now that its field is private.
|
||||
#[test]
|
||||
#[should_panic(expected = "gamma must be finite and non-negative")]
|
||||
fn a_negative_gamma_is_rejected_by_constant_drift_new() {
|
||||
let _ = ConstantDrift::new(-0.0833);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "gamma must be finite and non-negative")]
|
||||
fn a_non_finite_gamma_is_rejected_by_constant_drift_new() {
|
||||
let _ = ConstantDrift::new(f64::NAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamma_reads_back_what_was_given() {
|
||||
assert_eq!(ConstantDrift::new(0.25).gamma(), 0.25);
|
||||
assert_eq!(ConstantDrift::new(0.0).gamma(), 0.0);
|
||||
}
|
||||
|
||||
/// `HistoryBuilder::drift` is generic and cannot inspect an arbitrary
|
||||
/// `Drift`, so the check on the variance each competitor accumulates is
|
||||
/// still needed — it is the only thing standing between a custom
|
||||
/// implementation and a NaN fit. `ConstantDrift` can no longer reach it,
|
||||
/// so this uses an implementation that can.
|
||||
#[test]
|
||||
fn a_custom_drift_returning_a_bad_variance_is_rejected_at_convergence() {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct BadDrift(f64);
|
||||
|
||||
impl trueskill_tt::Drift<i64> for BadDrift {
|
||||
fn variance_delta(&self, _from: &i64, _to: &i64) -> f64 {
|
||||
self.0
|
||||
}
|
||||
fn variance_for_elapsed(&self, _elapsed: i64) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
for bad in [f64::NAN, f64::INFINITY, -1.0] {
|
||||
let mut h = History::builder().drift(BadDrift(bad)).build();
|
||||
h.record_winner(&"a", &"b", 1).unwrap();
|
||||
h.record_winner(&"a", &"b", 5).unwrap();
|
||||
let err = h.converge().unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
name: "drift variance",
|
||||
..
|
||||
}
|
||||
),
|
||||
"drift {bad}: {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An ordinary drift is untouched.
|
||||
#[test]
|
||||
fn an_ordinary_drift_still_converges() {
|
||||
let mut h = History::builder()
|
||||
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||
.build();
|
||||
h.record_winner(&"a", &"b", 1).unwrap();
|
||||
h.record_winner(&"a", &"b", 5).unwrap();
|
||||
assert!(h.converge().unwrap().converged);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.unknown_keys(policy)
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 20_000,
|
||||
|
||||
Reference in New Issue
Block a user