Compare commits
@@ -2,12 +2,65 @@
|
|||||||
|
|
||||||
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
|
## 0.6.0 - 2026-09-08
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
- fix!: make the joint span slices, not just the latest one
|
- 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
|
## 0.5.0 - 2026-09-08
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "trueskill-tt"
|
name = "trueskill-tt"
|
||||||
version = "0.6.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"
|
||||||
@@ -79,3 +79,7 @@ debug = true
|
|||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
debug = true
|
debug = true
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "joint"
|
||||||
|
harness = false
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
//! 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_with_key()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift(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);
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+10
-3
@@ -62,10 +62,17 @@ impl Default for ConvergenceOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Post-hoc summary of a `History::converge` call.
|
/// Post-hoc summary of a `History::converge` call.
|
||||||
|
///
|
||||||
|
/// From [`History::converge`](crate::History::converge) this always describes a
|
||||||
|
/// converged fit — stopping at `max_iter` is
|
||||||
|
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged) there.
|
||||||
|
/// From [`History::converge_partial`](crate::History::converge_partial) it may
|
||||||
|
/// not be, and `converged` is what says so.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
#[must_use = "a ConvergenceReport carries `converged`, and a fit that stopped \
|
#[must_use = "from `converge_partial` this may describe a fit that stopped at \
|
||||||
at `max_iter` is wrong by a little rather than loudly broken — \
|
`max_iter`, which is wrong by a little rather than loudly \
|
||||||
check it, or bind it to `_` to say you have decided not to"]
|
broken — check `converged`, or bind it to `_` to say you have \
|
||||||
|
decided not to"]
|
||||||
pub struct ConvergenceReport {
|
pub struct ConvergenceReport {
|
||||||
pub iterations: usize,
|
pub iterations: usize,
|
||||||
pub final_step: (f64, f64),
|
pub final_step: (f64, f64),
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ pub enum InferenceError {
|
|||||||
/// 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.
|
||||||
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 rating is finite, the ordering looks sensible,
|
||||||
|
/// and nothing in the numbers says they were still moving. Reported rather
|
||||||
|
/// than returned as a flag on an `Ok`, because a flag has to be checked
|
||||||
|
/// and `let _ = h.converge()` is the natural way not to.
|
||||||
|
///
|
||||||
|
/// Either the history needs more iterations — raise `max_iter` — or it is
|
||||||
|
/// oscillating rather than converging, in which case `alpha < 1.0` damps
|
||||||
|
/// the within-game EP loop. [`History::converge_partial`](crate::History::converge_partial)
|
||||||
|
/// returns the short fit instead when that is genuinely what is wanted.
|
||||||
|
NotConverged {
|
||||||
|
iterations: usize,
|
||||||
|
final_step: (f64, f64),
|
||||||
|
epsilon: f64,
|
||||||
|
},
|
||||||
/// Inference produced a non-finite value (NaN or infinity).
|
/// 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
|
||||||
@@ -100,6 +118,17 @@ pub enum InferenceError {
|
|||||||
member: usize,
|
member: usize,
|
||||||
key: String,
|
key: String,
|
||||||
},
|
},
|
||||||
|
/// `History::register` was called for a competitor that already exists.
|
||||||
|
///
|
||||||
|
/// Registration states a competitor's configuration before anything has
|
||||||
|
/// been observed about them, so a competitor that already exists has
|
||||||
|
/// already been configured — by an earlier `register`, or by an event that
|
||||||
|
/// created them. Silently overwriting would reintroduce exactly the
|
||||||
|
/// order-dependence registration exists to remove.
|
||||||
|
///
|
||||||
|
/// To change an existing competitor's configuration, supply it on an event
|
||||||
|
/// through `Member`; that refits the whole history.
|
||||||
|
AlreadyRegistered { key: String },
|
||||||
/// A prediction was given a team with no members.
|
/// A prediction was given a team with no members.
|
||||||
EmptyTeam { team: usize },
|
EmptyTeam { team: usize },
|
||||||
/// A joint posterior was requested where one cannot be formed exactly.
|
/// A joint posterior was requested where one cannot be formed exactly.
|
||||||
@@ -144,6 +173,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,
|
||||||
@@ -167,6 +208,14 @@ impl fmt::Display for InferenceError {
|
|||||||
with `lookup` or `current_skill` if that is not guaranteed)"
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -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
|
||||||
@@ -104,7 +106,8 @@ impl<K> Member<K> {
|
|||||||
/// 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 {
|
||||||
|
|||||||
@@ -50,6 +50,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 +59,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`]
|
||||||
|
|||||||
+39
@@ -431,6 +431,29 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
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 +465,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 +525,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 +555,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(
|
||||||
|
|||||||
+624
-154
@@ -6,6 +6,7 @@ use crate::{
|
|||||||
convergence::{ConvergenceOptions, ConvergenceReport},
|
convergence::{ConvergenceOptions, ConvergenceReport},
|
||||||
drift::{ConstantDrift, Drift},
|
drift::{ConstantDrift, Drift},
|
||||||
error::InferenceError,
|
error::InferenceError,
|
||||||
|
event::Member,
|
||||||
gaussian::Gaussian,
|
gaussian::Gaussian,
|
||||||
key_table::KeyTable,
|
key_table::KeyTable,
|
||||||
observer::{NullObserver, Observer},
|
observer::{NullObserver, Observer},
|
||||||
@@ -39,17 +40,55 @@ pub struct HistoryBuilder<
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<T, D, O, K> {
|
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<T, D, O, K> {
|
||||||
|
/// Prior mean skill.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `mu` is not finite. A non-finite prior mean poisons every
|
||||||
|
/// posterior derived from it: `converge` reports `NonFiniteResult`, but a
|
||||||
|
/// caller who reads `current_skill` first is handed `tau: NaN`.
|
||||||
pub fn mu(mut self, mu: f64) -> Self {
|
pub fn mu(mut self, mu: f64) -> Self {
|
||||||
|
assert!(mu.is_finite(), "mu must be finite (got {mu})");
|
||||||
self.mu = mu;
|
self.mu = mu;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prior standard deviation.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics unless `sigma` is finite and strictly positive.
|
||||||
|
///
|
||||||
|
/// Zero and infinity both give a prior precision that is not a number, and
|
||||||
|
/// the whole fit comes back NaN. A *negative* sigma is the quieter half:
|
||||||
|
/// it is only ever squared, so `-8.33` produces bit-identical results to
|
||||||
|
/// `8.33` — a sign the caller cannot have meant, silently ignored.
|
||||||
pub fn sigma(mut self, sigma: f64) -> Self {
|
pub fn sigma(mut self, sigma: f64) -> Self {
|
||||||
|
assert!(
|
||||||
|
sigma.is_finite() && sigma > 0.0,
|
||||||
|
"sigma must be finite and positive (got {sigma})"
|
||||||
|
);
|
||||||
self.sigma = sigma;
|
self.sigma = sigma;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-event performance noise.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics unless `beta` is finite and non-negative.
|
||||||
|
///
|
||||||
|
/// Zero is allowed and meaningful — performance is then exactly skill, and
|
||||||
|
/// the fit differs measurably from a positive `beta` rather than
|
||||||
|
/// degenerating. Negative is rejected for the same reason as a negative
|
||||||
|
/// `sigma` or `Member::with_drift_scale`: `beta` enters only as `beta^2`,
|
||||||
|
/// so a negative value behaves as its absolute value and the sign is lost
|
||||||
|
/// without comment.
|
||||||
pub fn beta(mut self, beta: f64) -> Self {
|
pub fn beta(mut self, beta: f64) -> Self {
|
||||||
|
assert!(
|
||||||
|
beta.is_finite() && beta >= 0.0,
|
||||||
|
"beta must be finite and non-negative (got {beta})"
|
||||||
|
);
|
||||||
self.beta = beta;
|
self.beta = beta;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -169,6 +208,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
|||||||
convergence: self.convergence,
|
convergence: self.convergence,
|
||||||
observer: self.observer,
|
observer: self.observer,
|
||||||
unknown_keys: self.unknown_keys,
|
unknown_keys: self.unknown_keys,
|
||||||
|
declared: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,6 +316,12 @@ pub struct History<
|
|||||||
convergence: ConvergenceOptions,
|
convergence: ConvergenceOptions,
|
||||||
observer: O,
|
observer: O,
|
||||||
unknown_keys: crate::UnknownKeys,
|
unknown_keys: crate::UnknownKeys,
|
||||||
|
/// Competitor configuration explicitly declared so far, by whichever route.
|
||||||
|
///
|
||||||
|
/// Kept separate from the applied `Rating` because a `Rating` cannot say
|
||||||
|
/// whether a value was *chosen* or inherited from the history defaults,
|
||||||
|
/// and that is exactly the distinction a conflict check needs.
|
||||||
|
declared: HashMap<Index, CompetitorConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for History<i64, ConstantDrift, NullObserver, &'static str> {
|
impl Default for History<i64, ConstantDrift, NullObserver, &'static str> {
|
||||||
@@ -455,6 +501,111 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Skill estimate at the latest time slice the competitor appears in.
|
/// Skill estimate at the latest time slice the competitor appears in.
|
||||||
|
/// Configure a competitor before anything has been observed about them.
|
||||||
|
///
|
||||||
|
/// The configuration a competitor needs is often a property of the domain
|
||||||
|
/// rather than of any one event — "every layout is static", "this bot sits
|
||||||
|
/// at a known strength". Stating it per-event means every ingestion path
|
||||||
|
/// has to remember it, and the fluent and two-argument paths could not
|
||||||
|
/// state it at all.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use trueskill_tt::{History, Member};
|
||||||
|
/// let mut h = History::builder().build();
|
||||||
|
/// h.register(Member::new("layout_7").with_drift_scale(0.0))?;
|
||||||
|
///
|
||||||
|
/// // Reaches a competitor first seen through any route, including the
|
||||||
|
/// // two-argument one, which cannot carry configuration itself.
|
||||||
|
/// h.record_winner(&"player", &"layout_7", 1)?;
|
||||||
|
/// assert_eq!(h.rating(&"layout_7").unwrap().drift_scale(), 0.0);
|
||||||
|
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The competitor exists from this point on, with no appearances, so
|
||||||
|
/// [`History::rating`] can read back what was actually stored — the
|
||||||
|
/// diagnostic that was previously missing entirely.
|
||||||
|
///
|
||||||
|
/// `weight` is per-event and has no meaning here, so a `Member` carrying a
|
||||||
|
/// non-default one is rejected rather than silently ignored.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// `AlreadyRegistered` if the competitor already exists, whether from an
|
||||||
|
/// earlier `register` or from an event. `InvalidParameter` for a `weight`
|
||||||
|
/// other than 1.0, or a `drift_scale` that is negative or non-finite.
|
||||||
|
pub fn register(&mut self, member: Member<K>) -> Result<(), InferenceError>
|
||||||
|
where
|
||||||
|
K: std::fmt::Debug,
|
||||||
|
{
|
||||||
|
if member.weight != 1.0 {
|
||||||
|
return Err(InferenceError::InvalidParameter {
|
||||||
|
name: "weight",
|
||||||
|
value: member.weight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(scale) = member.drift_scale {
|
||||||
|
if !scale.is_finite() || scale < 0.0 {
|
||||||
|
return Err(InferenceError::InvalidParameter {
|
||||||
|
name: "drift_scale",
|
||||||
|
value: scale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = format!("{:?}", member.key);
|
||||||
|
let idx = self.keys.get_or_create(&member.key);
|
||||||
|
if self.agents.contains(idx) {
|
||||||
|
return Err(InferenceError::AlreadyRegistered { key });
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rating = Rating::new(
|
||||||
|
Gaussian::from_ms(self.mu, self.sigma),
|
||||||
|
self.beta,
|
||||||
|
self.drift,
|
||||||
|
);
|
||||||
|
if let Some(prior) = member.prior {
|
||||||
|
rating.prior = prior;
|
||||||
|
}
|
||||||
|
if let Some(scale) = member.drift_scale {
|
||||||
|
rating.drift_scale = scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.declared.insert(
|
||||||
|
idx,
|
||||||
|
CompetitorConfig {
|
||||||
|
prior: member.prior,
|
||||||
|
drift_scale: member.drift_scale,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.agents.insert(
|
||||||
|
idx,
|
||||||
|
Competitor {
|
||||||
|
rating,
|
||||||
|
message: None,
|
||||||
|
last_time: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configuration in force for a competitor, or `None` if the history
|
||||||
|
/// has never seen them.
|
||||||
|
///
|
||||||
|
/// Reads back what was actually stored, which is what makes a
|
||||||
|
/// configuration mistake detectable from outside the crate. Every other
|
||||||
|
/// accessor returns what inference *inferred*; this returns what it was
|
||||||
|
/// told.
|
||||||
|
#[must_use]
|
||||||
|
pub fn rating<Q>(&self, key: &Q) -> Option<Rating<T, D>>
|
||||||
|
where
|
||||||
|
K: std::borrow::Borrow<Q>,
|
||||||
|
Q: std::hash::Hash + Eq + ?Sized,
|
||||||
|
{
|
||||||
|
let idx = self.keys.get(key)?;
|
||||||
|
self.agents.contains(idx).then(|| self.agents[idx].rating)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian>
|
pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian>
|
||||||
where
|
where
|
||||||
K: std::borrow::Borrow<Q>,
|
K: std::borrow::Borrow<Q>,
|
||||||
@@ -950,6 +1101,22 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
/// per-day or per-event slices, competitors are rarely all present in any
|
/// per-day or per-event slices, competitors are rarely all present in any
|
||||||
/// one of them. Use [`History::posterior_of_at`] to pin a time instead.
|
/// one of them. Use [`History::posterior_of_at`] to pin a time instead.
|
||||||
///
|
///
|
||||||
|
/// # Asking more than one question
|
||||||
|
///
|
||||||
|
/// This factorises the joint, uses it once, and throws it away. The
|
||||||
|
/// factorisation is the expensive part and it depends only on the fit, so
|
||||||
|
/// asking `n` questions this way pays for it `n` times. Take a
|
||||||
|
/// [`Joint`] with [`History::joint`] instead — the answers are identical,
|
||||||
|
/// and only the first one pays.
|
||||||
|
///
|
||||||
|
/// # Cost
|
||||||
|
///
|
||||||
|
/// A dense solve over the history's *appearances*, not its competitors. A
|
||||||
|
/// drift-free competitor collapses to a single variable however long the
|
||||||
|
/// history, so the same events can differ enormously in cost depending on
|
||||||
|
/// the drift configuration — see [`Joint`], which also amortises this
|
||||||
|
/// across many questions.
|
||||||
|
///
|
||||||
/// # Limitations
|
/// # Limitations
|
||||||
///
|
///
|
||||||
/// Exact only for a history whose events are all scored, because a scored
|
/// Exact only for a history whose events are all scored, because a scored
|
||||||
@@ -959,10 +1126,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
/// so a history containing ranked events returns `JointUnavailable` rather
|
/// so a history containing ranked events returns `JointUnavailable` rather
|
||||||
/// than a plausible wrong number.
|
/// than a plausible wrong number.
|
||||||
///
|
///
|
||||||
/// Cost is a dense solve over the history's *appearances*, not its
|
|
||||||
/// competitors: a competitor contributes one variable per slice it appears
|
|
||||||
/// in, minus any consecutive pair with no drift between them.
|
|
||||||
///
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// `UnknownKey` for a competitor the history has never seen, and
|
/// `UnknownKey` for a competitor the history has never seen, and
|
||||||
@@ -972,42 +1135,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
where
|
where
|
||||||
K: std::fmt::Debug,
|
K: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
if self.time_slices.is_empty() {
|
self.joint()?.posterior_of(terms)
|
||||||
return Err(InferenceError::JointUnavailable {
|
|
||||||
reason: "the history has no events",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if !self.time_slices.iter().all(TimeSlice::all_scored) {
|
|
||||||
return Err(InferenceError::JointUnavailable {
|
|
||||||
reason: "the history contains ranked events, whose EP factors are \
|
|
||||||
not retained after convergence",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let TimeExpanded {
|
|
||||||
lambda,
|
|
||||||
latest,
|
|
||||||
width,
|
|
||||||
..
|
|
||||||
} = self.time_expanded_joint();
|
|
||||||
|
|
||||||
let ResolvedTerms {
|
|
||||||
contrast,
|
|
||||||
unseen,
|
|
||||||
mean,
|
|
||||||
} = self.resolve_terms(terms, width, |index| latest.get(&index).copied())?;
|
|
||||||
|
|
||||||
let z =
|
|
||||||
crate::joint::solve_spd(lambda, &contrast).ok_or(InferenceError::JointUnavailable {
|
|
||||||
reason: "the precision matrix is not positive-definite, which means \
|
|
||||||
a competitor has neither a proper prior nor any evidence",
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let prior_var = self.sigma * self.sigma;
|
|
||||||
let variance: f64 = contrast.iter().zip(&z).map(|(c, z)| c * z).sum::<f64>()
|
|
||||||
+ unseen.values().map(|c| c * c * prior_var).sum::<f64>();
|
|
||||||
|
|
||||||
Ok(Gaussian::from_mv(mean, variance))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Posterior of a linear combination, read as of `time`.
|
/// Posterior of a linear combination, read as of `time`.
|
||||||
@@ -1018,6 +1146,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
/// stand at the end of last season" — rather than to wherever each
|
/// stand at the end of last season" — rather than to wherever each
|
||||||
/// competitor was last seen.
|
/// competitor was last seen.
|
||||||
///
|
///
|
||||||
|
/// As with [`History::posterior_of`], this factorises the joint for one
|
||||||
|
/// question; [`History::joint`] amortises that across many.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// As [`History::posterior_of`], plus `UnknownKey` for a competitor with no
|
/// As [`History::posterior_of`], plus `UnknownKey` for a competitor with no
|
||||||
@@ -1026,54 +1157,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
where
|
where
|
||||||
K: std::fmt::Debug,
|
K: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
if self.time_slices.is_empty() {
|
self.joint()?.posterior_of_at(time, terms)
|
||||||
return Err(InferenceError::JointUnavailable {
|
|
||||||
reason: "the history has no events",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if !self.time_slices.iter().all(TimeSlice::all_scored) {
|
|
||||||
return Err(InferenceError::JointUnavailable {
|
|
||||||
reason: "the history contains ranked events, whose EP factors are \
|
|
||||||
not retained after convergence",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let TimeExpanded {
|
|
||||||
lambda,
|
|
||||||
at_slice,
|
|
||||||
width,
|
|
||||||
..
|
|
||||||
} = self.time_expanded_joint();
|
|
||||||
|
|
||||||
// Latest appearance at or before `time`, per competitor.
|
|
||||||
let mut as_of: HashMap<Index, (usize, usize)> = HashMap::new();
|
|
||||||
for (slice_idx, slice) in self.time_slices.iter().enumerate() {
|
|
||||||
if slice.time > time {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
for (agent, _) in slice.appearances() {
|
|
||||||
if let Some(row) = at_slice.get(&(agent, slice_idx)) {
|
|
||||||
as_of.insert(agent, (*row, slice_idx));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let ResolvedTerms {
|
|
||||||
contrast,
|
|
||||||
unseen,
|
|
||||||
mean,
|
|
||||||
} = self.resolve_terms(terms, width, |index| as_of.get(&index).copied())?;
|
|
||||||
|
|
||||||
let z =
|
|
||||||
crate::joint::solve_spd(lambda, &contrast).ok_or(InferenceError::JointUnavailable {
|
|
||||||
reason: "the precision matrix is not positive-definite",
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let prior_var = self.sigma * self.sigma;
|
|
||||||
let variance: f64 = contrast.iter().zip(&z).map(|(c, z)| c * z).sum::<f64>()
|
|
||||||
+ unseen.values().map(|c| c * c * prior_var).sum::<f64>();
|
|
||||||
|
|
||||||
Ok(Gaussian::from_mv(mean, variance))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much observing this matchup would shrink the variance of `target`.
|
/// How much observing this matchup would shrink the variance of `target`.
|
||||||
@@ -1089,6 +1173,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
/// score. It is also far cheaper: one linear solve rather than a full
|
/// score. It is also far cheaper: one linear solve rather than a full
|
||||||
/// inference pass per possible outcome.
|
/// inference pass per possible outcome.
|
||||||
///
|
///
|
||||||
|
/// Scoring a field of candidates is the whole point of this call, and each
|
||||||
|
/// candidate is one question against an unchanged fit — so use
|
||||||
|
/// [`Joint::expected_variance_reduction`] for anything past a single
|
||||||
|
/// candidate, or pay for the factorisation once per candidate.
|
||||||
|
///
|
||||||
/// # There is no expectation to take
|
/// # There is no expectation to take
|
||||||
///
|
///
|
||||||
/// Observing a scored event is a rank-one update to the precision matrix,
|
/// Observing a scored event is a rank-one update to the precision matrix,
|
||||||
@@ -1118,14 +1207,56 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
where
|
where
|
||||||
K: std::fmt::Debug,
|
K: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
if teams.len() != 2 {
|
self.joint()?.expected_variance_reduction(teams, target)
|
||||||
return Err(InferenceError::MismatchedShape {
|
|
||||||
kind: "expected_variance_reduction takes exactly 2 teams",
|
|
||||||
expected: 2,
|
|
||||||
got: teams.len(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Factorise the joint posterior once, to answer many questions against it.
|
||||||
|
///
|
||||||
|
/// [`History::posterior_of`] and its neighbours each build and factorise
|
||||||
|
/// the joint, use it once, and drop it. The factorisation is `O(n^3)` in
|
||||||
|
/// the history's *appearances* and depends only on the fit, so a caller
|
||||||
|
/// asking about every pair in a standings table, every cell in a grid, or
|
||||||
|
/// every candidate in an active-learning sweep pays for the same
|
||||||
|
/// factorisation once per question.
|
||||||
|
///
|
||||||
|
/// A `Joint` pays it once. Each subsequent query is `O(n^2)` — one forward
|
||||||
|
/// substitution — and returns exactly what the one-shot call would.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use smallvec::smallvec;
|
||||||
|
/// # use trueskill_tt::{Event, History, Member, Outcome, Team};
|
||||||
|
/// # let mut h = History::builder().score_sigma(1.0).build();
|
||||||
|
/// # let round = |x, y, sx, sy, t| Event {
|
||||||
|
/// # time: t,
|
||||||
|
/// # teams: smallvec![
|
||||||
|
/// # Team::with_members([Member::new(x)]),
|
||||||
|
/// # Team::with_members([Member::new(y)]),
|
||||||
|
/// # ],
|
||||||
|
/// # outcome: Outcome::scores([sx, sy]),
|
||||||
|
/// # };
|
||||||
|
/// # h.add_events(vec![
|
||||||
|
/// # round("a", "b", 3.0, 1.0, 1),
|
||||||
|
/// # round("b", "c", 2.0, 1.0, 2),
|
||||||
|
/// # ]).unwrap();
|
||||||
|
/// # h.converge().unwrap();
|
||||||
|
/// let joint = h.joint()?;
|
||||||
|
/// for (a, b) in [("a", "b"), ("a", "c"), ("b", "c")] {
|
||||||
|
/// let gap = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)])?;
|
||||||
|
/// println!("{a} - {b}: {:.3} +/- {:.3}", gap.mu(), gap.sigma());
|
||||||
|
/// }
|
||||||
|
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The handle borrows the history, so the borrow checker enforces what a
|
||||||
|
/// cache would otherwise have to invalidate: no events can be added and no
|
||||||
|
/// refit can run while it is alive. Drop it to release the factorisation,
|
||||||
|
/// which is `n^2` floats and is the largest thing this crate allocates.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// `JointUnavailable` if the history is empty, contains ranked events, or
|
||||||
|
/// yields a precision matrix that is not positive-definite.
|
||||||
|
pub fn joint(&self) -> Result<Joint<'_, T, D, O, K>, InferenceError> {
|
||||||
if self.time_slices.is_empty() {
|
if self.time_slices.is_empty() {
|
||||||
return Err(InferenceError::JointUnavailable {
|
return Err(InferenceError::JointUnavailable {
|
||||||
reason: "the history has no events",
|
reason: "the history has no events",
|
||||||
@@ -1138,70 +1269,28 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// The candidate matchup, expressed as the same kind of linear
|
|
||||||
// functional as the target.
|
|
||||||
let mut matchup: Vec<(&K, f64)> = Vec::new();
|
|
||||||
let mut noise = self.score_sigma * self.score_sigma;
|
|
||||||
for (team_idx, team) in teams.iter().enumerate() {
|
|
||||||
if team.is_empty() {
|
|
||||||
return Err(InferenceError::EmptyTeam { team: team_idx });
|
|
||||||
}
|
|
||||||
let sign = if team_idx == 0 { 1.0 } else { -1.0 };
|
|
||||||
for key in team.iter() {
|
|
||||||
matchup.push((*key, sign));
|
|
||||||
let beta = self
|
|
||||||
.keys
|
|
||||||
.get(*key)
|
|
||||||
.map_or(self.beta, |index| self.agents[index].rating.beta);
|
|
||||||
noise += beta * beta;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let TimeExpanded {
|
let TimeExpanded {
|
||||||
lambda,
|
lambda,
|
||||||
latest,
|
latest,
|
||||||
|
at_slice,
|
||||||
width,
|
width,
|
||||||
..
|
|
||||||
} = self.time_expanded_joint();
|
} = self.time_expanded_joint();
|
||||||
|
|
||||||
let target = self.resolve_terms(target, width, |i| latest.get(&i).copied())?;
|
let cholesky = crate::joint::Cholesky::factor(lambda, width).ok_or(
|
||||||
let matchup = self.resolve_terms(&matchup, width, |i| latest.get(&i).copied())?;
|
|
||||||
let (target_contrast, target_unseen) = (target.contrast, target.unseen);
|
|
||||||
let (matchup_contrast, matchup_unseen) = (matchup.contrast, matchup.unseen);
|
|
||||||
|
|
||||||
// One solve: z = L^-1 a serves both inner products, since
|
|
||||||
// c^T L^-1 a = c^T z and a^T L^-1 a = a^T z.
|
|
||||||
let z = crate::joint::solve_spd(lambda, &matchup_contrast).ok_or(
|
|
||||||
InferenceError::JointUnavailable {
|
InferenceError::JointUnavailable {
|
||||||
reason: "the precision matrix is not positive-definite",
|
reason: "the precision matrix is not positive-definite, which means \
|
||||||
|
a competitor has neither a proper prior nor any evidence",
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let prior_var = self.sigma * self.sigma;
|
Ok(Joint {
|
||||||
// Competitors outside the history are independent, so they contribute
|
history: self,
|
||||||
// only where the same key appears in both functionals.
|
cholesky,
|
||||||
let cross_unseen: f64 = target_unseen
|
latest,
|
||||||
.iter()
|
at_slice,
|
||||||
.map(|(k, tc)| tc * matchup_unseen.get(k).copied().unwrap_or(0.0) * prior_var)
|
width,
|
||||||
.sum();
|
})
|
||||||
let self_unseen: f64 = matchup_unseen.values().map(|c| c * c * prior_var).sum();
|
|
||||||
|
|
||||||
let cross: f64 = target_contrast
|
|
||||||
.iter()
|
|
||||||
.zip(&z)
|
|
||||||
.map(|(c, z)| c * z)
|
|
||||||
.sum::<f64>()
|
|
||||||
+ cross_unseen;
|
|
||||||
let matchup_var: f64 = matchup_contrast
|
|
||||||
.iter()
|
|
||||||
.zip(&z)
|
|
||||||
.map(|(a, z)| a * z)
|
|
||||||
.sum::<f64>()
|
|
||||||
+ self_unseen;
|
|
||||||
|
|
||||||
Ok(cross * cross / (noise + matchup_var))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Predictive distribution of the score margin between two teams.
|
/// Predictive distribution of the score margin between two teams.
|
||||||
///
|
///
|
||||||
/// Answers "what will the gap be, and how wide is that interval" for a
|
/// Answers "what will the gap be, and how wide is that interval" for a
|
||||||
@@ -1460,17 +1549,62 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the full forward+backward convergence loop and return a summary.
|
/// Run the full forward+backward convergence loop to a fixed point.
|
||||||
///
|
///
|
||||||
/// Failing to reach `epsilon` within `max_iter` is not an error: the
|
/// # Stopping short is an error
|
||||||
/// returned report carries `converged: false` and the final step.
|
///
|
||||||
|
/// Hitting `max_iter` without reaching `epsilon` returns `NotConverged`.
|
||||||
|
///
|
||||||
|
/// It used to return `Ok` with `converged: false`, which was the worst
|
||||||
|
/// available shape. A fit that stops short is *wrong by a little*: every
|
||||||
|
/// rating is finite, the ordering looks sensible, and nothing about the
|
||||||
|
/// output says the numbers were still moving. Detection was opt-in, and
|
||||||
|
/// `let _ = h.converge()` silently opted out — which is how a real defect
|
||||||
|
/// hid in this crate's own test suite.
|
||||||
|
///
|
||||||
|
/// The default `max_iter` is [`ITERATIONS`](crate::ITERATIONS), which is
|
||||||
|
/// set high enough that reaching it means something is genuinely wrong
|
||||||
|
/// rather than that the history is merely large. Raising the cap costs
|
||||||
|
/// nothing when it is not needed, because the loop exits at `epsilon`.
|
||||||
|
///
|
||||||
|
/// Use [`History::converge_partial`] when a capped, unconverged fit is
|
||||||
|
/// what you actually want.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
|
/// `NotConverged` if the sweep hits `max_iter` with the step still above
|
||||||
|
/// `epsilon`.
|
||||||
|
///
|
||||||
/// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has
|
/// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has
|
||||||
/// broken down at that point and further iterations cannot recover, so the
|
/// broken down at that point and further iterations cannot recover, so the
|
||||||
/// loop stops rather than reporting a NaN step as convergence.
|
/// loop stops rather than reporting a NaN step as convergence.
|
||||||
pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> {
|
pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> {
|
||||||
|
let report = self.converge_partial()?;
|
||||||
|
|
||||||
|
if report.converged {
|
||||||
|
Ok(report)
|
||||||
|
} else {
|
||||||
|
Err(InferenceError::NotConverged {
|
||||||
|
iterations: report.iterations,
|
||||||
|
final_step: report.final_step,
|
||||||
|
epsilon: self.convergence.epsilon,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As [`History::converge`], but a fit that stops at `max_iter` is
|
||||||
|
/// returned rather than reported as an error.
|
||||||
|
///
|
||||||
|
/// The report's `converged` flag says which happened. Use this when a
|
||||||
|
/// deliberately capped sweep is the point — a cheap approximate fit, or a
|
||||||
|
/// test that pins what a fixed number of iterations produces. Prefer
|
||||||
|
/// `converge` everywhere else: an unconverged fit that nobody checks is
|
||||||
|
/// indistinguishable from a converged one.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// `NonFiniteResult` if a sweep produces a NaN or infinite step.
|
||||||
|
pub fn converge_partial(&mut self) -> Result<ConvergenceReport, InferenceError> {
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
@@ -1575,6 +1709,73 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chokepoint for event shape, for the same reason as the tie check
|
||||||
|
// below: every ingestion route lands here.
|
||||||
|
//
|
||||||
|
// `run_chain` builds one diff link per adjacent pair of teams, so a
|
||||||
|
// one-team event leaves it with an empty link vector and panics
|
||||||
|
// indexing `links[1..]` — a reachable panic from safe API, in release.
|
||||||
|
// An empty team is the quieter half: it contributes no performance,
|
||||||
|
// so a malformed event yields a finite, plausible-looking posterior
|
||||||
|
// for whoever it was matched against.
|
||||||
|
//
|
||||||
|
// Both errors already existed; they were only ever checked on the
|
||||||
|
// prediction paths, which is why ingestion could still produce them.
|
||||||
|
for teams in &composition {
|
||||||
|
if teams.len() < 2 {
|
||||||
|
return Err(InferenceError::NotEnoughTeams { got: teams.len() });
|
||||||
|
}
|
||||||
|
for (team, members) in teams.iter().enumerate() {
|
||||||
|
if members.is_empty() {
|
||||||
|
return Err(InferenceError::EmptyTeam { team });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-finite outcome poisons the history rather than failing it:
|
||||||
|
// `converge` does report `NonFiniteResult`, but a caller who reads
|
||||||
|
// `current_skill` before converging is handed a NaN posterior with
|
||||||
|
// nothing to say it is one.
|
||||||
|
if let Some(results) = results.as_ref() {
|
||||||
|
for (event_results, kind) in results.iter().zip(kinds.iter()) {
|
||||||
|
let name = match kind {
|
||||||
|
EventKind::Ranked => "rank",
|
||||||
|
EventKind::Scored { .. } => "score",
|
||||||
|
};
|
||||||
|
for value in event_results {
|
||||||
|
if !value.is_finite() {
|
||||||
|
return Err(InferenceError::InvalidParameter {
|
||||||
|
name,
|
||||||
|
value: *value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-finite weight is not a weight. Measured, it behaves exactly as
|
||||||
|
// `0.0` — the member contributes nothing — while `converge` reports
|
||||||
|
// `converged: true` after one iteration with a step of `(0.0, 0.0)`.
|
||||||
|
// So a NaN arriving from a division or a parse is indistinguishable
|
||||||
|
// from a deliberate zero, and looks like a clean fit.
|
||||||
|
//
|
||||||
|
// Zero and negative weights stay accepted: both are expressible
|
||||||
|
// choices about how much a member contributes, and
|
||||||
|
// `tests/degenerate_inputs.rs` pins them deliberately. Only the values
|
||||||
|
// that are not quantities at all are rejected.
|
||||||
|
if let Some(weights) = weights.as_ref() {
|
||||||
|
for team_weights in weights.iter().flatten() {
|
||||||
|
for weight in team_weights {
|
||||||
|
if !weight.is_finite() {
|
||||||
|
return Err(InferenceError::InvalidParameter {
|
||||||
|
name: "weight",
|
||||||
|
value: *weight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chokepoint for tie validation: every ingestion route lands here,
|
// Chokepoint for tie validation: every ingestion route lands here,
|
||||||
// including `record_draw`, which builds its results directly rather
|
// including `record_draw`, which builds its results directly rather
|
||||||
// than going through `Outcome`.
|
// than going through `Outcome`.
|
||||||
@@ -1590,6 +1791,47 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-batch conflict. The in-batch check upstream rejects one batch
|
||||||
|
// that sets a field twice; `priors` is rebuilt per call, so without
|
||||||
|
// this a *second* batch could quietly overwrite what a first one
|
||||||
|
// declared, last-write-wins.
|
||||||
|
//
|
||||||
|
// That asymmetry cut against the invariant `tests/ingestion_equivalence.rs`
|
||||||
|
// exists to protect: the same contradictory events errored when
|
||||||
|
// batched and succeeded, order-dependently, when fed one at a time.
|
||||||
|
// Checked before anything mutates, so a rejected batch leaves the
|
||||||
|
// history untouched.
|
||||||
|
for (agent, batch) in &priors {
|
||||||
|
let held = self.declared.get(agent).copied().unwrap_or_default();
|
||||||
|
|
||||||
|
if let (Some(existing), Some(new)) = (held.prior, batch.prior) {
|
||||||
|
if existing != new {
|
||||||
|
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||||
|
competitor: agent.get(),
|
||||||
|
field: "prior",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let (Some(existing), Some(new)) = (held.drift_scale, batch.drift_scale) {
|
||||||
|
if existing != new {
|
||||||
|
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||||
|
competitor: agent.get(),
|
||||||
|
field: "drift_scale",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (agent, batch) in &priors {
|
||||||
|
let entry = self.declared.entry(*agent).or_default();
|
||||||
|
if batch.prior.is_some() {
|
||||||
|
entry.prior = batch.prior;
|
||||||
|
}
|
||||||
|
if batch.drift_scale.is_some() {
|
||||||
|
entry.drift_scale = batch.drift_scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
competitor::clean(self.agents.values_mut(), true);
|
competitor::clean(self.agents.values_mut(), true);
|
||||||
|
|
||||||
let mut this_agent = Vec::with_capacity(1024);
|
let mut this_agent = Vec::with_capacity(1024);
|
||||||
@@ -1601,7 +1843,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
|
|
||||||
this_agent.push(*agent);
|
this_agent.push(*agent);
|
||||||
|
|
||||||
let config = priors.get(agent).copied().unwrap_or_default();
|
// From `declared` rather than `priors`: a competitor configured by
|
||||||
|
// `register` before any event has nothing in this batch's map.
|
||||||
|
let config = self.declared.get(agent).copied().unwrap_or_default();
|
||||||
|
|
||||||
if self.agents.contains(*agent) {
|
if self.agents.contains(*agent) {
|
||||||
// Seeding a competitor the history already knows. This used to
|
// Seeding a competitor the history already knows. This used to
|
||||||
@@ -1980,6 +2224,228 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A factorised joint posterior, reusable across many queries.
|
||||||
|
///
|
||||||
|
/// Built by [`History::joint`]. Every question the joint answers — the width of
|
||||||
|
/// a contrast, the covariance of two, how much a candidate matchup would
|
||||||
|
/// sharpen either — is a bilinear form in the inverse precision matrix, and all
|
||||||
|
/// of them share one factorisation. That factorisation is the whole cost:
|
||||||
|
/// `O(n^3)` in the history's appearances to build, `O(n^2)` per question after.
|
||||||
|
///
|
||||||
|
/// The handle borrows the history, so no refit can run and no events can be
|
||||||
|
/// added while it is alive. That is what makes it correct without any
|
||||||
|
/// invalidation logic: there is no window in which the factorisation could
|
||||||
|
/// describe a fit that no longer exists.
|
||||||
|
///
|
||||||
|
/// # What the cost actually scales in
|
||||||
|
///
|
||||||
|
/// Not competitors, and not slices times competitors. One variable per
|
||||||
|
/// *appearance* — a competitor per slice they appear in — minus every
|
||||||
|
/// consecutive pair with no drift between them, which collapse to a single
|
||||||
|
/// latent variable.
|
||||||
|
///
|
||||||
|
/// That last clause dominates, and it is not obvious. A competitor whose drift
|
||||||
|
/// is zero contributes **one** variable however long the history: whole-history
|
||||||
|
/// `gamma = 0`, or `drift_scale = 0` on that competitor. So two fits over the
|
||||||
|
/// same events and the same slices can differ in problem size by roughly the
|
||||||
|
/// slice count, and in factorisation time by its cube. Measured by a consumer
|
||||||
|
/// on a ~2,000-node model over 76 slices:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// career fit (gamma = 0) 787 ms per solve
|
||||||
|
/// drifting fit (gamma = 0.15) 6214 ms per solve
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Choosing between a drifting and a drift-free configuration is therefore also
|
||||||
|
/// choosing an 8x difference in query cost. [`Joint::variables`] reports the
|
||||||
|
/// number that decides it, and can be read before committing to a batch of
|
||||||
|
/// queries.
|
||||||
|
///
|
||||||
|
/// Slices a competitor sits out cost nothing: an absence is not an appearance,
|
||||||
|
/// so a competitor seen in the first and last of a hundred slices contributes
|
||||||
|
/// two variables, not a hundred.
|
||||||
|
pub struct Joint<'h, T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> {
|
||||||
|
history: &'h History<T, D, O, K>,
|
||||||
|
cholesky: crate::joint::Cholesky,
|
||||||
|
/// `(row, slice)` of each competitor's latest appearance.
|
||||||
|
latest: HashMap<Index, (usize, usize)>,
|
||||||
|
/// Row of each `(competitor, slice)` appearance.
|
||||||
|
at_slice: HashMap<(Index, usize), usize>,
|
||||||
|
/// Side length of the precision matrix.
|
||||||
|
width: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deliberately does not print the factorisation, which is `n^2` floats and
|
||||||
|
/// would make a `{:?}` of a large joint unreadable and slow.
|
||||||
|
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> std::fmt::Debug
|
||||||
|
for Joint<'_, T, D, O, K>
|
||||||
|
{
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Joint")
|
||||||
|
.field("variables", &self.width)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D, O, K> {
|
||||||
|
/// Number of variables in the joint: the history's appearances, after
|
||||||
|
/// collapsing consecutive pairs a competitor does not drift between.
|
||||||
|
///
|
||||||
|
/// This is what the cost scales in — `O(n^3)` to factorise, `O(n^2)` per
|
||||||
|
/// query — and it is neither the competitor count nor slices times
|
||||||
|
/// competitors. A drift-free competitor contributes one variable however
|
||||||
|
/// many slices they appear in; see the type docs for how large that
|
||||||
|
/// difference gets.
|
||||||
|
///
|
||||||
|
/// Worth reading before committing to a batch of queries: it is the one
|
||||||
|
/// number that says whether a joint over this history is affordable.
|
||||||
|
#[must_use]
|
||||||
|
pub fn variables(&self) -> usize {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a resolved functional into its posterior.
|
||||||
|
///
|
||||||
|
/// The variance is `|L^-1 c|^2` over the competitors the history knows,
|
||||||
|
/// plus an independent prior variance for each competitor it does not —
|
||||||
|
/// unseen competitors are uncorrelated with everything by construction.
|
||||||
|
fn distribution(&self, resolved: &ResolvedTerms) -> Gaussian {
|
||||||
|
let y = self.cholesky.whiten(&resolved.contrast);
|
||||||
|
let prior_var = self.history.sigma * self.history.sigma;
|
||||||
|
let variance = crate::joint::bilinear(&y, &y)
|
||||||
|
+ resolved
|
||||||
|
.unseen
|
||||||
|
.values()
|
||||||
|
.map(|c| c * c * prior_var)
|
||||||
|
.sum::<f64>();
|
||||||
|
Gaussian::from_mv(resolved.mean, variance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posterior of a linear combination of competitors' skills.
|
||||||
|
///
|
||||||
|
/// Identical to [`History::posterior_of`], including which appearance each
|
||||||
|
/// competitor is read at, without re-paying the factorisation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// `UnknownKey` for a competitor the history has never seen.
|
||||||
|
pub fn posterior_of(&self, terms: &[(&K, f64)]) -> Result<Gaussian, InferenceError>
|
||||||
|
where
|
||||||
|
K: std::fmt::Debug,
|
||||||
|
{
|
||||||
|
let resolved = self
|
||||||
|
.history
|
||||||
|
.resolve_terms(terms, self.width, |index| self.latest.get(&index).copied())?;
|
||||||
|
Ok(self.distribution(&resolved))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posterior of a linear combination, read as of `time`.
|
||||||
|
///
|
||||||
|
/// Identical to [`History::posterior_of_at`] without re-paying the
|
||||||
|
/// factorisation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// `UnknownKey` for a competitor with no appearance at or before `time`.
|
||||||
|
pub fn posterior_of_at(&self, time: T, terms: &[(&K, f64)]) -> Result<Gaussian, InferenceError>
|
||||||
|
where
|
||||||
|
K: std::fmt::Debug,
|
||||||
|
{
|
||||||
|
let as_of = self.rows_as_of(time);
|
||||||
|
let resolved = self
|
||||||
|
.history
|
||||||
|
.resolve_terms(terms, self.width, |index| as_of.get(&index).copied())?;
|
||||||
|
Ok(self.distribution(&resolved))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Latest appearance at or before `time`, per competitor.
|
||||||
|
fn rows_as_of(&self, time: T) -> HashMap<Index, (usize, usize)> {
|
||||||
|
let mut as_of: HashMap<Index, (usize, usize)> = HashMap::new();
|
||||||
|
for (slice_idx, slice) in self.history.time_slices.iter().enumerate() {
|
||||||
|
if slice.time > time {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (agent, _) in slice.appearances() {
|
||||||
|
if let Some(row) = self.at_slice.get(&(agent, slice_idx)) {
|
||||||
|
as_of.insert(agent, (*row, slice_idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
as_of
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much observing this matchup would shrink the variance of `target`.
|
||||||
|
///
|
||||||
|
/// Identical to [`History::expected_variance_reduction`] without re-paying
|
||||||
|
/// the factorisation, which is the shape this call is normally used in:
|
||||||
|
/// one target, a field of candidate matchups, one unchanged fit.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam` for
|
||||||
|
/// an empty one, and `UnknownKey` for an unseen competitor.
|
||||||
|
pub fn expected_variance_reduction(
|
||||||
|
&self,
|
||||||
|
teams: &[&[&K]],
|
||||||
|
target: &[(&K, f64)],
|
||||||
|
) -> Result<f64, InferenceError>
|
||||||
|
where
|
||||||
|
K: std::fmt::Debug,
|
||||||
|
{
|
||||||
|
if teams.len() != 2 {
|
||||||
|
return Err(InferenceError::MismatchedShape {
|
||||||
|
kind: "expected_variance_reduction takes exactly 2 teams",
|
||||||
|
expected: 2,
|
||||||
|
got: teams.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The candidate matchup, expressed as the same kind of linear
|
||||||
|
// functional as the target.
|
||||||
|
let mut matchup: Vec<(&K, f64)> = Vec::new();
|
||||||
|
let mut noise = self.history.score_sigma * self.history.score_sigma;
|
||||||
|
for (team_idx, team) in teams.iter().enumerate() {
|
||||||
|
if team.is_empty() {
|
||||||
|
return Err(InferenceError::EmptyTeam { team: team_idx });
|
||||||
|
}
|
||||||
|
let sign = if team_idx == 0 { 1.0 } else { -1.0 };
|
||||||
|
for key in team.iter() {
|
||||||
|
matchup.push((*key, sign));
|
||||||
|
let beta = self
|
||||||
|
.history
|
||||||
|
.keys
|
||||||
|
.get(*key)
|
||||||
|
.map_or(self.history.beta, |index| {
|
||||||
|
self.history.agents[index].rating.beta
|
||||||
|
});
|
||||||
|
noise += beta * beta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let row_for = |index: Index| self.latest.get(&index).copied();
|
||||||
|
let target = self.history.resolve_terms(target, self.width, row_for)?;
|
||||||
|
let matchup = self.history.resolve_terms(&matchup, self.width, row_for)?;
|
||||||
|
|
||||||
|
let y_target = self.cholesky.whiten(&target.contrast);
|
||||||
|
let y_matchup = self.cholesky.whiten(&matchup.contrast);
|
||||||
|
|
||||||
|
let prior_var = self.history.sigma * self.history.sigma;
|
||||||
|
// Competitors outside the history are independent, so they contribute
|
||||||
|
// only where the same key appears in both functionals.
|
||||||
|
let cross_unseen: f64 = target
|
||||||
|
.unseen
|
||||||
|
.iter()
|
||||||
|
.map(|(k, tc)| tc * matchup.unseen.get(k).copied().unwrap_or(0.0) * prior_var)
|
||||||
|
.sum();
|
||||||
|
let self_unseen: f64 = matchup.unseen.values().map(|c| c * c * prior_var).sum();
|
||||||
|
|
||||||
|
let cross = crate::joint::bilinear(&y_target, &y_matchup) + cross_unseen;
|
||||||
|
let matchup_var = crate::joint::bilinear(&y_matchup, &y_matchup) + self_unseen;
|
||||||
|
|
||||||
|
Ok(cross * cross / (noise + matchup_var))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use approx::assert_ulps_eq;
|
use approx::assert_ulps_eq;
|
||||||
@@ -2636,13 +3102,15 @@ mod tests {
|
|||||||
epsilon = 1e-6
|
epsilon = 1e-6
|
||||||
);
|
);
|
||||||
|
|
||||||
// run exactly 11 iterations (old test used convergence(11, ...))
|
// Run exactly 11 iterations. `converge_partial` rather than
|
||||||
|
// `converge`: stopping at the cap is the point here, and `converge`
|
||||||
|
// now reports that as `NotConverged`.
|
||||||
h.convergence = ConvergenceOptions {
|
h.convergence = ConvergenceOptions {
|
||||||
max_iter: 11,
|
max_iter: 11,
|
||||||
epsilon: EPSILON,
|
epsilon: EPSILON,
|
||||||
alpha: 1.0,
|
alpha: 1.0,
|
||||||
};
|
};
|
||||||
let _ = h.converge().unwrap();
|
let _ = h.converge_partial().unwrap();
|
||||||
|
|
||||||
let loocv_approx_2 = h.log_evidence_internal(false, &[]).exp().sqrt();
|
let loocv_approx_2 = h.log_evidence_internal(false, &[]).exp().sqrt();
|
||||||
|
|
||||||
@@ -3009,7 +3477,9 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
events_for(&mut h_capped);
|
events_for(&mut h_capped);
|
||||||
let _ = h_capped.converge().unwrap();
|
// A one-iteration cap is deliberate here, so the short fit is the
|
||||||
|
// result rather than an error.
|
||||||
|
let _ = h_capped.converge_partial().unwrap();
|
||||||
|
|
||||||
let mut h_full: History<i64, _, _, &'static str> = History::builder().build();
|
let mut h_full: History<i64, _, _, &'static str> = History::builder().build();
|
||||||
events_for(&mut h_full);
|
events_for(&mut h_full);
|
||||||
|
|||||||
+100
-47
@@ -1,34 +1,54 @@
|
|||||||
//! Posterior of a linear combination of competitors.
|
//! Cholesky factorisation of a joint precision matrix.
|
||||||
//!
|
//!
|
||||||
//! Every accessor on `History` returns a per-competitor marginal, and almost
|
//! Every question the joint answers is a *bilinear form* in the precision
|
||||||
//! nothing a consumer publishes is one competitor: "can we tell these two
|
//! matrix's inverse — the variance of a contrast is `c^T L^-1 c`, and the
|
||||||
//! apart" is a difference, "what was this round worth" is a sum. Combining
|
//! covariance of two contrasts is `c^T L^-1 a`. None of them wants `L^-1 c`
|
||||||
//! marginals means assuming the competitors are independent, and they are
|
//! itself, which is what makes the shape here worth stating explicitly.
|
||||||
//! correlated through every event they share — which is the mechanism the model
|
|
||||||
//! exists to exploit.
|
|
||||||
//!
|
//!
|
||||||
//! Measured on a five-competitor round robin, the exact correlation is +0.857,
|
//! Writing the precision as `A = L L^T`,
|
||||||
//! so `sqrt(sa^2 + sb^2)` overstates the width of a difference by 2.6x.
|
//!
|
||||||
|
//! ```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.
|
||||||
|
|
||||||
/// Solve `A z = b` for a symmetric positive-definite `A`, by Cholesky.
|
/// A factorised symmetric positive-definite matrix, reusable across queries.
|
||||||
///
|
pub(crate) struct Cholesky {
|
||||||
/// `a` is row-major and is consumed as scratch.
|
/// Lower triangle of `L`, row-major `n * n`. The upper triangle is
|
||||||
///
|
/// leftover scratch from the factorisation and is never read.
|
||||||
/// Returns `None` if the matrix is not positive-definite, which for a precision
|
l: Vec<f64>,
|
||||||
/// matrix means the model is improper — a competitor with no prior and no
|
n: usize,
|
||||||
/// evidence.
|
}
|
||||||
pub(crate) fn solve_spd(mut a: Vec<f64>, b: &[f64]) -> Option<Vec<f64>> {
|
|
||||||
let n = b.len();
|
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);
|
debug_assert_eq!(a.len(), n * n);
|
||||||
|
|
||||||
// In-place Cholesky: A = L L^T, lower triangle.
|
|
||||||
for j in 0..n {
|
for j in 0..n {
|
||||||
let mut d = a[j * n + j];
|
let mut d = a[j * n + j];
|
||||||
for k in 0..j {
|
for k in 0..j {
|
||||||
d -= a[j * n + k] * a[j * n + k];
|
d -= a[j * n + k] * a[j * n + k];
|
||||||
}
|
}
|
||||||
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here too,
|
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here
|
||||||
// and a negated comparison would let it through as "not positive".
|
// too, and a negated comparison would let it through as "not
|
||||||
|
// positive".
|
||||||
if d.is_nan() || d <= 0.0 {
|
if d.is_nan() || d <= 0.0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -44,56 +64,89 @@ pub(crate) fn solve_spd(mut a: Vec<f64>, b: &[f64]) -> Option<Vec<f64>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward substitution, then back substitution.
|
Some(Self { l: a, n })
|
||||||
let mut z = b.to_vec();
|
|
||||||
for i in 0..n {
|
|
||||||
let mut s = z[i];
|
|
||||||
for k in 0..i {
|
|
||||||
s -= a[i * n + k] * z[k];
|
|
||||||
}
|
|
||||||
z[i] = s / a[i * n + i];
|
|
||||||
}
|
|
||||||
for i in (0..n).rev() {
|
|
||||||
let mut s = z[i];
|
|
||||||
for k in i + 1..n {
|
|
||||||
s -= a[k * n + i] * z[k];
|
|
||||||
}
|
|
||||||
z[i] = s / a[i * n + i];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(z)
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn solves_a_known_system() {
|
fn reproduces_a_known_quadratic_form() {
|
||||||
// [[4, 1], [1, 3]] z = [1, 2] => z = [1/11, 7/11]
|
let c = Cholesky::factor(vec![4.0, 1.0, 1.0, 3.0], 2).unwrap();
|
||||||
let a = vec![4.0, 1.0, 1.0, 3.0];
|
let y = c.whiten(&[1.0, 2.0]);
|
||||||
let z = solve_spd(a, &[1.0, 2.0]).unwrap();
|
assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12);
|
||||||
assert!((z[0] - 1.0 / 11.0).abs() < 1e-12, "{z:?}");
|
|
||||||
assert!((z[1] - 7.0 / 11.0).abs() < 1e-12, "{z:?}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whitening `e_i` recovers the inverse's diagonal, which is the variance
|
||||||
|
/// of a single variable.
|
||||||
#[test]
|
#[test]
|
||||||
fn recovers_the_inverse_diagonal() {
|
fn recovers_the_inverse_diagonal() {
|
||||||
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
|
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
|
||||||
// [0.75, 1.0, 0.75].
|
// [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 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() {
|
for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() {
|
||||||
let mut e = vec![0.0; 3];
|
let mut e = vec![0.0; 3];
|
||||||
e[i] = 1.0;
|
e[i] = 1.0;
|
||||||
let z = solve_spd(a.clone(), &e).unwrap();
|
let y = c.whiten(&e);
|
||||||
assert!((z[i] - expected).abs() < 1e-12, "row {i}: {z:?}");
|
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]
|
#[test]
|
||||||
fn rejects_a_non_positive_definite_matrix() {
|
fn rejects_a_non_positive_definite_matrix() {
|
||||||
// Singular: the second row is a multiple of the first.
|
// Singular: the second row is a multiple of the first.
|
||||||
let a = vec![1.0, 2.0, 2.0, 4.0];
|
assert!(Cholesky::factor(vec![1.0, 2.0, 2.0, 4.0], 2).is_none());
|
||||||
assert!(solve_spd(a, &[1.0, 1.0]).is_none());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-15
@@ -141,7 +141,7 @@ 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;
|
pub use key_table::KeyTable;
|
||||||
use matrix::Matrix;
|
use matrix::Matrix;
|
||||||
pub use observer::{NullObserver, Observer};
|
pub use observer::{NullObserver, Observer};
|
||||||
@@ -158,22 +158,43 @@ pub const P_DRAW: f64 = 0.0;
|
|||||||
pub const EPSILON: f64 = 1e-6;
|
pub const EPSILON: f64 = 1e-6;
|
||||||
/// Default cap on convergence sweeps.
|
/// Default cap on convergence sweeps.
|
||||||
///
|
///
|
||||||
/// **This is a floor, not a recommendation.** It is adequate for small
|
/// **A runaway guard, not a budget.** The sweep exits as soon as the step falls
|
||||||
/// histories and is quickly outgrown: a history of 400 events over 100
|
/// below `epsilon`, so the cap is never reached by a history that converges and
|
||||||
/// competitors already stops here with a final step of ~7e-3 against the 1e-6
|
/// raising it costs nothing. Measured on a history that needs four sweeps:
|
||||||
/// default tolerance — four orders of magnitude short — and a dense joint model
|
|
||||||
/// of ~2,000 nodes over ~3,300 events has been measured needing 76 to 161.
|
|
||||||
///
|
///
|
||||||
/// Overrunning it is not an error, and deliberately so: `converge` returns a
|
/// ```text
|
||||||
/// [`ConvergenceReport`] whose `converged` flag says what happened. But a fit
|
/// max_iter 30: 4 iterations, 129.9 us
|
||||||
/// that stopped short is *wrong by a little*, which is the worst available
|
/// max_iter 100_000: 4 iterations, 131.9 us
|
||||||
/// failure — every rating is finite and ordered sensibly, and nothing in the
|
/// ```
|
||||||
/// numbers themselves says they were still moving. Read the report; the type is
|
|
||||||
/// `#[must_use]` for that reason.
|
|
||||||
///
|
///
|
||||||
/// Raise it via [`ConvergenceOptions`]. Convergence cost is roughly linear in
|
/// This was `30` until it was measured, and 30 truncated ordinary healthy
|
||||||
/// the cap, and for anything but a toy the extra sweeps are milliseconds.
|
/// histories: 160 events over 100 competitors already needs 42. Because a short
|
||||||
pub const ITERATIONS: usize = 30;
|
/// 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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! Stopping short of convergence is an error, not a flag on a success.
|
||||||
|
//!
|
||||||
|
//! A fit that hits `max_iter` is wrong by a little: every rating is finite,
|
||||||
|
//! the ordering looks sensible, and nothing in the numbers says they were
|
||||||
|
//! still moving. When that was `Ok` with `converged: false`, detecting it was
|
||||||
|
//! opt-in and `let _ = h.converge()` was the natural way to opt out — which is
|
||||||
|
//! how a real defect once hid in this crate's own suite.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
|
||||||
|
};
|
||||||
|
|
||||||
|
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||||
|
|
||||||
|
fn duel(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> {
|
||||||
|
Event {
|
||||||
|
time: t,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a)]),
|
||||||
|
Team::with_members([Member::new(b)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([3.0, 1.0]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capped(max_iter: usize) -> H {
|
||||||
|
History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift(0.5))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill(h: &mut H) {
|
||||||
|
h.add_events((1..=6).map(|t| duel("a", "b", t)).collect::<Vec<_>>())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hitting_the_cap_is_an_error() {
|
||||||
|
let mut h = capped(1);
|
||||||
|
fill(&mut h);
|
||||||
|
let err = h.converge().unwrap_err();
|
||||||
|
match err {
|
||||||
|
InferenceError::NotConverged {
|
||||||
|
iterations,
|
||||||
|
final_step,
|
||||||
|
epsilon,
|
||||||
|
} => {
|
||||||
|
assert_eq!(iterations, 1);
|
||||||
|
assert!(
|
||||||
|
final_step.0 > epsilon || final_step.1 > epsilon,
|
||||||
|
"{final_step:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected NotConverged, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message has to name what to do about it, since the fit looks fine.
|
||||||
|
#[test]
|
||||||
|
fn the_error_says_how_to_fix_it() {
|
||||||
|
let mut h = capped(1);
|
||||||
|
fill(&mut h);
|
||||||
|
let text = h.converge().unwrap_err().to_string();
|
||||||
|
assert!(text.contains("did not converge in 1 iterations"), "{text}");
|
||||||
|
assert!(text.contains("max_iter"), "{text}");
|
||||||
|
assert!(text.contains("alpha"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The escape hatch: a deliberately capped fit is still reachable.
|
||||||
|
#[test]
|
||||||
|
fn converge_partial_returns_the_short_fit() {
|
||||||
|
let mut h = capped(1);
|
||||||
|
fill(&mut h);
|
||||||
|
let report = h.converge_partial().unwrap();
|
||||||
|
assert_eq!(report.iterations, 1);
|
||||||
|
assert!(!report.converged);
|
||||||
|
assert!(h.current_skill(&"a").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both agree when the fit does converge, so the strict path costs nothing.
|
||||||
|
#[test]
|
||||||
|
fn the_two_agree_on_a_converged_fit() {
|
||||||
|
let mut strict = capped(20_000);
|
||||||
|
fill(&mut strict);
|
||||||
|
let a = strict.converge().unwrap();
|
||||||
|
|
||||||
|
let mut partial = capped(20_000);
|
||||||
|
fill(&mut partial);
|
||||||
|
let b = partial.converge_partial().unwrap();
|
||||||
|
|
||||||
|
assert!(a.converged && b.converged);
|
||||||
|
assert_eq!(a.iterations, b.iterations);
|
||||||
|
assert_eq!(a.final_step, b.final_step);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default cap must be high enough that an ordinary history clears it.
|
||||||
|
/// At the old value of 30 this history stopped short and said nothing.
|
||||||
|
#[test]
|
||||||
|
fn the_default_cap_clears_an_ordinary_history() {
|
||||||
|
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift(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);
|
||||||
|
}
|
||||||
@@ -170,8 +170,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"])
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
//! `EventBuilder::members` must reach exactly what the typed path reaches.
|
||||||
|
//!
|
||||||
|
//! Before this existed, `EventBuilder` could set weights and nothing else, so
|
||||||
|
//! `prior` and `drift_scale` were expressible only through `Event`/`Team`/
|
||||||
|
//! `Member` + `add_events`. Which ingestion route a competitor arrived through
|
||||||
|
//! decided whether it could be configured at all.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
|
||||||
|
Team,
|
||||||
|
};
|
||||||
|
|
||||||
|
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||||
|
|
||||||
|
fn history() -> H {
|
||||||
|
History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift(0.5))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIOR: Gaussian = Gaussian::from_ms(3.0, 1.5);
|
||||||
|
|
||||||
|
/// The contract that makes the escape hatch worth having: same configuration,
|
||||||
|
/// same fit, bit for bit.
|
||||||
|
#[test]
|
||||||
|
fn members_matches_the_typed_path_exactly() {
|
||||||
|
let mut typed = history();
|
||||||
|
typed
|
||||||
|
.add_events(vec![Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("player")]),
|
||||||
|
Team::with_members([Member::new("layout_7")
|
||||||
|
.with_drift_scale(0.0)
|
||||||
|
.with_prior(PRIOR)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([5.0, 2.0]),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
assert!(typed.converge().unwrap().converged);
|
||||||
|
|
||||||
|
let mut fluent = history();
|
||||||
|
fluent
|
||||||
|
.event(1)
|
||||||
|
.team(["player"])
|
||||||
|
.members([Member::new("layout_7")
|
||||||
|
.with_drift_scale(0.0)
|
||||||
|
.with_prior(PRIOR)])
|
||||||
|
.scores([5.0, 2.0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
assert!(fluent.converge().unwrap().converged);
|
||||||
|
|
||||||
|
for key in ["player", "layout_7"] {
|
||||||
|
let a = typed.current_skill(&key).unwrap();
|
||||||
|
let b = fluent.current_skill(&key).unwrap();
|
||||||
|
assert_eq!(a.pi(), b.pi(), "{key} pi");
|
||||||
|
assert_eq!(a.tau(), b.tau(), "{key} tau");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configuration has to actually take effect, not merely round-trip: a
|
||||||
|
/// competitor pinned with `drift_scale = 0.0` must not move across slices,
|
||||||
|
/// where an unpinned one does.
|
||||||
|
///
|
||||||
|
/// The comparison is against a control rather than against a fixed epsilon.
|
||||||
|
/// Pinned marginals are not bit-identical across slices — each slice combines
|
||||||
|
/// its own forward and backward messages, so the arithmetic order differs and
|
||||||
|
/// the last bit moves. What "pinned" promises is that no drift variance
|
||||||
|
/// accumulates, and the control is what makes that measurable.
|
||||||
|
#[test]
|
||||||
|
fn a_drift_scale_set_through_members_is_applied() {
|
||||||
|
fn spread(h: &H, key: &'static str) -> f64 {
|
||||||
|
let curve = h.learning_curve(&key);
|
||||||
|
assert!(curve.len() >= 2, "{key}: expected several appearances");
|
||||||
|
let (lo, hi) = curve.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (_, g)| {
|
||||||
|
(lo.min(g.sigma()), hi.max(g.sigma()))
|
||||||
|
});
|
||||||
|
(hi - lo) / hi
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut h = history();
|
||||||
|
for t in 1..=4 {
|
||||||
|
h.event(t)
|
||||||
|
.team(["player"])
|
||||||
|
.members([Member::new("pinned").with_drift_scale(0.0)])
|
||||||
|
.scores([5.0, 2.0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
// Same shape, no pinning: the control.
|
||||||
|
h.event(t)
|
||||||
|
.team(["rival"])
|
||||||
|
.team(["drifting"])
|
||||||
|
.scores([5.0, 2.0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
|
||||||
|
let pinned = spread(&h, "pinned");
|
||||||
|
let drifting = spread(&h, "drifting");
|
||||||
|
assert!(pinned < 1e-9, "pinned competitor moved: {pinned:e}");
|
||||||
|
assert!(
|
||||||
|
drifting > 1e-3,
|
||||||
|
"control did not move, so the test proves nothing: {drifting:e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `weights` still applies to a team added through `members`, and still
|
||||||
|
/// records a mismatch rather than partially applying it.
|
||||||
|
#[test]
|
||||||
|
fn weights_still_guards_a_members_team() {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h
|
||||||
|
.event(1)
|
||||||
|
.team(["a"])
|
||||||
|
.members([Member::new("b"), Member::new("c")])
|
||||||
|
.weights([1.0])
|
||||||
|
.winner(0)
|
||||||
|
.commit()
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::MismatchedShape {
|
||||||
|
kind: "weights",
|
||||||
|
expected: 2,
|
||||||
|
got: 1
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
assert!(h.current_skill(&"b").is_none(), "nothing may reach history");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An invalid `drift_scale` surfaces from `commit`, not from a panic and not
|
||||||
|
/// silently.
|
||||||
|
#[test]
|
||||||
|
fn an_invalid_drift_scale_surfaces_from_commit() {
|
||||||
|
for bad in [-1.0, f64::NAN, f64::INFINITY] {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h
|
||||||
|
.event(1)
|
||||||
|
.team(["a"])
|
||||||
|
.members([Member::new("b").with_drift_scale(bad)])
|
||||||
|
.winner(0)
|
||||||
|
.commit()
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
name: "drift_scale",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"{bad}: {err:?}"
|
||||||
|
);
|
||||||
|
assert!(h.current_skill(&"b").is_none(), "{bad} reached the history");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `members` and `team` compose in either order.
|
||||||
|
#[test]
|
||||||
|
fn members_and_team_interleave() {
|
||||||
|
let mut h = history();
|
||||||
|
h.event(1)
|
||||||
|
.members([Member::new("a").with_prior(PRIOR)])
|
||||||
|
.team(["b"])
|
||||||
|
.scores([3.0, 1.0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
h.event(2)
|
||||||
|
.team(["b"])
|
||||||
|
.members([Member::new("c").with_prior(PRIOR)])
|
||||||
|
.scores([2.0, 4.0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
for key in ["a", "b", "c"] {
|
||||||
|
assert!(h.current_skill(&key).is_some(), "{key} missing");
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -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,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,267 @@
|
|||||||
|
//! `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(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(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(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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//! 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::{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());
|
||||||
|
}
|
||||||
@@ -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(0.5))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duel(
|
||||||
|
a: &'static str,
|
||||||
|
b: &'static str,
|
||||||
|
t: i64,
|
||||||
|
m: Option<Member<&'static str>>,
|
||||||
|
) -> Event<i64, &'static str> {
|
||||||
|
Event {
|
||||||
|
time: t,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a)]),
|
||||||
|
Team::with_members([m.unwrap_or_else(|| Member::new(b))]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([5.0, 2.0]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skills(h: &H) -> Vec<(&'static str, Gaussian)> {
|
||||||
|
["player", "layout"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|k| (k, h.current_skill(&k).unwrap()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The headline contract.
|
||||||
|
#[test]
|
||||||
|
fn registering_matches_configuring_on_the_first_event() {
|
||||||
|
let configured = {
|
||||||
|
let mut h = history();
|
||||||
|
h.add_events(vec![
|
||||||
|
duel(
|
||||||
|
"player",
|
||||||
|
"layout",
|
||||||
|
1,
|
||||||
|
Some(
|
||||||
|
Member::new("layout")
|
||||||
|
.with_drift_scale(0.0)
|
||||||
|
.with_prior(PINNED),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
duel("player", "layout", 2, None),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h
|
||||||
|
};
|
||||||
|
|
||||||
|
let registered = {
|
||||||
|
let mut h = history();
|
||||||
|
h.register(
|
||||||
|
Member::new("layout")
|
||||||
|
.with_drift_scale(0.0)
|
||||||
|
.with_prior(PINNED),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
h.add_events(vec![
|
||||||
|
duel("player", "layout", 1, None),
|
||||||
|
duel("player", "layout", 2, None),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h
|
||||||
|
};
|
||||||
|
|
||||||
|
for ((k, a), (_, b)) in skills(&configured).into_iter().zip(skills(®istered)) {
|
||||||
|
assert_eq!(a.pi(), b.pi(), "{k} pi");
|
||||||
|
assert_eq!(a.tau(), b.tau(), "{k} tau");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The case `EventBuilder` and the typed path cannot reach: a competitor whose
|
||||||
|
/// first appearance arrives through the two-argument convenience route.
|
||||||
|
#[test]
|
||||||
|
fn registration_reaches_a_competitor_first_seen_through_record_winner() {
|
||||||
|
let mut h = history();
|
||||||
|
h.register(
|
||||||
|
Member::new("layout")
|
||||||
|
.with_drift_scale(0.0)
|
||||||
|
.with_prior(PINNED),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
h.record_winner(&"player", &"layout", 1).unwrap();
|
||||||
|
h.record_winner(&"player", &"layout", 2).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let rating = h.rating(&"layout").unwrap();
|
||||||
|
assert_eq!(rating.drift_scale(), 0.0);
|
||||||
|
assert_eq!(rating.prior().mu(), PINNED.mu());
|
||||||
|
|
||||||
|
// Pinned means pinned: no drift across the two slices.
|
||||||
|
let curve = h.learning_curve(&"layout");
|
||||||
|
assert!(curve.len() >= 2);
|
||||||
|
let widest = curve
|
||||||
|
.iter()
|
||||||
|
.map(|(_, g)| g.sigma())
|
||||||
|
.fold(f64::MIN, f64::max);
|
||||||
|
let narrowest = curve
|
||||||
|
.iter()
|
||||||
|
.map(|(_, g)| g.sigma())
|
||||||
|
.fold(f64::MAX, f64::min);
|
||||||
|
assert!(
|
||||||
|
(widest - narrowest) / widest < 1e-9,
|
||||||
|
"{narrowest} .. {widest}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registering_a_known_competitor_is_an_error() {
|
||||||
|
let mut h = history();
|
||||||
|
h.record_winner(&"player", &"layout", 1).unwrap();
|
||||||
|
let err = h.register(Member::new("layout")).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::AlreadyRegistered { .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registering_twice_is_an_error() {
|
||||||
|
let mut h = history();
|
||||||
|
h.register(Member::new("layout").with_drift_scale(0.0))
|
||||||
|
.unwrap();
|
||||||
|
let err = h
|
||||||
|
.register(Member::new("layout").with_drift_scale(1.0))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::AlreadyRegistered { .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
// The first registration stands.
|
||||||
|
assert_eq!(h.rating(&"layout").unwrap().drift_scale(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `weight` is per-event and meaningless here, so it is rejected rather than
|
||||||
|
/// dropped — dropping it silently is the defect class this whole area keeps
|
||||||
|
/// producing.
|
||||||
|
#[test]
|
||||||
|
fn a_weight_on_a_registration_is_rejected() {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h
|
||||||
|
.register(Member::new("layout").with_weight(0.5))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_invalid_drift_scale_on_a_registration_is_rejected() {
|
||||||
|
for bad in [-1.0, f64::NAN, f64::INFINITY] {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h
|
||||||
|
.register(Member::new("layout").with_drift_scale(bad))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
name: "drift_scale",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"{bad}: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registration makes the fit independent of the order events arrive in,
|
||||||
|
/// which is what the per-event shape could not guarantee.
|
||||||
|
#[test]
|
||||||
|
fn registration_makes_the_fit_order_independent() {
|
||||||
|
let build = |reversed: bool| {
|
||||||
|
let mut h = history();
|
||||||
|
h.register(
|
||||||
|
Member::new("layout")
|
||||||
|
.with_drift_scale(0.0)
|
||||||
|
.with_prior(PINNED),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut events = vec![
|
||||||
|
duel("player", "layout", 1, None),
|
||||||
|
duel("player", "layout", 2, None),
|
||||||
|
duel("player", "layout", 3, None),
|
||||||
|
];
|
||||||
|
if reversed {
|
||||||
|
events.reverse();
|
||||||
|
}
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h
|
||||||
|
};
|
||||||
|
|
||||||
|
let forward = build(false);
|
||||||
|
let backward = build(true);
|
||||||
|
for ((k, a), (_, b)) in skills(&forward).into_iter().zip(skills(&backward)) {
|
||||||
|
assert_eq!(a.pi(), b.pi(), "{k} pi");
|
||||||
|
assert_eq!(a.tau(), b.tau(), "{k} tau");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `rating` is the read-back that made a configuration mistake detectable from
|
||||||
|
/// outside the crate at all. Every other accessor reports what inference
|
||||||
|
/// inferred; this reports what it was told.
|
||||||
|
#[test]
|
||||||
|
fn rating_reads_back_what_was_stored() {
|
||||||
|
let mut h = history();
|
||||||
|
assert!(h.rating(&"nobody").is_none());
|
||||||
|
|
||||||
|
h.register(
|
||||||
|
Member::new("layout")
|
||||||
|
.with_drift_scale(0.25)
|
||||||
|
.with_prior(PINNED),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = h.rating(&"layout").unwrap();
|
||||||
|
assert_eq!(r.drift_scale(), 0.25);
|
||||||
|
assert_eq!(r.prior().pi(), PINNED.pi());
|
||||||
|
assert_eq!(r.prior().tau(), PINNED.tau());
|
||||||
|
|
||||||
|
// A competitor created by an event reports the history defaults.
|
||||||
|
h.record_winner(&"player", &"layout", 1).unwrap();
|
||||||
|
assert_eq!(h.rating(&"player").unwrap().drift_scale(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decision this issue turned on: two different values for one competitor
|
||||||
|
/// are an error whether they arrive in one batch or two.
|
||||||
|
///
|
||||||
|
/// Last-write-wins across batches cut against the invariant
|
||||||
|
/// `tests/ingestion_equivalence.rs` protects — the same contradictory events
|
||||||
|
/// errored when batched and succeeded, order-dependently, one at a time.
|
||||||
|
mod conflicting_configuration {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn seed(scale: f64) -> Event<i64, &'static str> {
|
||||||
|
duel(
|
||||||
|
"player",
|
||||||
|
"layout",
|
||||||
|
1,
|
||||||
|
Some(Member::new("layout").with_drift_scale(scale)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn within_one_batch_is_an_error() {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h.add_events(vec![seed(0.0), seed(1.0)]).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
|
field: "drift_scale",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn across_two_batches_is_also_an_error() {
|
||||||
|
let mut h = history();
|
||||||
|
h.add_events(vec![seed(0.0)]).unwrap();
|
||||||
|
let err = h.add_events(vec![seed(1.0)]).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
|
field: "drift_scale",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
// Rejected before anything mutates: the first declaration stands.
|
||||||
|
assert_eq!(h.rating(&"layout").unwrap().drift_scale(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repeating the *same* value stays inert, which is the expected shape
|
||||||
|
/// when the configuration is a property of the domain.
|
||||||
|
#[test]
|
||||||
|
fn repeating_the_same_value_is_inert() {
|
||||||
|
let mut h = history();
|
||||||
|
h.add_events(vec![seed(0.0)]).unwrap();
|
||||||
|
h.add_events(vec![seed(0.0)]).unwrap();
|
||||||
|
assert_eq!(h.rating(&"layout").unwrap().drift_scale(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registration and a later event that agree are fine; one that
|
||||||
|
/// disagrees is the same error.
|
||||||
|
#[test]
|
||||||
|
fn a_registration_conflicts_with_a_later_event() {
|
||||||
|
let mut h = history();
|
||||||
|
h.register(Member::new("layout").with_drift_scale(0.0))
|
||||||
|
.unwrap();
|
||||||
|
h.add_events(vec![seed(0.0)]).unwrap();
|
||||||
|
|
||||||
|
let mut h2 = history();
|
||||||
|
h2.register(Member::new("layout").with_drift_scale(0.0))
|
||||||
|
.unwrap();
|
||||||
|
let err = h2.add_events(vec![seed(1.0)]).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::ConflictingCompetitorConfig { .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,3 +184,74 @@ 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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user