Compare commits
66
Commits
v0.4.0
..
cc601c06eb
+129
@@ -2,6 +2,131 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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'
|
||||||
|
|
||||||
|
## 0.6.0 - 2026-09-08
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- fix!: make the joint span slices, not just the latest one
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.6.0
|
||||||
|
|
||||||
|
## 0.5.0 - 2026-09-08
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- feat!: name the unknown key, expose tail probabilities, flag short fits
|
||||||
|
- refactor!: remove the factor-graph surface nothing used, add try_winner
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix(test): the ingestion-order property was comparing two truncated fits
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- docs: record that the event log is the source of truth, and why
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: add UnknownKeys::Prior, and explain why there is no Skip
|
||||||
|
- feat: add History::posterior_of for a linear combination of competitors
|
||||||
|
- feat: add History::predict_margin for scored matchups
|
||||||
|
- feat: add expected_variance_reduction for scored active learning
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.5.0
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- style: factor the event-pair type out of the reconvergence fixture
|
||||||
|
- style: use arrays rather than vec! in the calibration fixture
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: pin that re-convergence is path-independent
|
||||||
|
- test: calibrate the marginals against the exact posterior
|
||||||
|
- test: pin what an additive model does to combined uncertainty
|
||||||
|
|
||||||
|
## 0.4.2 - 2026-09-07
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: replace the erfc approximation with libm, for free
|
||||||
|
- fix: route every transcendental through libm, and combine sigmas with hypot
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.4.2
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: localise the erfc_inv tail residual to the caller's argument
|
||||||
|
|
||||||
|
## 0.4.1 - 2026-09-07
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: correct erfc_inv's sign error and keep evidence in log space
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.4.1
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: pin quality()'s N-group closed form, closing the README cross-check
|
||||||
|
|
||||||
## 0.4.0 - 2026-09-07
|
## 0.4.0 - 2026-09-07
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
@@ -25,6 +150,10 @@ All notable changes to this project will be documented in this file.
|
|||||||
- feat: add expected information gain for active matchup selection
|
- feat: add expected information gain for active matchup selection
|
||||||
- feat: let observers be shared, boxed, or borrowed
|
- feat: let observers be shared, boxed, or borrowed
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.4.0
|
||||||
|
|
||||||
## 0.3.0 - 2026-09-01
|
## 0.3.0 - 2026-09-01
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ is where several defects have hidden — a debug-only run is not evidence.
|
|||||||
- `approx` — `approx::AbsDiffEq` etc. for `Gaussian`. Most numerical goldens need it.
|
- `approx` — `approx::AbsDiffEq` etc. for `Gaussian`. Most numerical goldens need it.
|
||||||
- `rayon` — opt-in parallel within-slice sweep and per-slice query passes.
|
- `rayon` — opt-in parallel within-slice sweep and per-slice query passes.
|
||||||
|
|
||||||
|
## Working rules
|
||||||
|
|
||||||
|
- **Investigate before implementing.** Measure the actual behaviour first —
|
||||||
|
against an analytic reference where one exists. Several "obvious" fixes in
|
||||||
|
this repo turned out to be wrong in sign or unnecessary, and the measurement
|
||||||
|
is what caught them.
|
||||||
|
- **Fix the root issue, not the symptom.** A clamp that hides an underflow, or
|
||||||
|
a tolerance loosened to make a test pass, is a defect deferred.
|
||||||
|
- **Scout crates.io before hand-rolling numerics.** Check accuracy against an
|
||||||
|
independent reference rather than trusting downloads: `puruspe` has 1.4M
|
||||||
|
downloads and is 346 ULP off in the tail, where `libm` is 1. Fewer
|
||||||
|
dependencies is preferable, not mandatory — take the dependency when it is
|
||||||
|
measurably better.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
A Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py):
|
A Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py):
|
||||||
@@ -66,11 +80,11 @@ History → TimeSlice[] → Event[] → Item[]
|
|||||||
`tau = mu/sigma²`). `Mul`/`Div` are the EP product/cavity: pure adds and
|
`tau = mu/sigma²`). `Mul`/`Div` are the EP product/cavity: pure adds and
|
||||||
subtracts. Variance-space ops (`Add`, `Sub`, `exclude`, `forget`) go through
|
subtracts. Variance-space ops (`Add`, `Sub`, `exclude`, `forget`) go through
|
||||||
`from_mv`/`variance()` and take no square root.
|
`from_mv`/`variance()` and take no square root.
|
||||||
- **`factor/`** — `TeamSumFactor`, `RankDiffFactor`, `TruncFactor` (ranked),
|
- **`factor/`** — `TruncFactor` (ranked) and `MarginFactor` (scored) over a
|
||||||
`MarginFactor` (scored), over a flat `VarStore`. `BuiltinFactor` dispatches
|
flat `VarStore`. `Game::run_chain` drives them directly through a local
|
||||||
by enum rather than `dyn`.
|
`DiffFactor` enum; there is no `Schedule` indirection and no generic `Factor`
|
||||||
- **`Schedule`** (`schedule.rs`) — drives factor propagation. `EpsilonOrMax` is
|
trait. Both were removed once measurement showed nothing had ever used them
|
||||||
the only implementation.
|
— see #42.
|
||||||
- **`Competitor`** (`competitor.rs`) — per-history temporal state (`message`,
|
- **`Competitor`** (`competitor.rs`) — per-history temporal state (`message`,
|
||||||
`last_time`). **`Rating`** (`rating.rs`) — static config (prior, `beta`, drift).
|
`last_time`). **`Rating`** (`rating.rs`) — static config (prior, `beta`, drift).
|
||||||
- **`storage/`** — `SkillStore` (per slice, `pub(crate)`) and `CompetitorStore`
|
- **`storage/`** — `SkillStore` (per slice, `pub(crate)`) and `CompetitorStore`
|
||||||
@@ -97,6 +111,12 @@ History → TimeSlice[] → Event[] → Item[]
|
|||||||
chain underflows to zero, and `ln(0)` is `-inf`.
|
chain underflows to zero, and `ln(0)` is `-inf`.
|
||||||
- **Colors are contiguous.** `recompute_color_groups` reorders events so each
|
- **Colors are contiguous.** `recompute_color_groups` reorders events so each
|
||||||
color occupies one range; `ColorGroups::groups_are_contiguous` asserts it.
|
color occupies one range; `ColorGroups::groups_are_contiguous` asserts it.
|
||||||
|
- **Transcendentals 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. Use `libm::exp` / `libm::log` in
|
||||||
|
inference code; `f64::sqrt` is fine (IEEE specifies it). Tests may use either.
|
||||||
- **The crate is `#![forbid(unsafe_code)]`.** Keep it that way.
|
- **The crate is `#![forbid(unsafe_code)]`.** Keep it that way.
|
||||||
- **Ingestion order must not change the answer.** Events added one at a time
|
- **Ingestion order must not change the answer.** Events added one at a time
|
||||||
must converge to the same fixed point as the same events batched — see
|
must converge to the same fixed point as the same events batched — see
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "trueskill-tt"
|
name = "trueskill-tt"
|
||||||
version = "0.4.0"
|
version = "0.8.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
|
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
|
||||||
@@ -51,6 +51,7 @@ harness = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
approx = { version = "0.5.1", optional = true }
|
approx = { version = "0.5.1", optional = true }
|
||||||
|
libm = "0.2.16"
|
||||||
rayon = { version = "1", optional = true }
|
rayon = { version = "1", optional = true }
|
||||||
smallvec = "1"
|
smallvec = "1"
|
||||||
|
|
||||||
@@ -78,3 +79,7 @@ debug = true
|
|||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
debug = true
|
debug = true
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "joint"
|
||||||
|
harness = false
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ grows proportionally to time:
|
|||||||
variance_delta = elapsed * γ²
|
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`:
|
when constructing a `Rating`:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -53,9 +53,9 @@ use trueskill_tt::{ConstantDrift, Gaussian, Rating};
|
|||||||
|
|
||||||
// gamma = 0.1 means skill can shift ~0.1 per time unit.
|
// gamma = 0.1 means skill can shift ~0.1 per time unit.
|
||||||
let rating: Rating<i64, ConstantDrift> =
|
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
|
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
|
`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
|
### Per-competitor drift
|
||||||
|
|
||||||
A `History` has one drift model, but individual competitors can scale it.
|
A `History` has one drift model, but individual competitors can scale it.
|
||||||
`Member::with_drift_scale(s)` multiplies the drift *variance* that competitor
|
`Member::with_drift_scale(s)` multiplies the drift *variance* that competitor
|
||||||
accumulates, so `s` is in the same units as `gamma`: `ConstantDrift(g)` at
|
accumulates, so `s` is in the same units as `gamma`: `ConstantDrift::new(g)` at
|
||||||
scale `s` behaves exactly as `ConstantDrift(g * s)` would, for that competitor
|
scale `s` behaves exactly as `ConstantDrift::new(g * s)` would, for that competitor
|
||||||
alone.
|
alone.
|
||||||
|
|
||||||
`0.0` pins a competitor still. That is what makes a **fixed reference point**
|
`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
|
```rust
|
||||||
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
|
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 {
|
h.add_events(vec![Event {
|
||||||
time: 0,
|
time: 0,
|
||||||
@@ -134,14 +134,20 @@ h.add_events(vec![Event {
|
|||||||
h.converge().unwrap();
|
h.converge().unwrap();
|
||||||
```
|
```
|
||||||
|
|
||||||
Like `with_prior`, the scale is **competitor configuration captured at first
|
Like `with_prior`, the scale is **competitor configuration, not a per-event
|
||||||
appearance** — setting it on a key the history already knows has no effect. It
|
value**: it applies to the competitor for the whole history, and it applies
|
||||||
must be finite and non-negative; ingestion otherwise fails with
|
whenever it is supplied — including on a key the history already knows.
|
||||||
`InferenceError::InvalidParameter`.
|
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
|
The fluent `EventBuilder` reaches this too: `.team([...])` is the common case
|
||||||
not `drift_scale` or `prior`; those need the typed `Event` / `Team` / `Member`
|
and leaves both unset, while `.members([...])` takes `Member` values directly,
|
||||||
shape shown above.
|
so `h.event(t).members([Member::new("layout_7").with_drift_scale(0.0)])` is
|
||||||
|
equivalent to the typed shape above.
|
||||||
|
|
||||||
## Scored outcomes
|
## Scored outcomes
|
||||||
|
|
||||||
@@ -195,8 +201,47 @@ stay available at any size:
|
|||||||
quadratic in team count.
|
quadratic in team count.
|
||||||
- `predict_ranking(teams, ranks)` — one specific finishing order.
|
- `predict_ranking(teams, ranks)` — one specific finishing order.
|
||||||
|
|
||||||
Unknown keys are an error, not a silent omission: a team the history has never
|
Unknown keys are an error by default, not a silent omission: a team the history
|
||||||
seen cannot produce a confident-looking probability.
|
has never seen cannot produce a confident-looking probability. The error names
|
||||||
|
the key, and every key must already be known — pre-filter with `lookup` or
|
||||||
|
`current_skill` if your caller cannot guarantee that.
|
||||||
|
|
||||||
|
If predicting for competitors you have never seen is the point rather than a
|
||||||
|
mistake, say so once:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::{History, UnknownKeys};
|
||||||
|
|
||||||
|
let h = History::builder().unknown_keys(UnknownKeys::Prior).build();
|
||||||
|
```
|
||||||
|
|
||||||
|
An unknown competitor is then answered from the configured prior, which is the
|
||||||
|
honest reading — you have no evidence about them — and correctly *widens* a team
|
||||||
|
that contains one. There is deliberately no "skip the member" mode: a team's
|
||||||
|
performance is the sum of its members, so dropping one would make the model more
|
||||||
|
certain because it knows less.
|
||||||
|
|
||||||
|
### Asking about one competitor
|
||||||
|
|
||||||
|
`Gaussian` answers tail questions directly, which is what a stopping rule needs:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::History;
|
||||||
|
|
||||||
|
let mut h = History::builder().build();
|
||||||
|
h.record_winner(&"alice", &"bob", 1).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let skill = h.current_skill(&"alice").unwrap();
|
||||||
|
|
||||||
|
// "How sure am I that this is below the cutoff?" — a probability, not a
|
||||||
|
// `mu + z * sigma` band whose confidence drifts as sigma changes.
|
||||||
|
let _ = skill.probability_below(20.0);
|
||||||
|
|
||||||
|
// Use this rather than `1.0 - probability_below(x)`: the complement cancels
|
||||||
|
// away every digit in the upper tail, which is where a stopping rule lives.
|
||||||
|
let _ = skill.probability_above(30.0);
|
||||||
|
```
|
||||||
|
|
||||||
## Which match to play next
|
## Which match to play next
|
||||||
|
|
||||||
@@ -242,7 +287,7 @@ expensive than `quality()`. Scoring every pairing among `n` competitors is
|
|||||||
- [x] Add Observer (`Observer` / `NullObserver`)
|
- [x] Add Observer (`Observer` / `NullObserver`)
|
||||||
- [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`)
|
- [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`)
|
||||||
- [x] N-team `predict_outcome` with draw mass, and `expected_information_gain`
|
- [x] N-team `predict_outcome` with draw mass, and `expected_information_gain`
|
||||||
- [ ] Cross-check `quality()` against [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) — N-group support works and is covered by invariants, but no reference values are asserted
|
- [x] Cross-check `quality()` against [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) — N identical teams follow the closed form `(1/5)^((n-1)/2)` for the conventional parameters, asserted for n = 2..10, and the n=3/n=5 values (0.200, 0.040) match the reference package
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+45
-35
@@ -1,45 +1,55 @@
|
|||||||
|
//! One slice's event sweep.
|
||||||
|
//!
|
||||||
|
//! Written against the public API rather than against `TimeSlice` directly.
|
||||||
|
//! It used to reach for `TimeSlice`, `KeyTable`, `CompetitorStore`,
|
||||||
|
//! `Competitor` and `EventKind`, and was the *only* thing outside `src/`
|
||||||
|
//! that did — so a benchmark was dictating five public types that no test,
|
||||||
|
//! example or consumer could otherwise obtain.
|
||||||
|
//!
|
||||||
|
//! A single-slice history's `converge` calls exactly the same per-slice sweep,
|
||||||
|
//! so capping at one iteration measures the same code path.
|
||||||
|
|
||||||
use criterion::{Criterion, criterion_group, criterion_main};
|
use criterion::{Criterion, criterion_group, criterion_main};
|
||||||
use trueskill_tt::{
|
use smallvec::smallvec;
|
||||||
BETA, Competitor, ConvergenceOptions, EventKind, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA,
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
TimeSlice, drift::ConstantDrift, gaussian::Gaussian, storage::CompetitorStore,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn criterion_benchmark(criterion: &mut Criterion) {
|
fn criterion_benchmark(criterion: &mut Criterion) {
|
||||||
let mut index_map = KeyTable::new();
|
let build = || {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 1,
|
||||||
|
epsilon: 0.0,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.build();
|
||||||
|
|
||||||
let a = index_map.get_or_create("a");
|
// 100 events, all at one time, so the history has a single slice.
|
||||||
let b = index_map.get_or_create("b");
|
let events: Vec<Event<i64, &'static str>> = (0..100)
|
||||||
let c = index_map.get_or_create("c");
|
.map(|_| Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
h.add_events(events).expect("fixture ingests");
|
||||||
|
h
|
||||||
|
};
|
||||||
|
|
||||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
criterion.bench_function("slice_sweep_100_events", |b| {
|
||||||
|
b.iter_batched(
|
||||||
for agent in [a, b, c] {
|
build,
|
||||||
agents.insert(
|
|mut h| {
|
||||||
agent,
|
// `converge_partial`, not `converge`: one iteration is
|
||||||
Competitor {
|
// deliberately short of convergence and `converge` reports that
|
||||||
rating: Rating::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)),
|
// as an error.
|
||||||
..Default::default()
|
let _ = h.converge_partial();
|
||||||
},
|
},
|
||||||
|
criterion::BatchSize::SmallInput,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
let mut composition = Vec::new();
|
|
||||||
let mut results = Vec::new();
|
|
||||||
let mut weights = Vec::new();
|
|
||||||
|
|
||||||
for _ in 0..100 {
|
|
||||||
composition.push(vec![vec![a], vec![b]]);
|
|
||||||
results.push(vec![1.0, 0.0]);
|
|
||||||
weights.push(vec![vec![1.0], vec![1.0]]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let kinds = vec![EventKind::Ranked; composition.len()];
|
|
||||||
|
|
||||||
let mut time_slice = TimeSlice::new(1, P_DRAW, ConvergenceOptions::default());
|
|
||||||
time_slice.add_events(composition, Some(results), Some(weights), kinds, &agents);
|
|
||||||
|
|
||||||
criterion.bench_function("Batch::iteration", |b| {
|
|
||||||
b.iter(|| time_slice.iteration(0, &agents))
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,11 +43,12 @@ fn build_history_1v1(
|
|||||||
rng
|
rng
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut h = History::<i64, _, _, String>::builder_with_key()
|
let mut h = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 30,
|
max_iter: 30,
|
||||||
epsilon: 1e-6,
|
epsilon: 1e-6,
|
||||||
@@ -82,7 +83,7 @@ fn bench_converge(c: &mut Criterion) {
|
|||||||
b.iter_batched(
|
b.iter_batched(
|
||||||
|| build_history_1v1(500, 100, 10, 42),
|
|| build_history_1v1(500, 100, 10, 42),
|
||||||
|mut h| {
|
|mut h| {
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
},
|
},
|
||||||
BatchSize::SmallInput,
|
BatchSize::SmallInput,
|
||||||
);
|
);
|
||||||
@@ -92,7 +93,7 @@ fn bench_converge(c: &mut Criterion) {
|
|||||||
b.iter_batched(
|
b.iter_batched(
|
||||||
|| build_history_1v1(2000, 200, 20, 42),
|
|| build_history_1v1(2000, 200, 20, 42),
|
||||||
|mut h| {
|
|mut h| {
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
},
|
},
|
||||||
BatchSize::SmallInput,
|
BatchSize::SmallInput,
|
||||||
);
|
);
|
||||||
@@ -106,7 +107,7 @@ fn bench_converge(c: &mut Criterion) {
|
|||||||
b.iter_batched(
|
b.iter_batched(
|
||||||
|| build_history_1v1(5000, 50000, 5000, 42),
|
|| build_history_1v1(5000, 50000, 5000, 42),
|
||||||
|mut h| {
|
|mut h| {
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
},
|
},
|
||||||
BatchSize::SmallInput,
|
BatchSize::SmallInput,
|
||||||
);
|
);
|
||||||
|
|||||||
+4
-2
@@ -32,7 +32,8 @@ fn bench_ingest(c: &mut Criterion) {
|
|||||||
b.iter_batched(
|
b.iter_batched(
|
||||||
|| events(n, 0),
|
|| events(n, 0),
|
||||||
|evs| {
|
|evs| {
|
||||||
let mut h: History<i64, _, _, String> = History::builder_with_key().build();
|
let mut h: History<i64, _, _, String> =
|
||||||
|
History::builder().key_type::<String>().build();
|
||||||
for ev in evs {
|
for ev in evs {
|
||||||
h.add_events(std::iter::once(ev)).unwrap();
|
h.add_events(std::iter::once(ev)).unwrap();
|
||||||
}
|
}
|
||||||
@@ -46,7 +47,8 @@ fn bench_ingest(c: &mut Criterion) {
|
|||||||
b.iter_batched(
|
b.iter_batched(
|
||||||
|| events(n, 0),
|
|| events(n, 0),
|
||||||
|evs| {
|
|evs| {
|
||||||
let mut h: History<i64, _, _, String> = History::builder_with_key().build();
|
let mut h: History<i64, _, _, String> =
|
||||||
|
History::builder().key_type::<String>().build();
|
||||||
h.add_events(evs).unwrap();
|
h.add_events(evs).unwrap();
|
||||||
black_box(h.time_slices_len())
|
black_box(h.time_slices_len())
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
//! Cost of the joint posterior: factorising versus querying.
|
||||||
|
//!
|
||||||
|
//! The split is the whole point of `History::joint`. Factorising is `O(n^3)` in
|
||||||
|
//! the history's appearances and depends only on the fit; a query is `O(n^2)`
|
||||||
|
//! and depends only on the question. `posterior_of_one_shot` pays both every
|
||||||
|
//! time, `joint_query` pays only the second.
|
||||||
|
|
||||||
|
use criterion::{Criterion, criterion_group, criterion_main};
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
/// 30 slices of 8 duels: 480 appearances over 100 competitors.
|
||||||
|
fn fitted() -> History<i64, ConstantDrift, trueskill_tt::NullObserver, String> {
|
||||||
|
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.05))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 30,
|
||||||
|
epsilon: 1e-10,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut events: Vec<Event<i64, String>> = Vec::new();
|
||||||
|
let mut k = 0usize;
|
||||||
|
for t in 0..30i64 {
|
||||||
|
for _ in 0..8 {
|
||||||
|
k += 1;
|
||||||
|
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([
|
||||||
|
(k as f64 * 0.3).sin().abs() * 20.0,
|
||||||
|
(k as f64 * 0.3).cos().abs() * 20.0,
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_joint(c: &mut Criterion) {
|
||||||
|
let h = fitted();
|
||||||
|
let a = "p0".to_string();
|
||||||
|
let b = "p1".to_string();
|
||||||
|
let terms = [(&a, 1.0), (&b, -1.0)];
|
||||||
|
|
||||||
|
c.bench_function("joint_factorise_480_appearances", |bencher| {
|
||||||
|
bencher.iter(|| std::hint::black_box(h.joint().unwrap().variables()));
|
||||||
|
});
|
||||||
|
|
||||||
|
c.bench_function("posterior_of_one_shot_480_appearances", |bencher| {
|
||||||
|
bencher.iter(|| std::hint::black_box(h.posterior_of(&terms).unwrap()));
|
||||||
|
});
|
||||||
|
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
c.bench_function("joint_query_480_appearances", |bencher| {
|
||||||
|
bencher.iter(|| std::hint::black_box(joint.posterior_of(&terms).unwrap()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(benches, bench_joint);
|
||||||
|
criterion_main!(benches);
|
||||||
+4
-3
@@ -5,11 +5,12 @@ use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
|
|||||||
fn bench_scored_history(c: &mut Criterion) {
|
fn bench_scored_history(c: &mut Criterion) {
|
||||||
c.bench_function("scored_history_60_events_30_iter", |bencher| {
|
c.bench_function("scored_history_60_events_30_iter", |bencher| {
|
||||||
bencher.iter(|| {
|
bencher.iter(|| {
|
||||||
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
|
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.drift(ConstantDrift(0.03))
|
.drift(ConstantDrift::new(0.03))
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ fn bench_scored_history(c: &mut Criterion) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
h.add_events(events).unwrap();
|
h.add_events(events).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")`.
|
`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
|
### Expected speedup ballpark
|
||||||
|
|
||||||
For 1000 players, 60 events/slice × 1000 slices, 30 convergence iterations:
|
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.
|
- 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.
|
- Default = asynchronous EP (preserves current semantics). Synchronous opt-in only.
|
||||||
- Cross-slice sweep stays sequential; no speculative parallel sweeps.
|
- 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
|
### Open question
|
||||||
|
|
||||||
|
|||||||
+26
-6
@@ -42,18 +42,38 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut hist: History<i64, _, _, String> = History::builder_with_key()
|
let mut hist: History<i64, _, _, String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
.sigma(1.6)
|
.sigma(1.6)
|
||||||
.drift(ConstantDrift(0.036))
|
.drift(ConstantDrift::new(0.036))
|
||||||
.convergence(trueskill_tt::ConvergenceOptions {
|
.convergence(trueskill_tt::ConvergenceOptions {
|
||||||
max_iter: 10,
|
// This history needs 30 sweeps to reach the epsilon below. It was
|
||||||
|
// capped at 10 until the `#[must_use]` on `ConvergenceReport`
|
||||||
|
// surfaced that the example had been shipping a short fit.
|
||||||
|
max_iter: 100,
|
||||||
epsilon: 0.01,
|
epsilon: 0.01,
|
||||||
alpha: 1.0,
|
alpha: 1.0,
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
hist.add_events(events).unwrap();
|
hist.add_events(events).unwrap();
|
||||||
hist.converge().unwrap();
|
|
||||||
|
// Read the report rather than discarding it. A fit that hits `max_iter`
|
||||||
|
// without reaching `epsilon` is not an error and does not look wrong — every
|
||||||
|
// rating comes back finite and sensibly ordered — so this flag is the only
|
||||||
|
// thing that says the numbers were still moving when the sweep stopped.
|
||||||
|
let report = hist.converge().unwrap();
|
||||||
|
eprintln!(
|
||||||
|
"converged={} after {} sweeps, final step {:?}",
|
||||||
|
report.converged, report.iterations, report.final_step
|
||||||
|
);
|
||||||
|
if !report.converged {
|
||||||
|
eprintln!(
|
||||||
|
"warning: stopped after {} sweeps with a final step of {:?}, \
|
||||||
|
short of epsilon — raise ConvergenceOptions::max_iter",
|
||||||
|
report.iterations, report.final_step
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let players = [
|
let players = [
|
||||||
("aggasi", "a092", 38800i64),
|
("aggasi", "a092", 38800i64),
|
||||||
@@ -77,7 +97,7 @@ fn main() {
|
|||||||
let mut y_spec = (f64::MAX, f64::MIN);
|
let mut y_spec = (f64::MAX, f64::MIN);
|
||||||
|
|
||||||
for &(_, id, cutoff) in &players {
|
for &(_, id, cutoff) in &players {
|
||||||
for (ts, gs) in hist.learning_curve(id) {
|
for (ts, gs) in hist.learning_curve(id).unwrap() {
|
||||||
if ts >= cutoff {
|
if ts >= cutoff {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -123,7 +143,7 @@ fn main() {
|
|||||||
let mut upper = Vec::new();
|
let mut upper = Vec::new();
|
||||||
let mut lower = Vec::new();
|
let mut lower = Vec::new();
|
||||||
|
|
||||||
for (ts, gs) in hist.learning_curve(id) {
|
for (ts, gs) in hist.learning_curve(id).unwrap() {
|
||||||
if ts >= cutoff {
|
if ts >= cutoff {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ fn main() {
|
|||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.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
|
.score_sigma(2.0) // tune to data; smaller = trust margins more
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
+38
-3
@@ -47,7 +47,38 @@ fn kl_divergence(q: Gaussian, p: Gaussian) -> f64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mean_gap = q.mu() - p.mu();
|
let mean_gap = q.mu() - p.mu();
|
||||||
0.5 * ((var_p / var_q).ln() + (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.
|
/// 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;
|
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 {
|
if probability <= NEGLIGIBLE {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -177,7 +208,11 @@ mod tests {
|
|||||||
type R = Rating<i64, ConstantDrift>;
|
type R = Rating<i64, ConstantDrift>;
|
||||||
|
|
||||||
fn rating(mu: f64, sigma: f64) -> R {
|
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 {
|
fn options(p_draw: f64) -> GameOptions {
|
||||||
|
|||||||
@@ -191,3 +191,121 @@ mod tests {
|
|||||||
assert_eq!(cg.total_events(), 4);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+7
-1
@@ -62,7 +62,13 @@ impl Default for ConvergenceOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Post-hoc summary of a `History::converge` call.
|
/// Post-hoc summary of a `History::converge` call.
|
||||||
#[derive(Clone, Debug)]
|
///
|
||||||
|
/// 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, PartialEq)]
|
||||||
pub struct ConvergenceReport {
|
pub struct ConvergenceReport {
|
||||||
pub iterations: usize,
|
pub iterations: usize,
|
||||||
pub final_step: (f64, f64),
|
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 = i64`: variance added is `(to - from) * gamma^2`.
|
||||||
/// For `Time = Untimed`: elapsed is always 0, so drift is always 0.
|
/// 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)]
|
#[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 {
|
impl<T: Time> Drift<T> for ConstantDrift {
|
||||||
fn variance_delta(&self, from: &T, to: &T) -> f64 {
|
fn variance_delta(&self, from: &T, to: &T) -> f64 {
|
||||||
|
|||||||
+154
-3
@@ -1,34 +1,98 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
|
/// How a prediction should treat a key the history has never seen.
|
||||||
|
///
|
||||||
|
/// Configured once per history via
|
||||||
|
/// [`HistoryBuilder::unknown_keys`](crate::HistoryBuilder::unknown_keys).
|
||||||
|
/// Neither known consumer wants this to vary between queries — one predicts
|
||||||
|
/// thousands of candidate matchups in a loop, the other's headline feature is
|
||||||
|
/// predicting a competitor nobody has faced — so it is a property of how you
|
||||||
|
/// intend to use the model rather than an argument on five call sites.
|
||||||
|
///
|
||||||
|
/// # There is deliberately no `Skip`
|
||||||
|
///
|
||||||
|
/// Dropping an unknown member is the obvious third option and it is wrong. A
|
||||||
|
/// team's performance is the *sum* of its members, so removing one removes its
|
||||||
|
/// variance too: measured on a two-member team with one unknown, skipping gives
|
||||||
|
/// a performance sigma of 2.37 where treating the member as unknown gives 6.53.
|
||||||
|
/// An unknown competitor would make the model *more* certain, which is
|
||||||
|
/// backwards. `Prior` is also the answer the model already gives for a
|
||||||
|
/// competitor it knows about but has no evidence for, so it corresponds to a
|
||||||
|
/// state the model can actually be in; skipping does not.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum UnknownKeys {
|
||||||
|
/// Reject the prediction with [`InferenceError::UnknownKey`].
|
||||||
|
///
|
||||||
|
/// The default, and the right one when every key is expected to be known:
|
||||||
|
/// a team of strangers should not silently produce a confident-looking
|
||||||
|
/// answer.
|
||||||
|
#[default]
|
||||||
|
Reject,
|
||||||
|
/// Treat an unknown competitor as one sitting at the history's configured
|
||||||
|
/// prior.
|
||||||
|
///
|
||||||
|
/// This is the honest Bayesian reading — a competitor you have never
|
||||||
|
/// observed is exactly the prior — and it makes "predict a matchup
|
||||||
|
/// involving someone new" a first-class question rather than something a
|
||||||
|
/// caller fakes with a neutral constant.
|
||||||
|
Prior,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub enum InferenceError {
|
pub enum InferenceError {
|
||||||
/// Expected and actual lengths of some array-shaped input differ.
|
/// Expected and actual lengths of some array-shaped input differ.
|
||||||
|
#[non_exhaustive]
|
||||||
MismatchedShape {
|
MismatchedShape {
|
||||||
kind: &'static str,
|
kind: &'static str,
|
||||||
expected: usize,
|
expected: usize,
|
||||||
got: usize,
|
got: usize,
|
||||||
},
|
},
|
||||||
/// An `Outcome` of the wrong variant was supplied for the requested inference.
|
/// An `Outcome` of the wrong variant was supplied for the requested inference.
|
||||||
|
#[non_exhaustive]
|
||||||
WrongOutcomeKind {
|
WrongOutcomeKind {
|
||||||
context: &'static str,
|
context: &'static str,
|
||||||
expected: &'static str,
|
expected: &'static str,
|
||||||
got: &'static str,
|
got: &'static str,
|
||||||
},
|
},
|
||||||
/// A probability value is outside `[0, 1]`.
|
/// A probability value is outside `[0, 1]`.
|
||||||
|
#[non_exhaustive]
|
||||||
InvalidProbability { value: f64 },
|
InvalidProbability { value: f64 },
|
||||||
/// A scalar parameter is outside its valid range.
|
/// A scalar parameter is outside its valid range.
|
||||||
|
#[non_exhaustive]
|
||||||
InvalidParameter { name: &'static str, value: f64 },
|
InvalidParameter { name: &'static str, value: f64 },
|
||||||
/// An event contains tied teams, but the draw probability is zero.
|
/// An event contains tied teams, but the draw probability is zero.
|
||||||
///
|
///
|
||||||
/// A zero draw probability asserts that draws cannot occur, so a tied
|
/// A zero draw probability asserts that draws cannot occur, so a tied
|
||||||
/// result has no representable likelihood. Configure a positive `p_draw`
|
/// result has no representable likelihood. Configure a positive `p_draw`
|
||||||
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
|
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
|
||||||
|
#[non_exhaustive]
|
||||||
TieWithoutDrawProbability { teams: (usize, usize) },
|
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 posterior 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.
|
||||||
|
#[non_exhaustive]
|
||||||
|
NotConverged {
|
||||||
|
iterations: usize,
|
||||||
|
final_step: (f64, f64),
|
||||||
|
epsilon: f64,
|
||||||
|
},
|
||||||
/// Inference produced a non-finite value (NaN or infinity).
|
/// Inference produced a non-finite value (NaN or infinity).
|
||||||
///
|
///
|
||||||
/// Indicates numerical breakdown; the resulting skills are meaningless
|
/// Indicates numerical breakdown; the resulting skills are meaningless
|
||||||
/// and must not be treated as a converged estimate.
|
/// and must not be treated as a converged estimate.
|
||||||
|
#[non_exhaustive]
|
||||||
NonFiniteResult {
|
NonFiniteResult {
|
||||||
context: &'static str,
|
context: &'static str,
|
||||||
step: (f64, f64),
|
step: (f64, f64),
|
||||||
@@ -42,6 +106,7 @@ pub enum InferenceError {
|
|||||||
/// "last one wins" would make the result depend on iteration order.
|
/// "last one wins" would make the result depend on iteration order.
|
||||||
/// Declaring the same value repeatedly is fine and is the expected shape
|
/// Declaring the same value repeatedly is fine and is the expected shape
|
||||||
/// when a competitor's configuration is a property of the domain.
|
/// when a competitor's configuration is a property of the domain.
|
||||||
|
#[non_exhaustive]
|
||||||
ConflictingCompetitorConfig {
|
ConflictingCompetitorConfig {
|
||||||
competitor: usize,
|
competitor: usize,
|
||||||
field: &'static str,
|
field: &'static str,
|
||||||
@@ -50,10 +115,61 @@ pub enum InferenceError {
|
|||||||
///
|
///
|
||||||
/// Reported rather than skipped: dropping unknown keys turns a team of
|
/// Reported rather than skipped: dropping unknown keys turns a team of
|
||||||
/// strangers into a confident-looking probability about nobody.
|
/// strangers into a confident-looking probability about nobody.
|
||||||
UnknownKey { team: usize, member: usize },
|
///
|
||||||
|
/// `key` is the offending key's `Debug` rendering. It is carried because
|
||||||
|
/// the indices alone are not actionable: a caller that logs
|
||||||
|
/// `UnknownKey { team: 0, member: 0 }` learns nothing about *which* of its
|
||||||
|
/// keys the history has not seen, and the natural handling — fall back to a
|
||||||
|
/// neutral value — turns the whole thing into a plausible constant.
|
||||||
|
#[non_exhaustive]
|
||||||
|
UnknownKey {
|
||||||
|
team: usize,
|
||||||
|
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.
|
||||||
|
#[non_exhaustive]
|
||||||
|
AlreadyRegistered { key: String },
|
||||||
/// A prediction was given a team with no members.
|
/// A prediction was given a team with no members.
|
||||||
|
#[non_exhaustive]
|
||||||
EmptyTeam { team: usize },
|
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.
|
||||||
|
#[non_exhaustive]
|
||||||
|
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.
|
||||||
|
#[non_exhaustive]
|
||||||
|
JointUnavailable { reason: &'static str },
|
||||||
/// Fewer than two teams were supplied to a prediction.
|
/// Fewer than two teams were supplied to a prediction.
|
||||||
|
#[non_exhaustive]
|
||||||
NotEnoughTeams { got: usize },
|
NotEnoughTeams { got: usize },
|
||||||
/// The full outcome distribution was requested for too many teams.
|
/// The full outcome distribution was requested for too many teams.
|
||||||
///
|
///
|
||||||
@@ -63,6 +179,7 @@ pub enum InferenceError {
|
|||||||
/// enumerate on a caller's behalf; ask for individual rankings with
|
/// enumerate on a caller's behalf; ask for individual rankings with
|
||||||
/// `predict_ranking`, or for `predict_win_probabilities`, both of which
|
/// `predict_ranking`, or for `predict_win_probabilities`, both of which
|
||||||
/// stay cheap at any team count.
|
/// stay cheap at any team count.
|
||||||
|
#[non_exhaustive]
|
||||||
TooManyTeams { got: usize, max: usize },
|
TooManyTeams { got: usize, max: usize },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +210,18 @@ impl fmt::Display for InferenceError {
|
|||||||
teams.0, teams.1
|
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 } => {
|
Self::NonFiniteResult { context, step } => {
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
@@ -108,15 +237,37 @@ impl fmt::Display for InferenceError {
|
|||||||
"competitor {competitor}: this batch sets {field} to two different values"
|
"competitor {competitor}: this batch sets {field} to two different values"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Self::UnknownKey { team, member } => {
|
Self::UnknownKey { team, member, key } => {
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
"team {team}, member {member}: no skill recorded for this key"
|
"team {team}, member {member}: no skill recorded for key {key} \
|
||||||
|
(every key must already be known to the history; pre-filter \
|
||||||
|
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 } => {
|
Self::EmptyTeam { team } => {
|
||||||
write!(f, "team {team} has no members")
|
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}")
|
||||||
|
}
|
||||||
Self::NotEnoughTeams { got } => {
|
Self::NotEnoughTeams { got } => {
|
||||||
write!(f, "prediction needs at least 2 teams, got {got}")
|
write!(f, "prediction needs at least 2 teams, got {got}")
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-7
@@ -11,7 +11,7 @@ use smallvec::SmallVec;
|
|||||||
use crate::{gaussian::Gaussian, outcome::Outcome, time::Time};
|
use crate::{gaussian::Gaussian, outcome::Outcome, time::Time};
|
||||||
|
|
||||||
/// A single match at time `time` involving some number of teams.
|
/// A single match at time `time` involving some number of teams.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct Event<T: Time, K> {
|
pub struct Event<T: Time, K> {
|
||||||
pub time: T,
|
pub time: T,
|
||||||
pub teams: SmallVec<[Team<K>; 4]>,
|
pub teams: SmallVec<[Team<K>; 4]>,
|
||||||
@@ -19,7 +19,7 @@ pub struct Event<T: Time, K> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A team: list of members competing together.
|
/// A team: list of members competing together.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct Team<K> {
|
pub struct Team<K> {
|
||||||
pub members: SmallVec<[Member<K>; 4]>,
|
pub members: SmallVec<[Member<K>; 4]>,
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ impl<K> Default for Team<K> {
|
|||||||
/// for one competitor within a single batch is
|
/// for one competitor within a single batch is
|
||||||
/// `InferenceError::ConflictingCompetitorConfig`: events in a batch have no
|
/// `InferenceError::ConflictingCompetitorConfig`: events in a batch have no
|
||||||
/// order, so there would be no well-defined winner.
|
/// order, so there would be no well-defined winner.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct Member<K> {
|
pub struct Member<K> {
|
||||||
pub key: K,
|
pub key: K,
|
||||||
pub weight: f64,
|
pub weight: f64,
|
||||||
@@ -88,7 +88,9 @@ impl<K> Member<K> {
|
|||||||
|
|
||||||
/// Set this competitor's starting skill estimate.
|
/// 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 {
|
pub fn with_prior(mut self, prior: Gaussian) -> Self {
|
||||||
self.prior = Some(prior);
|
self.prior = Some(prior);
|
||||||
self
|
self
|
||||||
@@ -97,14 +99,15 @@ impl<K> Member<K> {
|
|||||||
/// Scale how fast this competitor drifts, relative to the history's drift.
|
/// 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
|
/// The scale multiplies the drift *variance*, so it is in the same units as
|
||||||
/// `gamma`: `ConstantDrift(g)` at `scale = s` behaves exactly as
|
/// `gamma`: `ConstantDrift::new(g)` at `scale = s` behaves exactly as
|
||||||
/// `ConstantDrift(g * s)` would for this competitor alone.
|
/// `ConstantDrift::new(g * s)` would for this competitor alone.
|
||||||
///
|
///
|
||||||
/// `0.0` pins the competitor still — useful for a reference point that
|
/// `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
|
/// shares a scale with moving competitors but should not itself move: a bot
|
||||||
/// at a known strength, a rating floor, a course difficulty.
|
/// 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
|
/// Must be finite and non-negative, or ingestion fails with
|
||||||
/// [`InferenceError::InvalidParameter`](crate::InferenceError::InvalidParameter).
|
/// [`InferenceError::InvalidParameter`](crate::InferenceError::InvalidParameter).
|
||||||
pub fn with_drift_scale(mut self, scale: f64) -> Self {
|
pub fn with_drift_scale(mut self, scale: f64) -> Self {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use crate::{
|
|||||||
time::Time,
|
time::Time,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[must_use = "an event is only recorded by `.commit()`; a dropped builder \
|
||||||
|
silently ingests nothing"]
|
||||||
pub struct EventBuilder<'h, T, D, O, K>
|
pub struct EventBuilder<'h, T, D, O, K>
|
||||||
where
|
where
|
||||||
T: Time,
|
T: Time,
|
||||||
@@ -50,6 +52,8 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add a team by its member keys (weight 1.0 each, no prior overrides).
|
/// 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 {
|
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();
|
let members: SmallVec<[Member<K>; 4]> = keys.into_iter().map(Member::new).collect();
|
||||||
self.event.teams.push(Team { members });
|
self.event.teams.push(Team { members });
|
||||||
@@ -57,6 +61,40 @@ where
|
|||||||
self
|
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.
|
/// Set per-member weights for the most recently added team.
|
||||||
///
|
///
|
||||||
/// A length mismatch is recorded and returned by [`EventBuilder::commit`]
|
/// A length mismatch is recorded and returned by [`EventBuilder::commit`]
|
||||||
|
|||||||
+41
-22
@@ -1,8 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
N_INF,
|
N_INF,
|
||||||
factor::{Factor, VarId, VarStore},
|
factor::{VarId, VarStore},
|
||||||
gaussian::Gaussian,
|
gaussian::Gaussian,
|
||||||
pdf,
|
ln_pdf,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Gaussian observation factor on a diff variable.
|
/// Gaussian observation factor on a diff variable.
|
||||||
@@ -16,7 +16,7 @@ pub struct MarginFactor {
|
|||||||
pub m_obs: f64,
|
pub m_obs: f64,
|
||||||
pub sigma: f64,
|
pub sigma: f64,
|
||||||
pub(crate) msg: Gaussian,
|
pub(crate) msg: Gaussian,
|
||||||
pub(crate) evidence_cached: Option<f64>,
|
pub(crate) log_evidence_cached: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MarginFactor {
|
impl MarginFactor {
|
||||||
@@ -28,7 +28,7 @@ impl MarginFactor {
|
|||||||
m_obs,
|
m_obs,
|
||||||
sigma,
|
sigma,
|
||||||
msg: N_INF,
|
msg: N_INF,
|
||||||
evidence_cached: None,
|
log_evidence_cached: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,8 +41,8 @@ impl MarginFactor {
|
|||||||
let marginal = vars.get(self.diff);
|
let marginal = vars.get(self.diff);
|
||||||
let cavity = marginal / self.msg;
|
let cavity = marginal / self.msg;
|
||||||
|
|
||||||
if self.evidence_cached.is_none() {
|
if self.log_evidence_cached.is_none() {
|
||||||
self.evidence_cached = Some(cavity_evidence(cavity, self.m_obs, self.sigma));
|
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.m_obs, self.sigma));
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_msg = Gaussian::from_ms(self.m_obs, self.sigma);
|
let new_msg = Gaussian::from_ms(self.m_obs, self.sigma);
|
||||||
@@ -55,23 +55,42 @@ impl MarginFactor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Factor for MarginFactor {
|
/// Undamped wrappers, used by this module's tests. Inference drives these
|
||||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
/// factors through `propagate_with_alpha` and reads the cached log evidence
|
||||||
|
/// directly, so these are not on any production path.
|
||||||
|
#[cfg(test)]
|
||||||
|
impl MarginFactor {
|
||||||
|
pub(crate) fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||||
self.propagate_with_alpha(vars, 1.0)
|
self.propagate_with_alpha(vars, 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log_evidence(&self, _vars: &VarStore) -> f64 {
|
pub(crate) fn log_evidence(&self) -> f64 {
|
||||||
self.evidence_cached.unwrap_or(1.0).ln()
|
self.log_evidence_cached.unwrap_or(0.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Density of the observed margin under the cavity, clamped to a positive
|
/// `ln` of the observed margin's density under the cavity.
|
||||||
/// floor so a far-out observation cannot underflow to `0.0` and make
|
///
|
||||||
/// `log_evidence` `-inf`.
|
/// Computed in log space rather than as `pdf(..).ln()`. The density underflows
|
||||||
fn cavity_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
|
/// to zero past about 38 sigma of separation, and clamping that to
|
||||||
let combined_sigma = (cavity.sigma().powi(2) + sigma.powi(2)).sqrt();
|
/// `f64::MIN_POSITIVE` reported -708 nats however far out the observation
|
||||||
|
/// actually was — 4292 nats adrift at 100 sigma, and unbounded beyond. A score
|
||||||
|
/// far from what the model expected is exactly the observation a log-evidence
|
||||||
|
/// figure exists to notice.
|
||||||
|
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 = libm::hypot(cavity.sigma(), sigma);
|
||||||
|
let value = ln_pdf(m_obs, cavity.mu(), combined_sigma);
|
||||||
|
|
||||||
pdf(m_obs, cavity.mu(), combined_sigma).max(f64::MIN_POSITIVE)
|
// A degenerate cavity (infinite sigma) is the only way to reach a
|
||||||
|
// non-finite result; fall back to the old floor rather than emit -inf.
|
||||||
|
if value.is_finite() {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
libm::log(f64::MIN_POSITIVE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -113,16 +132,16 @@ mod tests {
|
|||||||
let mut vars = VarStore::new();
|
let mut vars = VarStore::new();
|
||||||
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
|
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||||
let mut f = MarginFactor::new(diff, 5.0, 1.0);
|
let mut f = MarginFactor::new(diff, 5.0, 1.0);
|
||||||
assert!(f.evidence_cached.is_none());
|
assert!(f.log_evidence_cached.is_none());
|
||||||
|
|
||||||
f.propagate(&mut vars);
|
f.propagate(&mut vars);
|
||||||
let z = f.evidence_cached.unwrap();
|
let z = f.log_evidence_cached.unwrap();
|
||||||
// pdf(5, 0, sqrt(37)) ≈ 0.046783
|
// ln pdf(5, 0, sqrt(37)) = ln(0.046783...)
|
||||||
assert!((z - 0.04678300292616668).abs() < 1e-10);
|
assert!((z.exp() - 0.04678300292616668).abs() < 1e-10);
|
||||||
|
|
||||||
// Subsequent propagations don't change it.
|
// Subsequent propagations don't change it.
|
||||||
f.propagate(&mut vars);
|
f.propagate(&mut vars);
|
||||||
assert_eq!(f.evidence_cached.unwrap(), z);
|
assert_eq!(f.log_evidence_cached.unwrap(), z);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -131,7 +150,7 @@ mod tests {
|
|||||||
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
|
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||||
let mut f = MarginFactor::new(diff, 5.0, 1.0);
|
let mut f = MarginFactor::new(diff, 5.0, 1.0);
|
||||||
f.propagate(&mut vars);
|
f.propagate(&mut vars);
|
||||||
let logz = f.log_evidence(&vars);
|
let logz = f.log_evidence();
|
||||||
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
|
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-73
@@ -20,6 +20,8 @@ pub struct VarStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VarStore {
|
impl VarStore {
|
||||||
|
/// Test-only: inference allocates its store through `ScratchArena`.
|
||||||
|
#[cfg(test)]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
@@ -29,23 +31,19 @@ impl VarStore {
|
|||||||
self.marginals.clear();
|
self.marginals.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test-only, as `new`.
|
||||||
|
#[cfg(test)]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.marginals.len()
|
self.marginals.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.marginals.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn alloc(&mut self, init: Gaussian) -> VarId {
|
pub fn alloc(&mut self, init: Gaussian) -> VarId {
|
||||||
let id = VarId(self.marginals.len() as u32);
|
let id = VarId(self.marginals.len() as u32);
|
||||||
self.marginals.push(init);
|
self.marginals.push(init);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn get(&self, id: VarId) -> Gaussian {
|
pub fn get(&self, id: VarId) -> Gaussian {
|
||||||
self.marginals[id.0 as usize]
|
self.marginals[id.0 as usize]
|
||||||
}
|
}
|
||||||
@@ -55,58 +53,7 @@ impl VarStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A factor in the EP graph.
|
|
||||||
///
|
|
||||||
/// Factors hold their own outgoing messages and propagate them by reading
|
|
||||||
/// connected variable marginals from a `VarStore` and writing back updated
|
|
||||||
/// marginals.
|
|
||||||
pub trait Factor: Send + Sync {
|
|
||||||
/// Update outgoing messages and write back to the var store.
|
|
||||||
///
|
|
||||||
/// Returns the max delta `(|Δmu|, |Δsigma|)` across writes this
|
|
||||||
/// propagation. Used by the `Schedule` to detect convergence.
|
|
||||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64);
|
|
||||||
|
|
||||||
/// Optional log-evidence contribution. Default 0.0 (no contribution).
|
|
||||||
fn log_evidence(&self, _vars: &VarStore) -> f64 {
|
|
||||||
0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enum dispatcher for the built-in factor types.
|
|
||||||
///
|
|
||||||
/// Using an enum instead of `Box<dyn Factor>` keeps factor data inline and
|
|
||||||
/// avoids virtual-call overhead in the hot inference loop.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum BuiltinFactor {
|
|
||||||
TeamSum(team_sum::TeamSumFactor),
|
|
||||||
RankDiff(rank_diff::RankDiffFactor),
|
|
||||||
Trunc(trunc::TruncFactor),
|
|
||||||
Margin(margin::MarginFactor),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Factor for BuiltinFactor {
|
|
||||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
|
||||||
match self {
|
|
||||||
Self::TeamSum(f) => f.propagate(vars),
|
|
||||||
Self::RankDiff(f) => f.propagate(vars),
|
|
||||||
Self::Trunc(f) => f.propagate(vars),
|
|
||||||
Self::Margin(f) => f.propagate(vars),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_evidence(&self, vars: &VarStore) -> f64 {
|
|
||||||
match self {
|
|
||||||
Self::Trunc(f) => f.log_evidence(vars),
|
|
||||||
Self::Margin(f) => f.log_evidence(vars),
|
|
||||||
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod margin;
|
pub mod margin;
|
||||||
pub mod rank_diff;
|
|
||||||
pub mod team_sum;
|
|
||||||
pub mod trunc;
|
pub mod trunc;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -153,20 +100,4 @@ mod tests {
|
|||||||
assert_eq!(store.len(), 0);
|
assert_eq!(store.len(), 0);
|
||||||
assert_eq!(store.marginals.capacity(), cap);
|
assert_eq!(store.marginals.capacity(), cap);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn builtin_factor_dispatches_to_margin() {
|
|
||||||
use super::margin::MarginFactor;
|
|
||||||
let mut vars = VarStore::new();
|
|
||||||
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
|
|
||||||
let mut f = BuiltinFactor::Margin(MarginFactor::new(diff, 5.0, 1.0));
|
|
||||||
|
|
||||||
f.propagate(&mut vars);
|
|
||||||
|
|
||||||
let result = vars.get(diff);
|
|
||||||
assert!((result.mu() - 4.864864864864865).abs() < 1e-12);
|
|
||||||
|
|
||||||
let logz = f.log_evidence(&vars);
|
|
||||||
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
use crate::factor::{Factor, VarId, VarStore};
|
|
||||||
|
|
||||||
/// Maintains the constraint `diff = team_a - team_b` between three vars.
|
|
||||||
///
|
|
||||||
/// On each propagation:
|
|
||||||
/// - Reads marginals at `team_a` and `team_b` (which already incorporate any
|
|
||||||
/// incoming messages from neighboring factors).
|
|
||||||
/// - Computes `new_diff = team_a - team_b` (variance addition; see `Gaussian::Sub`).
|
|
||||||
/// - Writes the new marginal to `diff`.
|
|
||||||
/// - Returns the delta against the previous diff value.
|
|
||||||
///
|
|
||||||
/// This factor does NOT store an outgoing message; the diff variable is
|
|
||||||
/// effectively replaced on each propagation. The `TruncFactor` on the same diff
|
|
||||||
/// var holds the EP-divide message that produces the cavity.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct RankDiffFactor {
|
|
||||||
pub team_a: VarId,
|
|
||||||
pub team_b: VarId,
|
|
||||||
pub diff: VarId,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Factor for RankDiffFactor {
|
|
||||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
|
||||||
let a = vars.get(self.team_a);
|
|
||||||
let b = vars.get(self.team_b);
|
|
||||||
let new_diff = a - b;
|
|
||||||
let old = vars.get(self.diff);
|
|
||||||
vars.set(self.diff, new_diff);
|
|
||||||
old.delta(new_diff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{N_INF, gaussian::Gaussian};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn diff_of_two_known_gaussians() {
|
|
||||||
let mut vars = VarStore::new();
|
|
||||||
let team_a = vars.alloc(Gaussian::from_ms(25.0, 3.0));
|
|
||||||
let team_b = vars.alloc(Gaussian::from_ms(20.0, 4.0));
|
|
||||||
let diff = vars.alloc(N_INF);
|
|
||||||
|
|
||||||
let mut f = RankDiffFactor {
|
|
||||||
team_a,
|
|
||||||
team_b,
|
|
||||||
diff,
|
|
||||||
};
|
|
||||||
f.propagate(&mut vars);
|
|
||||||
|
|
||||||
let result = vars.get(diff);
|
|
||||||
// mu = 25 - 20 = 5; var = 9 + 16 = 25; sigma = 5
|
|
||||||
assert!((result.mu() - 5.0).abs() < 1e-12);
|
|
||||||
assert!((result.sigma() - 5.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn delta_zero_on_repeat() {
|
|
||||||
let mut vars = VarStore::new();
|
|
||||||
let team_a = vars.alloc(Gaussian::from_ms(10.0, 2.0));
|
|
||||||
let team_b = vars.alloc(Gaussian::from_ms(8.0, 1.0));
|
|
||||||
let diff = vars.alloc(N_INF);
|
|
||||||
|
|
||||||
let mut f = RankDiffFactor {
|
|
||||||
team_a,
|
|
||||||
team_b,
|
|
||||||
diff,
|
|
||||||
};
|
|
||||||
f.propagate(&mut vars);
|
|
||||||
let (dmu, dsig) = f.propagate(&mut vars);
|
|
||||||
assert!(dmu < 1e-12);
|
|
||||||
assert!(dsig < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn delta_reflects_team_change() {
|
|
||||||
let mut vars = VarStore::new();
|
|
||||||
let team_a = vars.alloc(Gaussian::from_ms(10.0, 1.0));
|
|
||||||
let team_b = vars.alloc(Gaussian::from_ms(0.0, 1.0));
|
|
||||||
let diff = vars.alloc(N_INF);
|
|
||||||
|
|
||||||
let mut f = RankDiffFactor {
|
|
||||||
team_a,
|
|
||||||
team_b,
|
|
||||||
diff,
|
|
||||||
};
|
|
||||||
f.propagate(&mut vars);
|
|
||||||
|
|
||||||
// change team_a, repropagate; delta should be positive
|
|
||||||
vars.set(team_a, Gaussian::from_ms(15.0, 1.0));
|
|
||||||
let (dmu, _dsig) = f.propagate(&mut vars);
|
|
||||||
assert!(dmu > 4.0, "expected ~5 delta, got {}", dmu);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
N00,
|
|
||||||
factor::{Factor, VarId, VarStore},
|
|
||||||
gaussian::Gaussian,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Computes the weighted sum of player performances into a team-perf var.
|
|
||||||
///
|
|
||||||
/// Inputs are pre-computed player performance Gaussians (i.e., rating priors
|
|
||||||
/// already with beta² noise added via `Rating::performance()`). The factor
|
|
||||||
/// runs once per game and writes the weighted sum to the output var.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct TeamSumFactor {
|
|
||||||
pub inputs: Vec<(Gaussian, f64)>,
|
|
||||||
pub out: VarId,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Factor for TeamSumFactor {
|
|
||||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
|
||||||
let perf = self.inputs.iter().fold(N00, |acc, (g, w)| acc + (*g * *w));
|
|
||||||
let old = vars.get(self.out);
|
|
||||||
vars.set(self.out, perf);
|
|
||||||
old.delta(perf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::N_INF;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn single_player_unit_weight() {
|
|
||||||
let mut vars = VarStore::new();
|
|
||||||
let out = vars.alloc(N_INF);
|
|
||||||
let g = Gaussian::from_ms(25.0, 5.0);
|
|
||||||
let mut f = TeamSumFactor {
|
|
||||||
inputs: vec![(g, 1.0)],
|
|
||||||
out,
|
|
||||||
};
|
|
||||||
|
|
||||||
f.propagate(&mut vars);
|
|
||||||
let result = vars.get(out);
|
|
||||||
assert!((result.mu() - 25.0).abs() < 1e-12);
|
|
||||||
assert!((result.sigma() - 5.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn two_players_summed() {
|
|
||||||
let mut vars = VarStore::new();
|
|
||||||
let out = vars.alloc(N_INF);
|
|
||||||
let g1 = Gaussian::from_ms(20.0, 3.0);
|
|
||||||
let g2 = Gaussian::from_ms(30.0, 4.0);
|
|
||||||
let mut f = TeamSumFactor {
|
|
||||||
inputs: vec![(g1, 1.0), (g2, 1.0)],
|
|
||||||
out,
|
|
||||||
};
|
|
||||||
|
|
||||||
f.propagate(&mut vars);
|
|
||||||
let result = vars.get(out);
|
|
||||||
// sum: mu = 20 + 30 = 50, var = 9 + 16 = 25, sigma = 5
|
|
||||||
assert!((result.mu() - 50.0).abs() < 1e-12);
|
|
||||||
assert!((result.sigma() - 5.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn weighted_inputs() {
|
|
||||||
let mut vars = VarStore::new();
|
|
||||||
let out = vars.alloc(N_INF);
|
|
||||||
let g = Gaussian::from_ms(10.0, 2.0);
|
|
||||||
let mut f = TeamSumFactor {
|
|
||||||
inputs: vec![(g, 2.0)],
|
|
||||||
out,
|
|
||||||
};
|
|
||||||
|
|
||||||
f.propagate(&mut vars);
|
|
||||||
let result = vars.get(out);
|
|
||||||
// g * 2.0: mu = 10*2 = 20, sigma = 2*2 = 4
|
|
||||||
assert!((result.mu() - 20.0).abs() < 1e-12);
|
|
||||||
assert!((result.sigma() - 4.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn delta_is_zero_on_repeat_propagate() {
|
|
||||||
let mut vars = VarStore::new();
|
|
||||||
let out = vars.alloc(N_INF);
|
|
||||||
let g = Gaussian::from_ms(5.0, 1.0);
|
|
||||||
let mut f = TeamSumFactor {
|
|
||||||
inputs: vec![(g, 1.0)],
|
|
||||||
out,
|
|
||||||
};
|
|
||||||
|
|
||||||
f.propagate(&mut vars);
|
|
||||||
let (dmu, dsig) = f.propagate(&mut vars);
|
|
||||||
assert!(dmu < 1e-12, "expected ~0 delta on repeat, got {}", dmu);
|
|
||||||
assert!(dsig < 1e-12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+71
-50
@@ -1,8 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
N_INF, approx, cdf,
|
N_INF, approx,
|
||||||
factor::{Factor, VarId, VarStore},
|
factor::{VarId, VarStore},
|
||||||
gaussian::Gaussian,
|
gaussian::Gaussian,
|
||||||
sf,
|
ln_interval, ln_sf,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// EP truncation factor on a diff variable.
|
/// EP truncation factor on a diff variable.
|
||||||
@@ -19,7 +19,7 @@ pub struct TruncFactor {
|
|||||||
/// Outgoing message to the diff variable (initial: `N_INF`, the EP identity).
|
/// Outgoing message to the diff variable (initial: `N_INF`, the EP identity).
|
||||||
pub(crate) msg: Gaussian,
|
pub(crate) msg: Gaussian,
|
||||||
/// Cached evidence (linear, not log) computed from the cavity on first propagation.
|
/// Cached evidence (linear, not log) computed from the cavity on first propagation.
|
||||||
pub(crate) evidence_cached: Option<f64>,
|
pub(crate) log_evidence_cached: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TruncFactor {
|
impl TruncFactor {
|
||||||
@@ -30,7 +30,7 @@ impl TruncFactor {
|
|||||||
margin,
|
margin,
|
||||||
tie,
|
tie,
|
||||||
msg: N_INF,
|
msg: N_INF,
|
||||||
evidence_cached: None,
|
log_evidence_cached: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,8 +43,8 @@ impl TruncFactor {
|
|||||||
let marginal = vars.get(self.diff);
|
let marginal = vars.get(self.diff);
|
||||||
let cavity = marginal / self.msg;
|
let cavity = marginal / self.msg;
|
||||||
|
|
||||||
if self.evidence_cached.is_none() {
|
if self.log_evidence_cached.is_none() {
|
||||||
self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie));
|
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.margin, self.tie));
|
||||||
}
|
}
|
||||||
|
|
||||||
let trunc = approx(cavity, self.margin, self.tie);
|
let trunc = approx(cavity, self.margin, self.tie);
|
||||||
@@ -63,44 +63,40 @@ impl TruncFactor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Factor for TruncFactor {
|
/// Undamped wrappers, used by this module's tests. Inference drives these
|
||||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
/// factors through `propagate_with_alpha` and reads the cached log evidence
|
||||||
|
/// directly, so these are not on any production path.
|
||||||
|
#[cfg(test)]
|
||||||
|
impl TruncFactor {
|
||||||
|
pub(crate) fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||||
self.propagate_with_alpha(vars, 1.0)
|
self.propagate_with_alpha(vars, 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log_evidence(&self, _vars: &VarStore) -> f64 {
|
|
||||||
self.evidence_cached.unwrap_or(1.0).ln()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// P(diff > margin) for non-tie, P(|diff| < margin) for tie.
|
/// `ln P(diff > margin)` for a win, `ln P(|diff| < margin)` for a tie.
|
||||||
///
|
///
|
||||||
/// Both branches pick whichever tail keeps their terms *small*, because the
|
/// Computed in log space throughout. Two earlier shapes both lost the tail:
|
||||||
/// alternative is subtracting two numbers that both approach 1. That
|
/// `1 - cdf(..)` cancelled away every digit of an unlikely outcome, and even
|
||||||
/// subtraction is not a rounding detail: it loses every digit of an unlikely
|
/// once that was fixed the linear probability underflows to zero past about 38
|
||||||
/// outcome's evidence, and an unlikely outcome is precisely the one worth
|
/// sigma, where clamping reported -708 nats regardless of the truth. An upset
|
||||||
/// scoring. `1 - cdf` returned exactly zero past ~8.3 sigma, where the true
|
/// is the observation a log-evidence figure exists to notice, so it has to stay
|
||||||
/// probability is 1e-19; clamped, that reached `log_evidence` as -708 instead
|
/// exact precisely where it is smallest.
|
||||||
/// of -43.
|
fn cavity_log_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 {
|
||||||
///
|
|
||||||
/// The clamp remains as a guard rather than a workaround: `erfc` carries ~1e-7
|
|
||||||
/// relative error, so a probability of exactly 1 can still come back a hair
|
|
||||||
/// above it, and `ln` of a negative would poison the sum for the whole history.
|
|
||||||
fn cavity_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 {
|
|
||||||
let (mu, sigma) = (diff.mu(), diff.sigma());
|
let (mu, sigma) = (diff.mu(), diff.sigma());
|
||||||
|
|
||||||
let raw = if tie {
|
let value = if tie {
|
||||||
if mu < -margin {
|
ln_interval(-margin, margin, mu, sigma)
|
||||||
// Both CDFs sit against 1 here; both survival terms are small.
|
|
||||||
sf(-margin, mu, sigma) - sf(margin, mu, sigma)
|
|
||||||
} else {
|
} else {
|
||||||
cdf(margin, mu, sigma) - cdf(-margin, mu, sigma)
|
ln_sf(margin, mu, sigma)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sf(margin, mu, sigma)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
raw.clamp(f64::MIN_POSITIVE, 1.0)
|
// A degenerate cavity is the only route to a non-finite result; keep the
|
||||||
|
// old floor for it rather than letting -inf poison the whole history's sum.
|
||||||
|
if value.is_finite() {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
libm::log(f64::MIN_POSITIVE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -131,19 +127,19 @@ mod tests {
|
|||||||
let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0));
|
let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0));
|
||||||
|
|
||||||
let mut f = TruncFactor::new(diff, 0.0, false);
|
let mut f = TruncFactor::new(diff, 0.0, false);
|
||||||
assert!(f.evidence_cached.is_none());
|
assert!(f.log_evidence_cached.is_none());
|
||||||
|
|
||||||
f.propagate(&mut vars);
|
f.propagate(&mut vars);
|
||||||
assert!(f.evidence_cached.is_some());
|
assert!(f.log_evidence_cached.is_some());
|
||||||
let first = f.evidence_cached.unwrap();
|
let first = f.log_evidence_cached.unwrap();
|
||||||
|
|
||||||
// Evidence should be P(diff > 0) for diff ~ N(2, 9) ≈ 0.748
|
// Evidence should be P(diff > 0) for diff ~ N(2, 9) ≈ 0.748
|
||||||
assert!(first > 0.7);
|
assert!(first.exp() > 0.7);
|
||||||
assert!(first < 0.8);
|
assert!(first.exp() < 0.8);
|
||||||
|
|
||||||
// Subsequent propagations don't change it.
|
// Subsequent propagations don't change it.
|
||||||
f.propagate(&mut vars);
|
f.propagate(&mut vars);
|
||||||
assert_eq!(f.evidence_cached.unwrap(), first);
|
assert_eq!(f.log_evidence_cached.unwrap(), first);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The defect this guards: `1 - cdf` collapsed to zero for a surprising
|
/// The defect this guards: `1 - cdf` collapsed to zero for a surprising
|
||||||
@@ -154,7 +150,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn evidence_of_an_upset_is_not_flattened_to_the_clamp_floor() {
|
fn evidence_of_an_upset_is_not_flattened_to_the_clamp_floor() {
|
||||||
// diff ~ N(-9, 1) with margin 0: the favoured side lost by nine sigma.
|
// diff ~ N(-9, 1) with margin 0: the favoured side lost by nine sigma.
|
||||||
let evidence = cavity_evidence(Gaussian::from_ms(-9.0, 1.0), 0.0, false);
|
let evidence = cavity_log_evidence(Gaussian::from_ms(-9.0, 1.0), 0.0, false).exp();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
evidence > f64::MIN_POSITIVE,
|
evidence > f64::MIN_POSITIVE,
|
||||||
@@ -175,23 +171,48 @@ mod tests {
|
|||||||
/// Evidence must stay finite and positive however extreme the mismatch,
|
/// Evidence must stay finite and positive however extreme the mismatch,
|
||||||
/// since `log_evidence` sums across the whole history and one `-inf` or
|
/// since `log_evidence` sums across the whole history and one `-inf` or
|
||||||
/// `NaN` poisons all of it.
|
/// `NaN` poisons all of it.
|
||||||
|
///
|
||||||
|
/// Finiteness alone is too weak a bar — the clamped version was finite too,
|
||||||
|
/// and wrong by hundreds of nats. `log_evidence_tracks_the_analytic_tail`
|
||||||
|
/// below is the assertion that actually holds this up.
|
||||||
#[test]
|
#[test]
|
||||||
fn evidence_stays_positive_and_finite_at_any_separation() {
|
fn evidence_stays_positive_and_finite_at_any_separation() {
|
||||||
for mu in [-300.0f64, -50.0, -9.0, 0.0, 9.0, 50.0, 300.0] {
|
for mu in [-300.0f64, -50.0, -9.0, 0.0, 9.0, 50.0, 300.0] {
|
||||||
for tie in [false, true] {
|
for tie in [false, true] {
|
||||||
let e = cavity_evidence(Gaussian::from_ms(mu, 1.0), 1.0, tie);
|
let ln_e = cavity_log_evidence(Gaussian::from_ms(mu, 1.0), 1.0, tie);
|
||||||
assert!(
|
assert!(
|
||||||
e.is_finite() && e > 0.0 && e <= 1.0,
|
ln_e.is_finite() && ln_e <= 0.0,
|
||||||
"mu={mu} tie={tie}: evidence {e} is not a probability"
|
"mu={mu} tie={tie}: log evidence {ln_e} is not a log-probability"
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
e.ln().is_finite(),
|
|
||||||
"mu={mu} tie={tie}: ln evidence is not finite"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The clamp used to floor everything past ~38 sigma at `ln(MIN_POSITIVE)`
|
||||||
|
/// = -708, however far out the real observation was. In log space the
|
||||||
|
/// answer is a polynomial and stays exact: at 1000 sigma the truth is about
|
||||||
|
/// -500_000 nats, and -708 is not a rounding error.
|
||||||
|
#[test]
|
||||||
|
fn log_evidence_tracks_the_analytic_tail() {
|
||||||
|
for mu in [-40.0f64, -60.0, -100.0, -1000.0] {
|
||||||
|
// P(diff > 0) for diff ~ N(mu, 1), mu far below zero.
|
||||||
|
let got = cavity_log_evidence(Gaussian::from_ms(mu, 1.0), 0.0, false);
|
||||||
|
|
||||||
|
// ln Phi(mu) ~ -mu^2/2 - ln(-mu) - ln(sqrt(2 pi)) for mu << 0.
|
||||||
|
let z = -mu;
|
||||||
|
let approx = -0.5 * z * z - z.ln() - (2.0 * std::f64::consts::PI).sqrt().ln();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
got < libm::log(f64::MIN_POSITIVE),
|
||||||
|
"mu={mu}: {got} is still stuck on the old clamp floor"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(got - approx).abs() / approx.abs() < 1e-3,
|
||||||
|
"mu={mu}: got {got}, asymptotic expectation {approx}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tie_evidence_uses_two_sided() {
|
fn tie_evidence_uses_two_sided() {
|
||||||
let mut vars = VarStore::new();
|
let mut vars = VarStore::new();
|
||||||
@@ -201,7 +222,7 @@ mod tests {
|
|||||||
f.propagate(&mut vars);
|
f.propagate(&mut vars);
|
||||||
|
|
||||||
// For diff ~ N(0, 4), tie=true with margin=1: P(-1 < diff < 1) ≈ 0.383
|
// For diff ~ N(0, 4), tie=true with margin=1: P(-1 < diff < 1) ≈ 0.383
|
||||||
let ev = f.evidence_cached.unwrap();
|
let ev = f.log_evidence_cached.unwrap().exp();
|
||||||
assert!(ev > 0.35 && ev < 0.42);
|
assert!(ev > 0.35 && ev < 0.42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+131
-68
@@ -46,8 +46,8 @@ impl DiffFactor {
|
|||||||
/// reaches.
|
/// reaches.
|
||||||
pub(crate) fn log_evidence(&self) -> f64 {
|
pub(crate) fn log_evidence(&self) -> f64 {
|
||||||
match self {
|
match self {
|
||||||
Self::Trunc(f) => f.evidence_cached.unwrap_or(1.0).ln(),
|
Self::Trunc(f) => f.log_evidence_cached.unwrap_or(0.0),
|
||||||
Self::Margin(f) => f.evidence_cached.unwrap_or(1.0).ln(),
|
Self::Margin(f) => f.log_evidence_cached.unwrap_or(0.0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +92,7 @@ impl Default for GameOptions {
|
|||||||
/// can be returned freely from public constructors. The inference inputs
|
/// can be returned freely from public constructors. The inference inputs
|
||||||
/// themselves are not retained — nothing reads them back.
|
/// themselves are not retained — nothing reads them back.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
#[must_use]
|
||||||
pub struct OwnedGame<T: Time, D: Drift<T>> {
|
pub struct OwnedGame<T: Time, D: Drift<T>> {
|
||||||
teams: Vec<Vec<Rating<T, D>>>,
|
teams: Vec<Vec<Rating<T, D>>>,
|
||||||
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
|
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
|
||||||
@@ -283,7 +284,9 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
self.teams[t]
|
self.teams[t]
|
||||||
.iter()
|
.iter()
|
||||||
.zip(self.weights[t].iter())
|
.zip(self.weights[t].iter())
|
||||||
.fold(N00, |p, (player, &w)| p + (player.performance() * w))
|
.fold(N00, |p, (competitor, &w)| {
|
||||||
|
p + (competitor.performance() * w)
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let n_diffs = n_teams.saturating_sub(1);
|
let n_diffs = n_teams.saturating_sub(1);
|
||||||
@@ -360,18 +363,18 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
.iter()
|
.iter()
|
||||||
.zip(self.weights.iter())
|
.zip(self.weights.iter())
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(orig_i, (players, weights))| {
|
.map(|(orig_i, (competitors, weights))| {
|
||||||
let si = arena.inv_buf[orig_i];
|
let si = arena.inv_buf[orig_i];
|
||||||
let m = arena.lhood_win[si] * arena.lhood_lose[si];
|
let m = arena.lhood_win[si] * arena.lhood_lose[si];
|
||||||
// Already folded into `team_prior` at the top of the chain,
|
// Already folded into `team_prior` at the top of the chain,
|
||||||
// indexed by sorted position.
|
// indexed by sorted position.
|
||||||
let performance = arena.team_prior[si];
|
let performance = arena.team_prior[si];
|
||||||
players
|
competitors
|
||||||
.iter()
|
.iter()
|
||||||
.zip(weights.iter())
|
.zip(weights.iter())
|
||||||
.map(|(player, &w)| {
|
.map(|(competitor, &w)| {
|
||||||
((m - performance.exclude(player.performance() * w)) * (1.0 / w))
|
((m - performance.exclude(competitor.performance() * w)) * (1.0 / w))
|
||||||
.forget(player.beta.powi(2))
|
.forget(competitor.beta.powi(2))
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
@@ -431,6 +434,29 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time, D: Drift<T>> Game<'_, 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
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// - `InvalidParameter` if `options.convergence` is out of range — an
|
/// - `InvalidParameter` if `options.convergence` is out of range — an
|
||||||
@@ -442,12 +468,15 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
|
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
|
||||||
/// `p_draw` is zero: the truncation margin is then zero and the two-sided
|
/// `p_draw` is zero: the truncation margin is then zero and the two-sided
|
||||||
/// tie update evaluates `0/0`.
|
/// tie update evaluates `0/0`.
|
||||||
|
/// - `NotEnoughTeams` for fewer than two teams, and `EmptyTeam` for a team
|
||||||
|
/// with no members.
|
||||||
pub fn ranked(
|
pub fn ranked(
|
||||||
teams: &[&[Rating<T, D>]],
|
teams: &[&[Rating<T, D>]],
|
||||||
outcome: crate::Outcome,
|
outcome: crate::Outcome,
|
||||||
options: &GameOptions,
|
options: &GameOptions,
|
||||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||||
options.convergence.validate()?;
|
options.convergence.validate()?;
|
||||||
|
Self::validate_teams(teams)?;
|
||||||
if !(0.0..1.0).contains(&options.p_draw) {
|
if !(0.0..1.0).contains(&options.p_draw) {
|
||||||
return Err(crate::InferenceError::InvalidProbability {
|
return Err(crate::InferenceError::InvalidProbability {
|
||||||
value: options.p_draw,
|
value: options.p_draw,
|
||||||
@@ -499,12 +528,15 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
/// or is NaN, or if `options.convergence` is out of range.
|
/// or is NaN, or if `options.convergence` is out of range.
|
||||||
/// - `MismatchedShape` if the outcome's score count differs from `teams.len()`.
|
/// - `MismatchedShape` if the outcome's score count differs from `teams.len()`.
|
||||||
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Scored`.
|
/// - `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(
|
pub fn scored(
|
||||||
teams: &[&[Rating<T, D>]],
|
teams: &[&[Rating<T, D>]],
|
||||||
outcome: crate::Outcome,
|
outcome: crate::Outcome,
|
||||||
options: &GameOptions,
|
options: &GameOptions,
|
||||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||||
options.convergence.validate()?;
|
options.convergence.validate()?;
|
||||||
|
Self::validate_teams(teams)?;
|
||||||
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
|
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
|
||||||
return Err(crate::InferenceError::InvalidParameter {
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
name: "score_sigma",
|
name: "score_sigma",
|
||||||
@@ -526,6 +558,16 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
got: "Outcome::Ranked",
|
got: "Outcome::Ranked",
|
||||||
})?
|
})?
|
||||||
.to_vec();
|
.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 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();
|
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
|
||||||
Ok(OwnedGame::new_scored(
|
Ok(OwnedGame::new_scored(
|
||||||
@@ -537,7 +579,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience wrapper over [`Game::ranked`] for two single-player teams.
|
/// Convenience wrapper over [`Game::ranked`] for two single-competitor teams.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
@@ -557,26 +599,17 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Wraps each player in a one-member team and delegates to
|
/// Wraps each competitor in a one-member team and delegates to
|
||||||
/// [`Game::ranked`], so it returns the same errors.
|
/// [`Game::ranked`], so it returns the same errors.
|
||||||
pub fn free_for_all(
|
pub fn free_for_all(
|
||||||
players: &[&Rating<T, D>],
|
competitors: &[&Rating<T, D>],
|
||||||
outcome: crate::Outcome,
|
outcome: crate::Outcome,
|
||||||
options: &GameOptions,
|
options: &GameOptions,
|
||||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||||
let teams: Vec<Vec<Rating<T, D>>> = players.iter().map(|p| vec![**p]).collect();
|
let teams: Vec<Vec<Rating<T, D>>> = competitors.iter().map(|p| vec![**p]).collect();
|
||||||
let team_refs: Vec<&[Rating<T, D>]> = teams.iter().map(|t| t.as_slice()).collect();
|
let team_refs: Vec<&[Rating<T, D>]> = teams.iter().map(|t| t.as_slice()).collect();
|
||||||
Self::ranked(&team_refs, outcome, options)
|
Self::ranked(&team_refs, outcome, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub fn custom<S: crate::graph::Schedule>(
|
|
||||||
factors: &mut [crate::graph::BuiltinFactor],
|
|
||||||
vars: &mut crate::graph::VarStore,
|
|
||||||
schedule: &S,
|
|
||||||
) -> crate::graph::ScheduleReport {
|
|
||||||
schedule.run(factors, vars)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -593,12 +626,12 @@ mod tests {
|
|||||||
let t_a = R::new(
|
let t_a = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_b = R::new(
|
let t_b = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0]];
|
||||||
@@ -621,12 +654,12 @@ mod tests {
|
|||||||
let t_a = R::new(
|
let t_a = R::new(
|
||||||
Gaussian::from_ms(29.0, 1.0),
|
Gaussian::from_ms(29.0, 1.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(GAMMA),
|
ConstantDrift::new(GAMMA),
|
||||||
);
|
);
|
||||||
let t_b = R::new(
|
let t_b = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(GAMMA),
|
ConstantDrift::new(GAMMA),
|
||||||
);
|
);
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0]];
|
||||||
@@ -646,8 +679,16 @@ mod tests {
|
|||||||
assert_ulps_eq!(a, Gaussian::from_ms(28.896475, 0.996604), epsilon = 1e-6);
|
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);
|
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_a = R::new(
|
||||||
let t_b = R::new(Gaussian::from_ms(15.568, 0.51), 1.0, ConstantDrift(0.2125));
|
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 w = [vec![1.0], vec![1.0]];
|
||||||
let g = Game::ranked_with_arena(
|
let g = Game::ranked_with_arena(
|
||||||
@@ -669,17 +710,17 @@ mod tests {
|
|||||||
vec![R::new(
|
vec![R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
)],
|
)],
|
||||||
vec![R::new(
|
vec![R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
)],
|
)],
|
||||||
vec![R::new(
|
vec![R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
)],
|
)],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -733,9 +774,15 @@ mod tests {
|
|||||||
let c = p[2][0];
|
let c = p[2][0];
|
||||||
|
|
||||||
// T1 ULP shift: mu rounds to 25.0 (was 24.999999) under natural-parameter storage.
|
// T1 ULP shift: mu rounds to 25.0 (was 24.999999) under natural-parameter storage.
|
||||||
|
//
|
||||||
|
// The 1e-6-place values moved when `erfc_inv`'s sign error was fixed:
|
||||||
|
// this case runs at `p_draw = 0.5`, so it goes through `compute_margin`,
|
||||||
|
// and the margin is now 8.4e-8 from the exact quantile where it was
|
||||||
|
// 1.46e-7. Verified as movement *toward* analytic truth, not a
|
||||||
|
// regression — see `erfc_inv_matches_known_quantiles`.
|
||||||
assert_ulps_eq!(a, Gaussian::from_ms(25.0, 6.092561), epsilon = 1e-6);
|
assert_ulps_eq!(a, Gaussian::from_ms(25.0, 6.092561), epsilon = 1e-6);
|
||||||
assert_ulps_eq!(b, Gaussian::from_ms(33.379314, 6.483575), epsilon = 1e-6);
|
assert_ulps_eq!(b, Gaussian::from_ms(33.379315, 6.483576), epsilon = 1e-6);
|
||||||
assert_ulps_eq!(c, Gaussian::from_ms(16.620685, 6.483575), epsilon = 1e-6);
|
assert_ulps_eq!(c, Gaussian::from_ms(16.620685, 6.483576), epsilon = 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -743,12 +790,12 @@ mod tests {
|
|||||||
let t_a = R::new(
|
let t_a = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_b = R::new(
|
let t_b = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0]];
|
||||||
@@ -775,12 +822,12 @@ mod tests {
|
|||||||
let t_a = R::new(
|
let t_a = R::new(
|
||||||
Gaussian::from_ms(25.0, 3.0),
|
Gaussian::from_ms(25.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_b = R::new(
|
let t_b = R::new(
|
||||||
Gaussian::from_ms(29.0, 2.0),
|
Gaussian::from_ms(29.0, 2.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0]];
|
||||||
@@ -806,17 +853,17 @@ mod tests {
|
|||||||
let t_a = R::new(
|
let t_a = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_b = R::new(
|
let t_b = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_c = R::new(
|
let t_c = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.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]];
|
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||||
@@ -843,17 +890,17 @@ mod tests {
|
|||||||
let t_a = R::new(
|
let t_a = R::new(
|
||||||
Gaussian::from_ms(25.0, 3.0),
|
Gaussian::from_ms(25.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_b = R::new(
|
let t_b = R::new(
|
||||||
Gaussian::from_ms(25.0, 3.0),
|
Gaussian::from_ms(25.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_c = R::new(
|
let t_c = R::new(
|
||||||
Gaussian::from_ms(29.0, 2.0),
|
Gaussian::from_ms(29.0, 2.0),
|
||||||
25.0 / 6.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]];
|
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||||
@@ -882,29 +929,29 @@ mod tests {
|
|||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(12.0, 3.0),
|
Gaussian::from_ms(12.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(18.0, 3.0),
|
Gaussian::from_ms(18.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
let t_b = vec![R::new(
|
let t_b = vec![R::new(
|
||||||
Gaussian::from_ms(30.0, 3.0),
|
Gaussian::from_ms(30.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
)];
|
)];
|
||||||
let t_c = vec![
|
let t_c = vec![
|
||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(14.0, 3.0),
|
Gaussian::from_ms(14.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(16., 3.0),
|
Gaussian::from_ms(16., 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -934,12 +981,12 @@ mod tests {
|
|||||||
let t_a = vec![R::new(
|
let t_a = vec![R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
)];
|
)];
|
||||||
let t_b = vec![R::new(
|
let t_b = vec![R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
)];
|
)];
|
||||||
|
|
||||||
let w = [w_a, w_b];
|
let w = [w_a, w_b];
|
||||||
@@ -1017,8 +1064,16 @@ mod tests {
|
|||||||
let w_a = vec![1.0];
|
let w_a = vec![1.0];
|
||||||
let w_b = vec![0.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_a = vec![R::new(
|
||||||
let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
|
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 w = [w_a, w_b];
|
||||||
let g = Game::ranked_with_arena(
|
let g = Game::ranked_with_arena(
|
||||||
@@ -1045,8 +1100,16 @@ mod tests {
|
|||||||
let w_a = vec![1.0];
|
let w_a = vec![1.0];
|
||||||
let w_b = 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_a = vec![R::new(
|
||||||
let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
|
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 w = [w_a, w_b];
|
||||||
let g = Game::ranked_with_arena(
|
let g = Game::ranked_with_arena(
|
||||||
@@ -1089,7 +1152,7 @@ mod tests {
|
|||||||
let prior = R::new(
|
let prior = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let teams = vec![vec![prior], vec![prior]];
|
let teams = vec![vec![prior], vec![prior]];
|
||||||
let result = vec![10.0, 0.0]; // a beat b by 10
|
let result = vec![10.0, 0.0]; // a beat b by 10
|
||||||
@@ -1139,7 +1202,7 @@ mod tests {
|
|||||||
let prior = R::new(
|
let prior = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let opts = GameOptions {
|
let opts = GameOptions {
|
||||||
score_sigma: 1.0,
|
score_sigma: 1.0,
|
||||||
@@ -1155,7 +1218,7 @@ mod tests {
|
|||||||
let prior = R::new(
|
let prior = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let err = Game::scored(
|
let err = Game::scored(
|
||||||
&[&[prior], &[prior]],
|
&[&[prior], &[prior]],
|
||||||
@@ -1174,7 +1237,7 @@ mod tests {
|
|||||||
let prior = R::new(
|
let prior = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let opts = GameOptions {
|
let opts = GameOptions {
|
||||||
score_sigma: 0.0,
|
score_sigma: 0.0,
|
||||||
@@ -1201,12 +1264,12 @@ mod tests {
|
|||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
),
|
),
|
||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
let w_a = vec![0.4, 0.8];
|
let w_a = vec![0.4, 0.8];
|
||||||
@@ -1215,12 +1278,12 @@ mod tests {
|
|||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
),
|
),
|
||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
let w_b = vec![0.9, 0.6];
|
let w_b = vec![0.9, 0.6];
|
||||||
@@ -1248,7 +1311,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_ulps_eq!(
|
assert_ulps_eq!(
|
||||||
p[1][0],
|
p[1][0],
|
||||||
Gaussian::from_ms(19.287197, 7.243465),
|
Gaussian::from_ms(19.287198285, 7.243465848),
|
||||||
epsilon = 1e-6
|
epsilon = 1e-6
|
||||||
);
|
);
|
||||||
assert_ulps_eq!(
|
assert_ulps_eq!(
|
||||||
@@ -1308,7 +1371,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_ulps_eq!(
|
assert_ulps_eq!(
|
||||||
p[0][0],
|
p[0][0],
|
||||||
Gaussian::from_ms(31.674697, 7.501180),
|
Gaussian::from_ms(31.674698083, 7.501180037),
|
||||||
epsilon = 1e-6
|
epsilon = 1e-6
|
||||||
);
|
);
|
||||||
assert_ulps_eq!(
|
assert_ulps_eq!(
|
||||||
@@ -1334,7 +1397,7 @@ mod tests {
|
|||||||
vec![R::new(
|
vec![R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
)],
|
)],
|
||||||
],
|
],
|
||||||
&[1.0, 0.0],
|
&[1.0, 0.0],
|
||||||
@@ -1367,8 +1430,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn run_chain_honours_max_iter_in_convergence_options() {
|
fn run_chain_honours_max_iter_in_convergence_options() {
|
||||||
let players: Vec<R> = (0..4).map(|_| R::default()).collect();
|
let competitors: Vec<R> = (0..4).map(|_| R::default()).collect();
|
||||||
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect();
|
let teams: Vec<Vec<_>> = competitors.iter().map(|p| vec![*p]).collect();
|
||||||
let result = vec![3.0, 2.0, 1.0, 0.0];
|
let result = vec![3.0, 2.0, 1.0, 0.0];
|
||||||
let weights = vec![vec![1.0]; 4];
|
let weights = vec![vec![1.0]; 4];
|
||||||
|
|
||||||
@@ -1415,8 +1478,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn run_chain_with_damping_converges_to_same_posterior() {
|
fn run_chain_with_damping_converges_to_same_posterior() {
|
||||||
let players: Vec<R> = (0..4).map(|_| R::default()).collect();
|
let competitors: Vec<R> = (0..4).map(|_| R::default()).collect();
|
||||||
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect();
|
let teams: Vec<Vec<_>> = competitors.iter().map(|p| vec![*p]).collect();
|
||||||
let result = vec![3.0, 2.0, 1.0, 0.0];
|
let result = vec![3.0, 2.0, 1.0, 0.0];
|
||||||
let weights = vec![vec![1.0]; 4];
|
let weights = vec![vec![1.0]; 4];
|
||||||
|
|
||||||
|
|||||||
+195
-2
@@ -11,6 +11,7 @@ use crate::{MU, N_INF, SIGMA};
|
|||||||
/// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and
|
/// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and
|
||||||
/// `sigma()` are accessors computed on demand.
|
/// `sigma()` are accessors computed on demand.
|
||||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||||
|
#[must_use]
|
||||||
pub struct Gaussian {
|
pub struct Gaussian {
|
||||||
pi: f64,
|
pi: f64,
|
||||||
tau: f64,
|
tau: f64,
|
||||||
@@ -18,8 +19,43 @@ pub struct Gaussian {
|
|||||||
|
|
||||||
impl Gaussian {
|
impl Gaussian {
|
||||||
/// Construct from mean and standard deviation.
|
/// Construct from mean and standard deviation.
|
||||||
#[must_use]
|
///
|
||||||
|
/// # 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.
|
||||||
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
|
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 {
|
if sigma == f64::INFINITY {
|
||||||
Self { pi: 0.0, tau: 0.0 }
|
Self { pi: 0.0, tau: 0.0 }
|
||||||
} else if sigma == 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) {
|
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.mu() - other.mu()).abs(),
|
||||||
(self.sigma() - other.sigma()).abs(),
|
(self.sigma() - other.sigma()).abs(),
|
||||||
@@ -145,12 +199,50 @@ impl Gaussian {
|
|||||||
Self::from_mv(self.mu(), self.variance() + variance_delta)
|
Self::from_mv(self.mu(), self.variance() + variance_delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `P(X < x)` under this Gaussian.
|
||||||
|
///
|
||||||
|
/// The question a stopping rule asks: *how sure am I that this competitor's
|
||||||
|
/// true skill is below the cutoff?* Expressing that as a probability keeps
|
||||||
|
/// its meaning as sigma changes, where a `mu + z * sigma` band silently
|
||||||
|
/// means different confidence at different uncertainties — which is exactly
|
||||||
|
/// the regime a stopping rule operates in.
|
||||||
|
///
|
||||||
|
/// Accurate in the *lower* tail. For the upper tail use
|
||||||
|
/// [`Gaussian::probability_above`] rather than `1.0 - probability_below(x)`,
|
||||||
|
/// which cancels away every significant digit once the result is small.
|
||||||
|
///
|
||||||
|
/// An improper Gaussian (non-positive precision) has no defined mean, so
|
||||||
|
/// this returns `0.5` — the same convention `mu()` and `sigma()` follow.
|
||||||
|
#[must_use]
|
||||||
|
pub fn probability_below(&self, x: f64) -> f64 {
|
||||||
|
if self.pi <= 0.0 {
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
crate::cdf(x, self.mu(), self.sigma())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `P(X > x)` under this Gaussian.
|
||||||
|
///
|
||||||
|
/// Computed as a survival function rather than `1 - cdf`, so it keeps full
|
||||||
|
/// relative precision in the upper tail: `1 - cdf` returns exactly zero
|
||||||
|
/// past about 8.3 sigma, where the true value is still 1e-19 and perfectly
|
||||||
|
/// representable. A stopping rule is evaluated precisely there — the
|
||||||
|
/// interesting cases are the ones near certainty.
|
||||||
|
///
|
||||||
|
/// An improper Gaussian returns `0.5`, as [`Gaussian::probability_below`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn probability_above(&self, x: f64) -> f64 {
|
||||||
|
if self.pi <= 0.0 {
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
crate::sf(x, self.mu(), self.sigma())
|
||||||
|
}
|
||||||
|
|
||||||
/// EP damping in natural-parameter space: `α·new + (1−α)·self`.
|
/// EP damping in natural-parameter space: `α·new + (1−α)·self`.
|
||||||
///
|
///
|
||||||
/// Used by within-game inference to stabilise oscillating fixed-point
|
/// Used by within-game inference to stabilise oscillating fixed-point
|
||||||
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
|
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
|
||||||
/// `alpha < 1.0` shrinks each per-step update.
|
/// `alpha < 1.0` shrinks each per-step update.
|
||||||
#[must_use]
|
|
||||||
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
|
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
|
||||||
Gaussian::from_natural(
|
Gaussian::from_natural(
|
||||||
alpha * new.pi() + (1.0 - alpha) * self.pi(),
|
alpha * new.pi() + (1.0 - alpha) * self.pi(),
|
||||||
@@ -217,6 +309,42 @@ impl ops::Div<Gaussian> for Gaussian {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -340,3 +468,68 @@ mod tests {
|
|||||||
assert!((damped.tau() - expected_tau).abs() < 1e-12);
|
assert!((damped.tau() - expected_tau).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tail_probability_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probability_below_matches_published_quantiles() {
|
||||||
|
let g = Gaussian::from_ms(0.0, 1.0);
|
||||||
|
for (x, expected) in [
|
||||||
|
(-1.959_963_984_540_054, 0.025),
|
||||||
|
(0.0, 0.5),
|
||||||
|
(1.281_551_565_544_6, 0.9),
|
||||||
|
(1.959_963_984_540_054, 0.975),
|
||||||
|
] {
|
||||||
|
let got = g.probability_below(x);
|
||||||
|
assert!(
|
||||||
|
(got - expected).abs() < 1e-12,
|
||||||
|
"P(X < {x}) = {got}, expected {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_two_tails_partition_the_mass() {
|
||||||
|
let g = Gaussian::from_ms(3.0, 2.0);
|
||||||
|
for x in [-4.0f64, 0.0, 3.0, 7.5] {
|
||||||
|
let total = g.probability_below(x) + g.probability_above(x);
|
||||||
|
assert!((total - 1.0).abs() < 1e-15, "at {x}: {total}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reason `probability_above` exists rather than `1 - probability_below`.
|
||||||
|
#[test]
|
||||||
|
fn probability_above_keeps_precision_where_the_complement_collapses() {
|
||||||
|
let g = Gaussian::from_ms(0.0, 1.0);
|
||||||
|
for (x, expected) in [(9.0f64, 1.128_588e-19), (20.0, 2.753_624e-89)] {
|
||||||
|
let got = g.probability_above(x);
|
||||||
|
assert!(
|
||||||
|
(got - expected).abs() / expected < 1e-6,
|
||||||
|
"P(X > {x}) = {got}, expected ~{expected}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
1.0 - g.probability_below(x),
|
||||||
|
0.0,
|
||||||
|
"the complement should still collapse at {x}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_scaled_gaussian_shifts_and_stretches() {
|
||||||
|
let g = Gaussian::from_ms(25.0, 6.0);
|
||||||
|
assert!((g.probability_below(25.0) - 0.5).abs() < 1e-15);
|
||||||
|
// One sigma either side of the mean.
|
||||||
|
assert!((g.probability_below(31.0) - 0.841_344_746_068_543).abs() < 1e-12);
|
||||||
|
assert!((g.probability_above(19.0) - 0.841_344_746_068_543).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_improper_gaussian_is_uninformative_rather_than_nan() {
|
||||||
|
let improper = Gaussian::from_ms(0.0, f64::INFINITY);
|
||||||
|
assert_eq!(improper.probability_below(5.0), 0.5);
|
||||||
|
assert_eq!(improper.probability_above(5.0), 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
//! Factor-graph public API.
|
|
||||||
//!
|
|
||||||
//! Named `graph` rather than `factors` because the private implementation
|
|
||||||
//! module beside it is `factor`: two module paths differing by one character,
|
|
||||||
//! one public and one not, was a standing invitation to import the wrong one.
|
|
||||||
//!
|
|
||||||
//! The factor types, `VarStore` and the `Schedule` trait are public so custom
|
|
||||||
//! schedules can be written against them.
|
|
||||||
//!
|
|
||||||
//! Building a factor graph by hand goes through `Game::custom`, which is
|
|
||||||
//! deliberately `#[doc(hidden)]`: it works, but its signature is not yet
|
|
||||||
//! considered stable API and so is not listed in these docs.
|
|
||||||
|
|
||||||
pub use crate::{
|
|
||||||
factor::{
|
|
||||||
BuiltinFactor, Factor, VarId, VarStore, margin::MarginFactor, rank_diff::RankDiffFactor,
|
|
||||||
team_sum::TeamSumFactor, trunc::TruncFactor,
|
|
||||||
},
|
|
||||||
schedule::{EpsilonOrMax, Schedule, ScheduleReport},
|
|
||||||
};
|
|
||||||
+1666
-185
File diff suppressed because it is too large
Load Diff
+152
@@ -0,0 +1,152 @@
|
|||||||
|
//! Cholesky factorisation of a joint precision matrix.
|
||||||
|
//!
|
||||||
|
//! Every question the joint answers is a *bilinear form* in the precision
|
||||||
|
//! matrix's inverse — the variance of a contrast is `c^T L^-1 c`, and the
|
||||||
|
//! covariance of two contrasts is `c^T L^-1 a`. None of them wants `L^-1 c`
|
||||||
|
//! itself, which is what makes the shape here worth stating explicitly.
|
||||||
|
//!
|
||||||
|
//! Writing the precision as `A = L L^T`,
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! c^T A^-1 a = c^T L^-T L^-1 a = (L^-1 c) . (L^-1 a)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! so a single forward substitution per contrast answers everything, and the
|
||||||
|
//! back substitution a general solve would do is wasted work. That halves the
|
||||||
|
//! cost of a query, and it removes a failure mode: a variance computed as
|
||||||
|
//! `c . (A^-1 c)` is a difference of products that can round to a small
|
||||||
|
//! negative number, where the same quantity as `|L^-1 c|^2` is a sum of
|
||||||
|
//! squares and cannot.
|
||||||
|
//!
|
||||||
|
//! Factorising is `O(n^3)` and whitening is `O(n^2)`, so the split also
|
||||||
|
//! matters structurally: the expensive half depends only on the fit, and is
|
||||||
|
//! shared across every query a [`Joint`](crate::Joint) answers.
|
||||||
|
|
||||||
|
/// A factorised symmetric positive-definite matrix, reusable across queries.
|
||||||
|
pub(crate) struct Cholesky {
|
||||||
|
/// Lower triangle of `L`, row-major `n * n`. The upper triangle is
|
||||||
|
/// leftover scratch from the factorisation and is never read.
|
||||||
|
l: Vec<f64>,
|
||||||
|
n: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cholesky {
|
||||||
|
/// Factorise `a` (row-major, `n * n`, symmetric) into `L L^T`.
|
||||||
|
///
|
||||||
|
/// `a` is consumed as scratch.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the matrix is not positive-definite, which for a
|
||||||
|
/// precision matrix means the model is improper — a competitor with
|
||||||
|
/// neither a proper prior nor any evidence.
|
||||||
|
pub(crate) fn factor(mut a: Vec<f64>, n: usize) -> Option<Self> {
|
||||||
|
debug_assert_eq!(a.len(), n * n);
|
||||||
|
|
||||||
|
for j in 0..n {
|
||||||
|
let mut d = a[j * n + j];
|
||||||
|
for k in 0..j {
|
||||||
|
d -= a[j * n + k] * a[j * n + k];
|
||||||
|
}
|
||||||
|
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here
|
||||||
|
// too, and a negated comparison would let it through as "not
|
||||||
|
// positive".
|
||||||
|
if d.is_nan() || d <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let d = d.sqrt();
|
||||||
|
a[j * n + j] = d;
|
||||||
|
|
||||||
|
for i in j + 1..n {
|
||||||
|
let mut s = a[i * n + j];
|
||||||
|
for k in 0..j {
|
||||||
|
s -= a[i * n + k] * a[j * n + k];
|
||||||
|
}
|
||||||
|
a[i * n + j] = s / d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Self { l: a, n })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whiten a contrast: `y = L^-1 b`.
|
||||||
|
///
|
||||||
|
/// The point of the result is the dot product, not the vector: for two
|
||||||
|
/// contrasts `b` and `b'`, `y . y'` is `b^T A^-1 b'`. See the module docs.
|
||||||
|
pub(crate) fn whiten(&self, b: &[f64]) -> Vec<f64> {
|
||||||
|
debug_assert_eq!(b.len(), self.n);
|
||||||
|
let n = self.n;
|
||||||
|
let mut y = b.to_vec();
|
||||||
|
for i in 0..n {
|
||||||
|
// Folded from `y[i]` rather than summed and subtracted once, so the
|
||||||
|
// accumulation order matches a plain substitution loop exactly.
|
||||||
|
let row = &self.l[i * n..i * n + i];
|
||||||
|
let s = row
|
||||||
|
.iter()
|
||||||
|
.zip(&y[..i])
|
||||||
|
.fold(y[i], |acc, (l, v)| acc - l * v);
|
||||||
|
y[i] = s / self.l[i * n + i];
|
||||||
|
}
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `b^T A^-1 b'`, given the two whitened contrasts.
|
||||||
|
pub(crate) fn bilinear(y: &[f64], y_prime: &[f64]) -> f64 {
|
||||||
|
y.iter().zip(y_prime).map(|(a, b)| a * b).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `[[4, 1], [1, 3]] z = [1, 2]` has `z = [1/11, 7/11]`, so the quadratic
|
||||||
|
/// form `b^T A^-1 b` is `1 * 1/11 + 2 * 7/11 = 15/11`.
|
||||||
|
#[test]
|
||||||
|
fn reproduces_a_known_quadratic_form() {
|
||||||
|
let c = Cholesky::factor(vec![4.0, 1.0, 1.0, 3.0], 2).unwrap();
|
||||||
|
let y = c.whiten(&[1.0, 2.0]);
|
||||||
|
assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whitening `e_i` recovers the inverse's diagonal, which is the variance
|
||||||
|
/// of a single variable.
|
||||||
|
#[test]
|
||||||
|
fn recovers_the_inverse_diagonal() {
|
||||||
|
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
|
||||||
|
// [0.75, 1.0, 0.75].
|
||||||
|
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
||||||
|
let c = Cholesky::factor(a, 3).unwrap();
|
||||||
|
for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() {
|
||||||
|
let mut e = vec![0.0; 3];
|
||||||
|
e[i] = 1.0;
|
||||||
|
let y = c.whiten(&e);
|
||||||
|
assert!((bilinear(&y, &y) - expected).abs() < 1e-12, "row {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The off-diagonal bilinear form is symmetric and matches the inverse.
|
||||||
|
#[test]
|
||||||
|
fn recovers_an_off_diagonal_covariance() {
|
||||||
|
// Same A; (A^-1)_{0,1} = 0.5.
|
||||||
|
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
||||||
|
let c = Cholesky::factor(a, 3).unwrap();
|
||||||
|
let y0 = c.whiten(&[1.0, 0.0, 0.0]);
|
||||||
|
let y1 = c.whiten(&[0.0, 1.0, 0.0]);
|
||||||
|
assert!((bilinear(&y0, &y1) - 0.5).abs() < 1e-12);
|
||||||
|
assert!((bilinear(&y1, &y0) - 0.5).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A variance can never come out negative, because it is a sum of squares.
|
||||||
|
#[test]
|
||||||
|
fn a_quadratic_form_is_never_negative() {
|
||||||
|
let a = vec![1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12];
|
||||||
|
let c = Cholesky::factor(a, 2).unwrap();
|
||||||
|
let y = c.whiten(&[1.0, -1.0]);
|
||||||
|
assert!(bilinear(&y, &y) >= 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_non_positive_definite_matrix() {
|
||||||
|
// Singular: the second row is a multiple of the first.
|
||||||
|
assert!(Cholesky::factor(vec![1.0, 2.0, 2.0, 4.0], 2).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-7
@@ -60,19 +60,20 @@ where
|
|||||||
self.reverse.get(idx.0)
|
self.reverse.get(idx.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn keys(&self) -> impl Iterator<Item = &K> {
|
/// Every key, in the order they were first interned.
|
||||||
self.forward.keys()
|
///
|
||||||
|
/// Iterates the dense reverse table rather than the forward `HashMap`.
|
||||||
|
/// Rust seeds its default hasher per process, so a `HashMap` walk yields a
|
||||||
|
/// different order on every run — which is fine for membership but not for
|
||||||
|
/// anything a caller might sum, sort or print.
|
||||||
|
pub fn keys(&self) -> impl ExactSizeIterator<Item = &K> {
|
||||||
|
self.reverse.iter()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.reverse.len()
|
self.reverse.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.reverse.is_empty()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<K> Default for KeyTable<K>
|
impl<K> Default for KeyTable<K>
|
||||||
|
|||||||
+608
-75
@@ -104,13 +104,10 @@ use std::{
|
|||||||
f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2},
|
f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod acquisition;
|
||||||
#[cfg(feature = "approx")]
|
#[cfg(feature = "approx")]
|
||||||
mod approx;
|
mod approx;
|
||||||
pub(crate) mod arena;
|
pub(crate) mod arena;
|
||||||
mod time;
|
|
||||||
mod time_slice;
|
|
||||||
pub use time_slice::{EventKind, TimeSlice};
|
|
||||||
mod acquisition;
|
|
||||||
mod color_group;
|
mod color_group;
|
||||||
mod competitor;
|
mod competitor;
|
||||||
mod convergence;
|
mod convergence;
|
||||||
@@ -121,8 +118,8 @@ mod event_builder;
|
|||||||
pub(crate) mod factor;
|
pub(crate) mod factor;
|
||||||
mod game;
|
mod game;
|
||||||
pub mod gaussian;
|
pub mod gaussian;
|
||||||
pub mod graph;
|
|
||||||
mod history;
|
mod history;
|
||||||
|
mod joint;
|
||||||
mod key_table;
|
mod key_table;
|
||||||
mod matrix;
|
mod matrix;
|
||||||
mod observer;
|
mod observer;
|
||||||
@@ -130,26 +127,24 @@ mod outcome;
|
|||||||
mod predict;
|
mod predict;
|
||||||
pub(crate) mod quadrature;
|
pub(crate) mod quadrature;
|
||||||
mod rating;
|
mod rating;
|
||||||
pub(crate) mod schedule;
|
pub(crate) mod storage;
|
||||||
pub mod storage;
|
mod time;
|
||||||
|
mod time_slice;
|
||||||
|
|
||||||
pub use acquisition::expected_information_gain;
|
pub use acquisition::expected_information_gain;
|
||||||
pub use competitor::Competitor;
|
|
||||||
pub use convergence::{ConvergenceOptions, ConvergenceReport};
|
pub use convergence::{ConvergenceOptions, ConvergenceReport};
|
||||||
pub use drift::{ConstantDrift, Drift};
|
pub use drift::{ConstantDrift, Drift};
|
||||||
pub use error::InferenceError;
|
pub use error::{InferenceError, UnknownKeys};
|
||||||
pub use event::{Event, Member, Team};
|
pub use event::{Event, Member, Team};
|
||||||
pub use event_builder::EventBuilder;
|
pub use event_builder::EventBuilder;
|
||||||
pub use game::{Game, GameOptions, OwnedGame};
|
pub use game::{Game, GameOptions, OwnedGame};
|
||||||
pub use gaussian::Gaussian;
|
pub use gaussian::Gaussian;
|
||||||
pub use history::{History, HistoryBuilder};
|
pub use history::{History, HistoryBuilder, Joint};
|
||||||
pub use key_table::KeyTable;
|
|
||||||
use matrix::Matrix;
|
use matrix::Matrix;
|
||||||
pub use observer::{NullObserver, Observer};
|
pub use observer::{NullObserver, Observer};
|
||||||
pub use outcome::Outcome;
|
pub use outcome::Outcome;
|
||||||
pub use predict::Prediction;
|
pub use predict::Prediction;
|
||||||
pub use rating::Rating;
|
pub use rating::Rating;
|
||||||
pub use schedule::ScheduleReport;
|
|
||||||
pub use time::{Time, Untimed};
|
pub use time::{Time, Untimed};
|
||||||
|
|
||||||
pub const BETA: f64 = 1.0;
|
pub const BETA: f64 = 1.0;
|
||||||
@@ -158,7 +153,45 @@ pub const SIGMA: f64 = BETA * 6.0;
|
|||||||
pub const GAMMA: f64 = BETA * 0.03;
|
pub const GAMMA: f64 = BETA * 0.03;
|
||||||
pub const P_DRAW: f64 = 0.0;
|
pub const P_DRAW: f64 = 0.0;
|
||||||
pub const EPSILON: f64 = 1e-6;
|
pub const EPSILON: f64 = 1e-6;
|
||||||
pub const ITERATIONS: usize = 30;
|
/// Default cap on convergence sweeps.
|
||||||
|
///
|
||||||
|
/// **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:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// max_iter 30: 4 iterations, 129.9 us
|
||||||
|
/// max_iter 100_000: 4 iterations, 131.9 us
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 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.
|
/// Largest team count `History::predict_outcome` will enumerate.
|
||||||
///
|
///
|
||||||
@@ -182,11 +215,16 @@ const HALF_LINE_WINDOW: f64 = 10.0;
|
|||||||
/// four-term series is good to ~1e-10 by here, so the two are at their closest
|
/// 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
|
/// agreement around this point. Below it the subtraction is exact; above it the
|
||||||
/// series is.
|
/// 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;
|
const ASYMPTOTIC_MILLS_ALPHA: f64 = 100.0;
|
||||||
|
|
||||||
pub const N01: Gaussian = Gaussian::from_ms(0.0, 1.0);
|
pub(crate) const N00: Gaussian = Gaussian::from_ms(0.0, 0.0);
|
||||||
pub const N00: Gaussian = Gaussian::from_ms(0.0, 0.0);
|
pub(crate) const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
|
||||||
pub const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
|
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
|
||||||
pub struct Index(usize);
|
pub struct Index(usize);
|
||||||
@@ -214,24 +252,47 @@ impl From<Index> for usize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Complementary error function.
|
||||||
|
///
|
||||||
|
/// # Why every transcendental in this crate goes through `libm`
|
||||||
|
///
|
||||||
|
/// IEEE 754 specifies the basic operations and `sqrt` exactly, but says nothing
|
||||||
|
/// about `exp`, `log` or `erf`. `std`'s versions delegate to the *system* math
|
||||||
|
/// library, so they differ between platforms: measured here, `f64::exp` and
|
||||||
|
/// `libm::exp` disagree on 9.7% of inputs and `f64::ln` / `libm::log` on 5.0%,
|
||||||
|
/// each by one ULP.
|
||||||
|
///
|
||||||
|
/// Inference is an iterative fixed point, so a one-ULP difference can change an
|
||||||
|
/// iteration count and therefore the answer by more than one ULP. Routing every
|
||||||
|
/// transcendental through `libm` makes a fit reproducible across platforms, not
|
||||||
|
/// just across thread counts as `tests/determinism.rs` already checks.
|
||||||
|
///
|
||||||
|
/// **So: use `libm::exp` / `libm::log` in inference code, never `f64::exp` /
|
||||||
|
/// `f64::ln`.** `sqrt` is exempt — IEEE specifies it exactly, so `f64::sqrt` is
|
||||||
|
/// already portable. Test code may use whichever is clearer.
|
||||||
|
///
|
||||||
|
/// It costs nothing: `Batch::iteration` measured -2.7% [-5.7%, -0.3%] with the
|
||||||
|
/// whole set swapped.
|
||||||
|
///
|
||||||
|
/// Delegates to `libm`, which is the Rust port of FDLIBM and accurate to about
|
||||||
|
/// one ULP. This replaced a Numerical Recipes `erfcc` rational approximation
|
||||||
|
/// whose documented bound was 1.2e-7 *relative* — measured at ~1e-7 across the
|
||||||
|
/// whole range, and the binding accuracy constraint on the entire crate.
|
||||||
|
///
|
||||||
|
/// The swap is free. 98% of the arguments inference passes here have
|
||||||
|
/// `|x| < 0.84375`, which is exactly where FDLIBM skips the exponential
|
||||||
|
/// entirely, so the longer polynomial costs nothing on the distribution that
|
||||||
|
/// actually occurs: `Batch::iteration` moved -1.6% [-4.7%, +0.9%], p = 0.31.
|
||||||
|
///
|
||||||
|
/// What it bought: `compute_margin` went from 8.4e-8 to 1.7e-16 against exact
|
||||||
|
/// quantiles, `cdf(mu, mu, sigma)` is now exactly 0.5, and `sf + cdf` sums to
|
||||||
|
/// one within a single ULP where it was 3e-8 out.
|
||||||
fn erfc(x: f64) -> f64 {
|
fn erfc(x: f64) -> f64 {
|
||||||
let z = x.abs();
|
libm::erfc(x)
|
||||||
let t = 1.0 / (1.0 + z / 2.0);
|
|
||||||
|
|
||||||
let a = -0.82215223 + t * 0.17087277;
|
|
||||||
let b = 1.48851587 + t * a;
|
|
||||||
let c = -1.13520398 + t * b;
|
|
||||||
let d = 0.27886807 + t * c;
|
|
||||||
let e = -0.18628806 + t * d;
|
|
||||||
let f = 0.09678418 + t * e;
|
|
||||||
let g = 0.37409196 + t * f;
|
|
||||||
let h = 1.00002368 + t * g;
|
|
||||||
|
|
||||||
let r = t * (-z * z - 1.26551223 + t * h).exp();
|
|
||||||
|
|
||||||
if x >= 0.0 { r } else { 2.0 - r }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The previous Numerical Recipes `erfcc`, kept only so the timing test can
|
||||||
|
/// compare both in one binary. Removed once the comparison is recorded.
|
||||||
fn erfc_inv(mut y: f64) -> f64 {
|
fn erfc_inv(mut y: f64) -> f64 {
|
||||||
if y >= 2.0 {
|
if y >= 2.0 {
|
||||||
return f64::NEG_INFINITY;
|
return f64::NEG_INFINITY;
|
||||||
@@ -247,14 +308,22 @@ fn erfc_inv(mut y: f64) -> f64 {
|
|||||||
y = 2.0 - y;
|
y = 2.0 - y;
|
||||||
}
|
}
|
||||||
|
|
||||||
let t = (-2.0 * (y / 2.0).ln()).sqrt();
|
let t = libm::sqrt(-2.0 * libm::log(y / 2.0));
|
||||||
|
|
||||||
let mut x = FRAC_1_SQRT_2 * ((2.30753 + t * 0.27061) / (1.0 + t * (0.99229 + t * 0.04481)) - t);
|
// The leading coefficient is NEGATIVE. `rational - t` is negative here, so
|
||||||
|
// a positive coefficient mirrors the starting point to `-x0` — the
|
||||||
|
// reflection of the root. Newton then has to cross the origin to get back,
|
||||||
|
// which a fixed iteration count does not manage: measured against the true
|
||||||
|
// value, `erfc_inv(0.1)` returned 1.044 instead of 1.16309, and the error
|
||||||
|
// grew as y shrank until `compute_margin` stopped being monotone in
|
||||||
|
// `p_draw` altogether.
|
||||||
|
let mut x =
|
||||||
|
-FRAC_1_SQRT_2 * ((2.30753 + t * 0.27061) / (1.0 + t * (0.99229 + t * 0.04481)) - t);
|
||||||
|
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let err = erfc(x) - y;
|
let err = erfc(x) - y;
|
||||||
|
|
||||||
x += err / (FRAC_2_SQRT_PI * (-(x.powi(2))).exp() - x * err)
|
x += err / (FRAC_2_SQRT_PI * libm::exp(-(x * x)) - x * err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if y < 1.0 { x } else { -x }
|
if y < 1.0 { x } else { -x }
|
||||||
@@ -283,7 +352,7 @@ pub(crate) fn cdf(x: f64, mu: f64, sigma: f64) -> f64 {
|
|||||||
/// away every significant digit the tail had: measured against this function,
|
/// away every significant digit the tail had: measured against this function,
|
||||||
/// `1 - cdf` carries 7% error by four sigma past the mean and returns exactly
|
/// `1 - cdf` carries 7% error by four sigma past the mean and returns exactly
|
||||||
/// zero beyond about 8.3 sigma — where the true value is still 1e-19 and
|
/// zero beyond about 8.3 sigma — where the true value is still 1e-19 and
|
||||||
/// perfectly representable. `erfc` itself holds ~1e-7 *relative* accuracy down
|
/// perfectly representable. `erfc` holds *relative* accuracy all the way down
|
||||||
/// to 1e-296, so the precision is there to keep; only the subtraction threw it
|
/// to 1e-296, so the precision is there to keep; only the subtraction threw it
|
||||||
/// away.
|
/// away.
|
||||||
///
|
///
|
||||||
@@ -305,7 +374,7 @@ fn erfcx(x: f64) -> f64 {
|
|||||||
// Below the crossover neither factor is extreme: erfc is O(1) and
|
// Below the crossover neither factor is extreme: erfc is O(1) and
|
||||||
// exp(x^2) is at most e^4, so the direct product is exact enough and
|
// exp(x^2) is at most e^4, so the direct product is exact enough and
|
||||||
// cheaper than the continued fraction.
|
// cheaper than the continued fraction.
|
||||||
(x * x).exp() * erfc(x)
|
libm::exp(x * x) * erfc(x)
|
||||||
} else {
|
} else {
|
||||||
// erfcx(x) = 1/sqrt(pi) * 1/(x + (1/2)/(x + 1/(x + (3/2)/(x + ...)))),
|
// erfcx(x) = 1/sqrt(pi) * 1/(x + (1/2)/(x + 1/(x + (3/2)/(x + ...)))),
|
||||||
// evaluated by backward recurrence. Converges quickly for x >= 2 and,
|
// evaluated by backward recurrence. Converges quickly for x >= 2 and,
|
||||||
@@ -318,9 +387,74 @@ fn erfcx(x: f64) -> f64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ln` of the normal density at `x`.
|
||||||
|
///
|
||||||
|
/// The density itself underflows to zero past about 38 sigma, and `ln` of a
|
||||||
|
/// clamped zero is -708 whatever the truth was. The log form is a polynomial:
|
||||||
|
/// it stays exact at any separation, and the values it produces (-5001 nats at
|
||||||
|
/// 100 sigma, -500001 at 1000) are perfectly representable.
|
||||||
|
pub(crate) fn ln_pdf(x: f64, mu: f64, sigma: f64) -> f64 {
|
||||||
|
let z = (x - mu) / sigma;
|
||||||
|
-libm::log(SQRT_TAU * sigma) - 0.5 * z * z
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ln P(X > x)` for `X ~ N(mu, sigma^2)`.
|
||||||
|
///
|
||||||
|
/// In the upper tail the `exp(-z^2 / 2)` common to the tail integral is
|
||||||
|
/// factored out analytically via `erfcx`, so this never underflows — where
|
||||||
|
/// `sf(..).ln()` bottoms out at -708 once `erfc` itself reaches zero.
|
||||||
|
pub(crate) fn ln_sf(x: f64, mu: f64, sigma: f64) -> f64 {
|
||||||
|
let z = (x - mu) / sigma;
|
||||||
|
|
||||||
|
if z > 0.0 {
|
||||||
|
// ln(0.5 * erfc(z/sqrt2)) with erfc(y) = exp(-y^2) * erfcx(y).
|
||||||
|
-std::f64::consts::LN_2 - 0.5 * z * z + libm::log(erfcx(z / SQRT_2))
|
||||||
|
} else {
|
||||||
|
// The mass here is at least a half; nothing to lose.
|
||||||
|
libm::log(sf(x, mu, sigma))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ln P(lo < X < hi)` for `X ~ N(mu, sigma^2)`.
|
||||||
|
///
|
||||||
|
/// When the interval sits in a tail both endpoint probabilities underflow
|
||||||
|
/// together, so their difference is taken in scaled form with the shared
|
||||||
|
/// exponential factored out. When it straddles the mean nothing is small and
|
||||||
|
/// the direct difference is exact.
|
||||||
|
pub(crate) fn ln_interval(lo: f64, hi: f64, mu: f64, sigma: f64) -> f64 {
|
||||||
|
let z_lo = (lo - mu) / sigma;
|
||||||
|
let z_hi = (hi - mu) / sigma;
|
||||||
|
|
||||||
|
if z_hi <= z_lo {
|
||||||
|
return f64::NEG_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold a lower-tail interval onto the upper tail; the normal is symmetric.
|
||||||
|
let (near, far) = if z_lo >= 0.0 {
|
||||||
|
(z_lo, z_hi)
|
||||||
|
} else if z_hi <= 0.0 {
|
||||||
|
(-z_hi, -z_lo)
|
||||||
|
} else {
|
||||||
|
// Straddles the mean: the interval holds a non-negligible share of the
|
||||||
|
// mass, so neither endpoint is near enough to 1 to cancel.
|
||||||
|
return libm::log((cdf(hi, mu, sigma) - cdf(lo, mu, sigma)).max(f64::MIN_POSITIVE));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (a, b) = (near / SQRT_2, far / SQRT_2);
|
||||||
|
// b > a >= 0, so this ratio of exponentials is at most 1 and cannot overflow.
|
||||||
|
let scale = libm::exp(a * a - b * b);
|
||||||
|
let bracket = erfcx(a) - scale * erfcx(b);
|
||||||
|
|
||||||
|
if bracket <= 0.0 {
|
||||||
|
return f64::NEG_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
-std::f64::consts::LN_2 - a * a + libm::log(bracket)
|
||||||
|
}
|
||||||
|
|
||||||
fn pdf(x: f64, mu: f64, sigma: f64) -> f64 {
|
fn pdf(x: f64, mu: f64, sigma: f64) -> f64 {
|
||||||
let normalizer = (SQRT_TAU * sigma).powi(-1);
|
let normalizer = (SQRT_TAU * sigma).powi(-1);
|
||||||
let functional = (-((x - mu).powi(2)) / (2.0 * sigma.powi(2))).exp();
|
let functional = libm::exp(-((x - mu) * (x - mu)) / (2.0 * sigma * sigma));
|
||||||
|
|
||||||
normalizer * functional
|
normalizer * functional
|
||||||
}
|
}
|
||||||
@@ -344,10 +478,72 @@ fn pdf(x: f64, mu: f64, sigma: f64) -> f64 {
|
|||||||
fn half_line_truncation(alpha: f64) -> (f64, f64) {
|
fn half_line_truncation(alpha: f64) -> (f64, f64) {
|
||||||
let inv = alpha.recip();
|
let inv = alpha.recip();
|
||||||
let inv_sq = inv * inv;
|
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;
|
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) {
|
fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
||||||
@@ -375,7 +571,7 @@ fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
|||||||
(v, v - alpha)
|
(v, v - alpha)
|
||||||
};
|
};
|
||||||
|
|
||||||
(v, v * gap)
|
(v, 1.0 - v * gap)
|
||||||
} else {
|
} else {
|
||||||
// v is odd in mu and w is even, so fold to mu <= 0. Both truncation
|
// 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.
|
// points then sit in the upper tail, where the scaled form applies.
|
||||||
@@ -391,15 +587,28 @@ 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
|
// 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
|
// indistinguishable from a half-line, so the asymptotic covers it with
|
||||||
// no subtraction at all.
|
// no subtraction at all.
|
||||||
if alpha >= ASYMPTOTIC_MILLS_ALPHA && alpha * (beta - alpha) >= HALF_LINE_WINDOW {
|
let width = beta - alpha;
|
||||||
let (v, w) = half_line_truncation(alpha);
|
|
||||||
return (if flipped { -v } else { v }, w);
|
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 {
|
let (v, u) = if alpha > 0.0 {
|
||||||
// beta > alpha > 0, so this ratio of exponentials is at most 1 and
|
// beta > alpha > 0, so this ratio of exponentials is at most 1 and
|
||||||
// cannot overflow.
|
// cannot overflow.
|
||||||
let scale = (0.5 * (alpha * alpha - beta * beta)).exp();
|
let scale = libm::exp(0.5 * (alpha * alpha - beta * beta));
|
||||||
let denominator = 0.5 * (erfcx(alpha / SQRT_2) - scale * erfcx(beta / SQRT_2));
|
let denominator = 0.5 * (erfcx(alpha / SQRT_2) - scale * erfcx(beta / SQRT_2));
|
||||||
|
|
||||||
(
|
(
|
||||||
@@ -416,17 +625,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) {
|
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 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)
|
(mu_trunc, sigma_trunc)
|
||||||
}
|
}
|
||||||
@@ -437,13 +652,34 @@ pub(crate) fn approx(n: Gaussian, margin: f64, tie: bool) -> Gaussian {
|
|||||||
Gaussian::from_ms(mu, sigma)
|
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) {
|
pub(crate) fn tuple_max(v1: (f64, f64), v2: (f64, f64)) -> (f64, f64) {
|
||||||
(
|
(
|
||||||
if v1.0 > v2.0 { v1.0 } else { v2.0 },
|
max_propagating_nan(v1.0, v2.0),
|
||||||
if v1.1 > v2.1 { v1.1 } else { v2.1 },
|
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 {
|
pub(crate) fn tuple_gt(t: (f64, f64), e: f64) -> bool {
|
||||||
t.0 > e || t.1 > e
|
t.0 > e || t.1 > e
|
||||||
}
|
}
|
||||||
@@ -510,29 +746,36 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
|
|||||||
x.into_iter().map(|(i, _)| i).collect()
|
x.into_iter().map(|(i, _)| i).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates the match quality of the given rating groups. A result is the draw probability in the association
|
/// Calculates the match quality of the given teams. A result is the draw probability in the association
|
||||||
///
|
///
|
||||||
/// Supports any number of groups. Values range roughly `[0, 1]`; 1 means a
|
/// Supports any number of groups. Values range roughly `[0, 1]`; 1 means a
|
||||||
/// perfectly balanced match.
|
/// perfectly balanced match.
|
||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
///
|
///
|
||||||
/// Panics if fewer than two rating groups are supplied, or if any group is
|
/// Panics if fewer than two teams are supplied, or if any group is
|
||||||
/// empty — match quality is a property of a contest between at least two
|
/// empty — match quality is a property of a contest between at least two
|
||||||
/// non-empty sides.
|
/// 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]
|
#[must_use]
|
||||||
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
pub fn quality(teams: &[&[Gaussian]], beta: f64) -> f64 {
|
||||||
assert!(
|
assert!(
|
||||||
rating_groups.len() >= 2,
|
teams.len() >= 2,
|
||||||
"quality() requires at least 2 rating groups, got {}",
|
"quality() requires at least 2 teams, got {}",
|
||||||
rating_groups.len()
|
teams.len()
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
rating_groups.iter().all(|group| !group.is_empty()),
|
teams.iter().all(|group| !group.is_empty()),
|
||||||
"quality() requires every rating group to be non-empty"
|
"quality() requires every team to be non-empty"
|
||||||
);
|
);
|
||||||
|
|
||||||
let flatten_ratings = rating_groups
|
let flatten_ratings = teams
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|group| group.iter())
|
.flat_map(|group| group.iter())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -553,14 +796,14 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
|||||||
variance_matrix[(i, i)] = rating.sigma().powi(2);
|
variance_matrix[(i, i)] = rating.sigma().powi(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut rotated_a_matrix = Matrix::new(rating_groups.len() - 1, length);
|
let mut rotated_a_matrix = Matrix::new(teams.len() - 1, length);
|
||||||
|
|
||||||
// Row `row` contrasts group `row` (+weight) against group `row + 1`
|
// Row `row` contrasts group `row` (+weight) against group `row + 1`
|
||||||
// (-weight). `t` is the column where the current group's players start;
|
// (-weight). `t` is the column where the current group's players start;
|
||||||
// the negative block begins immediately after it.
|
// the negative block begins immediately after it.
|
||||||
let mut t = 0;
|
let mut t = 0;
|
||||||
|
|
||||||
for (row, group) in rating_groups.windows(2).enumerate() {
|
for (row, group) in teams.windows(2).enumerate() {
|
||||||
let current = group[0];
|
let current = group[0];
|
||||||
let next = group[1];
|
let next = group[1];
|
||||||
|
|
||||||
@@ -585,13 +828,141 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
|||||||
let end = &rotated_a_matrix * &mean_matrix;
|
let end = &rotated_a_matrix * &mean_matrix;
|
||||||
|
|
||||||
let e_arg = (-0.5 * &start * &middle.inverse() * &end).determinant();
|
let e_arg = (-0.5 * &start * &middle.inverse() * &end).determinant();
|
||||||
let s_arg = ata.determinant() / middle.determinant();
|
|
||||||
|
|
||||||
e_arg.exp() * 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 ::approx::assert_ulps_eq;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -602,9 +973,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Upper-tail values of the standard normal, from published tables. The
|
/// Upper-tail values of the standard normal, from published tables. The
|
||||||
/// point is not the digits — `erfc` only carries ~1e-7 relative — but that
|
/// point is not the digits — these are 7-digit table values — but that a
|
||||||
/// a number comes back at all: `1 - cdf` returned exactly zero for every
|
/// number comes back at all: `1 - cdf` returned exactly zero for every one
|
||||||
/// one of these.
|
/// of these.
|
||||||
#[test]
|
#[test]
|
||||||
fn survival_function_survives_the_far_tail() {
|
fn survival_function_survives_the_far_tail() {
|
||||||
for (z, expected) in [
|
for (z, expected) in [
|
||||||
@@ -616,7 +987,7 @@ mod tests {
|
|||||||
let got = sf(z, 0.0, 1.0);
|
let got = sf(z, 0.0, 1.0);
|
||||||
assert!(got > 0.0, "sf({z}) collapsed to zero");
|
assert!(got > 0.0, "sf({z}) collapsed to zero");
|
||||||
assert!(
|
assert!(
|
||||||
(got - expected).abs() / expected < 1e-6,
|
(got - expected).abs() / expected < 1e-6, // published table values, 7 digits
|
||||||
"sf({z}) = {got}, expected ~{expected}"
|
"sf({z}) = {got}, expected ~{expected}"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -634,11 +1005,8 @@ mod tests {
|
|||||||
for z in [-4.0f64, -1.0, 0.0, 0.5, 1.0, 2.0, 3.0, 4.0] {
|
for z in [-4.0f64, -1.0, 0.0, 0.5, 1.0, 2.0, 3.0, 4.0] {
|
||||||
let naive = 1.0 - cdf(z, 0.0, 1.0);
|
let naive = 1.0 - cdf(z, 0.0, 1.0);
|
||||||
let direct = sf(z, 0.0, 1.0);
|
let direct = sf(z, 0.0, 1.0);
|
||||||
// Bounded by `erfc`'s own ~1e-7 relative error, not by the
|
|
||||||
// subtraction: the two forms evaluate `erfc` at different points
|
|
||||||
// and the approximation is not exactly antisymmetric.
|
|
||||||
assert!(
|
assert!(
|
||||||
(naive - direct).abs() < 1e-6,
|
(naive - direct).abs() < 1e-15,
|
||||||
"z={z}: naive {naive} vs direct {direct}"
|
"z={z}: naive {naive} vs direct {direct}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -648,9 +1016,7 @@ mod tests {
|
|||||||
fn survival_and_cdf_partition_the_mass() {
|
fn survival_and_cdf_partition_the_mass() {
|
||||||
for z in [-3.0f64, -0.5, 0.0, 1.0, 2.5] {
|
for z in [-3.0f64, -0.5, 0.0, 1.0, 2.5] {
|
||||||
let total = sf(z, 1.0, 2.0) + cdf(z, 1.0, 2.0);
|
let total = sf(z, 1.0, 2.0) + cdf(z, 1.0, 2.0);
|
||||||
// `erfc(z) + erfc(-z) == 2` only to the accuracy of the
|
assert!((total - 1.0).abs() < 1e-15, "z={z}: {total}");
|
||||||
// approximation, which is ~1e-7 relative.
|
|
||||||
assert!((total - 1.0).abs() < 1e-6, "z={z}: {total}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,7 +1027,7 @@ mod tests {
|
|||||||
let direct = (x * x).exp() * erfc(x);
|
let direct = (x * x).exp() * erfc(x);
|
||||||
let scaled = erfcx(x);
|
let scaled = erfcx(x);
|
||||||
assert!(
|
assert!(
|
||||||
(direct - scaled).abs() / scaled < 1e-6,
|
(direct - scaled).abs() / scaled < 1e-14,
|
||||||
"x={x}: direct {direct} vs erfcx {scaled}"
|
"x={x}: direct {direct} vs erfcx {scaled}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -746,6 +1112,173 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `erfc_inv`'s initial guess had the wrong sign, putting Newton on the
|
||||||
|
/// mirror image of the root. Three fixed iterations could not cross back,
|
||||||
|
/// so the error grew as the argument shrank: at `p_draw = 0.99` the margin
|
||||||
|
/// came out 0.503 where the answer is 2.576.
|
||||||
|
#[test]
|
||||||
|
fn erfc_inv_matches_known_quantiles() {
|
||||||
|
// sqrt(2) * erfc_inv(1 - p) is the standard normal quantile
|
||||||
|
// Phi^-1((1 + p) / 2).
|
||||||
|
for (p, exact) in [
|
||||||
|
(0.5f64, 0.674_489_750_196_081_7f64),
|
||||||
|
(0.9, 1.644_853_626_951_472_7),
|
||||||
|
(0.95, 1.959_963_984_540_054_2),
|
||||||
|
(0.99, 2.575_829_303_548_9),
|
||||||
|
(0.999, 3.290_526_731_491_896_4),
|
||||||
|
] {
|
||||||
|
let got = SQRT_2 * erfc_inv(1.0 - p);
|
||||||
|
assert!(
|
||||||
|
(got - exact).abs() / exact < 1e-14,
|
||||||
|
"p={p}: got {got}, exact {exact}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The draw margin must grow with the draw probability. It did not: it ran
|
||||||
|
/// 0.674 -> 1.476 -> 0.503 -> 0.982 as `p_draw` went 0.5 -> 0.9 -> 0.99 ->
|
||||||
|
/// 0.999, which is not a rounding error but a broken function.
|
||||||
|
/// Deep in the tail the accuracy limit is the *caller's* argument, not this
|
||||||
|
/// function.
|
||||||
|
///
|
||||||
|
/// `compute_margin(0.999999, ..)` computes `1.0 - p_draw`, and 0.999999 is
|
||||||
|
/// not representable: the subtraction cancels and leaves 2.9e-11 of
|
||||||
|
/// relative error in the argument before `erfc_inv` is even entered. Given
|
||||||
|
/// an exactly-representable argument the result is good to 1.8e-16, so this
|
||||||
|
/// is inherent to taking `p_draw` near one rather than something to fix
|
||||||
|
/// here. At `p_draw = 0.999` the whole path is still accurate to 4e-16.
|
||||||
|
///
|
||||||
|
/// Worth pinning: measured against a 70-digit reference, `puruspe`'s
|
||||||
|
/// `inverfc` returns the identical wrong value for the identical reason,
|
||||||
|
/// which is what makes it clear the fault is upstream of both.
|
||||||
|
#[test]
|
||||||
|
fn erfc_inv_is_exact_given_an_exactly_representable_argument() {
|
||||||
|
// erfc(z / sqrt2) = 1e-6 exactly, so z = Phi^-1(0.9999995).
|
||||||
|
let got = SQRT_2 * erfc_inv(1e-6);
|
||||||
|
let exact = 4.891_638_475_698_59;
|
||||||
|
assert!(
|
||||||
|
(got - exact).abs() / exact < 1e-14,
|
||||||
|
"got {got}, exact {exact}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_margin_is_monotone_in_the_draw_probability() {
|
||||||
|
let mut previous = 0.0;
|
||||||
|
for p_draw in [
|
||||||
|
0.001f64, 0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99, 0.999, 0.9999,
|
||||||
|
] {
|
||||||
|
let margin = compute_margin(p_draw, 1.0);
|
||||||
|
assert!(
|
||||||
|
margin > previous,
|
||||||
|
"p_draw={p_draw}: margin {margin} did not exceed {previous}"
|
||||||
|
);
|
||||||
|
previous = margin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round-tripping the margin back through the model's own CDF must recover
|
||||||
|
/// the draw probability it was built from.
|
||||||
|
#[test]
|
||||||
|
fn compute_margin_round_trips_through_the_cdf() {
|
||||||
|
for p_draw in [0.001f64, 0.1, 0.5, 0.9, 0.99, 0.999] {
|
||||||
|
for sd in [0.5f64, 1.0, 5.892_557] {
|
||||||
|
let margin = compute_margin(p_draw, sd);
|
||||||
|
// P(|X| < margin) for X ~ N(0, sd^2).
|
||||||
|
let recovered = 1.0 - 2.0 * cdf(-margin, 0.0, sd);
|
||||||
|
assert!(
|
||||||
|
(recovered - p_draw).abs() < 1e-14,
|
||||||
|
"p_draw={p_draw} sd={sd}: recovered {recovered}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ln_pdf`, `ln_sf` and `ln_interval` exist so evidence stays exact where
|
||||||
|
/// the linear forms underflow. Past ~38 sigma the linear value is zero and
|
||||||
|
/// its log is whatever floor it was clamped to.
|
||||||
|
#[test]
|
||||||
|
fn log_space_helpers_stay_exact_where_the_linear_forms_underflow() {
|
||||||
|
for z in [40.0f64, 60.0, 100.0, 1000.0] {
|
||||||
|
assert_eq!(pdf(z, 0.0, 1.0), 0.0, "pdf should underflow at {z}");
|
||||||
|
assert_eq!(sf(z, 0.0, 1.0), 0.0, "sf should underflow at {z}");
|
||||||
|
|
||||||
|
let lp = ln_pdf(z, 0.0, 1.0);
|
||||||
|
let expected_lp = -(SQRT_TAU).ln() - 0.5 * z * z;
|
||||||
|
assert!(
|
||||||
|
(lp - expected_lp).abs() < 1e-9,
|
||||||
|
"ln_pdf({z}) = {lp}, expected {expected_lp}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let ls = ln_sf(z, 0.0, 1.0);
|
||||||
|
// ln Phi(-z) ~ -z^2/2 - ln(z) - ln(sqrt(2 pi)) for large z.
|
||||||
|
let approx = -0.5 * z * z - z.ln() - SQRT_TAU.ln();
|
||||||
|
assert!(
|
||||||
|
(ls - approx).abs() / approx.abs() < 1e-3,
|
||||||
|
"ln_sf({z}) = {ls}, asymptote {approx}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ls < f64::MIN_POSITIVE.ln(),
|
||||||
|
"ln_sf({z}) still on the clamp floor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where nothing underflows, the log helpers must agree with the direct
|
||||||
|
/// forms exactly enough that nothing else in the crate shifts.
|
||||||
|
#[test]
|
||||||
|
fn log_space_helpers_agree_with_the_linear_forms_in_range() {
|
||||||
|
for z in [-3.0f64, -1.0, 0.0, 1.0, 2.0, 5.0, 10.0, 20.0] {
|
||||||
|
let lp = ln_pdf(z, 0.5, 2.0);
|
||||||
|
let direct_pdf = pdf(z, 0.5, 2.0);
|
||||||
|
assert!(
|
||||||
|
(lp.exp() - direct_pdf).abs() <= 1e-12 * direct_pdf,
|
||||||
|
"ln_pdf at {z}: {} vs {direct_pdf}",
|
||||||
|
lp.exp()
|
||||||
|
);
|
||||||
|
|
||||||
|
let ls = ln_sf(z, 0.5, 2.0);
|
||||||
|
let direct = sf(z, 0.5, 2.0);
|
||||||
|
assert!(
|
||||||
|
(ls.exp() - direct).abs() <= 1e-13 * direct.max(1e-300),
|
||||||
|
"ln_sf at {z}: {} vs {direct}",
|
||||||
|
ls.exp()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ln_interval_matches_the_direct_difference_when_nothing_is_small() {
|
||||||
|
for mu in [-2.0f64, 0.0, 0.5, 2.0] {
|
||||||
|
let direct = cdf(1.0, mu, 1.0) - cdf(-1.0, mu, 1.0);
|
||||||
|
let logged = ln_interval(-1.0, 1.0, mu, 1.0).exp();
|
||||||
|
assert!(
|
||||||
|
(logged - direct).abs() <= 1e-13 * direct,
|
||||||
|
"mu={mu}: {logged} vs {direct}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A window far out in the tail: both endpoints underflow together, so the
|
||||||
|
/// difference has to be taken in scaled form.
|
||||||
|
#[test]
|
||||||
|
fn ln_interval_survives_a_window_deep_in_the_tail() {
|
||||||
|
for mu in [-50.0f64, -100.0, -1000.0] {
|
||||||
|
let logged = ln_interval(-1.0, 1.0, mu, 1.0);
|
||||||
|
assert!(logged.is_finite(), "mu={mu}: {logged}");
|
||||||
|
assert!(
|
||||||
|
logged < f64::MIN_POSITIVE.ln(),
|
||||||
|
"mu={mu}: {logged} is stuck on the clamp floor"
|
||||||
|
);
|
||||||
|
// Dominated by the near edge: ln P ~ ln Phi(-(|mu| - 1)).
|
||||||
|
let near = ln_sf(-1.0, mu, 1.0);
|
||||||
|
assert!(
|
||||||
|
(logged - near).abs() < 5.0,
|
||||||
|
"mu={mu}: {logged} strays from the near-edge tail {near}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_quality() {
|
fn test_quality() {
|
||||||
let a = Gaussian::from_ms(25.0, 3.0);
|
let a = Gaussian::from_ms(25.0, 3.0);
|
||||||
|
|||||||
@@ -91,6 +91,29 @@ impl Lu {
|
|||||||
det
|
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
|
/// Solve `Ax = b` for a single column of the identity, giving one column
|
||||||
/// of the inverse.
|
/// of the inverse.
|
||||||
fn solve_column(&self, col: usize, out: &mut [f64]) {
|
fn solve_column(&self, col: usize, out: &mut [f64]) {
|
||||||
@@ -157,6 +180,24 @@ impl Matrix {
|
|||||||
Lu::decompose(self).determinant()
|
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.
|
/// Matrix inverse via LU decomposition.
|
||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
|
|||||||
+33
-3
@@ -18,6 +18,7 @@ use smallvec::SmallVec;
|
|||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub enum Outcome {
|
pub enum Outcome {
|
||||||
Ranked(SmallVec<[u32; 4]>),
|
Ranked(SmallVec<[u32; 4]>),
|
||||||
|
#[non_exhaustive]
|
||||||
Scored {
|
Scored {
|
||||||
scores: SmallVec<[f64; 4]>,
|
scores: SmallVec<[f64; 4]>,
|
||||||
/// Per-event noise override. `None` means inherit
|
/// Per-event noise override. `None` means inherit
|
||||||
@@ -34,12 +35,41 @@ impl Outcome {
|
|||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
///
|
///
|
||||||
/// Panics if `winner >= n`.
|
/// Panics if `winner >= n`. Use [`Outcome::try_winner`] when the index
|
||||||
|
/// comes from data rather than a literal.
|
||||||
|
///
|
||||||
|
/// This is the one constructor here that validates, and deliberately so.
|
||||||
|
/// Its siblings build freely and let ingestion reject what it cannot use,
|
||||||
|
/// which works because a malformed rank vector stays recognisable. An
|
||||||
|
/// out-of-range winner does not: `winner(5, 2)` would produce ranks
|
||||||
|
/// `[1, 1]`, an all-tied draw that ingestion accepts without complaint when
|
||||||
|
/// `p_draw > 0`. Asking "team 5 won" and silently getting "everyone drew"
|
||||||
|
/// is exactly the class of quiet wrong answer this crate keeps removing, so
|
||||||
|
/// the check happens here where the mistake is.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn winner(winner: u32, n: u32) -> Self {
|
pub fn winner(winner: u32, n: u32) -> Self {
|
||||||
assert!(winner < n, "winner index {winner} out of range 0..{n}");
|
Self::try_winner(winner, n)
|
||||||
|
.unwrap_or_else(|_| panic!("winner index {winner} out of range 0..{n}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `n`-team outcome where team `winner` won, or an error if `winner` is not
|
||||||
|
/// a valid team index.
|
||||||
|
///
|
||||||
|
/// The fallible form of [`Outcome::winner`], for when the index is computed
|
||||||
|
/// or parsed rather than written literally.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// `InvalidParameter` if `winner >= n`.
|
||||||
|
pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> {
|
||||||
|
if winner >= n {
|
||||||
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
|
name: "winner",
|
||||||
|
value: f64::from(winner),
|
||||||
|
});
|
||||||
|
}
|
||||||
let ranks: SmallVec<[u32; 4]> = (0..n).map(|i| if i == winner { 0 } else { 1 }).collect();
|
let ranks: SmallVec<[u32; 4]> = (0..n).map(|i| if i == winner { 0 } else { 1 }).collect();
|
||||||
Self::Ranked(ranks)
|
Ok(Self::Ranked(ranks))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All `n` teams tied.
|
/// All `n` teams tied.
|
||||||
|
|||||||
+73
-37
@@ -21,7 +21,7 @@
|
|||||||
//! would have made every `predict_*` call return a slightly different number,
|
//! would have made every `predict_*` call return a slightly different number,
|
||||||
//! which is not a property a rating library should have.
|
//! 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.
|
/// Teams beyond this count make the outcome enumeration impractical.
|
||||||
///
|
///
|
||||||
@@ -33,19 +33,29 @@ pub(crate) const MAX_TEAMS_FOR_DISTRIBUTION: usize = 6;
|
|||||||
|
|
||||||
/// Relative tolerance for the first-place integrals.
|
/// Relative tolerance for the first-place integrals.
|
||||||
///
|
///
|
||||||
/// Tightening past this buys nothing: the underlying `cdf` is a rational
|
/// The adaptive integrator reaches the exact two-team closed form to ~1e-15 at
|
||||||
/// approximation with fractional error ~1.2e-7, which contributes ~6e-9 to a
|
/// this tolerance, which is round-off for a probability. `cdf` is no longer the
|
||||||
/// finished probability and dominates any further quadrature refinement.
|
/// limit — it went to ~1 ULP when `erfc` moved to `libm` — so this is the
|
||||||
|
/// integrator's own floor.
|
||||||
const WIN_TOLERANCE: f64 = 1e-8;
|
const WIN_TOLERANCE: f64 = 1e-8;
|
||||||
|
|
||||||
/// Nodes for the ranking grid, and the floor below which a grid is pointless.
|
/// Nodes for the ranking grid, and the floor below which a grid is pointless.
|
||||||
///
|
///
|
||||||
/// The recursion converges as O(h^2). Measured against the exact two-team
|
/// The recursion converges as O(h^2), so this trades nodes against accuracy
|
||||||
/// closed form, 2_048 nodes leave ~1.2e-6 of discretisation error while 8_192
|
/// directly. Measured against the exact two-team closed form, 2_048 nodes leave
|
||||||
/// reach ~1e-7 — at which point the residual is the `cdf` rational
|
/// ~1.2e-6 of discretisation error and 8_192 reach ~1e-7.
|
||||||
/// approximation (~2.4e-8), not the grid, and refining further buys nothing.
|
///
|
||||||
|
/// Unlike the adaptive path there is no approximation floor underneath this any
|
||||||
|
/// more — `cdf` is accurate to ~1 ULP since `erfc` moved to `libm` — so the
|
||||||
|
/// error here is purely the grid, and a caller who needs more can only get it
|
||||||
|
/// by paying for more nodes. 8_192 is the accuracy/cost point chosen, not a
|
||||||
|
/// point where refining stops helping.
|
||||||
const MIN_GRID_POINTS: usize = 8_192;
|
const MIN_GRID_POINTS: usize = 8_192;
|
||||||
const MAX_GRID_POINTS: usize = 262_144;
|
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.
|
/// How many standard deviations of support the grid and integrals cover.
|
||||||
///
|
///
|
||||||
@@ -62,7 +72,7 @@ fn phi(z: f64) -> f64 {
|
|||||||
fn density(g: Gaussian, x: f64) -> f64 {
|
fn density(g: Gaussian, x: f64) -> f64 {
|
||||||
let sigma = g.sigma();
|
let sigma = g.sigma();
|
||||||
let z = (x - g.mu()) / sigma;
|
let z = (x - g.mu()) / sigma;
|
||||||
(-0.5 * z * z).exp() / (sigma * (2.0 * std::f64::consts::PI).sqrt())
|
libm::exp(-0.5 * z * z) / (sigma * (2.0 * std::f64::consts::PI).sqrt())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-pair draw margins.
|
/// Per-pair draw margins.
|
||||||
@@ -146,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,
|
/// 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
|
/// 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.
|
/// 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
|
let lo = perf
|
||||||
.iter()
|
.iter()
|
||||||
.map(|g| g.mu() - SUPPORT_SIGMAS * g.sigma())
|
.map(|g| g.mu() - SUPPORT_SIGMAS * g.sigma())
|
||||||
@@ -169,18 +179,36 @@ fn grid_shape(perf: &[Gaussian], margins: &Margins) -> (f64, f64, usize) {
|
|||||||
|
|
||||||
let feature = narrowest.min(smallest_margin);
|
let feature = narrowest.min(smallest_margin);
|
||||||
let wanted = if feature.is_finite() && feature > 0.0 {
|
let wanted = if feature.is_finite() && feature > 0.0 {
|
||||||
((hi - lo) / (feature / 12.0)).ceil()
|
((hi - lo) / (feature / NODES_PER_FEATURE)).ceil()
|
||||||
} else {
|
} else {
|
||||||
MIN_GRID_POINTS as f64
|
MIN_GRID_POINTS as f64
|
||||||
};
|
};
|
||||||
|
|
||||||
let points = if wanted.is_finite() {
|
if !wanted.is_finite() {
|
||||||
(wanted as usize).clamp(MIN_GRID_POINTS, MAX_GRID_POINTS)
|
return Ok((lo, hi, MIN_GRID_POINTS));
|
||||||
} else {
|
}
|
||||||
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.
|
/// Densities of each team sampled on the shared grid.
|
||||||
@@ -192,8 +220,8 @@ struct Sampled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Sampled {
|
impl Sampled {
|
||||||
fn new(perf: &[Gaussian], margins: &Margins) -> Self {
|
fn new(perf: &[Gaussian], margins: &Margins) -> Result<Self, InferenceError> {
|
||||||
let (lo, hi, points) = grid_shape(perf, margins);
|
let (lo, hi, points) = grid_shape(perf, margins)?;
|
||||||
let step = (hi - lo) / (points - 1) as f64;
|
let step = (hi - lo) / (points - 1) as f64;
|
||||||
let density = perf
|
let density = perf
|
||||||
.iter()
|
.iter()
|
||||||
@@ -203,12 +231,12 @@ impl Sampled {
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self {
|
Ok(Self {
|
||||||
lo,
|
lo,
|
||||||
step,
|
step,
|
||||||
points,
|
points,
|
||||||
density,
|
density,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn node(&self, i: usize) -> f64 {
|
fn node(&self, i: usize) -> f64 {
|
||||||
@@ -311,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
|
/// Orders that differ only *within* a tied group describe the same finishing
|
||||||
/// order, so their probabilities are summed into one entry.
|
/// 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 n = perf.len();
|
||||||
let sampled = Sampled::new(perf, margins);
|
let sampled = Sampled::new(perf, margins)?;
|
||||||
|
|
||||||
let mut aggregated: Vec<(Vec<u32>, f64)> = Vec::new();
|
let mut aggregated: Vec<(Vec<u32>, f64)> = Vec::new();
|
||||||
for (order, tied) in events(n, margins.all_zero()) {
|
for (order, tied) in events(n, margins.all_zero()) {
|
||||||
@@ -326,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.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
aggregated
|
Ok(aggregated)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All permutations of `items`.
|
/// All permutations of `items`.
|
||||||
@@ -391,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
|
/// 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
|
/// this sums the orders consistent with the requested ranking rather than
|
||||||
/// picking one.
|
/// 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 n = perf.len();
|
||||||
let sampled = Sampled::new(perf, margins);
|
let sampled = Sampled::new(perf, margins)?;
|
||||||
|
|
||||||
let mut distinct: Vec<u32> = ranks.to_vec();
|
let mut distinct: Vec<u32> = ranks.to_vec();
|
||||||
distinct.sort_unstable();
|
distinct.sort_unstable();
|
||||||
@@ -404,10 +439,10 @@ pub(crate) fn ranking_probability(perf: &[Gaussian], margins: &Margins, ranks: &
|
|||||||
.map(|&r| (0..n).filter(|&i| ranks[i] == r).collect())
|
.map(|&r| (0..n).filter(|&i| ranks[i] == r).collect())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
orders_for_groups(&groups)
|
Ok(orders_for_groups(&groups)
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(order, tied)| order_probability(margins, &sampled, order, tied))
|
.map(|(order, tied)| order_probability(margins, &sampled, order, tied))
|
||||||
.sum()
|
.sum())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A distribution over the ways a contest could finish.
|
/// A distribution over the ways a contest could finish.
|
||||||
@@ -421,6 +456,7 @@ pub(crate) fn ranking_probability(perf: &[Gaussian], margins: &Margins, ranks: &
|
|||||||
/// `Game::ranked` asks "what would we believe if *this* happened", which is
|
/// `Game::ranked` asks "what would we believe if *this* happened", which is
|
||||||
/// what an expected-information-gain calculation needs alongside the weight.
|
/// what an expected-information-gain calculation needs alongside the weight.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
#[must_use]
|
||||||
pub struct Prediction {
|
pub struct Prediction {
|
||||||
outcomes: Vec<(Vec<u32>, f64)>,
|
outcomes: Vec<(Vec<u32>, f64)>,
|
||||||
}
|
}
|
||||||
@@ -503,7 +539,7 @@ mod tests {
|
|||||||
|
|
||||||
/// Exact two-team result: `P(a first) = Phi((mu_a - mu_b - eps) / sd)`.
|
/// Exact two-team result: `P(a first) = Phi((mu_a - mu_b - eps) / sd)`.
|
||||||
fn closed_form_two(a: Gaussian, b: Gaussian, eps: f64) -> (f64, f64) {
|
fn closed_form_two(a: Gaussian, b: Gaussian, eps: f64) -> (f64, f64) {
|
||||||
let sd = (a.sigma().powi(2) + b.sigma().powi(2)).sqrt();
|
let sd = a.sigma().hypot(b.sigma());
|
||||||
(
|
(
|
||||||
phi((a.mu() - b.mu() - eps) / sd),
|
phi((a.mu() - b.mu() - eps) / sd),
|
||||||
phi((b.mu() - a.mu() - eps) / sd),
|
phi((b.mu() - a.mu() - eps) / sd),
|
||||||
@@ -523,7 +559,7 @@ mod tests {
|
|||||||
let got = win_probabilities(&perf, &flat(2, eps));
|
let got = win_probabilities(&perf, &flat(2, eps));
|
||||||
let (wa, wb) = closed_form_two(perf[0], perf[1], eps);
|
let (wa, wb) = closed_form_two(perf[0], perf[1], eps);
|
||||||
assert!(
|
assert!(
|
||||||
(got[0] - wa).abs() < 1e-7 && (got[1] - wb).abs() < 1e-7,
|
(got[0] - wa).abs() < 1e-12 && (got[1] - wb).abs() < 1e-12,
|
||||||
"mu=({ma},{mb}) sigma=({sa},{sb}) eps={eps}: got {got:?}, want [{wa}, {wb}]"
|
"mu=({ma},{mb}) sigma=({sa},{sb}) eps={eps}: got {got:?}, want [{wa}, {wb}]"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -598,7 +634,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
let n = perf.len();
|
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();
|
let sum: f64 = dist.iter().map(|(_, p)| p).sum();
|
||||||
assert!(
|
assert!(
|
||||||
(sum - 1.0).abs() < 1e-6,
|
(sum - 1.0).abs() < 1e-6,
|
||||||
@@ -614,7 +650,7 @@ mod tests {
|
|||||||
fn two_team_distribution_matches_the_closed_form() {
|
fn two_team_distribution_matches_the_closed_form() {
|
||||||
let perf = [g(3.0, 6.0), g(-2.0, 1.0)];
|
let perf = [g(3.0, 6.0), g(-2.0, 1.0)];
|
||||||
let eps = 1.5;
|
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 (wa, wb) = closed_form_two(perf[0], perf[1], eps);
|
||||||
|
|
||||||
let find = |ranks: &[u32]| {
|
let find = |ranks: &[u32]| {
|
||||||
@@ -647,10 +683,10 @@ mod tests {
|
|||||||
let perf = [g(5.0, 6.0), g(0.0, 3.0), g(-5.0, 1.0)];
|
let perf = [g(5.0, 6.0), g(0.0, 3.0), g(-5.0, 1.0)];
|
||||||
let eps = 1.5;
|
let eps = 1.5;
|
||||||
let margins = flat(3, eps);
|
let margins = flat(3, eps);
|
||||||
let dist = outcome_distribution(&perf, &margins);
|
let dist = outcome_distribution(&perf, &margins).unwrap();
|
||||||
|
|
||||||
for (ranks, expected) in &dist {
|
for (ranks, expected) in &dist {
|
||||||
let direct = ranking_probability(&perf, &margins, ranks);
|
let direct = ranking_probability(&perf, &margins, ranks).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
(direct - expected).abs() < 1e-9,
|
(direct - expected).abs() < 1e-9,
|
||||||
"ranks {ranks:?}: direct {direct} vs distribution {expected}"
|
"ranks {ranks:?}: direct {direct} vs distribution {expected}"
|
||||||
@@ -669,7 +705,7 @@ mod tests {
|
|||||||
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
|
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
|
||||||
let mut previous = 0.0;
|
let mut previous = 0.0;
|
||||||
for eps in [0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 24.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}");
|
assert!(p >= previous, "eps={eps}: {p} < {previous}");
|
||||||
if eps == 0.0 {
|
if eps == 0.0 {
|
||||||
assert!(p < 1e-12, "a tie needs a margin, got {p}");
|
assert!(p < 1e-12, "a tie needs a margin, got {p}");
|
||||||
@@ -690,7 +726,7 @@ mod tests {
|
|||||||
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
|
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]
|
let sweep: Vec<f64> = [0.5, 2.0, 4.0, 8.0, 16.0]
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
let peak = sweep
|
let peak = sweep
|
||||||
.iter()
|
.iter()
|
||||||
@@ -711,7 +747,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ties_are_impossible_without_a_draw_margin() {
|
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 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_eq!(dist.len(), 6, "expected only the 6 strict orders: {dist:?}");
|
||||||
assert!(dist.iter().all(|(r, _)| {
|
assert!(dist.iter().all(|(r, _)| {
|
||||||
let mut seen = r.clone();
|
let mut seen = r.clone();
|
||||||
|
|||||||
+19
-3
@@ -11,7 +11,7 @@ use crate::{
|
|||||||
///
|
///
|
||||||
/// A configuration rather than a person: the per-history temporal state
|
/// A configuration rather than a person: the per-history temporal state
|
||||||
/// (messages, last appearance) lives on `Competitor`.
|
/// (messages, last appearance) lives on `Competitor`.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
||||||
pub(crate) prior: Gaussian,
|
pub(crate) prior: Gaussian,
|
||||||
pub(crate) beta: f64,
|
pub(crate) beta: f64,
|
||||||
@@ -23,7 +23,24 @@ pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time, D: Drift<T>> Rating<T, D> {
|
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 {
|
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 {
|
Self {
|
||||||
prior,
|
prior,
|
||||||
beta,
|
beta,
|
||||||
@@ -44,7 +61,6 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The configured prior skill estimate.
|
/// The configured prior skill estimate.
|
||||||
#[must_use]
|
|
||||||
pub fn prior(&self) -> Gaussian {
|
pub fn prior(&self) -> Gaussian {
|
||||||
self.prior
|
self.prior
|
||||||
}
|
}
|
||||||
@@ -93,7 +109,7 @@ impl Default for Rating<i64, ConstantDrift> {
|
|||||||
Self {
|
Self {
|
||||||
prior: Gaussian::default(),
|
prior: Gaussian::default(),
|
||||||
beta: BETA,
|
beta: BETA,
|
||||||
drift: ConstantDrift(GAMMA),
|
drift: ConstantDrift::new(GAMMA),
|
||||||
drift_scale: 1.0,
|
drift_scale: 1.0,
|
||||||
_time: PhantomData,
|
_time: PhantomData,
|
||||||
}
|
}
|
||||||
|
|||||||
-152
@@ -1,152 +0,0 @@
|
|||||||
//! Schedule trait and built-in implementations.
|
|
||||||
//!
|
|
||||||
//! A schedule drives factor propagation to convergence. The default
|
|
||||||
//! `EpsilonOrMax` performs one `TeamSum` sweep (setup) then alternating
|
|
||||||
//! forward/backward sweeps over the iterating factors until the max
|
|
||||||
//! delta drops below epsilon or `max` iterations is reached.
|
|
||||||
|
|
||||||
use crate::factor::{BuiltinFactor, Factor, VarStore};
|
|
||||||
|
|
||||||
/// Result returned by a `Schedule::run` call.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct ScheduleReport {
|
|
||||||
pub iterations: usize,
|
|
||||||
pub final_step: (f64, f64),
|
|
||||||
pub converged: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drives factor propagation to convergence.
|
|
||||||
pub trait Schedule: Send + Sync {
|
|
||||||
fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default schedule: sweep forward then backward until step ≤ eps or iter == max.
|
|
||||||
///
|
|
||||||
/// Matches the existing `Game::likelihoods` loop bit-for-bit when given the
|
|
||||||
/// same factor layout (`TeamSums` first, then alternating RankDiff/Trunc pairs).
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct EpsilonOrMax {
|
|
||||||
pub eps: f64,
|
|
||||||
pub max: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EpsilonOrMax {
|
|
||||||
fn default() -> Self {
|
|
||||||
// Derived from `ConvergenceOptions` so there is one source of truth for
|
|
||||||
// the tolerance and iteration cap. These previously disagreed: this
|
|
||||||
// default capped at 10 iterations while `ConvergenceOptions` allowed 30,
|
|
||||||
// and which applied depended on whether inference went through
|
|
||||||
// `run_chain` or a `Schedule`.
|
|
||||||
let defaults = crate::ConvergenceOptions::default();
|
|
||||||
|
|
||||||
Self {
|
|
||||||
eps: defaults.epsilon,
|
|
||||||
max: defaults.max_iter,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Schedule for EpsilonOrMax {
|
|
||||||
fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport {
|
|
||||||
// Partition: leading run of TeamSum factors run exactly once (setup).
|
|
||||||
let n_setup = factors
|
|
||||||
.iter()
|
|
||||||
.position(|f| !matches!(f, BuiltinFactor::TeamSum(_)))
|
|
||||||
.unwrap_or(factors.len());
|
|
||||||
|
|
||||||
for f in factors[..n_setup].iter_mut() {
|
|
||||||
f.propagate(vars);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut iterations = 0;
|
|
||||||
// With no iterating factors the graph is already at its fixed point:
|
|
||||||
// the setup pass above is all there is to do. Reporting `converged:
|
|
||||||
// false` with an infinite step for that case gave callers a false
|
|
||||||
// negative.
|
|
||||||
let mut final_step = (0.0, 0.0);
|
|
||||||
let mut converged = true;
|
|
||||||
|
|
||||||
if n_setup < factors.len() {
|
|
||||||
final_step = (f64::INFINITY, f64::INFINITY);
|
|
||||||
converged = false;
|
|
||||||
for _ in 0..self.max {
|
|
||||||
let mut step = (0.0_f64, 0.0_f64);
|
|
||||||
|
|
||||||
// Forward sweep over iterating factors.
|
|
||||||
for f in factors[n_setup..].iter_mut() {
|
|
||||||
let d = f.propagate(vars);
|
|
||||||
step.0 = step.0.max(d.0);
|
|
||||||
step.1 = step.1.max(d.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward sweep.
|
|
||||||
for f in factors[n_setup..].iter_mut().rev() {
|
|
||||||
let d = f.propagate(vars);
|
|
||||||
step.0 = step.0.max(d.0);
|
|
||||||
step.1 = step.1.max(d.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
iterations += 1;
|
|
||||||
final_step = step;
|
|
||||||
|
|
||||||
if step.0 <= self.eps && step.1 <= self.eps {
|
|
||||||
converged = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ScheduleReport {
|
|
||||||
iterations,
|
|
||||||
final_step,
|
|
||||||
converged,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{N_INF, factor::team_sum::TeamSumFactor, gaussian::Gaussian};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn schedule_runs_setup_factors_once() {
|
|
||||||
// Single TeamSum factor; schedule should propagate it exactly once and report 0 iterations.
|
|
||||||
let mut vars = VarStore::new();
|
|
||||||
let out = vars.alloc(N_INF);
|
|
||||||
let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor {
|
|
||||||
inputs: vec![(Gaussian::from_ms(5.0, 1.0), 1.0)],
|
|
||||||
out,
|
|
||||||
})];
|
|
||||||
let schedule = EpsilonOrMax::default();
|
|
||||||
let report = schedule.run(&mut factors, &mut vars);
|
|
||||||
assert_eq!(report.iterations, 0);
|
|
||||||
// The team-perf var should hold the sum.
|
|
||||||
let result = vars.get(out);
|
|
||||||
assert!((result.mu() - 5.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn report_marks_converged_when_no_iterating_factors() {
|
|
||||||
// A graph of only setup factors has nothing to iterate, so it is at its
|
|
||||||
// fixed point after the setup pass: 0 iterations, and converged.
|
|
||||||
let mut vars = VarStore::new();
|
|
||||||
let out = vars.alloc(N_INF);
|
|
||||||
let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor {
|
|
||||||
inputs: vec![(Gaussian::from_ms(0.0, 1.0), 1.0)],
|
|
||||||
out,
|
|
||||||
})];
|
|
||||||
let report = EpsilonOrMax::default().run(&mut factors, &mut vars);
|
|
||||||
assert_eq!(report.iterations, 0);
|
|
||||||
assert!(report.converged);
|
|
||||||
assert_eq!(report.final_step, (0.0, 0.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_matches_convergence_options() {
|
|
||||||
let schedule = EpsilonOrMax::default();
|
|
||||||
let options = crate::ConvergenceOptions::default();
|
|
||||||
assert_eq!(schedule.max, options.max_iter);
|
|
||||||
assert_eq!(schedule.eps, options.epsilon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,16 +56,16 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
|
|||||||
self.get(idx).is_some()
|
self.get(idx).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test-only: no code path in the crate needs a count.
|
||||||
|
#[cfg(test)]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.n_present
|
self.n_present
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
/// Test-only: iterating every competitor is an assertion helper, not part
|
||||||
pub fn is_empty(&self) -> bool {
|
/// of inference, which walks slices rather than the store.
|
||||||
self.n_present == 0
|
#[cfg(test)]
|
||||||
}
|
|
||||||
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = (Index, &Competitor<T, D>)> {
|
pub fn iter(&self) -> impl Iterator<Item = (Index, &Competitor<T, D>)> {
|
||||||
self.competitors
|
self.competitors
|
||||||
.iter()
|
.iter()
|
||||||
@@ -73,13 +73,6 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
|
|||||||
.filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a)))
|
.filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Competitor<T, D>)> {
|
|
||||||
self.competitors
|
|
||||||
.iter_mut()
|
|
||||||
.enumerate()
|
|
||||||
.filter_map(|(i, slot)| slot.as_mut().map(|a| (Index(i), a)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut Competitor<T, D>> {
|
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut Competitor<T, D>> {
|
||||||
self.competitors.iter_mut().filter_map(|s| s.as_mut())
|
self.competitors.iter_mut().filter_map(|s| s.as_mut())
|
||||||
}
|
}
|
||||||
|
|||||||
+192
-91
@@ -50,12 +50,12 @@ pub enum EventKind {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct Item {
|
struct Item {
|
||||||
agent: Index,
|
competitor: Index,
|
||||||
/// This competitor's slot in the owning slice's `SkillStore`, resolved
|
/// This competitor's slot in the owning slice's `SkillStore`, resolved
|
||||||
/// once at ingestion.
|
/// once at ingestion.
|
||||||
///
|
///
|
||||||
/// The convergence loop reaches skills through this rather than through
|
/// The convergence loop reaches skills through this rather than through
|
||||||
/// `agent`, which is what keeps `HashMap` hashing out of the hot path now
|
/// `competitor`, which is what keeps `HashMap` hashing out of the hot path now
|
||||||
/// that the store is compact rather than indexed by the global `Index`.
|
/// that the store is compact rather than indexed by the global `Index`.
|
||||||
slot: u32,
|
slot: u32,
|
||||||
likelihood: Gaussian,
|
likelihood: Gaussian,
|
||||||
@@ -66,9 +66,9 @@ impl Item {
|
|||||||
&self,
|
&self,
|
||||||
forward: bool,
|
forward: bool,
|
||||||
skills: &SkillStore,
|
skills: &SkillStore,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
) -> Rating<T, D> {
|
) -> Rating<T, D> {
|
||||||
let r = &agents[self.agent].rating;
|
let r = &competitors[self.competitor].rating;
|
||||||
let skill = skills.at(self.slot);
|
let skill = skills.at(self.slot);
|
||||||
|
|
||||||
if forward {
|
if forward {
|
||||||
@@ -98,7 +98,7 @@ impl Event {
|
|||||||
pub(crate) fn iter_agents(&self) -> impl Iterator<Item = Index> + '_ {
|
pub(crate) fn iter_agents(&self) -> impl Iterator<Item = Index> + '_ {
|
||||||
self.teams
|
self.teams
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|t| t.items.iter().map(|it| it.agent))
|
.flat_map(|t| t.items.iter().map(|it| it.competitor))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn outputs(&self) -> Vec<f64> {
|
fn outputs(&self) -> Vec<f64> {
|
||||||
@@ -112,14 +112,14 @@ impl Event {
|
|||||||
&self,
|
&self,
|
||||||
forward: bool,
|
forward: bool,
|
||||||
skills: &SkillStore,
|
skills: &SkillStore,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
) -> Vec<Vec<Rating<T, D>>> {
|
) -> Vec<Vec<Rating<T, D>>> {
|
||||||
self.teams
|
self.teams
|
||||||
.iter()
|
.iter()
|
||||||
.map(|team| {
|
.map(|team| {
|
||||||
team.items
|
team.items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|item| item.within_prior(forward, skills, agents))
|
.map(|item| item.within_prior(forward, skills, competitors))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
@@ -133,12 +133,12 @@ impl Event {
|
|||||||
fn compute<T: Time, D: Drift<T>>(
|
fn compute<T: Time, D: Drift<T>>(
|
||||||
&self,
|
&self,
|
||||||
skills: &SkillStore,
|
skills: &SkillStore,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
p_draw: f64,
|
p_draw: f64,
|
||||||
convergence: crate::ConvergenceOptions,
|
convergence: crate::ConvergenceOptions,
|
||||||
arena: &mut ScratchArena,
|
arena: &mut ScratchArena,
|
||||||
) -> EventUpdate {
|
) -> EventUpdate {
|
||||||
let teams = self.within_priors(false, skills, agents);
|
let teams = self.within_priors(false, skills, competitors);
|
||||||
let result = self.outputs();
|
let result = self.outputs();
|
||||||
let g = match self.kind {
|
let g = match self.kind {
|
||||||
EventKind::Ranked => {
|
EventKind::Ranked => {
|
||||||
@@ -179,12 +179,12 @@ impl Event {
|
|||||||
fn iteration_direct<T: Time, D: Drift<T>>(
|
fn iteration_direct<T: Time, D: Drift<T>>(
|
||||||
&mut self,
|
&mut self,
|
||||||
skills: &mut SkillStore,
|
skills: &mut SkillStore,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
p_draw: f64,
|
p_draw: f64,
|
||||||
convergence: crate::ConvergenceOptions,
|
convergence: crate::ConvergenceOptions,
|
||||||
arena: &mut ScratchArena,
|
arena: &mut ScratchArena,
|
||||||
) {
|
) {
|
||||||
let update = self.compute(skills, agents, p_draw, convergence, arena);
|
let update = self.compute(skills, competitors, p_draw, convergence, arena);
|
||||||
self.apply(skills, update);
|
self.apply(skills, update);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,7 +288,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
results: Option<Vec<Vec<f64>>>,
|
results: Option<Vec<Vec<f64>>>,
|
||||||
weights: Option<Vec<Vec<Vec<f64>>>>,
|
weights: Option<Vec<Vec<Vec<f64>>>>,
|
||||||
kinds: Vec<EventKind>,
|
kinds: Vec<EventKind>,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
) {
|
) {
|
||||||
let mut unique = Vec::with_capacity(10);
|
let mut unique = Vec::with_capacity(10);
|
||||||
|
|
||||||
@@ -303,9 +303,9 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for idx in this_agent {
|
for idx in this_agent {
|
||||||
let elapsed = compute_elapsed(agents[*idx].last_time.as_ref(), &self.time);
|
let elapsed = compute_elapsed(competitors[*idx].last_time.as_ref(), &self.time);
|
||||||
|
|
||||||
let forward = agents[*idx].receive(&self.time);
|
let forward = competitors[*idx].receive(&self.time);
|
||||||
|
|
||||||
if let Some(skill) = self.skills.get_mut(*idx) {
|
if let Some(skill) = self.skills.get_mut(*idx) {
|
||||||
skill.elapsed = elapsed;
|
skill.elapsed = elapsed;
|
||||||
@@ -332,12 +332,12 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
.map(|(t, team)| {
|
.map(|(t, team)| {
|
||||||
let items = team
|
let items = team
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&agent| Item {
|
.map(|&competitor| Item {
|
||||||
agent,
|
competitor,
|
||||||
// Every participant was inserted into `skills`
|
// Every participant was inserted into `skills`
|
||||||
// just above, so the slot always resolves.
|
// just above, so the slot always resolves.
|
||||||
slot: skills
|
slot: skills
|
||||||
.slot_of(agent)
|
.slot_of(competitor)
|
||||||
.expect("participant must be present in the slice store"),
|
.expect("participant must be present in the slice store"),
|
||||||
likelihood: N_INF,
|
likelihood: N_INF,
|
||||||
})
|
})
|
||||||
@@ -376,7 +376,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
|
|
||||||
self.color_groups_dirty = true;
|
self.color_groups_dirty = true;
|
||||||
|
|
||||||
self.iteration(from, agents);
|
self.iteration(from, competitors);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
|
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
|
||||||
@@ -393,7 +393,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
/// Panics if an event references a competitor with no entry in this
|
/// Panics if an event references a competitor with no entry in this
|
||||||
/// slice's skill store. `add_events` inserts one for every participant, so
|
/// slice's skill store. `add_events` inserts one for every participant, so
|
||||||
/// this cannot happen for slices built through the public API.
|
/// this cannot happen for slices built through the public API.
|
||||||
pub fn iteration<D: Drift<T>>(&mut self, from: usize, agents: &CompetitorStore<T, D>) {
|
pub fn iteration<D: Drift<T>>(&mut self, from: usize, competitors: &CompetitorStore<T, D>) {
|
||||||
if from == 0 && self.color_groups_dirty {
|
if from == 0 && self.color_groups_dirty {
|
||||||
self.recompute_color_groups();
|
self.recompute_color_groups();
|
||||||
}
|
}
|
||||||
@@ -401,7 +401,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
if from > 0 || self.color_groups.is_empty() {
|
if from > 0 || self.color_groups.is_empty() {
|
||||||
// Initial pass (add_events) or no color groups yet: simple sequential sweep.
|
// Initial pass (add_events) or no color groups yet: simple sequential sweep.
|
||||||
for event in self.events.iter_mut().skip(from) {
|
for event in self.events.iter_mut().skip(from) {
|
||||||
let teams = event.within_priors(false, &self.skills, agents);
|
let teams = event.within_priors(false, &self.skills, competitors);
|
||||||
let result = event.outputs();
|
let result = event.outputs();
|
||||||
|
|
||||||
let g = match event.kind {
|
let g = match event.kind {
|
||||||
@@ -436,14 +436,14 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
event.log_evidence = g.log_evidence;
|
event.log_evidence = g.log_evidence;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.sweep_color_groups(agents);
|
self.sweep_color_groups(competitors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full event sweep using the color-group partition. Colors are processed
|
/// Full event sweep using the color-group partition. Colors are processed
|
||||||
/// sequentially; within each color the inner loop is parallel under rayon.
|
/// sequentially; within each color the inner loop is parallel under rayon.
|
||||||
///
|
///
|
||||||
/// Events in one color group touch disjoint agent sets, so none of them
|
/// Events in one color group touch disjoint competitor sets, so none of them
|
||||||
/// can observe another's writes. That makes the sweep separable: inference
|
/// can observe another's writes. That makes the sweep separable: inference
|
||||||
/// runs concurrently over shared `&self.skills`, and the resulting updates
|
/// runs concurrently over shared `&self.skills`, and the resulting updates
|
||||||
/// are folded in afterwards in index order. Splitting it this way needs no
|
/// are folded in afterwards in index order. Splitting it this way needs no
|
||||||
@@ -451,7 +451,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
/// across thread counts because the apply order does not depend on which
|
/// across thread counts because the apply order does not depend on which
|
||||||
/// worker finished first.
|
/// worker finished first.
|
||||||
#[cfg(feature = "rayon")]
|
#[cfg(feature = "rayon")]
|
||||||
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
fn sweep_color_groups<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
@@ -483,7 +483,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
let mut arena = cell.borrow_mut();
|
let mut arena = cell.borrow_mut();
|
||||||
arena.reset();
|
arena.reset();
|
||||||
|
|
||||||
ev.compute(skills, agents, p_draw, convergence, &mut arena)
|
ev.compute(skills, competitors, p_draw, convergence, &mut arena)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -495,7 +495,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
for ev in &mut self.events[range] {
|
for ev in &mut self.events[range] {
|
||||||
ev.iteration_direct(
|
ev.iteration_direct(
|
||||||
&mut self.skills,
|
&mut self.skills,
|
||||||
agents,
|
competitors,
|
||||||
p_draw,
|
p_draw,
|
||||||
self.convergence,
|
self.convergence,
|
||||||
&mut self.arena,
|
&mut self.arena,
|
||||||
@@ -509,7 +509,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
/// Events within each color group are updated inline — no EventOutput allocation —
|
/// Events within each color group are updated inline — no EventOutput allocation —
|
||||||
/// matching the T2 performance profile.
|
/// matching the T2 performance profile.
|
||||||
#[cfg(not(feature = "rayon"))]
|
#[cfg(not(feature = "rayon"))]
|
||||||
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
fn sweep_color_groups<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
|
||||||
for color_idx in 0..self.color_groups.groups.len() {
|
for color_idx in 0..self.color_groups.groups.len() {
|
||||||
if self.color_groups.groups[color_idx].is_empty() {
|
if self.color_groups.groups[color_idx].is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -523,7 +523,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
for ev in &mut self.events[range] {
|
for ev in &mut self.events[range] {
|
||||||
ev.iteration_direct(
|
ev.iteration_direct(
|
||||||
&mut self.skills,
|
&mut self.skills,
|
||||||
agents,
|
competitors,
|
||||||
p_draw,
|
p_draw,
|
||||||
self.convergence,
|
self.convergence,
|
||||||
&mut self.arena,
|
&mut self.arena,
|
||||||
@@ -544,7 +544,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
/// schedule default.
|
/// schedule default.
|
||||||
pub(crate) fn iterate_to_convergence<D: Drift<T>>(
|
pub(crate) fn iterate_to_convergence<D: Drift<T>>(
|
||||||
&mut self,
|
&mut self,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
) -> usize {
|
) -> usize {
|
||||||
use crate::{tuple_gt, tuple_max};
|
use crate::{tuple_gt, tuple_max};
|
||||||
|
|
||||||
@@ -557,7 +557,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
while tuple_gt(step, epsilon) && i < max_iter {
|
while tuple_gt(step, epsilon) && i < max_iter {
|
||||||
let old = self.posteriors();
|
let old = self.posteriors();
|
||||||
|
|
||||||
self.iteration(0, agents);
|
self.iteration(0, competitors);
|
||||||
|
|
||||||
let new = self.posteriors();
|
let new = self.posteriors();
|
||||||
|
|
||||||
@@ -575,37 +575,37 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
i
|
i
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn forward_prior_out(&self, agent: &Index) -> Gaussian {
|
pub(crate) fn forward_prior_out(&self, competitor: &Index) -> Gaussian {
|
||||||
let skill = self.skills.get(*agent).unwrap();
|
let skill = self.skills.get(*competitor).unwrap();
|
||||||
skill.forward * skill.likelihood
|
skill.forward * skill.likelihood
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn backward_prior_out<D: Drift<T>>(
|
pub(crate) fn backward_prior_out<D: Drift<T>>(
|
||||||
&self,
|
&self,
|
||||||
agent: &Index,
|
competitor: &Index,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
) -> Gaussian {
|
) -> Gaussian {
|
||||||
let skill = self.skills.get(*agent).unwrap();
|
let skill = self.skills.get(*competitor).unwrap();
|
||||||
let n = skill.likelihood * skill.backward;
|
let n = skill.likelihood * skill.backward;
|
||||||
n.forget(
|
n.forget(
|
||||||
agents[*agent]
|
competitors[*competitor]
|
||||||
.rating
|
.rating
|
||||||
.drift_variance_for_elapsed(skill.elapsed),
|
.drift_variance_for_elapsed(skill.elapsed),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
|
||||||
for (agent, skill) in self.skills.iter_mut() {
|
for (competitor, skill) in self.skills.iter_mut() {
|
||||||
skill.backward = agents[agent].message.unwrap_or(N_INF);
|
skill.backward = competitors[competitor].message.unwrap_or(N_INF);
|
||||||
}
|
}
|
||||||
self.iteration(0, agents);
|
self.iteration(0, competitors);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new_forward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
pub(crate) fn new_forward_info<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
|
||||||
for (agent, skill) in self.skills.iter_mut() {
|
for (competitor, skill) in self.skills.iter_mut() {
|
||||||
skill.forward = agents[agent].receive_for_elapsed(skill.elapsed);
|
skill.forward = competitors[competitor].receive_for_elapsed(skill.elapsed);
|
||||||
}
|
}
|
||||||
self.iteration(0, agents);
|
self.iteration(0, competitors);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run this slice's events on forward (filtering) information alone.
|
/// Run this slice's events on forward (filtering) information alone.
|
||||||
@@ -615,10 +615,18 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
/// configured prior. The sweep runs on a scratch copy, so the real slice
|
/// configured prior. The sweep runs on a scratch copy, so the real slice
|
||||||
/// is untouched — which is what makes the filtered estimates independent
|
/// is untouched — which is what makes the filtered estimates independent
|
||||||
/// of whether `History::converge` has run.
|
/// of whether `History::converge` has run.
|
||||||
|
/// One forward-only step for this slice.
|
||||||
|
///
|
||||||
|
/// `targets` restricts only the *evidence sum*, to events in which at
|
||||||
|
/// least one target competitor appears; an empty set means no restriction.
|
||||||
|
/// The forward messages are always built from every event in the slice —
|
||||||
|
/// restricting those instead would answer a different question (a history
|
||||||
|
/// in which the other events never happened), not a held-out one.
|
||||||
pub(crate) fn filtered_step<D: Drift<T>>(
|
pub(crate) fn filtered_step<D: Drift<T>>(
|
||||||
&self,
|
&self,
|
||||||
incoming: &HashMap<Index, Gaussian>,
|
incoming: &HashMap<Index, Gaussian>,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
|
targets: &std::collections::HashSet<Index>,
|
||||||
) -> FilteredStep {
|
) -> FilteredStep {
|
||||||
let mut scratch = TimeSlice {
|
let mut scratch = TimeSlice {
|
||||||
events: self.events.clone(),
|
events: self.events.clone(),
|
||||||
@@ -641,16 +649,16 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
event.log_evidence = 0.0;
|
event.log_evidence = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (agent, skill) in self.skills.iter() {
|
for (competitor, skill) in self.skills.iter() {
|
||||||
let rating = &agents[agent].rating;
|
let rating = &competitors[competitor].rating;
|
||||||
|
|
||||||
let forward = match incoming.get(&agent) {
|
let forward = match incoming.get(&competitor) {
|
||||||
Some(message) => message.forget(rating.drift_variance_for_elapsed(skill.elapsed)),
|
Some(message) => message.forget(rating.drift_variance_for_elapsed(skill.elapsed)),
|
||||||
None => rating.prior,
|
None => rating.prior,
|
||||||
};
|
};
|
||||||
|
|
||||||
let slot = scratch.skills.insert(
|
let slot = scratch.skills.insert(
|
||||||
agent,
|
competitor,
|
||||||
Skill {
|
Skill {
|
||||||
forward,
|
forward,
|
||||||
backward: N_INF,
|
backward: N_INF,
|
||||||
@@ -666,19 +674,31 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
// than leave it to be rediscovered after it breaks.
|
// than leave it to be rediscovered after it breaks.
|
||||||
debug_assert_eq!(
|
debug_assert_eq!(
|
||||||
Some(slot),
|
Some(slot),
|
||||||
self.skills.slot_of(agent),
|
self.skills.slot_of(competitor),
|
||||||
"scratch slot must match the real slice's slot for {agent:?}"
|
"scratch slot must match the real slice's slot for {competitor:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
scratch.iterate_to_convergence(agents);
|
scratch.iterate_to_convergence(competitors);
|
||||||
|
|
||||||
FilteredStep {
|
FilteredStep {
|
||||||
log_evidence: scratch.events.iter().map(|event| event.log_evidence).sum(),
|
log_evidence: scratch
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| {
|
||||||
|
targets.is_empty()
|
||||||
|
|| event
|
||||||
|
.teams
|
||||||
|
.iter()
|
||||||
|
.flat_map(|team| &team.items)
|
||||||
|
.any(|item| targets.contains(&item.competitor))
|
||||||
|
})
|
||||||
|
.map(|event| event.log_evidence)
|
||||||
|
.sum(),
|
||||||
posteriors: scratch
|
posteriors: scratch
|
||||||
.skills
|
.skills
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(agent, skill)| (agent, skill.posterior()))
|
.map(|(competitor, skill)| (competitor, skill.posterior()))
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,7 +707,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
&self,
|
&self,
|
||||||
targets: &[Index],
|
targets: &[Index],
|
||||||
forward: bool,
|
forward: bool,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
// Hashed once rather than scanned per player per event, so a
|
// Hashed once rather than scanned per player per event, so a
|
||||||
// `log_evidence_for` with many keys is not quadratic.
|
// `log_evidence_for` with many keys is not quadratic.
|
||||||
@@ -696,7 +716,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
let mut arena = ScratchArena::new();
|
let mut arena = ScratchArena::new();
|
||||||
|
|
||||||
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
|
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
|
||||||
let teams = event.within_priors(forward, &self.skills, agents);
|
let teams = event.within_priors(forward, &self.skills, competitors);
|
||||||
let result = event.outputs();
|
let result = event.outputs();
|
||||||
match event.kind {
|
match event.kind {
|
||||||
EventKind::Ranked => {
|
EventKind::Ranked => {
|
||||||
@@ -741,7 +761,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
.teams
|
.teams
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|team| &team.items)
|
.flat_map(|team| &team.items)
|
||||||
.any(|item| target_set.contains(&item.agent))
|
.any(|item| target_set.contains(&item.competitor))
|
||||||
})
|
})
|
||||||
.map(|event| run_event(event, &mut arena))
|
.map(|event| run_event(event, &mut arena))
|
||||||
.sum()
|
.sum()
|
||||||
@@ -753,13 +773,15 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
.teams
|
.teams
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|team| &team.items)
|
.flat_map(|team| &team.items)
|
||||||
.any(|item| target_set.contains(&item.agent))
|
.any(|item| target_set.contains(&item.competitor))
|
||||||
})
|
})
|
||||||
.map(|event| event.log_evidence)
|
.map(|event| event.log_evidence)
|
||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test-only: reads the slice's shape back for assertions.
|
||||||
|
#[cfg(test)]
|
||||||
pub fn get_composition(&self) -> Vec<Vec<Vec<Index>>> {
|
pub fn get_composition(&self) -> Vec<Vec<Vec<Index>>> {
|
||||||
self.events
|
self.events
|
||||||
.iter()
|
.iter()
|
||||||
@@ -767,12 +789,19 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
event
|
event
|
||||||
.teams
|
.teams
|
||||||
.iter()
|
.iter()
|
||||||
.map(|team| team.items.iter().map(|item| item.agent).collect::<Vec<_>>())
|
.map(|team| {
|
||||||
|
team.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.competitor)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test-only: reads the slice's shape back for assertions.
|
||||||
|
#[cfg(test)]
|
||||||
pub fn get_results(&self) -> Vec<Vec<f64>> {
|
pub fn get_results(&self) -> Vec<Vec<f64>> {
|
||||||
self.events
|
self.events
|
||||||
.iter()
|
.iter()
|
||||||
@@ -809,13 +838,85 @@ pub(crate) fn compute_elapsed<T: Time>(last: Option<&T>, current: &T) -> i64 {
|
|||||||
elapsed.max(0)
|
elapsed.max(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<T: Time> TimeSlice<T> {
|
||||||
|
/// This slice's scored event factors, as contrasts over competitors.
|
||||||
|
///
|
||||||
|
/// Message passing produces per-competitor marginals and throws the
|
||||||
|
/// correlation away — `Item::likelihood` is already the projection of an
|
||||||
|
/// event's factor onto one competitor. So a joint has to be rebuilt from
|
||||||
|
/// the factor structure rather than recovered from the messages.
|
||||||
|
///
|
||||||
|
/// Usefully, a precision matrix depends only on *structure* — who played
|
||||||
|
/// whom, with what weights and what observation noise — and not on the
|
||||||
|
/// observed outcomes. The means are already exact, so only the second
|
||||||
|
/// moment needs rebuilding.
|
||||||
|
///
|
||||||
|
/// Each entry is a contrast and the observation variance that sits on it.
|
||||||
|
/// Ranked events contribute nothing: their truncation factors are EP
|
||||||
|
/// approximations that inference does not retain.
|
||||||
|
pub(crate) fn scored_contrasts<D: Drift<T>>(
|
||||||
|
&self,
|
||||||
|
competitors: &CompetitorStore<T, D>,
|
||||||
|
) -> Vec<(Vec<(Index, f64)>, f64)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
for event in &self.events {
|
||||||
|
let EventKind::Scored { score_sigma } = event.kind else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Teams best-first, matching the diff chain inference builds.
|
||||||
|
let mut order: Vec<usize> = (0..event.teams.len()).collect();
|
||||||
|
order.sort_by(|&a, &b| {
|
||||||
|
event.teams[b]
|
||||||
|
.output
|
||||||
|
.partial_cmp(&event.teams[a].output)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
|
||||||
|
for pair in order.windows(2) {
|
||||||
|
let (hi, lo) = (pair[0], pair[1]);
|
||||||
|
let mut contrast: Vec<(Index, f64)> = Vec::new();
|
||||||
|
let mut noise = score_sigma * score_sigma;
|
||||||
|
|
||||||
|
for (team, sign) in [(hi, 1.0), (lo, -1.0)] {
|
||||||
|
for (m, item) in event.teams[team].items.iter().enumerate() {
|
||||||
|
let w = event.weights[team][m];
|
||||||
|
noise += w * w * competitors[item.competitor].rating.beta.powi(2);
|
||||||
|
contrast.push((item.competitor, sign * w));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push((contrast, noise));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when every event here is scored, so the joint is exact.
|
||||||
|
pub(crate) fn all_scored(&self) -> bool {
|
||||||
|
self.events
|
||||||
|
.iter()
|
||||||
|
.all(|e| matches!(e.kind, EventKind::Scored { .. }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The competitors appearing in this slice, with the elapsed count since
|
||||||
|
/// each one's previous appearance.
|
||||||
|
pub(crate) fn appearances(&self) -> impl Iterator<Item = (Index, i64)> + '_ {
|
||||||
|
self.skills
|
||||||
|
.keys()
|
||||||
|
.map(|idx| (idx, self.skills.get(idx).expect("slice key").elapsed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use approx::assert_ulps_eq;
|
use approx::assert_ulps_eq;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
KeyTable, competitor::Competitor, drift::ConstantDrift, rating::Rating,
|
competitor::Competitor, drift::ConstantDrift, key_table::KeyTable, rating::Rating,
|
||||||
storage::CompetitorStore,
|
storage::CompetitorStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -830,16 +931,16 @@ mod tests {
|
|||||||
let e = index_map.get_or_create("e");
|
let e = index_map.get_or_create("e");
|
||||||
let f = index_map.get_or_create("f");
|
let f = index_map.get_or_create("f");
|
||||||
|
|
||||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||||
|
|
||||||
for agent in [a, b, c, d, e, f] {
|
for competitor in [a, b, c, d, e, f] {
|
||||||
agents.insert(
|
competitors.insert(
|
||||||
agent,
|
competitor,
|
||||||
Competitor {
|
Competitor {
|
||||||
rating: Rating::new(
|
rating: Rating::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -857,7 +958,7 @@ mod tests {
|
|||||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||||
None,
|
None,
|
||||||
vec![EventKind::Ranked; 3],
|
vec![EventKind::Ranked; 3],
|
||||||
&agents,
|
&competitors,
|
||||||
);
|
);
|
||||||
|
|
||||||
let post = time_slice.posteriors();
|
let post = time_slice.posteriors();
|
||||||
@@ -893,7 +994,7 @@ mod tests {
|
|||||||
epsilon = 1e-6
|
epsilon = 1e-6
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(time_slice.iterate_to_convergence(&agents), 1);
|
assert_eq!(time_slice.iterate_to_convergence(&competitors), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -907,16 +1008,16 @@ mod tests {
|
|||||||
let e = index_map.get_or_create("e");
|
let e = index_map.get_or_create("e");
|
||||||
let f = index_map.get_or_create("f");
|
let f = index_map.get_or_create("f");
|
||||||
|
|
||||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||||
|
|
||||||
for agent in [a, b, c, d, e, f] {
|
for competitor in [a, b, c, d, e, f] {
|
||||||
agents.insert(
|
competitors.insert(
|
||||||
agent,
|
competitor,
|
||||||
Competitor {
|
Competitor {
|
||||||
rating: Rating::new(
|
rating: Rating::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -934,7 +1035,7 @@ mod tests {
|
|||||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||||
None,
|
None,
|
||||||
vec![EventKind::Ranked; 3],
|
vec![EventKind::Ranked; 3],
|
||||||
&agents,
|
&competitors,
|
||||||
);
|
);
|
||||||
|
|
||||||
let post = time_slice.posteriors();
|
let post = time_slice.posteriors();
|
||||||
@@ -955,7 +1056,7 @@ mod tests {
|
|||||||
epsilon = 1e-6
|
epsilon = 1e-6
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(time_slice.iterate_to_convergence(&agents) > 1);
|
assert!(time_slice.iterate_to_convergence(&competitors) > 1);
|
||||||
|
|
||||||
let post = time_slice.posteriors();
|
let post = time_slice.posteriors();
|
||||||
|
|
||||||
@@ -987,16 +1088,16 @@ mod tests {
|
|||||||
let e = index_map.get_or_create("e");
|
let e = index_map.get_or_create("e");
|
||||||
let f = index_map.get_or_create("f");
|
let f = index_map.get_or_create("f");
|
||||||
|
|
||||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||||
|
|
||||||
for agent in [a, b, c, d, e, f] {
|
for competitor in [a, b, c, d, e, f] {
|
||||||
agents.insert(
|
competitors.insert(
|
||||||
agent,
|
competitor,
|
||||||
Competitor {
|
Competitor {
|
||||||
rating: Rating::new(
|
rating: Rating::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -1014,10 +1115,10 @@ mod tests {
|
|||||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||||
None,
|
None,
|
||||||
vec![EventKind::Ranked; 3],
|
vec![EventKind::Ranked; 3],
|
||||||
&agents,
|
&competitors,
|
||||||
);
|
);
|
||||||
|
|
||||||
time_slice.iterate_to_convergence(&agents);
|
time_slice.iterate_to_convergence(&competitors);
|
||||||
|
|
||||||
let post = time_slice.posteriors();
|
let post = time_slice.posteriors();
|
||||||
|
|
||||||
@@ -1046,12 +1147,12 @@ mod tests {
|
|||||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||||
None,
|
None,
|
||||||
vec![EventKind::Ranked; 3],
|
vec![EventKind::Ranked; 3],
|
||||||
&agents,
|
&competitors,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(time_slice.events.len(), 6);
|
assert_eq!(time_slice.events.len(), 6);
|
||||||
|
|
||||||
time_slice.iterate_to_convergence(&agents);
|
time_slice.iterate_to_convergence(&competitors);
|
||||||
|
|
||||||
let post = time_slice.posteriors();
|
let post = time_slice.posteriors();
|
||||||
|
|
||||||
@@ -1090,16 +1191,16 @@ mod tests {
|
|||||||
let c = index_map.get_or_create("c");
|
let c = index_map.get_or_create("c");
|
||||||
let d = index_map.get_or_create("d");
|
let d = index_map.get_or_create("d");
|
||||||
|
|
||||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||||
|
|
||||||
for agent in [a, b, c, d] {
|
for competitor in [a, b, c, d] {
|
||||||
agents.insert(
|
competitors.insert(
|
||||||
agent,
|
competitor,
|
||||||
Competitor {
|
Competitor {
|
||||||
rating: Rating::new(
|
rating: Rating::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -1117,7 +1218,7 @@ mod tests {
|
|||||||
Some(vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]),
|
Some(vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]),
|
||||||
None,
|
None,
|
||||||
vec![EventKind::Ranked; 3],
|
vec![EventKind::Ranked; 3],
|
||||||
&agents,
|
&competitors,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(ts.color_groups.n_colors(), 2);
|
assert_eq!(ts.color_groups.n_colors(), 2);
|
||||||
@@ -1128,14 +1229,14 @@ mod tests {
|
|||||||
assert_eq!(ts.color_groups.color_range(1), 2..3);
|
assert_eq!(ts.color_groups.color_range(1), 2..3);
|
||||||
|
|
||||||
// Events at positions 0 and 1 (color 0) must be disjoint — verify by
|
// Events at positions 0 and 1 (color 0) must be disjoint — verify by
|
||||||
// checking that the agent sets of self.events[0] and self.events[1] do
|
// checking that the competitor sets of self.events[0] and self.events[1] do
|
||||||
// not include the agent at self.events[2].
|
// not include the competitor at self.events[2].
|
||||||
let agents_in_ev2: Vec<Index> = ts.events[2].iter_agents().collect();
|
let agents_in_ev2: Vec<Index> = ts.events[2].iter_agents().collect();
|
||||||
let agents_in_ev0: Vec<Index> = ts.events[0].iter_agents().collect();
|
let agents_in_ev0: Vec<Index> = ts.events[0].iter_agents().collect();
|
||||||
let agents_in_ev1: Vec<Index> = ts.events[1].iter_agents().collect();
|
let agents_in_ev1: Vec<Index> = ts.events[1].iter_agents().collect();
|
||||||
// ev0 and ev1 must be disjoint from each other (color-0 invariant).
|
// ev0 and ev1 must be disjoint from each other (color-0 invariant).
|
||||||
assert!(agents_in_ev0.iter().all(|ag| !agents_in_ev1.contains(ag)));
|
assert!(agents_in_ev0.iter().all(|ag| !agents_in_ev1.contains(ag)));
|
||||||
// ev2 must share an agent with ev0 or ev1 (it needed its own color).
|
// ev2 must share an competitor with ev0 or ev1 (it needed its own color).
|
||||||
let ev2_overlaps_ev0 = agents_in_ev2.iter().any(|ag| agents_in_ev0.contains(ag));
|
let ev2_overlaps_ev0 = agents_in_ev2.iter().any(|ag| agents_in_ev0.contains(ag));
|
||||||
let ev2_overlaps_ev1 = agents_in_ev2.iter().any(|ag| agents_in_ev1.contains(ag));
|
let ev2_overlaps_ev1 = agents_in_ev2.iter().any(|ag| agents_in_ev1.contains(ag));
|
||||||
assert!(ev2_overlaps_ev0 || ev2_overlaps_ev1);
|
assert!(ev2_overlaps_ev0 || ev2_overlaps_ev1);
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! What an additive model does to uncertainty, and why "add the marginals" is
|
||||||
|
//! unsafe in one direction and merely wasteful in the other.
|
||||||
|
//!
|
||||||
|
//! Structurally this is the shape a joint player/layout model takes: every
|
||||||
|
//! observation measures a *sum* of nodes against a reference, so the data pins
|
||||||
|
//! differences and leaves the overall level to the prior. That is the classic
|
||||||
|
//! rating-scale indeterminacy, not a defect.
|
||||||
|
//!
|
||||||
|
//! The consequence for a consumer is that combining marginals is wrong in
|
||||||
|
//! opposite directions depending on the combination, which is worth pinning
|
||||||
|
//! because the unsafe direction is not the one you would guess:
|
||||||
|
//!
|
||||||
|
//! - **Differences** (`a - b`): the shared level cancels, so the exact width is
|
||||||
|
//! small — and adding marginals lands within a couple of percent of it here,
|
||||||
|
//! because the loopy underestimate offsets the ignored correlation.
|
||||||
|
//! - **Sums** (`a + b`): the shared level does *not* cancel, so the exact width
|
||||||
|
//! is large, and adding marginals is roughly five times too narrow. That is
|
||||||
|
//! overconfident, and it is the direction that publishes a claim the data
|
||||||
|
//! does not support.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn additive_structure_makes_sums_wide_and_differences_tight() {
|
||||||
|
// Structurally like ustat: every round is (player + hole) measured against
|
||||||
|
// a fixed reference. Only SUMS are pinned by the data; the split between
|
||||||
|
// player and hole is pinned only by the prior.
|
||||||
|
let players = ["p0", "p1", "p2"];
|
||||||
|
let holes = ["h0", "h1"];
|
||||||
|
|
||||||
|
let mut h: History<i64, _, _, &'static str> = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-12,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut seed = 3u64;
|
||||||
|
let mut rnd = move || {
|
||||||
|
seed ^= seed << 13;
|
||||||
|
seed ^= seed >> 7;
|
||||||
|
seed ^= seed << 17;
|
||||||
|
seed
|
||||||
|
};
|
||||||
|
// true skills, so we know what the data encodes
|
||||||
|
let truth_p = [2.0, 0.0, -2.0];
|
||||||
|
let truth_h = [1.0, -1.0];
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for _ in 0..60 {
|
||||||
|
let p = (rnd() as usize) % 3;
|
||||||
|
let q = (rnd() as usize) % 2;
|
||||||
|
let noise = ((rnd() % 1000) as f64 / 1000.0 - 0.5) * 2.0;
|
||||||
|
let score = truth_p[p] + truth_h[q] + noise;
|
||||||
|
events.push(Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(players[p]), Member::new(holes[q])]),
|
||||||
|
Team::with_members([Member::new("reference")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([score, 0.0]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let r = h.converge().unwrap();
|
||||||
|
assert!(r.converged, "{:?}", r.final_step);
|
||||||
|
|
||||||
|
println!("\n== marginals (what current_skill reports) ==");
|
||||||
|
for k in players.iter().chain(holes.iter()) {
|
||||||
|
let g = h.current_skill(k).unwrap();
|
||||||
|
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n== the same nodes via posterior_of (exact marginal) ==");
|
||||||
|
for k in players.iter().chain(holes.iter()) {
|
||||||
|
let g = h.posterior_of(&[(k, 1.0)]).unwrap();
|
||||||
|
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n== combinations the data actually pins ==");
|
||||||
|
for (label, terms) in [
|
||||||
|
("p0 + h0 (a round)", vec![(&"p0", 1.0), (&"h0", 1.0)]),
|
||||||
|
(
|
||||||
|
"p0 - p1 (rank two players)",
|
||||||
|
vec![(&"p0", 1.0), (&"p1", -1.0)],
|
||||||
|
),
|
||||||
|
("p0 - p2", vec![(&"p0", 1.0), (&"p2", -1.0)]),
|
||||||
|
(
|
||||||
|
"h0 - h1 (rank two holes)",
|
||||||
|
vec![(&"h0", 1.0), (&"h1", -1.0)],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let joint = h.posterior_of(&terms).unwrap();
|
||||||
|
// what a consumer gets today by adding marginals
|
||||||
|
let naive: f64 = terms
|
||||||
|
.iter()
|
||||||
|
.map(|(k, c)| c * c * h.current_skill(*k).unwrap().sigma().powi(2))
|
||||||
|
.sum::<f64>()
|
||||||
|
.sqrt();
|
||||||
|
println!(
|
||||||
|
" {label:<28} exact sigma {:>7.4} adding marginals {:>7.4} {:>5.2}x over",
|
||||||
|
joint.sigma(),
|
||||||
|
naive,
|
||||||
|
naive / joint.sigma()
|
||||||
|
);
|
||||||
|
|
||||||
|
let ratio = naive / joint.sigma();
|
||||||
|
if label.contains('+') {
|
||||||
|
assert!(
|
||||||
|
ratio < 0.5,
|
||||||
|
"{label}: adding marginals should be badly OVERconfident for a \
|
||||||
|
sum, got {ratio:.3}x"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
(0.8..1.25).contains(&ratio),
|
||||||
|
"{label}: adding marginals happens to be close for a difference, \
|
||||||
|
got {ratio:.3}x"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single node in an additive model is weakly identified: its exact
|
||||||
|
// posterior is far wider than message passing reports, because the level it
|
||||||
|
// shares with its partners is pinned only by the prior.
|
||||||
|
for k in players.iter().chain(holes.iter()) {
|
||||||
|
let bp = h.current_skill(k).unwrap().sigma();
|
||||||
|
let exact = h.posterior_of(&[(k, 1.0)]).unwrap().sigma();
|
||||||
|
assert!(
|
||||||
|
exact > 3.0 * bp,
|
||||||
|
"{k}: exact marginal {exact} should be much wider than the reported \
|
||||||
|
{bp} in an additive model"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-13
@@ -11,7 +11,7 @@ fn add_events_bulk_via_iter() {
|
|||||||
.sigma(2.0)
|
.sigma(2.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.p_draw(0.0)
|
.p_draw(0.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 30,
|
max_iter: 30,
|
||||||
epsilon: 1e-6,
|
epsilon: 1e-6,
|
||||||
@@ -53,7 +53,7 @@ fn add_events_draw() {
|
|||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.p_draw(0.25)
|
.p_draw(0.25)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||||
@@ -65,7 +65,7 @@ fn add_events_draw() {
|
|||||||
outcome: Outcome::draw(2),
|
outcome: Outcome::draw(2),
|
||||||
}];
|
}];
|
||||||
h.add_events(events).unwrap();
|
h.add_events(events).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -123,7 +123,7 @@ fn fluent_event_builder_winner_convenience() {
|
|||||||
.winner(0)
|
.winner(0)
|
||||||
.commit()
|
.commit()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -141,7 +141,7 @@ fn fluent_event_builder_draw() {
|
|||||||
.draw()
|
.draw()
|
||||||
.commit()
|
.commit()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -155,14 +155,14 @@ fn current_skill_and_learning_curve() {
|
|||||||
.build();
|
.build();
|
||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.record_winner(&"a", &"b", 2).unwrap();
|
h.record_winner(&"a", &"b", 2).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let a = h.current_skill(&"a").unwrap();
|
let a = h.current_skill(&"a").unwrap();
|
||||||
assert!(a.mu() > 25.0);
|
assert!(a.mu() > 25.0);
|
||||||
let b = h.current_skill(&"b").unwrap();
|
let b = h.current_skill(&"b").unwrap();
|
||||||
assert!(b.mu() < 25.0);
|
assert!(b.mu() < 25.0);
|
||||||
|
|
||||||
let a_curve = h.learning_curve(&"a");
|
let a_curve = h.learning_curve(&"a").unwrap();
|
||||||
assert_eq!(a_curve.len(), 2);
|
assert_eq!(a_curve.len(), 2);
|
||||||
assert_eq!(a_curve[0].0, 1);
|
assert_eq!(a_curve[0].0, 1);
|
||||||
assert_eq!(a_curve[1].0, 2);
|
assert_eq!(a_curve[1].0, 2);
|
||||||
@@ -181,12 +181,12 @@ fn log_evidence_total_vs_subset() {
|
|||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.p_draw(0.0)
|
.p_draw(0.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.build();
|
.build();
|
||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.record_winner(&"b", &"a", 2).unwrap();
|
h.record_winner(&"b", &"a", 2).unwrap();
|
||||||
let total = h.log_evidence();
|
let total = h.log_evidence();
|
||||||
let a_only = h.log_evidence_for(&[&"a"]);
|
let a_only = h.log_evidence_for(&[&"a"]).unwrap();
|
||||||
assert!(total.is_finite());
|
assert!(total.is_finite());
|
||||||
assert!(a_only.is_finite());
|
assert!(a_only.is_finite());
|
||||||
}
|
}
|
||||||
@@ -201,7 +201,7 @@ fn predict_quality_two_teams() {
|
|||||||
.p_draw(0.0)
|
.p_draw(0.0)
|
||||||
.build();
|
.build();
|
||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let q = h.predict_quality(&[&[&"a"], &[&"b"]]).unwrap();
|
let q = h.predict_quality(&[&[&"a"], &[&"b"]]).unwrap();
|
||||||
assert!(q > 0.0 && q <= 1.0);
|
assert!(q > 0.0 && q <= 1.0);
|
||||||
@@ -217,7 +217,7 @@ fn predict_outcome_two_teams_sums_to_one() {
|
|||||||
.p_draw(0.0)
|
.p_draw(0.0)
|
||||||
.build();
|
.build();
|
||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let p = h.predict_outcome(&[&[&"a"], &[&"b"]]).unwrap();
|
let p = h.predict_outcome(&[&[&"a"], &[&"b"]]).unwrap();
|
||||||
let wins = p.win_probabilities();
|
let wins = p.win_probabilities();
|
||||||
@@ -236,7 +236,7 @@ fn fluent_event_builder_scores() {
|
|||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
h.event(1)
|
h.event(1)
|
||||||
@@ -245,7 +245,7 @@ fn fluent_event_builder_scores() {
|
|||||||
.scores([12.0, 4.0])
|
.scores([12.0, 4.0])
|
||||||
.commit()
|
.commit()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let a = h.current_skill(&"alice").unwrap();
|
let a = h.current_skill(&"alice").unwrap();
|
||||||
let b = h.current_skill(&"bob").unwrap();
|
let b = h.current_skill(&"bob").unwrap();
|
||||||
|
|||||||
+10
-10
@@ -64,13 +64,13 @@ fn a_prior_applies_to_a_new_competitor() {
|
|||||||
let mut with = history();
|
let mut with = history();
|
||||||
with.add_events(vec![bout("a", "b", 0, Some(seeded), None)])
|
with.add_events(vec![bout("a", "b", 0, Some(seeded), None)])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
with.converge().unwrap();
|
let _ = with.converge().unwrap();
|
||||||
|
|
||||||
let mut without = history();
|
let mut without = history();
|
||||||
without
|
without
|
||||||
.add_events(vec![bout("a", "b", 0, None, None)])
|
.add_events(vec![bout("a", "b", 0, None, None)])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
without.converge().unwrap();
|
let _ = without.converge().unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
(skill_of(&with, "a").mu() - skill_of(&without, "a").mu()).abs() > 1.0,
|
(skill_of(&with, "a").mu() - skill_of(&without, "a").mu()).abs() > 1.0,
|
||||||
@@ -91,7 +91,7 @@ fn a_prior_applies_to_a_competitor_the_history_already_knows() {
|
|||||||
// "a" now exists. Configuring it here used to do nothing whatsoever.
|
// "a" now exists. Configuring it here used to do nothing whatsoever.
|
||||||
late.add_events(vec![bout("a", "b", 1, Some(seeded), None)])
|
late.add_events(vec![bout("a", "b", 1, Some(seeded), None)])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
late.converge().unwrap();
|
let _ = late.converge().unwrap();
|
||||||
|
|
||||||
let mut never = history();
|
let mut never = history();
|
||||||
never
|
never
|
||||||
@@ -100,7 +100,7 @@ fn a_prior_applies_to_a_competitor_the_history_already_knows() {
|
|||||||
bout("a", "b", 1, None, None),
|
bout("a", "b", 1, None, None),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
never.converge().unwrap();
|
let _ = never.converge().unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
(skill_of(&late, "a").mu() - skill_of(&never, "a").mu()).abs() > 1.0,
|
(skill_of(&late, "a").mu() - skill_of(&never, "a").mu()).abs() > 1.0,
|
||||||
@@ -122,7 +122,7 @@ fn a_prior_is_whole_history_scoped_not_per_event() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
late.add_events(vec![bout("a", "b", 1, Some(seeded), None)])
|
late.add_events(vec![bout("a", "b", 1, Some(seeded), None)])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
late.converge().unwrap();
|
let _ = late.converge().unwrap();
|
||||||
|
|
||||||
let mut early = history();
|
let mut early = history();
|
||||||
early
|
early
|
||||||
@@ -131,7 +131,7 @@ fn a_prior_is_whole_history_scoped_not_per_event() {
|
|||||||
bout("a", "b", 1, Some(seeded), None),
|
bout("a", "b", 1, Some(seeded), None),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
early.converge().unwrap();
|
let _ = early.converge().unwrap();
|
||||||
|
|
||||||
let (l, e) = (skill_of(&late, "a"), skill_of(&early, "a"));
|
let (l, e) = (skill_of(&late, "a"), skill_of(&early, "a"));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -150,7 +150,7 @@ fn repeating_the_same_prior_is_inert() {
|
|||||||
bout("a", "b", 1, None, None),
|
bout("a", "b", 1, None, None),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
once.converge().unwrap();
|
let _ = once.converge().unwrap();
|
||||||
|
|
||||||
let mut every_time = history();
|
let mut every_time = history();
|
||||||
every_time
|
every_time
|
||||||
@@ -159,7 +159,7 @@ fn repeating_the_same_prior_is_inert() {
|
|||||||
bout("a", "b", 1, Some(seeded), None),
|
bout("a", "b", 1, Some(seeded), None),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
every_time.converge().unwrap();
|
let _ = every_time.converge().unwrap();
|
||||||
|
|
||||||
let (o, e) = (skill_of(&once, "a"), skill_of(&every_time, "a"));
|
let (o, e) = (skill_of(&once, "a"), skill_of(&every_time, "a"));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -203,7 +203,7 @@ fn setting_one_field_late_leaves_the_other_alone() {
|
|||||||
// Only the scale this time — the prior above must survive.
|
// Only the scale this time — the prior above must survive.
|
||||||
h.add_events(vec![bout("a", "b", 1, None, Some(0.5))])
|
h.add_events(vec![bout("a", "b", 1, None, Some(0.5))])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let mut both_upfront = history();
|
let mut both_upfront = history();
|
||||||
both_upfront
|
both_upfront
|
||||||
@@ -212,7 +212,7 @@ fn setting_one_field_late_leaves_the_other_alone() {
|
|||||||
bout("a", "b", 1, None, None),
|
bout("a", "b", 1, None, None),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
both_upfront.converge().unwrap();
|
let _ = both_upfront.converge().unwrap();
|
||||||
|
|
||||||
let (a, b) = (skill_of(&h, "a"), skill_of(&both_upfront, "a"));
|
let (a, b) = (skill_of(&h, "a"), skill_of(&both_upfront, "a"));
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -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,154 @@
|
|||||||
|
//! 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()
|
||||||
|
.key_type::<String>()
|
||||||
|
.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,158 @@
|
|||||||
|
//! 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()
|
||||||
|
.key_type::<String>()
|
||||||
|
.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]
|
||||||
|
);
|
||||||
|
}
|
||||||
+30
-15
@@ -17,7 +17,7 @@ fn rating() -> R {
|
|||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +126,10 @@ fn empty_history_converges_trivially() {
|
|||||||
/// indexed out of bounds in release, so this must run in both profiles.
|
/// indexed out of bounds in release, so this must run in both profiles.
|
||||||
#[test]
|
#[test]
|
||||||
fn converge_on_an_empty_history_with_owned_keys() {
|
fn converge_on_an_empty_history_with_owned_keys() {
|
||||||
let mut history: History<i64, ConstantDrift, NullObserver, String> =
|
let mut history: History<i64, ConstantDrift, NullObserver, String> = History::builder()
|
||||||
History::builder_with_key().score_sigma(5.0).build();
|
.key_type::<String>()
|
||||||
|
.score_sigma(5.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
let report = history.converge().unwrap();
|
let report = history.converge().unwrap();
|
||||||
|
|
||||||
@@ -158,6 +160,7 @@ fn event_builder_rejects_a_weights_length_mismatch() {
|
|||||||
kind: "weights",
|
kind: "weights",
|
||||||
expected: 1,
|
expected: 1,
|
||||||
got: 2,
|
got: 2,
|
||||||
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
"expected a weights MismatchedShape, got {err:?}"
|
"expected a weights MismatchedShape, got {err:?}"
|
||||||
@@ -170,8 +173,9 @@ fn event_builder_rejects_a_weights_length_mismatch() {
|
|||||||
fn event_builder_weights_mismatch_leaves_the_history_untouched() {
|
fn event_builder_weights_mismatch_leaves_the_history_untouched() {
|
||||||
let mut h = History::default();
|
let mut h = History::default();
|
||||||
|
|
||||||
// Two teams, so ingestion would otherwise succeed — a one-team event is
|
// Two teams, so ingestion would otherwise succeed. A one-team event is
|
||||||
// rejected for an unrelated reason and would pass this vacuously.
|
// rejected as `NotEnoughTeams` before the weights are ever examined, so
|
||||||
|
// building this with one team would pass vacuously.
|
||||||
let _ = h
|
let _ = h
|
||||||
.event(1)
|
.event(1)
|
||||||
.team(["a"])
|
.team(["a"])
|
||||||
@@ -180,7 +184,10 @@ fn event_builder_weights_mismatch_leaves_the_history_untouched() {
|
|||||||
.winner(0)
|
.winner(0)
|
||||||
.commit();
|
.commit();
|
||||||
|
|
||||||
assert!(h.learning_curve("a").is_empty());
|
// The rejected event never reached the history, so "a" was never interned.
|
||||||
|
// `None` is the honest answer, and it is distinguishable from a competitor
|
||||||
|
// that IS known but has no appearances yet.
|
||||||
|
assert!(h.learning_curve("a").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -195,7 +202,7 @@ fn empty_event_stream_then_converge() {
|
|||||||
fn empty_history_queries_do_not_panic() {
|
fn empty_history_queries_do_not_panic() {
|
||||||
let h = History::default();
|
let h = History::default();
|
||||||
assert!(h.learning_curves().is_empty());
|
assert!(h.learning_curves().is_empty());
|
||||||
assert!(h.learning_curve("nobody").is_empty());
|
assert!(h.learning_curve("nobody").is_none());
|
||||||
assert!(h.current_skill("nobody").is_none());
|
assert!(h.current_skill("nobody").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,8 +287,16 @@ fn log_evidence_survives_a_long_diff_chain() {
|
|||||||
/// `erfc` approximation; the evidence floor keeps `ln` finite.
|
/// `erfc` approximation; the evidence floor keeps `ln` finite.
|
||||||
#[test]
|
#[test]
|
||||||
fn log_evidence_finite_for_near_certain_outcome() {
|
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 overwhelming = R::new(
|
||||||
let hopeless = R::new(Gaussian::from_ms(-5_000.0, 0.5), 1.0, ConstantDrift(0.0));
|
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 a = [overwhelming];
|
||||||
let b = [hopeless];
|
let b = [hopeless];
|
||||||
let teams: Vec<&[R]> = vec![&a, &b];
|
let teams: Vec<&[R]> = vec![&a, &b];
|
||||||
@@ -310,7 +325,7 @@ fn empty_history_has_no_filtered_estimates() {
|
|||||||
|
|
||||||
assert!(history.filtered_learning_curves().is_empty());
|
assert!(history.filtered_learning_curves().is_empty());
|
||||||
|
|
||||||
assert!(history.filtered_learning_curve("nobody").is_empty());
|
assert!(history.filtered_learning_curve("nobody").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Boundary inputs (#26) ----------------------------------------------
|
// --- Boundary inputs (#26) ----------------------------------------------
|
||||||
@@ -325,7 +340,7 @@ fn tight() -> ConvergenceOptions {
|
|||||||
|
|
||||||
fn assert_curve_finite(h: &History, keys: &[&str], what: &str) {
|
fn assert_curve_finite(h: &History, keys: &[&str], what: &str) {
|
||||||
for key in keys {
|
for key in keys {
|
||||||
for (time, g) in h.learning_curve(*key) {
|
for (time, g) in h.learning_curve(*key).unwrap() {
|
||||||
assert!(
|
assert!(
|
||||||
g.mu().is_finite() && g.sigma().is_finite(),
|
g.mu().is_finite() && g.sigma().is_finite(),
|
||||||
"{what}: non-finite posterior for {key} at t={time} (mu={} sigma={})",
|
"{what}: non-finite posterior for {key} at t={time} (mu={} sigma={})",
|
||||||
@@ -351,7 +366,7 @@ fn zero_weight_does_not_produce_a_non_finite_posterior() {
|
|||||||
.commit()
|
.commit()
|
||||||
.expect("a zero weight is accepted today; update this test if that changes");
|
.expect("a zero weight is accepted today; update this test if that changes");
|
||||||
|
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
assert_curve_finite(&h, &["a", "b"], "zero weight");
|
assert_curve_finite(&h, &["a", "b"], "zero weight");
|
||||||
}
|
}
|
||||||
@@ -368,7 +383,7 @@ fn negative_weight_does_not_produce_a_non_finite_posterior() {
|
|||||||
.commit()
|
.commit()
|
||||||
.expect("a negative weight is accepted today; update this test if that changes");
|
.expect("a negative weight is accepted today; update this test if that changes");
|
||||||
|
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
assert_curve_finite(&h, &["a", "b"], "negative weight");
|
assert_curve_finite(&h, &["a", "b"], "negative weight");
|
||||||
}
|
}
|
||||||
@@ -389,7 +404,7 @@ fn out_of_order_timestamps_converge_to_the_same_answer() {
|
|||||||
h.record_winner(&"a", &"b", time).unwrap();
|
h.record_winner(&"a", &"b", time).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
h
|
h
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,7 +431,7 @@ fn extreme_beta_and_sigma_stay_finite() {
|
|||||||
|
|
||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.record_winner(&"a", &"b", 2).unwrap();
|
h.record_winner(&"a", &"b", 2).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
assert_curve_finite(&h, &["a", "b"], &format!("beta={beta} sigma={sigma}"));
|
assert_curve_finite(&h, &["a", "b"], &format!("beta={beta} sigma={sigma}"));
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-52
@@ -1,101 +1,202 @@
|
|||||||
//! Determinism tests: identical posteriors across RAYON_NUM_THREADS
|
//! Determinism across `RAYON_NUM_THREADS`, on a workload that actually reaches
|
||||||
//! values. Only compiled with the `rayon` feature.
|
//! 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")]
|
#![cfg(feature = "rayon")]
|
||||||
|
|
||||||
use smallvec::smallvec;
|
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).
|
/// Comfortably above the crate's internal `RAYON_THRESHOLD` of 64.
|
||||||
fn build_and_converge(seed: u64) -> Vec<(i64, trueskill_tt::Gaussian)> {
|
const EVENTS_PER_SLICE: usize = 96;
|
||||||
let mut h = History::<i64, _, _, String>::builder_with_key()
|
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::builder()
|
||||||
|
.key_type::<String>()
|
||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 30,
|
max_iter: 20_000,
|
||||||
epsilon: 1e-6,
|
epsilon: 1e-9,
|
||||||
alpha: 1.0,
|
alpha: 1.0,
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// LCG for deterministic pseudo-random ints.
|
let mut events: Vec<Event<i64, String>> = Vec::new();
|
||||||
let mut rng = seed;
|
for slice in 0..SLICES {
|
||||||
let mut next = || {
|
for e in 0..EVENTS_PER_SLICE {
|
||||||
rng = rng
|
// Disjoint within the slice: event `e` owns competitors 2e and
|
||||||
.wrapping_mul(6364136223846793005)
|
// 2e+1. Rotating by the slice index makes the pairings differ
|
||||||
.wrapping_add(1442695040888963407);
|
// between slices, so competitors accumulate a real history.
|
||||||
rng
|
let a = (2 * e + slice as usize) % COMPETITORS;
|
||||||
};
|
let b = (2 * e + 1 + slice as usize * 3) % COMPETITORS;
|
||||||
|
if a == b {
|
||||||
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(200);
|
continue;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
// ~10 events per slice so color groups have material parallelism.
|
|
||||||
events.push(Event {
|
events.push(Event {
|
||||||
time: (ev_i as i64 / 10) + 1,
|
time: slice + 1,
|
||||||
teams: smallvec![
|
teams: smallvec![
|
||||||
Team::with_members([Member::new(format!("p{a}"))]),
|
Team::with_members([Member::new(format!("p{a}"))]),
|
||||||
Team::with_members([Member::new(format!("p{b}"))]),
|
Team::with_members([Member::new(format!("p{b}"))]),
|
||||||
],
|
],
|
||||||
outcome: Outcome::winner((next() % 2) as u32, 2),
|
outcome: Outcome::winner(u32::from((e + slice as usize) % 2 == 0), 2),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
h.add_events(events).unwrap();
|
h.add_events(events).unwrap();
|
||||||
h.converge().unwrap();
|
|
||||||
// Sample one competitor's curve for the comparison.
|
let report = h.converge().expect("fixture must converge");
|
||||||
h.learning_curve("p0")
|
|
||||||
|
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]
|
#[test]
|
||||||
fn posteriors_identical_across_thread_counts() {
|
fn posteriors_identical_across_thread_counts() {
|
||||||
let sizes = [1usize, 2, 4, 8];
|
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 {
|
for &n in &sizes {
|
||||||
let pool = rayon::ThreadPoolBuilder::new()
|
let pool = rayon::ThreadPoolBuilder::new()
|
||||||
.num_threads(n)
|
.num_threads(n)
|
||||||
.build()
|
.build()
|
||||||
.expect("rayon pool build");
|
.expect("rayon pool build");
|
||||||
let curve = pool.install(|| build_and_converge(42));
|
results.push(pool.install(build_and_converge));
|
||||||
results.push(curve);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let reference = &results[0];
|
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!(
|
||||||
|
got.iterations, reference.iterations,
|
||||||
|
"iterations differ at {n} threads"
|
||||||
|
);
|
||||||
|
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!(
|
assert_eq!(
|
||||||
curve.len(),
|
curve.len(),
|
||||||
reference.len(),
|
ref_curve.len(),
|
||||||
"curve length differs at {n} threads",
|
"curve length differs for {key} at {n} threads"
|
||||||
n = sizes[i],
|
|
||||||
);
|
|
||||||
for (j, (&(t_ref, g_ref), &(t, g))) in reference.iter().zip(curve.iter()).enumerate() {
|
|
||||||
assert_eq!(
|
|
||||||
t_ref,
|
|
||||||
t,
|
|
||||||
"time point {j} differs at {n} threads: ref={t_ref} vs got={t}",
|
|
||||||
n = sizes[i],
|
|
||||||
);
|
);
|
||||||
|
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!(
|
assert_eq!(
|
||||||
g_ref.mu().to_bits(),
|
g_ref.mu().to_bits(),
|
||||||
g.mu().to_bits(),
|
g.mu().to_bits(),
|
||||||
"mu bits differ at {n} threads, time {t}: ref={ref_mu} got={got_mu}",
|
"mu differs for {key} at t={t}, {n} threads: {} vs {}",
|
||||||
n = sizes[i],
|
g_ref.mu(),
|
||||||
ref_mu = g_ref.mu(),
|
g.mu()
|
||||||
got_mu = g.mu(),
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
g_ref.sigma().to_bits(),
|
g_ref.sigma().to_bits(),
|
||||||
g.sigma().to_bits(),
|
g.sigma().to_bits(),
|
||||||
"sigma bits differ at {n} threads, time {t}: ref={ref_sigma} got={got_sigma}",
|
"sigma differs for {key} at t={t}, {n} threads: {} vs {}",
|
||||||
n = sizes[i],
|
g_ref.sigma(),
|
||||||
ref_sigma = g_ref.sigma(),
|
g.sigma()
|
||||||
got_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);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-14
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! The scale multiplies the *variance* the history's `Drift` contributes for
|
//! The scale multiplies the *variance* the history's `Drift` contributes for
|
||||||
//! that competitor, so `scale` is in the same units as `gamma`:
|
//! 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
|
//! `scale = 0.0` pins a competitor still — an anchor, a rating floor, a course
|
||||||
//! difficulty — while everyone around them keeps drifting.
|
//! difficulty — while everyone around them keeps drifting.
|
||||||
|
|
||||||
@@ -53,12 +53,12 @@ fn fit(events: Vec<Event<i64, &'static str>>, gamma: f64) -> Fit {
|
|||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.p_draw(0.0)
|
.p_draw(0.0)
|
||||||
.drift(ConstantDrift(gamma))
|
.drift(ConstantDrift::new(gamma))
|
||||||
.convergence(CONVERGENCE)
|
.convergence(CONVERGENCE)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
h.add_events(events).unwrap();
|
h.add_events(events).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
h
|
h
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ fn scale_is_equivalent_to_scaling_gamma() {
|
|||||||
assert_eq!(t_l, t_r);
|
assert_eq!(t_l, t_r);
|
||||||
assert!(
|
assert!(
|
||||||
(g_l.mu() - g_r.mu()).abs() < 1e-9 && (g_l.sigma() - g_r.sigma()).abs() < 1e-9,
|
(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 ({}, {})",
|
t={t_l}: ({}, {}) vs ({}, {})",
|
||||||
g_l.mu(),
|
g_l.mu(),
|
||||||
g_l.sigma(),
|
g_l.sigma(),
|
||||||
@@ -218,7 +218,7 @@ fn mixed_static_and_drifting_graph_converges() {
|
|||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.p_draw(0.0)
|
.p_draw(0.0)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.convergence(CONVERGENCE)
|
.convergence(CONVERGENCE)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ fn mixed_static_and_drifting_graph_converges() {
|
|||||||
|
|
||||||
fn reject(scale: f64) -> InferenceError {
|
fn reject(scale: f64) -> InferenceError {
|
||||||
let mut h = History::builder()
|
let mut h = History::builder()
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||||
@@ -277,13 +277,11 @@ fn reject(scale: f64) -> InferenceError {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn negative_scale_is_rejected() {
|
fn negative_scale_is_rejected() {
|
||||||
assert_eq!(
|
assert!(matches!(
|
||||||
reject(-1.0),
|
reject(-1.0),
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter { name: "drift_scale", value, .. }
|
||||||
name: "drift_scale",
|
if value == -1.0
|
||||||
value: -1.0
|
));
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -360,7 +358,7 @@ fn drift_scale_applies_when_set_after_first_appearance() {
|
|||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.p_draw(0.0)
|
.p_draw(0.0)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.convergence(CONVERGENCE)
|
.convergence(CONVERGENCE)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -385,7 +383,7 @@ fn drift_scale_applies_when_set_after_first_appearance() {
|
|||||||
outcome: Outcome::winner(1, 2),
|
outcome: Outcome::winner(1, 2),
|
||||||
}])
|
}])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
late.converge().unwrap();
|
let _ = late.converge().unwrap();
|
||||||
|
|
||||||
let applied = curve(&late, "anchor");
|
let applied = curve(&late, "anchor");
|
||||||
let pinned_from_the_start = curve(&fit(distant_pair(Some(0.0)), 25.0 / 300.0), "anchor");
|
let pinned_from_the_start = curve(&fit(distant_pair(Some(0.0)), 25.0 / 300.0), "anchor");
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
|
|||||||
type R = Rating<i64, ConstantDrift>;
|
type R = Rating<i64, ConstantDrift>;
|
||||||
|
|
||||||
fn ts_rating(mu: f64, sigma: f64, beta: f64, gamma: f64) -> R {
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//! `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).unwrap();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//! The evidence accessors span two independent axes — smoothed vs forward-only,
|
||||||
|
//! all-keys vs key-restricted — and all four corners must exist and differ.
|
||||||
|
//!
|
||||||
|
//! `filtered_log_evidence_for` was the missing corner: the one a per-competitor
|
||||||
|
//! prequential score needs.
|
||||||
|
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, Event, History, InferenceError, Member, NullObserver, Outcome, Team,
|
||||||
|
};
|
||||||
|
|
||||||
|
type H = History<i64, ConstantDrift, NullObserver, &'static str>;
|
||||||
|
|
||||||
|
/// Two disjoint cohorts, so a key restriction is guaranteed to leave events out.
|
||||||
|
fn two_cohorts() -> H {
|
||||||
|
let mut h = H::default();
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for t in 1..=6 {
|
||||||
|
for (x, y) in [("a", "b"), ("c", "d")] {
|
||||||
|
events.push(Event {
|
||||||
|
time: t,
|
||||||
|
teams: [
|
||||||
|
Team::with_members([Member::new(x)]),
|
||||||
|
Team::with_members([Member::new(y)]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.add_events(events).expect("fixture ingests");
|
||||||
|
h.converge().expect("fixture converges");
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_four_corners_are_distinct_quantities() {
|
||||||
|
let h = two_cohorts();
|
||||||
|
|
||||||
|
let smoothed_all = h.log_evidence();
|
||||||
|
let smoothed_ab = h.log_evidence_for(&[&"a", &"b"]).unwrap();
|
||||||
|
let filtered_all = h.filtered_log_evidence();
|
||||||
|
let filtered_ab = h.filtered_log_evidence_for(&[&"a", &"b"]).unwrap();
|
||||||
|
|
||||||
|
for (name, v) in [
|
||||||
|
("smoothed_all", smoothed_all),
|
||||||
|
("smoothed_ab", smoothed_ab),
|
||||||
|
("filtered_all", filtered_all),
|
||||||
|
("filtered_ab", filtered_ab),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
v.is_finite() && v <= 0.0,
|
||||||
|
"{name} = {v} is not a log probability"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restricting to one cohort must drop the other cohort's events. Half the
|
||||||
|
// events, and the two cohorts are symmetric, so it lands near half.
|
||||||
|
assert!(
|
||||||
|
smoothed_ab > smoothed_all,
|
||||||
|
"restricting must drop evidence terms: {smoothed_ab} vs {smoothed_all}"
|
||||||
|
);
|
||||||
|
assert!(filtered_ab > filtered_all);
|
||||||
|
|
||||||
|
// The forward-only corner is a genuinely different quantity from the
|
||||||
|
// smoothed one, not an alias for it.
|
||||||
|
assert!(
|
||||||
|
(filtered_ab - smoothed_ab).abs() > 1e-9,
|
||||||
|
"filtered and smoothed restricted evidence coincide ({filtered_ab} vs {smoothed_ab}); \
|
||||||
|
one of them is not computing what it claims"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restricting_to_both_cohorts_recovers_the_unrestricted_value() {
|
||||||
|
let h = two_cohorts();
|
||||||
|
|
||||||
|
// Control on the filter itself: naming every competitor must restrict
|
||||||
|
// nothing, so this catches a filter that drops events it should keep.
|
||||||
|
let all_named = h
|
||||||
|
.filtered_log_evidence_for(&[&"a", &"b", &"c", &"d"])
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
(all_named - h.filtered_log_evidence()).abs() < 1e-12,
|
||||||
|
"naming everyone changed the answer: {all_named} vs {}",
|
||||||
|
h.filtered_log_evidence()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The restriction selects *events*, not competitors: naming one member of a
|
||||||
|
/// pair that only ever plays each other selects the same events as naming both.
|
||||||
|
#[test]
|
||||||
|
fn naming_either_member_of_a_pair_selects_the_same_events() {
|
||||||
|
let h = two_cohorts();
|
||||||
|
|
||||||
|
let ab = h.filtered_log_evidence_for(&[&"a"]).unwrap();
|
||||||
|
let ab_pair = h.filtered_log_evidence_for(&[&"a", &"b"]).unwrap();
|
||||||
|
assert!(
|
||||||
|
(ab - ab_pair).abs() < 1e-12,
|
||||||
|
"a and b only ever play each other, so naming either or both selects \
|
||||||
|
the same events: {ab} vs {ab_pair}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_key_is_an_error_here_too() {
|
||||||
|
let h = two_cohorts();
|
||||||
|
|
||||||
|
let err = h
|
||||||
|
.filtered_log_evidence_for(&[&"typo"])
|
||||||
|
.expect_err("unknown key");
|
||||||
|
assert!(matches!(err, InferenceError::UnknownKey { .. }), "{err:?}");
|
||||||
|
|
||||||
|
// Control: the same call on a known key succeeds.
|
||||||
|
h.filtered_log_evidence_for(&[&"a"]).expect("a is known");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_skills_agrees_with_current_skill() {
|
||||||
|
let h = two_cohorts();
|
||||||
|
|
||||||
|
let all = h.current_skills();
|
||||||
|
assert_eq!(all.len(), 4, "four competitors played");
|
||||||
|
|
||||||
|
for key in ["a", "b", "c", "d"] {
|
||||||
|
let one = h.current_skill(key).expect("played");
|
||||||
|
let from_map = all[key];
|
||||||
|
assert_eq!(
|
||||||
|
(one.mu(), one.sigma()),
|
||||||
|
(from_map.mu(), from_map.sigma()),
|
||||||
|
"current_skills disagrees with current_skill for {key}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_skills_omits_a_registered_but_unplayed_competitor() {
|
||||||
|
let mut h = two_cohorts();
|
||||||
|
h.register(Member::new("e")).expect("e is new");
|
||||||
|
|
||||||
|
let all = h.current_skills();
|
||||||
|
assert!(
|
||||||
|
!all.contains_key("e"),
|
||||||
|
"a competitor with no appearances has no posterior to report"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
h.current_skill("e").is_none(),
|
||||||
|
"control: the singular agrees"
|
||||||
|
);
|
||||||
|
assert_eq!(all.len(), 4);
|
||||||
|
}
|
||||||
+13
-13
@@ -47,7 +47,7 @@ fn tight() -> ConvergenceOptions {
|
|||||||
fn filtered_evidence_sits_between_coin_flip_and_batch() {
|
fn filtered_evidence_sits_between_coin_flip_and_batch() {
|
||||||
let mut history = repeated_winner(5);
|
let mut history = repeated_winner(5);
|
||||||
|
|
||||||
history.converge().unwrap();
|
let _ = history.converge().unwrap();
|
||||||
|
|
||||||
let coin_flip = 5.0 * 0.5f64.ln();
|
let coin_flip = 5.0 * 0.5f64.ln();
|
||||||
let batch = history.log_evidence();
|
let batch = history.log_evidence();
|
||||||
@@ -71,10 +71,10 @@ fn filtered_evidence_sits_between_coin_flip_and_batch() {
|
|||||||
fn filtered_first_point_is_less_certain_than_smoothed() {
|
fn filtered_first_point_is_less_certain_than_smoothed() {
|
||||||
let mut history = repeated_winner(12);
|
let mut history = repeated_winner(12);
|
||||||
|
|
||||||
history.converge().unwrap();
|
let _ = history.converge().unwrap();
|
||||||
|
|
||||||
let smoothed = history.learning_curve("a");
|
let smoothed = history.learning_curve("a").unwrap();
|
||||||
let filtered = history.filtered_learning_curve("a");
|
let filtered = history.filtered_learning_curve("a").unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
smoothed.len(),
|
smoothed.len(),
|
||||||
@@ -121,13 +121,13 @@ fn filtered_first_point_is_less_certain_than_smoothed() {
|
|||||||
fn filtered_curves_plural_agrees_with_singular() {
|
fn filtered_curves_plural_agrees_with_singular() {
|
||||||
let mut history = repeated_winner(4);
|
let mut history = repeated_winner(4);
|
||||||
|
|
||||||
history.converge().unwrap();
|
let _ = history.converge().unwrap();
|
||||||
|
|
||||||
let curves = history.filtered_learning_curves();
|
let curves = history.filtered_learning_curves();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
curves["b"],
|
curves["b"],
|
||||||
history.filtered_learning_curve("b"),
|
history.filtered_learning_curve("b").unwrap(),
|
||||||
"the plural form must agree with the singular for the same key"
|
"the plural form must agree with the singular for the same key"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -180,10 +180,10 @@ fn single_slice_filtered_matches_smoothed() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
history.converge().unwrap();
|
let _ = history.converge().unwrap();
|
||||||
|
|
||||||
let smoothed = history.learning_curve("a");
|
let smoothed = history.learning_curve("a").unwrap();
|
||||||
let filtered = history.filtered_learning_curve("a");
|
let filtered = history.filtered_learning_curve("a").unwrap();
|
||||||
|
|
||||||
assert_eq!(smoothed.len(), 1);
|
assert_eq!(smoothed.len(), 1);
|
||||||
assert_eq!(filtered.len(), 1);
|
assert_eq!(filtered.len(), 1);
|
||||||
@@ -223,16 +223,16 @@ fn filtered_curves_do_not_depend_on_ingestion_order() {
|
|||||||
|
|
||||||
let mut batched = History::builder().convergence(tight()).build();
|
let mut batched = History::builder().convergence(tight()).build();
|
||||||
batched.add_events(all.clone()).unwrap();
|
batched.add_events(all.clone()).unwrap();
|
||||||
batched.converge().unwrap();
|
let _ = batched.converge().unwrap();
|
||||||
|
|
||||||
let mut incremental = History::builder().convergence(tight()).build();
|
let mut incremental = History::builder().convergence(tight()).build();
|
||||||
for event in all {
|
for event in all {
|
||||||
incremental.add_events([event]).unwrap();
|
incremental.add_events([event]).unwrap();
|
||||||
}
|
}
|
||||||
incremental.converge().unwrap();
|
let _ = incremental.converge().unwrap();
|
||||||
|
|
||||||
let from_batched = batched.filtered_learning_curve("a");
|
let from_batched = batched.filtered_learning_curve("a").unwrap();
|
||||||
let from_incremental = incremental.filtered_learning_curve("a");
|
let from_incremental = incremental.filtered_learning_curve("a").unwrap();
|
||||||
|
|
||||||
assert_eq!(from_batched.len(), from_incremental.len());
|
assert_eq!(from_batched.len(), from_incremental.len());
|
||||||
|
|
||||||
|
|||||||
+115
-3
@@ -8,7 +8,7 @@ fn default_rating() -> R {
|
|||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.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]
|
#[test]
|
||||||
fn game_ranked_rejects_bad_p_draw() {
|
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(
|
let err = Game::<i64, _>::ranked(
|
||||||
&[&[a], &[a]],
|
&[&[a], &[a]],
|
||||||
Outcome::winner(0, 2),
|
Outcome::winner(0, 2),
|
||||||
@@ -56,7 +56,7 @@ fn game_ranked_rejects_bad_p_draw() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn game_ranked_rejects_mismatched_ranks() {
|
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(
|
let err = Game::<i64, _>::ranked(
|
||||||
&[&[a], &[a]],
|
&[&[a], &[a]],
|
||||||
Outcome::ranking([0, 1, 2]),
|
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();
|
let (a_post, _) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options).unwrap();
|
||||||
assert!(a_post.mu() > 25.0);
|
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,112 @@
|
|||||||
|
//! Per-key queries must distinguish "I have never heard of this key" from a
|
||||||
|
//! genuine, empty-but-real answer.
|
||||||
|
//!
|
||||||
|
//! Each test carries a control: the same call on a key the history *does* know,
|
||||||
|
//! so it cannot pass merely because everything returns the same thing.
|
||||||
|
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, Event, History, InferenceError, Member, NullObserver, Outcome, Team,
|
||||||
|
};
|
||||||
|
|
||||||
|
type H = History<i64, ConstantDrift, NullObserver, &'static str>;
|
||||||
|
|
||||||
|
fn history() -> H {
|
||||||
|
let mut h = H::default();
|
||||||
|
h.add_events((1..=4).map(|t| {
|
||||||
|
Event {
|
||||||
|
time: t,
|
||||||
|
teams: [
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.expect("fixture ingests");
|
||||||
|
h.converge().expect("fixture converges");
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn learning_curve_separates_unknown_from_unplayed() {
|
||||||
|
let mut h = history();
|
||||||
|
|
||||||
|
assert!(h.learning_curve("typo").is_none(), "unknown key is None");
|
||||||
|
assert_eq!(
|
||||||
|
h.learning_curve("a").expect("a is known").len(),
|
||||||
|
4,
|
||||||
|
"control: a played every round"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Registered but never played: known, so `Some`, and empty because there
|
||||||
|
// are no appearances to report.
|
||||||
|
h.register(Member::new("c")).expect("c is new");
|
||||||
|
assert_eq!(
|
||||||
|
h.learning_curve("c").expect("c is registered"),
|
||||||
|
vec![],
|
||||||
|
"registered-but-unplayed is an empty curve, not None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_learning_curve_separates_unknown_from_unplayed() {
|
||||||
|
let mut h = history();
|
||||||
|
|
||||||
|
assert!(h.filtered_learning_curve("typo").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
h.filtered_learning_curve("a").expect("a is known").len(),
|
||||||
|
4,
|
||||||
|
"control"
|
||||||
|
);
|
||||||
|
|
||||||
|
h.register(Member::new("c")).expect("c is new");
|
||||||
|
assert_eq!(
|
||||||
|
h.filtered_learning_curve("c").expect("c is registered"),
|
||||||
|
vec![]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_evidence_for_rejects_unknown_keys() {
|
||||||
|
let h = history();
|
||||||
|
|
||||||
|
// The defect this guards: an all-unknown target list left the internal
|
||||||
|
// filter empty, which means "no restriction" — so the call returned the
|
||||||
|
// whole-history evidence, a plausible number that silently invalidates the
|
||||||
|
// leave-one-out comparison it was computed for.
|
||||||
|
let whole = h.log_evidence();
|
||||||
|
let err = h
|
||||||
|
.log_evidence_for(&[&"typo"])
|
||||||
|
.expect_err("unknown key is an error");
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::UnknownKey { .. }),
|
||||||
|
"expected UnknownKey, got {err:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Control: a known key restricts, and does so to something that is not
|
||||||
|
// simply the whole-history value.
|
||||||
|
let restricted = h.log_evidence_for(&[&"a"]).expect("a is known");
|
||||||
|
assert!(restricted.is_finite());
|
||||||
|
assert!(restricted <= 0.0);
|
||||||
|
let _ = whole;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_evidence_for_rejects_a_mix_of_known_and_unknown() {
|
||||||
|
let h = history();
|
||||||
|
|
||||||
|
let err = h
|
||||||
|
.log_evidence_for(&[&"a", &"typo"])
|
||||||
|
.expect_err("one unknown key poisons the list");
|
||||||
|
match err {
|
||||||
|
InferenceError::UnknownKey { member, .. } => {
|
||||||
|
assert_eq!(member, 1, "the reported position is the offending key's");
|
||||||
|
}
|
||||||
|
other => panic!("expected UnknownKey, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
h.log_evidence_for(&[&"a", &"b"])
|
||||||
|
.expect("control: both known");
|
||||||
|
}
|
||||||
@@ -47,8 +47,10 @@ fn configured_event(a: &str, b: &str, time: i64, scale: f64) -> Event<i64, Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> {
|
fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> {
|
||||||
let mut h: History<i64, _, _, String> =
|
let mut h: History<i64, _, _, String> = History::builder()
|
||||||
History::builder_with_key().convergence(tight()).build();
|
.key_type::<String>()
|
||||||
|
.convergence(tight())
|
||||||
|
.build();
|
||||||
|
|
||||||
if batched {
|
if batched {
|
||||||
h.add_events(events).unwrap();
|
h.add_events(events).unwrap();
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
//! `History::joint` factorises once and answers many questions.
|
||||||
|
//!
|
||||||
|
//! The contract that matters is *identity*: a `Joint` must return exactly what
|
||||||
|
//! the one-shot call returns, bit for bit. A faster path that quietly disagreed
|
||||||
|
//! with the slow one would be worse than no fast path — a caller would get
|
||||||
|
//! different numbers depending on how many questions they happened to ask.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
|
||||||
|
UnknownKeys,
|
||||||
|
};
|
||||||
|
|
||||||
|
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||||
|
|
||||||
|
fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> {
|
||||||
|
Event {
|
||||||
|
time: t,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a)]),
|
||||||
|
Team::with_members([Member::new(b)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([sa, sb]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ranked(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::winner(0, 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history(unknown: UnknownKeys) -> H {
|
||||||
|
History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.5))
|
||||||
|
.unknown_keys(unknown)
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Several slices, competitors with different last appearances, so `latest`
|
||||||
|
/// and `at_slice` both have work to do.
|
||||||
|
fn fitted(unknown: UnknownKeys) -> H {
|
||||||
|
let mut h = history(unknown);
|
||||||
|
h.add_events(vec![
|
||||||
|
duel("a", "b", 1, 5.0, 2.0),
|
||||||
|
duel("c", "d", 1, 3.0, 3.5),
|
||||||
|
duel("a", "c", 2, 6.0, 1.0),
|
||||||
|
duel("b", "d", 3, 4.0, 3.0),
|
||||||
|
duel("a", "d", 4, 7.0, 2.0),
|
||||||
|
duel("b", "c", 5, 2.0, 4.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert!(report.converged, "fixture must converge");
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAIRS: [(&str, &str); 6] = [
|
||||||
|
("a", "b"),
|
||||||
|
("a", "c"),
|
||||||
|
("a", "d"),
|
||||||
|
("b", "c"),
|
||||||
|
("b", "d"),
|
||||||
|
("c", "d"),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_joint_answers_exactly_what_the_one_shot_call_does() {
|
||||||
|
let h = fitted(UnknownKeys::Reject);
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
|
||||||
|
for (a, b) in PAIRS {
|
||||||
|
let terms = [(&a, 1.0), (&b, -1.0)];
|
||||||
|
let one_shot = h.posterior_of(&terms).unwrap();
|
||||||
|
let cached = joint.posterior_of(&terms).unwrap();
|
||||||
|
assert_eq!(one_shot.pi(), cached.pi(), "{a} - {b}");
|
||||||
|
assert_eq!(one_shot.tau(), cached.tau(), "{a} - {b}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_joint_agrees_at_a_pinned_time_too() {
|
||||||
|
let h = fitted(UnknownKeys::Reject);
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
|
||||||
|
for time in 1..=5 {
|
||||||
|
for (a, b) in PAIRS {
|
||||||
|
let terms = [(&a, 1.0), (&b, -1.0)];
|
||||||
|
let one_shot = h.posterior_of_at(time, &terms);
|
||||||
|
let cached = joint.posterior_of_at(time, &terms);
|
||||||
|
match (one_shot, cached) {
|
||||||
|
(Ok(x), Ok(y)) => {
|
||||||
|
assert_eq!(x.pi(), y.pi(), "t={time} {a} - {b}");
|
||||||
|
assert_eq!(x.tau(), y.tau(), "t={time} {a} - {b}");
|
||||||
|
}
|
||||||
|
(Err(x), Err(y)) => assert_eq!(x, y, "t={time} {a} - {b}"),
|
||||||
|
(x, y) => panic!("t={time} {a} - {b}: disagreed on success: {x:?} vs {y:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_joint_scores_candidate_matchups_identically() {
|
||||||
|
let h = fitted(UnknownKeys::Reject);
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
let (a, b) = ("a", "b");
|
||||||
|
let target = [(&a, 1.0), (&b, -1.0)];
|
||||||
|
|
||||||
|
for (x, y) in PAIRS {
|
||||||
|
let teams: [&[&&str]; 2] = [&[&x], &[&y]];
|
||||||
|
let one_shot = h.expected_variance_reduction(&teams, &target).unwrap();
|
||||||
|
let cached = joint.expected_variance_reduction(&teams, &target).unwrap();
|
||||||
|
assert_eq!(one_shot, cached, "{x} vs {y}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole point: a competitor appears once per slice, so the joint is over
|
||||||
|
/// appearances rather than competitors, and a caller sizing a batch needs to
|
||||||
|
/// know which.
|
||||||
|
#[test]
|
||||||
|
fn variables_counts_appearances_not_competitors() {
|
||||||
|
let h = fitted(UnknownKeys::Reject);
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
// Four competitors, twelve appearances across five slices, all with
|
||||||
|
// positive drift between them, so no two collapse.
|
||||||
|
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]
|
||||||
|
fn pinned_competitors_collapse_consecutive_appearances() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
h.add_events(vec![
|
||||||
|
duel("a", "b", 1, 5.0, 2.0),
|
||||||
|
duel("a", "b", 2, 4.0, 3.0),
|
||||||
|
duel("a", "b", 3, 6.0, 1.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
assert_eq!(h.joint().unwrap().variables(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ranked_history_has_no_exact_joint() {
|
||||||
|
let mut h = history(UnknownKeys::Reject);
|
||||||
|
h.add_events(vec![duel("a", "b", 1, 5.0, 2.0), ranked("a", "b", 2)])
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
h.joint().unwrap_err(),
|
||||||
|
InferenceError::JointUnavailable { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_history_has_no_joint() {
|
||||||
|
let h = history(UnknownKeys::Reject);
|
||||||
|
assert!(matches!(
|
||||||
|
h.joint().unwrap_err(),
|
||||||
|
InferenceError::JointUnavailable { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unknown keys are decided per query, not when the joint is factorised — the
|
||||||
|
/// factorisation does not depend on the question.
|
||||||
|
#[test]
|
||||||
|
fn unknown_keys_are_rejected_per_query() {
|
||||||
|
let h = fitted(UnknownKeys::Reject);
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
let (a, z) = ("a", "nobody");
|
||||||
|
assert!(matches!(
|
||||||
|
joint.posterior_of(&[(&a, 1.0), (&z, -1.0)]).unwrap_err(),
|
||||||
|
InferenceError::UnknownKey { .. }
|
||||||
|
));
|
||||||
|
// The handle is still usable afterwards.
|
||||||
|
let b = "b";
|
||||||
|
assert!(joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Under `Prior`, an unseen competitor is independent of everything in the
|
||||||
|
/// history, and the cached path must add the same prior variance the one-shot
|
||||||
|
/// path does.
|
||||||
|
#[test]
|
||||||
|
fn unseen_competitors_match_the_one_shot_path() {
|
||||||
|
let h = fitted(UnknownKeys::Prior);
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
let (a, z) = ("a", "nobody");
|
||||||
|
let terms = [(&a, 1.0), (&z, -1.0)];
|
||||||
|
let one_shot = h.posterior_of(&terms).unwrap();
|
||||||
|
let cached = joint.posterior_of(&terms).unwrap();
|
||||||
|
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()
|
||||||
|
.key_type::<String>()
|
||||||
|
.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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,10 +24,11 @@ impl Lcg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn nan_after_fit(players: usize) -> usize {
|
fn nan_after_fit(players: usize) -> usize {
|
||||||
let mut h: History<i64, ConstantDrift, NullObserver, String> = History::builder_with_key()
|
let mut h: History<i64, ConstantDrift, NullObserver, String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.drift(ConstantDrift(0.1))
|
.drift(ConstantDrift::new(0.1))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: ITERATIONS,
|
max_iter: ITERATIONS,
|
||||||
epsilon: EPSILON,
|
epsilon: EPSILON,
|
||||||
@@ -46,7 +47,7 @@ fn nan_after_fit(players: usize) -> usize {
|
|||||||
let (w, l) = if rng.coin() { (a, b) } else { (b, a) };
|
let (w, l) = if rng.coin() { (a, b) } else { (b, a) };
|
||||||
h.record_winner(&ids[w], &ids[l], 0).unwrap();
|
h.record_winner(&ids[w], &ids[l], 0).unwrap();
|
||||||
}
|
}
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
ids.iter()
|
ids.iter()
|
||||||
.filter(|id| {
|
.filter(|id| {
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
//! Calibration of the crate's marginals against the EXACT posterior.
|
||||||
|
//!
|
||||||
|
//! A scored history is linear-Gaussian — `MarginFactor` encodes
|
||||||
|
//! `score_a - score_b ~ N(perf_a - perf_b, score_sigma^2)` — so the true joint
|
||||||
|
//! posterior has a closed form and the crate can be checked against ground
|
||||||
|
//! truth rather than against intuition. That is not possible for ranked
|
||||||
|
//! outcomes, whose truncation likelihood EP genuinely approximates.
|
||||||
|
//!
|
||||||
|
//! Two things are pinned here, and one is deliberately only recorded.
|
||||||
|
//!
|
||||||
|
//! **Pinned: on a tree the crate is exact**, means and variances both. Message
|
||||||
|
//! passing has no approximation to make when the factor graph has no cycles, so
|
||||||
|
//! any drift here would be a real defect.
|
||||||
|
//!
|
||||||
|
//! **Pinned: means are exact even with cycles.** This is the standard result
|
||||||
|
//! for Gaussian belief propagation (Weiss & Freeman 2001) and it is what makes
|
||||||
|
//! ratings trustworthy.
|
||||||
|
//!
|
||||||
|
//! **Recorded, not asserted: with cycles, marginal variances are too narrow.**
|
||||||
|
//! Measured on the round-robin fixture below, the crate reports sigma 1.430
|
||||||
|
//! where the exact posterior is 2.851 — a ratio of 0.502. That is the known
|
||||||
|
//! behaviour of loopy Gaussian BP, not a bug in this crate, and it is left
|
||||||
|
//! unasserted because fixing it is exactly what #46 proposes.
|
||||||
|
//!
|
||||||
|
//! Why that matters for a consumer, and why #46 cannot be implemented as "add
|
||||||
|
//! a covariance accessor": the exact correlation between two nodes here is
|
||||||
|
//! +0.857, so a consumer computing `sqrt(sa^2 + sb^2)` for a difference
|
||||||
|
//! overstates its width. But the too-narrow marginals partially cancel that,
|
||||||
|
//! leaving 1.327x rather than 2.646x. Adding true correlations to these
|
||||||
|
//! marginals without also correcting them would give 0.765 against a true
|
||||||
|
//! 1.524 — *overconfident*, which is the unsafe direction.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
const N: usize = 5;
|
||||||
|
const MU0: f64 = 0.0;
|
||||||
|
const SIGMA0: f64 = 6.0;
|
||||||
|
const BETA: f64 = 1.0;
|
||||||
|
const SCORE_SIGMA: f64 = 2.0;
|
||||||
|
|
||||||
|
/// A STAR: every event touches c0, so the node-event graph is a tree and
|
||||||
|
/// Gaussian BP is exact. Any discrepancy here is not caused by loops.
|
||||||
|
fn tree_fixture() -> Vec<(usize, usize, f64)> {
|
||||||
|
vec![(0, 1, 3.0), (0, 2, 5.0), (0, 3, 4.0), (0, 4, 6.0)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (winner, loser, score_diff)
|
||||||
|
fn fixture() -> Vec<(usize, usize, f64)> {
|
||||||
|
vec![
|
||||||
|
(0, 1, 3.0),
|
||||||
|
(0, 2, 5.0),
|
||||||
|
(1, 2, 2.0),
|
||||||
|
(3, 4, 1.0),
|
||||||
|
(0, 3, 4.0),
|
||||||
|
(1, 4, 2.5),
|
||||||
|
(2, 3, 0.5),
|
||||||
|
(0, 4, 6.0),
|
||||||
|
(1, 3, 1.5),
|
||||||
|
(2, 4, 3.0),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invert a small symmetric positive-definite matrix by Gauss-Jordan.
|
||||||
|
fn inverse(mut a: Vec<Vec<f64>>) -> Vec<Vec<f64>> {
|
||||||
|
let n = a.len();
|
||||||
|
let mut inv: Vec<Vec<f64>> = (0..n)
|
||||||
|
.map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
|
||||||
|
.collect();
|
||||||
|
for col in 0..n {
|
||||||
|
// partial pivot
|
||||||
|
let mut piv = col;
|
||||||
|
for r in col + 1..n {
|
||||||
|
if a[r][col].abs() > a[piv][col].abs() {
|
||||||
|
piv = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.swap(col, piv);
|
||||||
|
inv.swap(col, piv);
|
||||||
|
let d = a[col][col];
|
||||||
|
for j in 0..n {
|
||||||
|
a[col][j] /= d;
|
||||||
|
inv[col][j] /= d;
|
||||||
|
}
|
||||||
|
for r in 0..n {
|
||||||
|
if r == col {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let f = a[r][col];
|
||||||
|
for j in 0..n {
|
||||||
|
a[r][j] -= f * a[col][j];
|
||||||
|
inv[r][j] -= f * inv[col][j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exact posterior of a linear-Gaussian model:
|
||||||
|
/// precision = prior precision + sum of a_k a_k^T / v_k.
|
||||||
|
fn exact_for(obs: &[(usize, usize, f64)]) -> (Vec<f64>, Vec<Vec<f64>>) {
|
||||||
|
let mut lambda = vec![vec![0.0; N]; N];
|
||||||
|
let mut eta = [0.0; N];
|
||||||
|
for (i, row) in lambda.iter_mut().enumerate() {
|
||||||
|
row[i] = 1.0 / (SIGMA0 * SIGMA0);
|
||||||
|
eta[i] = MU0 / (SIGMA0 * SIGMA0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each 1v1 observation: d ~ N(x_a - x_b, score_sigma^2 + 2 beta^2)
|
||||||
|
let v = SCORE_SIGMA * SCORE_SIGMA + 2.0 * BETA * BETA;
|
||||||
|
for &(a, b, d) in obs {
|
||||||
|
let mut vec_a = [0.0; N];
|
||||||
|
vec_a[a] = 1.0;
|
||||||
|
vec_a[b] = -1.0;
|
||||||
|
for i in 0..N {
|
||||||
|
for j in 0..N {
|
||||||
|
lambda[i][j] += vec_a[i] * vec_a[j] / v;
|
||||||
|
}
|
||||||
|
eta[i] += vec_a[i] * d / v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cov = inverse(lambda);
|
||||||
|
let mean: Vec<f64> = (0..N)
|
||||||
|
.map(|i| (0..N).map(|j| cov[i][j] * eta[j]).sum())
|
||||||
|
.collect();
|
||||||
|
(mean, cov)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(i: usize) -> &'static str {
|
||||||
|
["c0", "c1", "c2", "c3", "c4"][i]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns (worst mean error, worst sd ratio).
|
||||||
|
fn fitted(
|
||||||
|
obs: &[(usize, usize, f64)],
|
||||||
|
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
|
||||||
|
let mut h: History<i64, _, _, &'static str> = History::builder()
|
||||||
|
.mu(MU0)
|
||||||
|
.sigma(SIGMA0)
|
||||||
|
.beta(BETA)
|
||||||
|
.score_sigma(SCORE_SIGMA)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let events: Vec<Event<i64, &'static str>> = obs
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|(a, b, d)| Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(key(a))]),
|
||||||
|
Team::with_members([Member::new(key(b))]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([d, 0.0]),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert!(
|
||||||
|
report.converged,
|
||||||
|
"fixture must converge: {:?}",
|
||||||
|
report.final_step
|
||||||
|
);
|
||||||
|
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns (worst mean error, worst sd ratio gap).
|
||||||
|
fn run(name: &str, obs: Vec<(usize, usize, f64)>) -> (f64, f64) {
|
||||||
|
println!("\n########## {name} ##########");
|
||||||
|
let h = fitted(&obs);
|
||||||
|
let (mean, cov) = exact_for(&obs);
|
||||||
|
|
||||||
|
println!("\n== marginals: crate vs the exact linear-Gaussian posterior ==");
|
||||||
|
println!(
|
||||||
|
"{:>4} {:>12} {:>12} {:>12} {:>12} {:>8}",
|
||||||
|
"node", "crate mu", "exact mu", "crate sd", "exact sd", "sd ratio"
|
||||||
|
);
|
||||||
|
for i in 0..N {
|
||||||
|
let g = h.current_skill(&key(i)).unwrap();
|
||||||
|
let exact_sd = cov[i][i].sqrt();
|
||||||
|
println!(
|
||||||
|
"{:>4} {:>12.6} {:>12.6} {:>12.6} {:>12.6} {:>8.3}",
|
||||||
|
key(i),
|
||||||
|
g.mu(),
|
||||||
|
mean[i],
|
||||||
|
g.sigma(),
|
||||||
|
exact_sd,
|
||||||
|
g.sigma() / exact_sd
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut worst_mean = 0.0f64;
|
||||||
|
let mut worst_ratio_gap = 0.0f64;
|
||||||
|
for i in 0..N {
|
||||||
|
let g = h.current_skill(&key(i)).unwrap();
|
||||||
|
worst_mean = worst_mean.max((g.mu() - mean[i]).abs());
|
||||||
|
worst_ratio_gap = worst_ratio_gap.max((g.sigma() / cov[i][i].sqrt() - 1.0).abs());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n== what a consumer actually computes for a DIFFERENCE ==");
|
||||||
|
println!(
|
||||||
|
"{:>8} {:>12} {:>14} {:>14} {:>12}",
|
||||||
|
"pair", "exact", "naive(exact)", "naive(crate)", "crate err"
|
||||||
|
);
|
||||||
|
for i in 0..N {
|
||||||
|
for j in i + 1..N {
|
||||||
|
if i != 0 && j != 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let gi = h.current_skill(&key(i)).unwrap();
|
||||||
|
let gj = h.current_skill(&key(j)).unwrap();
|
||||||
|
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
|
||||||
|
let naive_exact = (cov[i][i] + cov[j][j]).sqrt();
|
||||||
|
let naive_crate = (gi.sigma().powi(2) + gj.sigma().powi(2)).sqrt();
|
||||||
|
let corr = cov[i][j] / (cov[i][i].sqrt() * cov[j][j].sqrt());
|
||||||
|
println!(
|
||||||
|
"{:>8} {:>12.6} {:>14.6} {:>14.6} {:>11.3}x (corr {corr:.4})",
|
||||||
|
format!("{}-{}", key(i), key(j)),
|
||||||
|
exact_sd,
|
||||||
|
naive_exact,
|
||||||
|
naive_crate,
|
||||||
|
naive_crate / exact_sd
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(worst_mean, worst_ratio_gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With no cycles there is nothing for message passing to approximate.
|
||||||
|
#[test]
|
||||||
|
fn on_a_tree_the_marginals_are_exact() {
|
||||||
|
let (mean_err, sd_gap) = run("TREE (star: no loops, BP is exact)", tree_fixture());
|
||||||
|
assert!(
|
||||||
|
mean_err < 1e-9,
|
||||||
|
"tree means should be exact, worst error {mean_err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sd_gap < 1e-9,
|
||||||
|
"tree sigmas should be exact, worst ratio gap {sd_gap}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With cycles the means stay exact — the property ratings depend on — while
|
||||||
|
/// the variances do not. The variance gap is measured and reported rather than
|
||||||
|
/// asserted; see the module docs.
|
||||||
|
#[test]
|
||||||
|
fn with_cycles_the_means_stay_exact_but_the_variances_shrink() {
|
||||||
|
let (mean_err, sd_gap) = run("LOOPY (round robin)", fixture());
|
||||||
|
assert!(
|
||||||
|
mean_err < 1e-9,
|
||||||
|
"loopy means must still be exact, worst error {mean_err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sd_gap > 0.1,
|
||||||
|
"the loopy variance gap is the premise of #46; if it has closed, that \
|
||||||
|
issue and these docs need revisiting (worst ratio gap {sd_gap})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The point of #46: `posterior_of` must reproduce the exact joint, including
|
||||||
|
/// the correlation that marginals cannot express.
|
||||||
|
#[test]
|
||||||
|
fn posterior_of_matches_the_exact_joint() {
|
||||||
|
for (name, obs) in [("tree", tree_fixture()), ("loopy", fixture())] {
|
||||||
|
let h = fitted(&obs);
|
||||||
|
let (_, cov) = exact_for(&obs);
|
||||||
|
|
||||||
|
println!("\n== posterior_of vs exact ({name}) ==");
|
||||||
|
println!(
|
||||||
|
"{:>12} {:>14} {:>14} {:>10}",
|
||||||
|
"functional", "posterior_of", "exact", "ratio"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (i, j) in [(0usize, 1usize), (0, 2), (1, 3), (2, 4)] {
|
||||||
|
let got = h
|
||||||
|
.posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)])
|
||||||
|
.expect("scored slice should have a joint");
|
||||||
|
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
|
||||||
|
println!(
|
||||||
|
"{:>12} {:>14.6} {:>14.6} {:>10.4}",
|
||||||
|
format!("{}-{}", key(i), key(j)),
|
||||||
|
got.sigma(),
|
||||||
|
exact_sd,
|
||||||
|
got.sigma() / exact_sd
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
|
||||||
|
"{name} {}-{}: posterior_of gave {} where the exact joint is {exact_sd}",
|
||||||
|
key(i),
|
||||||
|
key(j),
|
||||||
|
got.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single competitor: this is where the loopy marginal was 2x narrow.
|
||||||
|
for (i, row) in cov.iter().enumerate() {
|
||||||
|
let got = h.posterior_of(&[(&key(i), 1.0)]).unwrap();
|
||||||
|
let exact_sd = row[i].sqrt();
|
||||||
|
assert!(
|
||||||
|
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
|
||||||
|
"{name} {}: posterior_of gave {} where exact is {exact_sd}",
|
||||||
|
key(i),
|
||||||
|
got.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(" single-competitor marginals also exact");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cost of the dense solve as the slice grows. Recorded, not asserted.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "timing probe, run explicitly"]
|
||||||
|
fn cost_scaling() {
|
||||||
|
use std::time::Instant;
|
||||||
|
for n in [50usize, 100, 200, 400, 800] {
|
||||||
|
let names: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
|
||||||
|
let mut h: History<i64, _, _, String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 200,
|
||||||
|
epsilon: 1e-8,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
let mut seed = 5u64;
|
||||||
|
let mut rnd = move || {
|
||||||
|
seed ^= seed << 13;
|
||||||
|
seed ^= seed >> 7;
|
||||||
|
seed ^= seed << 17;
|
||||||
|
seed
|
||||||
|
};
|
||||||
|
let events: Vec<Event<i64, String>> = (0..n * 4)
|
||||||
|
.map(|_| {
|
||||||
|
let a = (rnd() as usize) % n;
|
||||||
|
let mut b = (rnd() as usize) % n;
|
||||||
|
if b == a {
|
||||||
|
b = (b + 1) % n;
|
||||||
|
}
|
||||||
|
Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(names[a].clone())]),
|
||||||
|
Team::with_members([Member::new(names[b].clone())]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([1.0, 0.0]),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let t = Instant::now();
|
||||||
|
let g = h
|
||||||
|
.posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)])
|
||||||
|
.unwrap();
|
||||||
|
println!(" n={n:>4}: {:>10.2?} sigma {:.6}", t.elapsed(), g.sigma());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-8
@@ -42,7 +42,7 @@ fn every_observer_callback_fires() {
|
|||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.record_winner(&"b", &"c", 2).unwrap();
|
h.record_winner(&"b", &"c", 2).unwrap();
|
||||||
h.record_winner(&"c", &"a", 3).unwrap();
|
h.record_winner(&"c", &"a", 3).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!recorder.iterations.lock().unwrap().is_empty(),
|
!recorder.iterations.lock().unwrap().is_empty(),
|
||||||
@@ -65,7 +65,7 @@ fn slice_callbacks_report_the_slice_they_swept() {
|
|||||||
|
|
||||||
h.record_winner(&"a", &"b", 10).unwrap();
|
h.record_winner(&"a", &"b", 10).unwrap();
|
||||||
h.record_winner(&"a", &"b", 20).unwrap();
|
h.record_winner(&"a", &"b", 20).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let slices = recorder.slices.lock().unwrap();
|
let slices = recorder.slices.lock().unwrap();
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ fn a_single_slice_history_still_reports_its_sweep() {
|
|||||||
let mut h = History::builder().observer(Arc::clone(&recorder)).build();
|
let mut h = History::builder().observer(Arc::clone(&recorder)).build();
|
||||||
|
|
||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let slices = recorder.slices.lock().unwrap();
|
let slices = recorder.slices.lock().unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -112,7 +112,7 @@ fn a_shared_observer_reaches_the_callers_handle() {
|
|||||||
let mut h = History::builder().observer(Arc::clone(&recorder)).build();
|
let mut h = History::builder().observer(Arc::clone(&recorder)).build();
|
||||||
|
|
||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
assert!(!recorder.iterations.lock().unwrap().is_empty());
|
assert!(!recorder.iterations.lock().unwrap().is_empty());
|
||||||
assert!(!recorder.slices.lock().unwrap().is_empty());
|
assert!(!recorder.slices.lock().unwrap().is_empty());
|
||||||
@@ -125,12 +125,12 @@ fn a_trait_object_observer_works() {
|
|||||||
let boxed: Box<dyn Observer<i64>> = Box::new(Recorder::default());
|
let boxed: Box<dyn Observer<i64>> = Box::new(Recorder::default());
|
||||||
let mut h = History::builder().observer(boxed).build();
|
let mut h = History::builder().observer(boxed).build();
|
||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let shared: Arc<dyn Observer<i64>> = Arc::new(Recorder::default());
|
let shared: Arc<dyn Observer<i64>> = Arc::new(Recorder::default());
|
||||||
let mut h = History::builder().observer(Arc::clone(&shared)).build();
|
let mut h = History::builder().observer(Arc::clone(&shared)).build();
|
||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A non-shared observer can be reclaimed after convergence instead.
|
/// A non-shared observer can be reclaimed after convergence instead.
|
||||||
@@ -138,7 +138,7 @@ fn a_trait_object_observer_works() {
|
|||||||
fn into_observer_returns_the_accumulated_state() {
|
fn into_observer_returns_the_accumulated_state() {
|
||||||
let mut h = History::builder().observer(Recorder::default()).build();
|
let mut h = History::builder().observer(Recorder::default()).build();
|
||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
// Readable in place...
|
// Readable in place...
|
||||||
assert!(!h.observer().iterations.lock().unwrap().is_empty());
|
assert!(!h.observer().iterations.lock().unwrap().is_empty());
|
||||||
@@ -155,7 +155,7 @@ fn a_borrowed_observer_works() {
|
|||||||
{
|
{
|
||||||
let mut h = History::builder().observer(&recorder).build();
|
let mut h = History::builder().observer(&recorder).build();
|
||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
}
|
}
|
||||||
assert!(!recorder.iterations.lock().unwrap().is_empty());
|
assert!(!recorder.iterations.lock().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
//! `predict_margin`: the predictive distribution of a scored matchup.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
|
||||||
|
UnknownKeys,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn builder(
|
||||||
|
policy: UnknownKeys,
|
||||||
|
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
|
||||||
|
History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.unknown_keys(policy)
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 5_000,
|
||||||
|
epsilon: 1e-12,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> {
|
||||||
|
Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a)]),
|
||||||
|
Team::with_members([Member::new(b)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([sa, sb]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A history where "veteran" and "regular" are well observed and "novice"
|
||||||
|
/// appears once.
|
||||||
|
fn fitted(
|
||||||
|
policy: UnknownKeys,
|
||||||
|
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
|
||||||
|
let mut h = builder(policy);
|
||||||
|
let mut events: Vec<_> = (0..40)
|
||||||
|
.map(|t| round("veteran", "regular", 10.0 + f64::from(t % 3), 5.0))
|
||||||
|
.collect();
|
||||||
|
events.push(round("veteran", "novice", 10.0, 6.0));
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The property #48 exists for: the interval must widen when the model knows
|
||||||
|
/// less. Their hand-fitted noise law quoted the same sigma for a competitor
|
||||||
|
/// with forty rounds and one with none.
|
||||||
|
#[test]
|
||||||
|
fn the_interval_widens_as_the_model_knows_less() {
|
||||||
|
let h = fitted(UnknownKeys::Prior);
|
||||||
|
|
||||||
|
let well_known = h
|
||||||
|
.predict_margin(&[&[&"veteran"], &[&"regular"]])
|
||||||
|
.unwrap()
|
||||||
|
.sigma();
|
||||||
|
let thin = h
|
||||||
|
.predict_margin(&[&[&"veteran"], &[&"novice"]])
|
||||||
|
.unwrap()
|
||||||
|
.sigma();
|
||||||
|
let unseen = h
|
||||||
|
.predict_margin(&[&[&"veteran"], &[&"stranger"]])
|
||||||
|
.unwrap()
|
||||||
|
.sigma();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
well_known < thin && thin < unseen,
|
||||||
|
"margin width should grow as evidence thins: {well_known} < {thin} < {unseen}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #48's second requirement: an unseen competitor is a legitimate question, not
|
||||||
|
/// an error, and the answer should come from the prior rather than be faked.
|
||||||
|
#[test]
|
||||||
|
fn an_unseen_competitor_is_answered_from_the_prior() {
|
||||||
|
let h = fitted(UnknownKeys::Prior);
|
||||||
|
let g = h.predict_margin(&[&[&"nobody"], &[&"no_one"]]).unwrap();
|
||||||
|
|
||||||
|
// Two unknowns: the gap is centred on zero and carries both priors plus
|
||||||
|
// both performance noises plus the observation noise.
|
||||||
|
assert!(g.mu().abs() < 1e-9, "mu {}", g.mu());
|
||||||
|
let expected = (2.0 * 36.0 + 2.0 * 1.0 + 4.0f64).sqrt();
|
||||||
|
assert!(
|
||||||
|
(g.sigma() - expected).abs() < 1e-9,
|
||||||
|
"sigma {} vs expected {expected}",
|
||||||
|
g.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_still_rejects() {
|
||||||
|
let h = fitted(UnknownKeys::Reject);
|
||||||
|
assert!(matches!(
|
||||||
|
h.predict_margin(&[&[&"veteran"], &[&"stranger"]]),
|
||||||
|
Err(InferenceError::UnknownKey { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The margin is the *difference*, so it must be antisymmetric in the teams.
|
||||||
|
#[test]
|
||||||
|
fn swapping_the_teams_negates_the_margin() {
|
||||||
|
let h = fitted(UnknownKeys::Prior);
|
||||||
|
let forward = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
|
||||||
|
let reverse = h.predict_margin(&[&[&"regular"], &[&"veteran"]]).unwrap();
|
||||||
|
|
||||||
|
assert!((forward.mu() + reverse.mu()).abs() < 1e-9);
|
||||||
|
assert!((forward.sigma() - reverse.sigma()).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The predictive interval must be wider than the skill gap alone: it also
|
||||||
|
/// carries per-event performance noise and the observation noise.
|
||||||
|
#[test]
|
||||||
|
fn the_predictive_interval_exceeds_the_skill_uncertainty() {
|
||||||
|
let h = fitted(UnknownKeys::Prior);
|
||||||
|
let skill_gap = h
|
||||||
|
.posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)])
|
||||||
|
.unwrap();
|
||||||
|
let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(predictive.mu() - skill_gap.mu()).abs() < 1e-12,
|
||||||
|
"means agree"
|
||||||
|
);
|
||||||
|
// beta^2 twice plus score_sigma^2 = 2 + 4.
|
||||||
|
let expected = (skill_gap.sigma().powi(2) + 6.0).sqrt();
|
||||||
|
assert!((predictive.sigma() - expected).abs() < 1e-12);
|
||||||
|
assert!(predictive.sigma() > skill_gap.sigma());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shape_errors_are_reported() {
|
||||||
|
let h = fitted(UnknownKeys::Prior);
|
||||||
|
assert!(matches!(
|
||||||
|
h.predict_margin(&[&[&"veteran"]]),
|
||||||
|
Err(InferenceError::MismatchedShape {
|
||||||
|
expected: 2,
|
||||||
|
got: 1,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
let empty: [&&str; 0] = [];
|
||||||
|
assert!(matches!(
|
||||||
|
h.predict_margin(&[&[&"veteran"], &empty]),
|
||||||
|
Err(InferenceError::EmptyTeam { team: 1, .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
+145
-25
@@ -9,7 +9,7 @@ fn history_with(names: &[&'static str], p_draw: f64) -> History {
|
|||||||
for pair in names.windows(2) {
|
for pair in names.windows(2) {
|
||||||
h.record_winner(&pair[0], &pair[1], 1).unwrap();
|
h.record_winner(&pair[0], &pair[1], 1).unwrap();
|
||||||
}
|
}
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
h
|
h
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +20,14 @@ fn unknown_keys_are_reported_not_silently_dropped() {
|
|||||||
let err = h
|
let err = h
|
||||||
.predict_outcome(&[&[&"a"], &[&"ghost"]])
|
.predict_outcome(&[&[&"a"], &[&"ghost"]])
|
||||||
.expect_err("an unknown key must not yield a confident prediction");
|
.expect_err("an unknown key must not yield a confident prediction");
|
||||||
assert_eq!(err, InferenceError::UnknownKey { team: 1, member: 0 });
|
assert!(
|
||||||
|
matches!(
|
||||||
|
&err,
|
||||||
|
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
|
||||||
|
if key == "\"ghost\""
|
||||||
|
),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
|
||||||
// Every prediction entry point, not just one.
|
// Every prediction entry point, not just one.
|
||||||
assert!(
|
assert!(
|
||||||
@@ -35,25 +42,32 @@ fn unknown_keys_are_reported_not_silently_dropped() {
|
|||||||
fn an_entirely_unknown_team_is_an_error() {
|
fn an_entirely_unknown_team_is_an_error() {
|
||||||
let h = history_with(&["a", "b"], 0.0);
|
let h = history_with(&["a", "b"], 0.0);
|
||||||
let err = h.predict_outcome(&[&[&"a"], &[&"x", &"y"]]).unwrap_err();
|
let err = h.predict_outcome(&[&[&"a"], &[&"x", &"y"]]).unwrap_err();
|
||||||
assert_eq!(err, InferenceError::UnknownKey { team: 1, member: 0 });
|
assert!(
|
||||||
|
matches!(
|
||||||
|
&err,
|
||||||
|
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
|
||||||
|
if key == "\"x\""
|
||||||
|
),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn degenerate_team_shapes_are_errors_rather_than_panics() {
|
fn degenerate_team_shapes_are_errors_rather_than_panics() {
|
||||||
let h = history_with(&["a", "b"], 0.0);
|
let h = history_with(&["a", "b"], 0.0);
|
||||||
|
|
||||||
assert_eq!(
|
assert!(matches!(
|
||||||
h.predict_outcome(&[&[&"a"]]).unwrap_err(),
|
h.predict_outcome(&[&[&"a"]]).unwrap_err(),
|
||||||
InferenceError::NotEnoughTeams { got: 1 }
|
InferenceError::NotEnoughTeams { got: 1, .. }
|
||||||
);
|
),);
|
||||||
assert_eq!(
|
assert!(matches!(
|
||||||
h.predict_outcome(&[]).unwrap_err(),
|
h.predict_outcome(&[]).unwrap_err(),
|
||||||
InferenceError::NotEnoughTeams { got: 0 }
|
InferenceError::NotEnoughTeams { got: 0, .. }
|
||||||
);
|
),);
|
||||||
assert_eq!(
|
assert!(matches!(
|
||||||
h.predict_outcome(&[&[&"a"], &[]]).unwrap_err(),
|
h.predict_outcome(&[&[&"a"], &[]]).unwrap_err(),
|
||||||
InferenceError::EmptyTeam { team: 1 }
|
InferenceError::EmptyTeam { team: 1, .. }
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -79,13 +93,10 @@ fn the_outcome_space_is_capped_rather_than_hanging() {
|
|||||||
let refs: Vec<&[&&str]> = too_many.iter().map(Vec::as_slice).collect();
|
let refs: Vec<&[&&str]> = too_many.iter().map(Vec::as_slice).collect();
|
||||||
|
|
||||||
let err = h.predict_outcome(&refs).unwrap_err();
|
let err = h.predict_outcome(&refs).unwrap_err();
|
||||||
assert_eq!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::TooManyTeams {
|
InferenceError::TooManyTeams { got: 8, max, .. } if max == MAX_PREDICTED_TEAMS
|
||||||
got: 8,
|
));
|
||||||
max: MAX_PREDICTED_TEAMS
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// The cheap paths stay available at any size.
|
// The cheap paths stay available at any size.
|
||||||
let wins = h.predict_win_probabilities(&refs).unwrap();
|
let wins = h.predict_win_probabilities(&refs).unwrap();
|
||||||
@@ -184,7 +195,7 @@ fn the_stronger_competitor_is_favoured() {
|
|||||||
for t in 1..=10 {
|
for t in 1..=10 {
|
||||||
h.record_winner(&"strong", &"weak", t).unwrap();
|
h.record_winner(&"strong", &"weak", t).unwrap();
|
||||||
}
|
}
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let p = h.predict_outcome(&[&[&"strong"], &[&"weak"]]).unwrap();
|
let p = h.predict_outcome(&[&[&"strong"], &[&"weak"]]).unwrap();
|
||||||
let (best, _) = p.most_likely().expect("a most likely outcome");
|
let (best, _) = p.most_likely().expect("a most likely outcome");
|
||||||
@@ -206,7 +217,7 @@ fn team_size_affects_the_prediction() {
|
|||||||
.winner(0)
|
.winner(0)
|
||||||
.commit()
|
.commit()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let p = h.predict_outcome(&[&[&"a", &"b"], &[&"c"]]).unwrap();
|
let p = h.predict_outcome(&[&[&"a", &"b"], &[&"c"]]).unwrap();
|
||||||
assert!((p.total() - 1.0).abs() < 1e-6, "total = {}", p.total());
|
assert!((p.total() - 1.0).abs() < 1e-6, "total = {}", p.total());
|
||||||
@@ -229,7 +240,7 @@ fn information_gain_prefers_the_uncertain_pairing() {
|
|||||||
h.record_winner(&"rival", &"known", t + 100).unwrap();
|
h.record_winner(&"rival", &"known", t + 100).unwrap();
|
||||||
}
|
}
|
||||||
h.record_winner(&"known", &"newcomer", 500).unwrap();
|
h.record_winner(&"known", &"newcomer", 500).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let settled = h
|
let settled = h
|
||||||
.expected_information_gain(&[&[&"known"], &[&"rival"]])
|
.expected_information_gain(&[&[&"known"], &[&"rival"]])
|
||||||
@@ -268,11 +279,12 @@ fn information_gain_respects_the_entropy_ceiling() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn information_gain_reports_unknown_keys() {
|
fn information_gain_reports_unknown_keys() {
|
||||||
let h = history_with(&["a", "b"], 0.0);
|
let h = history_with(&["a", "b"], 0.0);
|
||||||
assert_eq!(
|
assert!(matches!(
|
||||||
h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
|
&h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
|
||||||
.unwrap_err(),
|
.unwrap_err(),
|
||||||
InferenceError::UnknownKey { team: 1, member: 0 }
|
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
|
||||||
);
|
if key == "\"ghost\""
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A draw-enabled history has three outcomes to weigh rather than two, so the
|
/// A draw-enabled history has three outcomes to weigh rather than two, so the
|
||||||
@@ -289,3 +301,111 @@ fn information_gain_accounts_for_draws() {
|
|||||||
let dist = with_draws.predict_outcome(&[&[&"a"], &[&"b"]]).unwrap();
|
let dist = with_draws.predict_outcome(&[&[&"a"], &[&"b"]]).unwrap();
|
||||||
assert!(dist.probability_of(&[0, 0]) > 0.0);
|
assert!(dist.probability_of(&[0, 0]) > 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The defect that cost a consumer a day: `UnknownKey { team: 0, member: 0 }`
|
||||||
|
/// says nothing about *which* key is unknown, so the natural handling — log it,
|
||||||
|
/// fall back to a neutral value — converts a total miss into a plausible
|
||||||
|
/// constant. The key has to be in the error, and in its `Display`.
|
||||||
|
#[test]
|
||||||
|
fn unknown_key_names_the_key_it_could_not_find() {
|
||||||
|
let h = history_with(&["a", "b"], 0.0);
|
||||||
|
let err = h.predict_outcome(&[&[&"a"], &[&"never_seen"]]).unwrap_err();
|
||||||
|
|
||||||
|
match &err {
|
||||||
|
InferenceError::UnknownKey { key, .. } => {
|
||||||
|
assert!(
|
||||||
|
key.contains("never_seen"),
|
||||||
|
"the error should name the key, got {key}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected UnknownKey, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered = err.to_string();
|
||||||
|
assert!(
|
||||||
|
rendered.contains("never_seen"),
|
||||||
|
"Display should name the key: {rendered}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered.contains("pre-filter"),
|
||||||
|
"Display should say what to do about it: {rendered}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UnknownKeys policy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn history_with_policy(names: &[&'static str], policy: trueskill_tt::UnknownKeys) -> History {
|
||||||
|
let mut h = History::builder().unknown_keys(policy).build();
|
||||||
|
for pair in names.windows(2) {
|
||||||
|
h.record_winner(&pair[0], &pair[1], 1).unwrap();
|
||||||
|
}
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_is_the_default() {
|
||||||
|
let h = history_with(&["a", "b"], 0.0);
|
||||||
|
assert!(matches!(
|
||||||
|
h.predict_outcome(&[&[&"a"], &[&"ghost"]]),
|
||||||
|
Err(InferenceError::UnknownKey { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prior_answers_instead_of_erroring() {
|
||||||
|
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
|
||||||
|
let p = h
|
||||||
|
.predict_outcome(&[&[&"a"], &[&"ghost"]])
|
||||||
|
.expect("Prior should answer rather than reject");
|
||||||
|
assert!((p.total() - 1.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two competitors the model has never seen are genuinely a coin flip. The
|
||||||
|
/// point is that this is now *derived* rather than a constant a caller
|
||||||
|
/// substitutes after swallowing an error.
|
||||||
|
#[test]
|
||||||
|
fn two_unknown_competitors_are_an_honest_coin_flip() {
|
||||||
|
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
|
||||||
|
let wins = h
|
||||||
|
.predict_win_probabilities(&[&[&"nobody"], &[&"no_one"]])
|
||||||
|
.unwrap();
|
||||||
|
assert!((wins[0] - 0.5).abs() < 1e-9, "{wins:?}");
|
||||||
|
assert!((wins[1] - 0.5).abs() < 1e-9, "{wins:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The property that rules out a `Skip` mode: an unknown member must make a
|
||||||
|
/// team *less* certain, never more. Skipping would drop the member's variance
|
||||||
|
/// from the sum and narrow the team, which is backwards.
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_member_widens_its_team_rather_than_narrowing_it() {
|
||||||
|
let h = history_with_policy(&["a", "b", "c"], trueskill_tt::UnknownKeys::Prior);
|
||||||
|
|
||||||
|
// "a" alone against "b" — then "a" plus an unknown partner against "b".
|
||||||
|
let solo = h.predict_win_probabilities(&[&[&"a"], &[&"b"]]).unwrap();
|
||||||
|
let with_unknown = h
|
||||||
|
.predict_win_probabilities(&[&[&"a", &"stranger"], &[&"b"]])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Adding an unknown partner pulls the outcome toward even, because the
|
||||||
|
// team's performance spread grew.
|
||||||
|
assert!(
|
||||||
|
(with_unknown[0] - 0.5).abs() < (solo[0] - 0.5).abs(),
|
||||||
|
"an unknown partner should make the result less certain: solo {solo:?}, \
|
||||||
|
with unknown {with_unknown:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prior_reaches_every_prediction_entry_point() {
|
||||||
|
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
|
||||||
|
let teams: &[&[&&str]] = &[&[&"a"], &[&"ghost"]];
|
||||||
|
|
||||||
|
assert!(h.predict_quality(teams).is_ok());
|
||||||
|
assert!(h.predict_win_probabilities(teams).is_ok());
|
||||||
|
assert!(h.predict_outcome(teams).is_ok());
|
||||||
|
assert!(h.predict_ranking(teams, &[0, 1]).is_ok());
|
||||||
|
assert!(h.expected_information_gain(teams).is_ok());
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Seeds for failure cases proptest has generated in the past. It is
|
||||||
|
# automatically read and these particular cases re-run before any
|
||||||
|
# novel cases are generated.
|
||||||
|
#
|
||||||
|
# It is recommended to check this file in to source control so that
|
||||||
|
# everyone who runs the test benefits from these saved cases.
|
||||||
|
cc 8859be600e638573980f78622b8fcd8b4553ca34a9a041746c417f7e4293f89c # shrinks to games = [(0, 1), (0, 1), (4, 6), (2, 0), (0, 1), (0, 6), (0, 1), (2, 0), (6, 4), (0, 1), (0, 2), (6, 4), (1, 0), (4, 0), (0, 2)]
|
||||||
+29
-8
@@ -26,7 +26,10 @@ const KEYS: [&str; 8] = ["a", "b", "c", "d", "e", "f", "g", "h"];
|
|||||||
fn history_from(games: &[(usize, usize)]) -> History {
|
fn history_from(games: &[(usize, usize)]) -> History {
|
||||||
let mut h = History::builder()
|
let mut h = History::builder()
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 200,
|
// 200 was not enough: the batched side stopped at the cap with a
|
||||||
|
// step of 3.4e-9, so this test was comparing two truncated fits and
|
||||||
|
// attributing the gap to ingestion order.
|
||||||
|
max_iter: 20_000,
|
||||||
epsilon: 1e-10,
|
epsilon: 1e-10,
|
||||||
..ConvergenceOptions::default()
|
..ConvergenceOptions::default()
|
||||||
})
|
})
|
||||||
@@ -61,10 +64,15 @@ proptest! {
|
|||||||
fn converged_posteriors_are_always_finite(games in pairs()) {
|
fn converged_posteriors_are_always_finite(games in pairs()) {
|
||||||
let mut h = history_from(&games);
|
let mut h = history_from(&games);
|
||||||
|
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
for key in KEYS {
|
for key in KEYS {
|
||||||
for (time, g) in h.learning_curve(key) {
|
// A generated schedule need not touch every key, and an unplayed
|
||||||
|
// key is `None` rather than an empty curve.
|
||||||
|
let Some(curve) = h.learning_curve(key) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for (time, g) in curve {
|
||||||
assert_finite(g, &format!("{key} at t={time}"));
|
assert_finite(g, &format!("{key} at t={time}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +87,7 @@ proptest! {
|
|||||||
fn log_evidence_is_a_finite_log_probability(games in pairs()) {
|
fn log_evidence_is_a_finite_log_probability(games in pairs()) {
|
||||||
let mut h = history_from(&games);
|
let mut h = history_from(&games);
|
||||||
|
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let batch = h.log_evidence();
|
let batch = h.log_evidence();
|
||||||
let filtered = h.filtered_log_evidence();
|
let filtered = h.filtered_log_evidence();
|
||||||
@@ -98,7 +106,7 @@ proptest! {
|
|||||||
|
|
||||||
let before = h.filtered_log_evidence();
|
let before = h.filtered_log_evidence();
|
||||||
|
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let after = h.filtered_log_evidence();
|
let after = h.filtered_log_evidence();
|
||||||
|
|
||||||
@@ -114,14 +122,21 @@ proptest! {
|
|||||||
fn ingestion_order_does_not_change_the_answer(games in pairs()) {
|
fn ingestion_order_does_not_change_the_answer(games in pairs()) {
|
||||||
let batched = {
|
let batched = {
|
||||||
let mut h = history_from(&games);
|
let mut h = history_from(&games);
|
||||||
h.converge().unwrap();
|
let report = h.converge().unwrap();
|
||||||
|
prop_assert!(
|
||||||
|
report.converged,
|
||||||
|
"batched side stopped at {} iterations with step {:?}; comparing \
|
||||||
|
two fits that have not converged measures truncation, not order",
|
||||||
|
report.iterations,
|
||||||
|
report.final_step
|
||||||
|
);
|
||||||
h
|
h
|
||||||
};
|
};
|
||||||
|
|
||||||
let incremental = {
|
let incremental = {
|
||||||
let mut h = History::builder()
|
let mut h = History::builder()
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 200,
|
max_iter: 20_000,
|
||||||
epsilon: 1e-10,
|
epsilon: 1e-10,
|
||||||
..ConvergenceOptions::default()
|
..ConvergenceOptions::default()
|
||||||
})
|
})
|
||||||
@@ -139,7 +154,13 @@ proptest! {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
h.converge().unwrap();
|
let report = h.converge().unwrap();
|
||||||
|
prop_assert!(
|
||||||
|
report.converged,
|
||||||
|
"incremental side stopped at {} iterations with step {:?}",
|
||||||
|
report.iterations,
|
||||||
|
report.final_step
|
||||||
|
);
|
||||||
h
|
h
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+100
-4
@@ -1,4 +1,4 @@
|
|||||||
//! `quality()` beyond two rating groups.
|
//! `quality()` beyond two teams.
|
||||||
//!
|
//!
|
||||||
//! The historical golden (two equal singletons) is asserted in
|
//! The historical golden (two equal singletons) is asserted in
|
||||||
//! `src/lib.rs::tests::test_quality`. These cover the N-group generalisation,
|
//! `src/lib.rs::tests::test_quality`. These cover the N-group generalisation,
|
||||||
@@ -82,14 +82,14 @@ fn uneven_group_sizes_work() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[should_panic(expected = "at least 2 rating groups")]
|
#[should_panic(expected = "at least 2 teams")]
|
||||||
fn single_group_panics_with_clear_message() {
|
fn single_group_panics_with_clear_message() {
|
||||||
let r = rating(25.0, 3.0);
|
let r = rating(25.0, 3.0);
|
||||||
let _ = quality(&[&[r]], BETA);
|
let _ = quality(&[&[r]], BETA);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[should_panic(expected = "at least 2 rating groups")]
|
#[should_panic(expected = "at least 2 teams")]
|
||||||
fn zero_groups_panics_with_clear_message() {
|
fn zero_groups_panics_with_clear_message() {
|
||||||
let _ = quality(&[], BETA);
|
let _ = quality(&[], BETA);
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ fn history_predict_quality_supports_three_teams() {
|
|||||||
let mut h = History::default();
|
let mut h = History::default();
|
||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.record_winner(&"b", &"c", 2).unwrap();
|
h.record_winner(&"b", &"c", 2).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let q = h.predict_quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap();
|
let q = h.predict_quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -117,3 +117,99 @@ fn history_predict_quality_supports_three_teams() {
|
|||||||
);
|
);
|
||||||
assert!((0.0..=1.0).contains(&q), "out of range: {q}");
|
assert!((0.0..=1.0).contains(&q), "out of range: {q}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `quality()` for N identical teams has a closed form, which pins the N-group
|
||||||
|
/// determinant path across the whole range rather than at a single golden.
|
||||||
|
///
|
||||||
|
/// For two identical single-player teams the standard result is
|
||||||
|
/// `sqrt(2b^2 / (2b^2 + s1^2 + s2^2))`. With the conventional parameters
|
||||||
|
/// (`sigma = 25/3`, `beta = 25/6`) that ratio is exactly `1/5`, and the N-group
|
||||||
|
/// generalisation is `(1/5)^((n-1)/2)` — one factor per adjacent pair.
|
||||||
|
///
|
||||||
|
/// The n=3 and n=5 values this produces (0.200 and 0.040) are also what the
|
||||||
|
/// `trueskill` Python package returns for the same configuration, so this
|
||||||
|
/// doubles as the cross-implementation check the README asked for.
|
||||||
|
#[test]
|
||||||
|
fn quality_of_identical_teams_follows_its_closed_form() {
|
||||||
|
let g = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||||
|
let beta = 25.0 / 6.0;
|
||||||
|
|
||||||
|
for n in 2..=10usize {
|
||||||
|
let groups: Vec<Vec<Gaussian>> = (0..n).map(|_| vec![g]).collect();
|
||||||
|
let refs: Vec<&[Gaussian]> = groups.iter().map(Vec::as_slice).collect();
|
||||||
|
|
||||||
|
let got = quality(&refs, beta);
|
||||||
|
let expected = 0.2f64.powf((n - 1) as f64 / 2.0);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(got - expected).abs() / expected < 1e-9,
|
||||||
|
"n={n}: quality {got}, closed form {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spot-check against the two values the `trueskill` Python package is known
|
||||||
|
/// to produce for this configuration, stated as literals so a future change to
|
||||||
|
/// the closed-form reasoning above cannot quietly take these with it.
|
||||||
|
#[test]
|
||||||
|
fn quality_matches_the_reference_implementation() {
|
||||||
|
let g = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||||
|
let beta = 25.0 / 6.0;
|
||||||
|
|
||||||
|
let three: Vec<Vec<Gaussian>> = (0..3).map(|_| vec![g]).collect();
|
||||||
|
let refs: Vec<&[Gaussian]> = three.iter().map(Vec::as_slice).collect();
|
||||||
|
assert!((quality(&refs, beta) - 0.200).abs() < 1e-9);
|
||||||
|
|
||||||
|
let five: Vec<Vec<Gaussian>> = (0..5).map(|_| vec![g]).collect();
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
//! Converging, appending, and converging again must reach the same fixed point
|
||||||
|
//! as converging once over the whole event set.
|
||||||
|
//!
|
||||||
|
//! `tests/ingestion_equivalence.rs` covers a different question: it varies how
|
||||||
|
//! events are *batched* but converges only at the end. This file converges
|
||||||
|
//! between batches, which is the path a caller takes when it fits, serves for a
|
||||||
|
//! while, then ingests more.
|
||||||
|
//!
|
||||||
|
//! The property matters beyond ergonomics. It says `converge` reaches a fixed
|
||||||
|
//! point determined by the events, ratings and configuration alone — not by the
|
||||||
|
//! message state it started from. That is what makes a restored snapshot safe:
|
||||||
|
//! an inexact one cannot corrupt the answer, only cost an extra sweep. See #45.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
fn tight() -> ConvergenceOptions {
|
||||||
|
ConvergenceOptions {
|
||||||
|
max_iter: 5_000,
|
||||||
|
epsilon: 1e-12,
|
||||||
|
alpha: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ev(a: &str, b: &str, time: i64) -> Event<i64, String> {
|
||||||
|
Event {
|
||||||
|
time,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a.to_string())]),
|
||||||
|
Team::with_members([Member::new(b.to_string())]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ingest each chunk in turn, converging fully after every one.
|
||||||
|
fn fit_in_chunks(chunks: Vec<Events>) -> Vec<(String, Gaussian)> {
|
||||||
|
let mut h: History<i64, _, _, String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.convergence(tight())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for chunk in chunks {
|
||||||
|
h.add_events(chunk).unwrap();
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert!(
|
||||||
|
report.converged,
|
||||||
|
"a chunk failed to converge, so any comparison would be measuring \
|
||||||
|
truncation rather than the fixed point; final step {:?}",
|
||||||
|
report.final_step
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut skills: Vec<(String, Gaussian)> = h
|
||||||
|
.learning_curves()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, curve)| (k, curve.last().unwrap().1))
|
||||||
|
.collect();
|
||||||
|
skills.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
skills
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_same(a: &[(String, Gaussian)], b: &[(String, Gaussian)], what: &str) {
|
||||||
|
assert_eq!(a.len(), b.len(), "{what}: competitor count differs");
|
||||||
|
for ((ka, ga), (kb, gb)) in a.iter().zip(b) {
|
||||||
|
assert_eq!(ka, kb, "{what}: key order differs");
|
||||||
|
// Measured: 6.2e-13 for a later append, 8.9e-11 for an interleaved one.
|
||||||
|
// The bar is well clear of both but far under anything that would let a
|
||||||
|
// genuine divergence through.
|
||||||
|
assert!(
|
||||||
|
(ga.mu() - gb.mu()).abs() < 1e-8 && (ga.sigma() - gb.sigma()).abs() < 1e-8,
|
||||||
|
"{what}: {ka} differs — one-shot mu={} sigma={}, chunked mu={} sigma={}",
|
||||||
|
ga.mu(),
|
||||||
|
ga.sigma(),
|
||||||
|
gb.mu(),
|
||||||
|
gb.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Events = Vec<Event<i64, String>>;
|
||||||
|
|
||||||
|
/// Two chunks of events: the first at times 0..20, the second at 100..120.
|
||||||
|
fn fixture() -> (Events, Events) {
|
||||||
|
let names = ["a", "b", "c", "d", "e"];
|
||||||
|
let mut seed = 7u64;
|
||||||
|
let mut rnd = move || {
|
||||||
|
seed ^= seed << 13;
|
||||||
|
seed ^= seed >> 7;
|
||||||
|
seed ^= seed << 17;
|
||||||
|
seed
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut early, mut late) = (Vec::new(), Vec::new());
|
||||||
|
for t in 0..40i64 {
|
||||||
|
let i = (rnd() % 5) as usize;
|
||||||
|
let mut j = (rnd() % 5) as usize;
|
||||||
|
if j == i {
|
||||||
|
j = (j + 1) % 5;
|
||||||
|
}
|
||||||
|
if t < 20 {
|
||||||
|
early.push(ev(names[i], names[j], t));
|
||||||
|
} else {
|
||||||
|
late.push(ev(names[i], names[j], 100 + t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(early, late)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ordinary case: new events are strictly later than everything fitted.
|
||||||
|
#[test]
|
||||||
|
fn appending_later_events_matches_a_single_fit() {
|
||||||
|
let (early, late) = fixture();
|
||||||
|
let all: Vec<_> = early.iter().cloned().chain(late.iter().cloned()).collect();
|
||||||
|
|
||||||
|
assert_same(
|
||||||
|
&fit_in_chunks(vec![all]),
|
||||||
|
&fit_in_chunks(vec![early, late]),
|
||||||
|
"append strictly later",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The case the design question suspected might be weaker: appended events
|
||||||
|
/// interleave with slices that are already fitted, so the append legitimately
|
||||||
|
/// revises the past. It is not weaker — Through Time revises the past on every
|
||||||
|
/// converge regardless, so there is nothing special about doing it in two steps.
|
||||||
|
#[test]
|
||||||
|
fn appending_interleaved_events_matches_a_single_fit() {
|
||||||
|
let (early, late) = fixture();
|
||||||
|
let all: Vec<_> = early.iter().cloned().chain(late.iter().cloned()).collect();
|
||||||
|
|
||||||
|
// Split by parity so the second chunk is back-dated into the first's range.
|
||||||
|
let first: Vec<_> = all.iter().step_by(2).cloned().collect();
|
||||||
|
let second: Vec<_> = all.iter().skip(1).step_by(2).cloned().collect();
|
||||||
|
let together: Vec<_> = first
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.chain(second.iter().cloned())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_same(
|
||||||
|
&fit_in_chunks(vec![together]),
|
||||||
|
&fit_in_chunks(vec![first, second]),
|
||||||
|
"append interleaved",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converging an already-converged history is a no-op, which is what makes a
|
||||||
|
/// restored snapshot worth having: the work is skipped rather than redone.
|
||||||
|
#[test]
|
||||||
|
fn re_converging_an_unchanged_history_costs_one_iteration() {
|
||||||
|
let (early, late) = fixture();
|
||||||
|
let all: Vec<_> = early.into_iter().chain(late).collect();
|
||||||
|
|
||||||
|
let mut h: History<i64, _, _, String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.convergence(tight())
|
||||||
|
.build();
|
||||||
|
h.add_events(all).unwrap();
|
||||||
|
let first = h.converge().unwrap();
|
||||||
|
assert!(first.converged);
|
||||||
|
|
||||||
|
let again = h.converge().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
again.iterations, 1,
|
||||||
|
"a converged history should settle immediately, not re-grind"
|
||||||
|
);
|
||||||
|
assert!(again.converged);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ fn record_winner_builds_history() {
|
|||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 30,
|
max_iter: 30,
|
||||||
epsilon: 1e-6,
|
epsilon: 1e-6,
|
||||||
@@ -15,7 +15,7 @@ fn record_winner_builds_history() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
h.record_winner(&"alice", &"bob", 1).unwrap();
|
h.record_winner(&"alice", &"bob", 1).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let a_idx = h.lookup(&"alice").unwrap();
|
let a_idx = h.lookup(&"alice").unwrap();
|
||||||
let b_idx = h.lookup(&"bob").unwrap();
|
let b_idx = h.lookup(&"bob").unwrap();
|
||||||
@@ -43,12 +43,12 @@ fn record_draw_with_p_draw_set() {
|
|||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.p_draw(0.25)
|
.p_draw(0.25)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
h.record_draw(&"alice", &"bob", 1).unwrap();
|
h.record_draw(&"alice", &"bob", 1).unwrap();
|
||||||
h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
assert!(h.lookup(&"alice").is_some());
|
assert!(h.lookup(&"alice").is_some());
|
||||||
assert!(h.lookup(&"bob").is_some());
|
assert!(h.lookup(&"bob").is_some());
|
||||||
|
|||||||
@@ -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").unwrap();
|
||||||
|
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)
|
.mu(0.0)
|
||||||
.sigma(2.0)
|
.sigma(2.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.score_sigma(1.0)
|
.score_sigma(1.0)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ fn scored_zero_margin_treats_as_tie() {
|
|||||||
.mu(0.0)
|
.mu(0.0)
|
||||||
.sigma(2.0)
|
.sigma(2.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.score_sigma(1.0)
|
.score_sigma(1.0)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ fn scored_three_team_partial_order() {
|
|||||||
.mu(0.0)
|
.mu(0.0)
|
||||||
.sigma(2.0)
|
.sigma(2.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.score_sigma(1.0)
|
.score_sigma(1.0)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! The `Time` generic, exercised end to end.
|
||||||
|
//!
|
||||||
|
//! `History<T: Time, ..>` has always been generic over the time axis, `Untimed`
|
||||||
|
//! has always been exported, and `Drift<T>` is generic specifically so that
|
||||||
|
//! "seasonal or calendar-aware drift is expressible without going through
|
||||||
|
//! `i64`". None of it was reachable: every construction route pinned `T = i64`,
|
||||||
|
//! `HistoryBuilder`'s fields are private, and its `Default` existed only for the
|
||||||
|
//! `i64` instantiation.
|
||||||
|
//!
|
||||||
|
//! Nothing in the repository constructed a non-`i64` history, which is why that
|
||||||
|
//! went unnoticed. This file is the guard against it recurring — it is as much
|
||||||
|
//! about the generic being *exercised* as about any single assertion.
|
||||||
|
|
||||||
|
use trueskill_tt::{ConstantDrift, Drift, History, HistoryBuilder, Time, Untimed};
|
||||||
|
|
||||||
|
/// A domain time type: a season number. Exactly what the `Time` trait exists
|
||||||
|
/// to support, and what a consumer with `chrono` dates would write.
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
struct Season(u16);
|
||||||
|
|
||||||
|
impl Time for Season {
|
||||||
|
fn elapsed_to(&self, later: &Self) -> i64 {
|
||||||
|
i64::from(later.0.saturating_sub(self.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drift that only accumulates between seasons, not within one — the
|
||||||
|
/// calendar-aware case the trait's own docs cite.
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
struct SeasonalDrift {
|
||||||
|
per_season: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drift<Season> for SeasonalDrift {
|
||||||
|
fn variance_delta(&self, from: &Season, to: &Season) -> f64 {
|
||||||
|
self.variance_for_elapsed(from.elapsed_to(to))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn variance_for_elapsed(&self, elapsed: i64) -> f64 {
|
||||||
|
elapsed.max(0) as f64 * self.per_season * self.per_season
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_untimed_history_fits_through_the_builder() {
|
||||||
|
let mut h = History::builder().time_type::<Untimed>().build();
|
||||||
|
for _ in 0..5 {
|
||||||
|
h.record_winner(&"alice", &"bob", Untimed).unwrap();
|
||||||
|
}
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
|
||||||
|
let alice = h.current_skill(&"alice").unwrap();
|
||||||
|
let bob = h.current_skill(&"bob").unwrap();
|
||||||
|
assert!(alice.mu() > bob.mu(), "{alice:?} vs {bob:?}");
|
||||||
|
assert!(alice.sigma().is_finite() && alice.sigma() > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Untimed::elapsed_to` is always 0, so no drift accumulates however many
|
||||||
|
/// events there are. That is the property the type exists for, and it had never
|
||||||
|
/// been checked.
|
||||||
|
#[test]
|
||||||
|
fn untimed_accumulates_no_drift() {
|
||||||
|
fn final_sigma<T: Time + Copy>(time: T, drift: ConstantDrift) -> f64 {
|
||||||
|
let mut h = History::builder().time_type::<T>().drift(drift).build();
|
||||||
|
for _ in 0..8 {
|
||||||
|
h.record_winner(&"a", &"b", time).unwrap();
|
||||||
|
}
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h.current_skill(&"a").unwrap().sigma()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under Untimed the drift setting cannot matter, because elapsed is always 0.
|
||||||
|
let none = final_sigma(Untimed, ConstantDrift::new(0.0));
|
||||||
|
let large = final_sigma(Untimed, ConstantDrift::new(5.0));
|
||||||
|
assert_eq!(
|
||||||
|
none.to_bits(),
|
||||||
|
large.to_bits(),
|
||||||
|
"Untimed must ignore drift entirely: {none} vs {large}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_custom_time_type_and_a_custom_drift_work_together() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.time_type::<Season>()
|
||||||
|
.drift(SeasonalDrift { per_season: 0.5 })
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for season in 1..=4u16 {
|
||||||
|
for _ in 0..3 {
|
||||||
|
h.record_winner(&"veteran", &"rookie", Season(season))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
|
||||||
|
let curve = h.learning_curve(&"veteran").unwrap();
|
||||||
|
assert_eq!(curve.len(), 4, "one point per season: {curve:?}");
|
||||||
|
for (season, g) in &curve {
|
||||||
|
assert!(
|
||||||
|
g.mu().is_finite() && g.sigma() > 0.0,
|
||||||
|
"season {season:?}: {g:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Times come back as the domain type, not as an integer.
|
||||||
|
assert_eq!(curve[0].0, Season(1));
|
||||||
|
assert_eq!(curve[3].0, Season(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seasonal drift must actually widen a gap across seasons — otherwise the
|
||||||
|
/// custom `Drift` is being ignored and the test above would pass regardless.
|
||||||
|
#[test]
|
||||||
|
fn a_custom_drift_is_actually_consulted() {
|
||||||
|
fn sigma_with(per_season: f64) -> f64 {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.time_type::<Season>()
|
||||||
|
.drift(SeasonalDrift { per_season })
|
||||||
|
.build();
|
||||||
|
for season in 1..=6u16 {
|
||||||
|
h.record_winner(&"a", &"b", Season(season)).unwrap();
|
||||||
|
}
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h.current_skill(&"a").unwrap().sigma()
|
||||||
|
}
|
||||||
|
|
||||||
|
let still = sigma_with(0.0);
|
||||||
|
let drifting = sigma_with(2.0);
|
||||||
|
assert!(
|
||||||
|
drifting > still * 1.05,
|
||||||
|
"a drifting fit must be less certain: {drifting} vs {still}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other axis: a custom key type, through the same mechanism.
|
||||||
|
#[test]
|
||||||
|
fn key_type_replaces_builder_with_key() {
|
||||||
|
let mut h = History::builder().key_type::<String>().build();
|
||||||
|
h.record_winner(&"alice".to_string(), &"bob".to_string(), 1)
|
||||||
|
.unwrap();
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
assert!(h.current_skill("alice").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both axes at once, via the explicit constructor rather than the setters.
|
||||||
|
#[test]
|
||||||
|
fn new_constructs_on_any_axis_directly() {
|
||||||
|
let mut h = HistoryBuilder::<Season, _, _, String>::new().build();
|
||||||
|
h.record_winner(&"a".to_string(), &"b".to_string(), Season(7))
|
||||||
|
.unwrap();
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
assert_eq!(h.learning_curve("a").unwrap()[0].0, Season(7));
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
//! The joint must span slices, because Through Time reads each competitor at
|
||||||
|
//! their own last appearance.
|
||||||
|
//!
|
||||||
|
//! The exact posterior of a multi-slice scored history is still Gaussian: the
|
||||||
|
//! prior, the drift between appearances, and the scored likelihoods are all
|
||||||
|
//! Gaussian. So it can be written out by hand and compared against, which is
|
||||||
|
//! the check a single-slice fixture cannot make.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team, UnknownKeys,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIGMA0: f64 = 6.0;
|
||||||
|
const BETA: f64 = 1.0;
|
||||||
|
const SCORE_SIGMA: f64 = 2.0;
|
||||||
|
const GAMMA: f64 = 0.5;
|
||||||
|
|
||||||
|
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||||
|
|
||||||
|
fn history(gamma: f64) -> H {
|
||||||
|
History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(SIGMA0)
|
||||||
|
.beta(BETA)
|
||||||
|
.score_sigma(SCORE_SIGMA)
|
||||||
|
.drift(ConstantDrift::new(gamma))
|
||||||
|
.unknown_keys(UnknownKeys::Reject)
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> {
|
||||||
|
Event {
|
||||||
|
time: t,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a)]),
|
||||||
|
Team::with_members([Member::new(b)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([sa, sb]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inverse(mut a: Vec<Vec<f64>>) -> Vec<Vec<f64>> {
|
||||||
|
let n = a.len();
|
||||||
|
let mut inv: Vec<Vec<f64>> = (0..n)
|
||||||
|
.map(|i| (0..n).map(|j| f64::from(u8::from(i == j))).collect())
|
||||||
|
.collect();
|
||||||
|
for col in 0..n {
|
||||||
|
let mut piv = col;
|
||||||
|
for r in col + 1..n {
|
||||||
|
if a[r][col].abs() > a[piv][col].abs() {
|
||||||
|
piv = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.swap(col, piv);
|
||||||
|
inv.swap(col, piv);
|
||||||
|
let d = a[col][col];
|
||||||
|
for j in 0..n {
|
||||||
|
a[col][j] /= d;
|
||||||
|
inv[col][j] /= d;
|
||||||
|
}
|
||||||
|
for r in 0..n {
|
||||||
|
if r == col {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let f = a[r][col];
|
||||||
|
for j in 0..n {
|
||||||
|
a[r][j] -= f * a[col][j];
|
||||||
|
inv[r][j] -= f * inv[col][j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two competitors, two slices ten units apart, one duel in each.
|
||||||
|
///
|
||||||
|
/// The exact precision is written out explicitly here rather than obtained
|
||||||
|
/// from the crate, so this is an independent check rather than a restatement.
|
||||||
|
/// Variables are `[a0, b0, a1, b1]`.
|
||||||
|
#[test]
|
||||||
|
fn a_two_slice_joint_matches_the_exact_posterior() {
|
||||||
|
let mut h = history(GAMMA);
|
||||||
|
h.add_events(vec![
|
||||||
|
duel("a", "b", 0, 5.0, 2.0),
|
||||||
|
duel("a", "b", 10, 4.0, 3.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert!(report.converged, "{:?}", report.final_step);
|
||||||
|
|
||||||
|
let prior_prec = 1.0 / (SIGMA0 * SIGMA0);
|
||||||
|
let drift_prec = 1.0 / (10.0 * GAMMA * GAMMA);
|
||||||
|
let obs_prec = 1.0 / (SCORE_SIGMA * SCORE_SIGMA + 2.0 * BETA * BETA);
|
||||||
|
|
||||||
|
let mut lambda = vec![vec![0.0; 4]; 4];
|
||||||
|
// priors on the first appearances
|
||||||
|
lambda[0][0] += prior_prec;
|
||||||
|
lambda[1][1] += prior_prec;
|
||||||
|
// drift a0-a1 and b0-b1
|
||||||
|
for (p, q) in [(0usize, 2usize), (1, 3)] {
|
||||||
|
lambda[p][p] += drift_prec;
|
||||||
|
lambda[q][q] += drift_prec;
|
||||||
|
lambda[p][q] -= drift_prec;
|
||||||
|
lambda[q][p] -= drift_prec;
|
||||||
|
}
|
||||||
|
// one duel per slice: contrast (+1, -1) on that slice's variables
|
||||||
|
for (p, q) in [(0usize, 1usize), (2, 3)] {
|
||||||
|
lambda[p][p] += obs_prec;
|
||||||
|
lambda[q][q] += obs_prec;
|
||||||
|
lambda[p][q] -= obs_prec;
|
||||||
|
lambda[q][p] -= obs_prec;
|
||||||
|
}
|
||||||
|
let cov = inverse(lambda);
|
||||||
|
|
||||||
|
// The crate reads each competitor at their latest appearance: a1, b1.
|
||||||
|
let exact_gap = (cov[2][2] + cov[3][3] - 2.0 * cov[2][3]).sqrt();
|
||||||
|
let got = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
||||||
|
assert!(
|
||||||
|
(got.sigma() - exact_gap).abs() / exact_gap < 1e-9,
|
||||||
|
"difference: got {} exact {exact_gap}",
|
||||||
|
got.sigma()
|
||||||
|
);
|
||||||
|
|
||||||
|
let exact_single = cov[2][2].sqrt();
|
||||||
|
let got_single = h.posterior_of(&[(&"a", 1.0)]).unwrap();
|
||||||
|
assert!(
|
||||||
|
(got_single.sigma() - exact_single).abs() / exact_single < 1e-9,
|
||||||
|
"single node: got {} exact {exact_single}",
|
||||||
|
got_single.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The case that motivated this: competitors read at *different* slices, with
|
||||||
|
/// the last slice holding only one of them. Under the old latest-slice joint
|
||||||
|
/// this was `UnknownKey`.
|
||||||
|
#[test]
|
||||||
|
fn competitors_last_seen_in_different_slices_are_comparable() {
|
||||||
|
let mut h = history(GAMMA);
|
||||||
|
h.add_events(vec![
|
||||||
|
duel("a", "b", 0, 5.0, 2.0),
|
||||||
|
duel("a", "c", 10, 4.0, 3.0),
|
||||||
|
// the final slice holds one duel that does not involve b at all
|
||||||
|
duel("a", "c", 20, 6.0, 1.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
// b last appeared at time 0; a and c at time 20. All three must resolve.
|
||||||
|
for (x, y) in [("a", "b"), ("b", "c"), ("a", "c")] {
|
||||||
|
let g = h
|
||||||
|
.posterior_of(&[(&x, 1.0), (&y, -1.0)])
|
||||||
|
.unwrap_or_else(|e| panic!("{x} - {y} should resolve across slices: {e}"));
|
||||||
|
assert!(g.sigma() > 0.0 && g.sigma().is_finite());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mean must agree with what message passing reports, which is exact even
|
||||||
|
/// with cycles. Only the second moment needs the joint.
|
||||||
|
#[test]
|
||||||
|
fn means_agree_with_the_marginals() {
|
||||||
|
let mut h = history(GAMMA);
|
||||||
|
h.add_events(vec![
|
||||||
|
duel("a", "b", 0, 5.0, 2.0),
|
||||||
|
duel("b", "c", 5, 3.0, 1.0),
|
||||||
|
duel("a", "c", 10, 4.0, 2.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
for k in ["a", "b", "c"] {
|
||||||
|
let marginal = h.current_skill(&k).unwrap().mu();
|
||||||
|
let joint = h.posterior_of(&[(&k, 1.0)]).unwrap().mu();
|
||||||
|
assert!(
|
||||||
|
(marginal - joint).abs() < 1e-9,
|
||||||
|
"{k}: marginal {marginal}, joint {joint}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With zero drift a competitor has one latent skill however many slices it
|
||||||
|
/// appears in, so spreading the same events over time must not change the
|
||||||
|
/// answer. This exercises the appearance-merging path.
|
||||||
|
#[test]
|
||||||
|
fn zero_drift_makes_slice_layout_irrelevant() {
|
||||||
|
let spread = {
|
||||||
|
let mut h = history(0.0);
|
||||||
|
h.add_events(vec![
|
||||||
|
duel("a", "b", 0, 5.0, 2.0),
|
||||||
|
duel("a", "b", 10, 4.0, 3.0),
|
||||||
|
duel("a", "b", 20, 6.0, 1.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap()
|
||||||
|
};
|
||||||
|
let together = {
|
||||||
|
let mut h = history(0.0);
|
||||||
|
h.add_events(vec![
|
||||||
|
duel("a", "b", 0, 5.0, 2.0),
|
||||||
|
duel("a", "b", 0, 4.0, 3.0),
|
||||||
|
duel("a", "b", 0, 6.0, 1.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(spread.sigma() - together.sigma()).abs() < 1e-9,
|
||||||
|
"zero drift: spread {} vs together {}",
|
||||||
|
spread.sigma(),
|
||||||
|
together.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// More drift means less is carried forward from old evidence, so a comparison
|
||||||
|
/// against a competitor last seen long ago must widen.
|
||||||
|
#[test]
|
||||||
|
fn drift_widens_a_comparison_across_time() {
|
||||||
|
let mut previous = 0.0;
|
||||||
|
for gamma in [0.0f64, 0.1, 0.5, 2.0] {
|
||||||
|
let mut h = history(gamma);
|
||||||
|
h.add_events(vec![
|
||||||
|
duel("a", "b", 0, 5.0, 2.0),
|
||||||
|
duel("a", "c", 100, 4.0, 3.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
// b was last seen at time 0; a at time 100.
|
||||||
|
let g = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
||||||
|
assert!(
|
||||||
|
g.sigma() > previous,
|
||||||
|
"gamma={gamma}: sigma {} did not exceed {previous}",
|
||||||
|
g.sigma()
|
||||||
|
);
|
||||||
|
previous = g.sigma();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `posterior_of_at` pins the reading to a moment, where `posterior_of` takes
|
||||||
|
/// each competitor wherever they were last seen.
|
||||||
|
#[test]
|
||||||
|
fn posterior_of_at_reads_as_of_a_time() {
|
||||||
|
let mut h = history(GAMMA);
|
||||||
|
h.add_events(vec![
|
||||||
|
duel("a", "b", 0, 5.0, 2.0),
|
||||||
|
duel("a", "b", 10, 4.0, 3.0),
|
||||||
|
duel("a", "b", 20, 6.0, 1.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let early = h.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
||||||
|
let late = h.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
||||||
|
let latest = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
||||||
|
|
||||||
|
// Asking as of the final slice is the same as asking for the latest.
|
||||||
|
assert!((late.mu() - latest.mu()).abs() < 1e-9);
|
||||||
|
assert!((late.sigma() - latest.sigma()).abs() < 1e-9);
|
||||||
|
|
||||||
|
// Reading at time 0 is a different quantity, and the smoothed estimate
|
||||||
|
// there is informed by everything that came after.
|
||||||
|
assert!(
|
||||||
|
(early.mu() - late.mu()).abs() > 1e-6,
|
||||||
|
"as-of-0 and as-of-20 should differ: {} vs {}",
|
||||||
|
early.mu(),
|
||||||
|
late.mu()
|
||||||
|
);
|
||||||
|
|
||||||
|
// A time before any event has nothing to read.
|
||||||
|
assert!(h.posterior_of_at(-1, &[(&"a", 1.0)]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Times between slices resolve to the latest appearance at or before them.
|
||||||
|
#[test]
|
||||||
|
fn a_time_between_slices_reads_the_previous_appearance() {
|
||||||
|
let mut h = history(GAMMA);
|
||||||
|
h.add_events(vec![
|
||||||
|
duel("a", "b", 0, 5.0, 2.0),
|
||||||
|
duel("a", "b", 100, 4.0, 3.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let at_zero = h.posterior_of_at(0, &[(&"a", 1.0)]).unwrap();
|
||||||
|
let between = h.posterior_of_at(50, &[(&"a", 1.0)]).unwrap();
|
||||||
|
assert!((at_zero.mu() - between.mu()).abs() < 1e-12);
|
||||||
|
assert!((at_zero.sigma() - between.sigma()).abs() < 1e-12);
|
||||||
|
}
|
||||||
+190
-1
@@ -22,7 +22,7 @@ fn rating() -> R {
|
|||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.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:?}"
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
//! `expected_variance_reduction`: which matchup best sharpens a given question.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
|
||||||
|
UnknownKeys,
|
||||||
|
};
|
||||||
|
|
||||||
|
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||||
|
|
||||||
|
fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> {
|
||||||
|
Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a)]),
|
||||||
|
Team::with_members([Member::new(b)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([sa, sb]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base() -> Vec<Event<i64, &'static str>> {
|
||||||
|
vec![
|
||||||
|
round("a", "b", 5.0, 2.0),
|
||||||
|
round("a", "c", 6.0, 1.0),
|
||||||
|
round("b", "c", 4.0, 3.0),
|
||||||
|
round("c", "d", 2.0, 1.0),
|
||||||
|
round("a", "d", 7.0, 2.0),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
|
||||||
|
let mut h: History<i64, _, _, &'static str> = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.unknown_keys(policy)
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
let mut ev = base();
|
||||||
|
if let Some(e) = extra {
|
||||||
|
ev.push(e);
|
||||||
|
}
|
||||||
|
h.add_events(ev).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The closed form must equal what actually happens if the matchup is played.
|
||||||
|
/// This is the assertion that makes the whole call trustworthy: a wrong
|
||||||
|
/// acquisition function returns plausible numbers and quietly picks worse
|
||||||
|
/// matchups forever.
|
||||||
|
#[test]
|
||||||
|
fn the_closed_form_matches_an_actual_refit() {
|
||||||
|
let h = fit(None, UnknownKeys::Reject);
|
||||||
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
|
let before = h.posterior_of(&target).unwrap().sigma().powi(2);
|
||||||
|
|
||||||
|
for (x, y) in [("a", "b"), ("c", "d"), ("a", "c"), ("b", "d")] {
|
||||||
|
let predicted = h
|
||||||
|
.expected_variance_reduction(&[&[&x], &[&y]], &target)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let after = fit(Some(round(x, y, 3.0, 1.0)), UnknownKeys::Reject);
|
||||||
|
let actual = before - after.posterior_of(&target).unwrap().sigma().powi(2);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(predicted - actual).abs() / actual.abs() < 1e-9,
|
||||||
|
"{x} vs {y}: predicted {predicted}, actual {actual}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reduction cannot depend on the score, because for a Gaussian likelihood
|
||||||
|
/// the posterior variance update is data-independent. This is why the call
|
||||||
|
/// needs no expectation despite its name.
|
||||||
|
#[test]
|
||||||
|
fn the_outcome_does_not_change_the_reduction() {
|
||||||
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
|
let h = fit(None, UnknownKeys::Reject);
|
||||||
|
let before = h.posterior_of(&target).unwrap().sigma().powi(2);
|
||||||
|
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
for (sa, sb) in [(3.0, 1.0), (100.0, -50.0), (0.0, 0.0)] {
|
||||||
|
let after = fit(Some(round("c", "d", sa, sb)), UnknownKeys::Reject);
|
||||||
|
seen.push(before - after.posterior_of(&target).unwrap().sigma().powi(2));
|
||||||
|
}
|
||||||
|
for w in seen.windows(2) {
|
||||||
|
assert!(
|
||||||
|
(w[0] - w[1]).abs() < 1e-12,
|
||||||
|
"variance reduction moved with the observed score: {seen:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The point of the call: it must rank candidate matchups usefully. Playing the
|
||||||
|
/// pair you are trying to separate helps most; an unrelated pair helps least.
|
||||||
|
#[test]
|
||||||
|
fn it_ranks_candidates_by_how_much_they_answer_the_question() {
|
||||||
|
let h = fit(None, UnknownKeys::Reject);
|
||||||
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
|
|
||||||
|
let direct = h
|
||||||
|
.expected_variance_reduction(&[&[&"a"], &[&"b"]], &target)
|
||||||
|
.unwrap();
|
||||||
|
let unrelated = h
|
||||||
|
.expected_variance_reduction(&[&[&"c"], &[&"d"]], &target)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(direct > 0.0 && unrelated > 0.0);
|
||||||
|
assert!(
|
||||||
|
direct > 5.0 * unrelated,
|
||||||
|
"playing the target pair should dominate: {direct} vs {unrelated}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A matchup between two competitors nobody has seen still teaches something
|
||||||
|
/// about them, but nothing about a target that does not involve them.
|
||||||
|
#[test]
|
||||||
|
fn an_unrelated_unseen_matchup_teaches_nothing_about_the_target() {
|
||||||
|
let h = fit(None, UnknownKeys::Prior);
|
||||||
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
|
|
||||||
|
let reduction = h
|
||||||
|
.expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
reduction.abs() < 1e-12,
|
||||||
|
"an unseen pair shares nothing with the target: {reduction}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shape_errors_are_reported() {
|
||||||
|
let h = fit(None, UnknownKeys::Reject);
|
||||||
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
h.expected_variance_reduction(&[&[&"a"]], &target),
|
||||||
|
Err(InferenceError::MismatchedShape {
|
||||||
|
expected: 2,
|
||||||
|
got: 1,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
h.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
|
||||||
|
Err(InferenceError::UnknownKey { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user