9 Commits
Author SHA1 Message Date
logaritmiskandClaude Opus 5 7c6965c6a9 Merge branch 'fix/ingestion-shape'
Reject malformed events at the ingestion boundary, add
EventBuilder::members, and record the rayon opt-in deviation.

Closes #5
Closes #37

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 15:53:13 +02:00
logaritmiskandClaude Opus 5 911b48faba feat: add EventBuilder::members for per-member configuration
`EventBuilder` could set weights and nothing else, so `prior` and
`drift_scale` were reachable only through the typed
`Event`/`Team`/`Member` shape plus `add_events`. Which ingestion route a
competitor arrived through decided whether it could be configured.

`members(...)` takes `Member` values directly, so `Member`'s own builder
expresses everything. `team(...)` stays the common case.

One escape hatch rather than `priors` and `drift_scales` setters beside
`weights`, as the issue suggested and then argued against itself: a
parallel array per field means a parallel length check per field, and
each one is a new way to get the lengths wrong. `Member` already has a
builder; this just lets the fluent path reach it.

`record_winner`/`record_draw` are deliberately left alone. They are the
two-argument convenience path, and extending them would be a breaking
signature change. The issue's reason for wanting them extended has also
weakened: it said a competitor arriving through them was "permanently
stuck on the history defaults", and since 8c087ad that is no longer true
— a later `add_events` carrying the `Member` refits the whole history.
Measured, late configuration through that route reaches mu 40.000000000,
identical to configuring from the start.

Refs #37

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 12:32:00 +02:00
logaritmiskandClaude Opus 5 f57784c141 docs: record the rayon opt-in deviation in spec section 6
Issue #5 asked for a decision, not an implementation: either flip rayon
to default-on, or record why the spec was deviated from and close.

Opt-in stands. The measured speedups are 1.0x realistic / 1.3x
pathological (#4), so default-on would cost every downstream user a
thread pool and a dependency for approximately nothing.

The condition the decision was waiting on cannot be met: #5 was blocked
on re-measuring after cross-slice dirty-bit skipping landed, and #4 was
closed by removing the inert slices_skipped field rather than by
implementing it. There is no forthcoming measurement to wait for.

Also corrects the spec's own reasoning. It cited an unsafe concurrent
write through SkillStore as a cost of going default-on; the crate is
forbid(unsafe_code) and the compute/apply split avoids that entirely.
The case for opt-in is the measurements, not a safety argument.

Closes #5

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 12:29:40 +02:00
logaritmiskandClaude Opus 5 8e4d6a637d fix: reject malformed events at the ingestion boundary
A one-team event reached `run_chain`, which builds one diff link per
adjacent pair of teams, leaving it to index `links[1..]` on an empty
vector. That panicked with "range start index 1 out of range for slice
of length 0" — from `History::add_events`, in a release build, through
entirely safe API.

An empty team was the quieter half of the same gap. It contributes no
performance, so a malformed event converged and handed back a finite,
plausible-looking posterior for whoever it was matched against. That is
this crate's characteristic defect: a public surface reporting a
constant that looks like an answer.

A non-finite score was the third. `converge` did report NonFiniteResult,
so it was detected — but a caller reading `current_skill` before
converging was handed `tau: NaN` with nothing to say so.

`NotEnoughTeams` and `EmptyTeam` already existed. They were checked on
the prediction paths and nowhere else, which is exactly why ingestion
could still manufacture the states they describe. The checks go in
`add_events_with_prior` alongside the tie check, for the same reason
that one is there: every ingestion route lands on it, so `record_winner`,
`record_draw` and `EventBuilder` inherit them rather than each needing
their own.

Also corrects documentation that had been stating the opposite of the
code since 8c087ad in 0.4.0. README.md and the `with_prior` /
`with_drift_scale` doc comments all still said competitor configuration
was "captured at first appearance" and had "no effect" on a known key.
It now applies whenever supplied and refits the whole history. A reader
would have concluded late configuration was impossible and built a
workaround for a limitation that does not exist. CI compiles README code
blocks but not prose, which is why it survived three releases.

The comment in tests/degenerate_inputs.rs claiming a one-team event was
"rejected for an unrelated reason" was wrong when written — it panicked.

Refs #18, #26

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 12:29:14 +02:00
logaritmisk 82eff740b6 chore: Release trueskill-tt version 0.7.0 2026-09-08 10:27:21 +02:00
logaritmiskandClaude Opus 5 c1b1c6c7d7 Merge branch 'feat/joint-handle'
Factorise the joint once with History::joint, so a batch of queries pays
the O(n^3) Cholesky once rather than once per question.

Closes #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 10:24:09 +02:00
logaritmiskandClaude Opus 5 1bb6bb31d8 feat: factorise the joint once with History::joint
`posterior_of`, `posterior_of_at` and `expected_variance_reduction` each
built the joint precision matrix, factorised it, asked one question and
threw it away. 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 paid for the same factorisation once per question.

`History::joint()` returns a `Joint` handle that pays it once. Measured
on 1976 appearances, 90 queries: 68.4s one-shot against 745ms factorise
plus 93ms of queries — 81.6x, with bit-identical answers. Per query,
Criterion at 480 appearances: 9.0ms one-shot against 48us cached, 187x.

The handle borrows the history, which is what makes it correct with no
invalidation logic: the borrow checker forbids adding events or refitting
while it is alive, so there is no window in which the factorisation could
describe a fit that no longer exists. It also makes the lifetime of the
n^2 factor explicit rather than parking it in the history forever — at
4000 appearances that is 128MB, which is not something to cache silently.

Every question the joint answers turns out to be a bilinear form,

    c^T A^-1 a = (L^-1 c) . (L^-1 a)

so no caller ever needs L^-1 c itself. Replacing the general solve with a
forward substitution drops the back substitution as wasted work, halving
a query, and removes a failure mode: a variance as `c . (A^-1 c)` is a
difference of products that can round negative, where `|L^-1 c|^2` is a
sum of squares and cannot.

The one-shot calls are unchanged in cost and now delegate to the handle,
so the two paths cannot drift apart. tests/joint_handle.rs asserts they
agree bit for bit, including at pinned times, under UnknownKeys::Prior,
and across candidate matchups.

Refs #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 07:53:51 +02:00
logaritmisk b113385c6f chore: Release trueskill-tt version 0.6.0 2026-09-08 06:46:36 +02:00
logaritmiskandClaude Opus 5 f345e7690e fix!: make the joint span slices, not just the latest one
`posterior_of` shipped in 0.5.0 reading a single slice. Measured against
a real Through-Time history that answers almost nothing: ustat's round
fit is 76 per-day slices whose last one holds a solo round, so 0 of 55
pair differences resolved and the single node that did was degenerate —
a one-competitor slice has no correlation to account for and returns the
marginal unchanged.

That was my mistake, and the fixture chose it. I validated against
single-slice histories, which is exactly the shape that cannot reveal
the problem. In a library whose premise is skill over time, competitors
are read at *their own* last appearance and those are different slices
by construction.

The joint is now time-expanded: one variable per appearance, linked by
the prior on a first appearance, the drift between consecutive ones, and
the within-slice event contrasts. Consecutive appearances with no drift
between them are the same variable rather than two joined by an infinite
precision, which keeps the matrix positive-definite when a competitor is
pinned with `drift_scale = 0`.

`posterior_of` now reads each competitor at their own latest appearance,
which is where `current_skill` reads them, so the two agree about which
posterior they describe. Adds `posterior_of_at(time, terms)` for a
comparison anchored to a moment, matching `learning_curve`'s reading.

Validated against a hand-written exact posterior for a two-competitor,
two-slice history — the precision matrix is spelled out in the test
rather than obtained from the crate, so it is an independent check
rather than a restatement. Also pinned: competitors last seen in
different slices now compare at all, means still agree with the
marginals, zero drift makes slice layout irrelevant, and more drift
widens a comparison across time.

BREAKING CHANGE: `posterior_of` and `expected_variance_reduction` now
consider the whole history rather than its latest slice, so results
change for any multi-slice history. `JointUnavailable` is now returned
when *any* slice holds ranked events, not just the last.

Refs #46, #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 06:04:41 +02:00
16 changed files with 1644 additions and 247 deletions
+24
View File
@@ -2,6 +2,26 @@
All notable changes to this project will be documented in this file.
## 0.7.0 - 2026-09-08
### Features
- feat: factorise the joint once with History::joint
### Other (unconventional)
- Merge branch 'feat/joint-handle'
## 0.6.0 - 2026-09-08
### Breaking Changes
- fix!: make the joint span slices, not just the latest one
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.6.0
## 0.5.0 - 2026-09-08
### Breaking Changes
@@ -24,6 +44,10 @@ All notable changes to this project will be documented in this file.
- feat: add History::predict_margin for scored matchups
- feat: add expected_variance_reduction for scored active learning
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.5.0
### Styling
- style: factor the event-pair type out of the reconvergence fixture
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "trueskill-tt"
version = "0.5.0"
version = "0.7.0"
edition = "2024"
rust-version = "1.85"
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
@@ -79,3 +79,7 @@ debug = true
[profile.dev]
debug = true
[[bench]]
name = "joint"
harness = false
+13 -7
View File
@@ -134,14 +134,20 @@ h.add_events(vec![Event {
h.converge().unwrap();
```
Like `with_prior`, the scale is **competitor configuration captured at first
appearance** — setting it on a key the history already knows has no effect. It
must be finite and non-negative; ingestion otherwise fails with
`InferenceError::InvalidParameter`.
Like `with_prior`, the scale is **competitor configuration, not a per-event
value**: it applies to the competitor for the whole history, and it applies
whenever it is supplied — including on a key the history already knows.
Configuring one late still refits the whole history rather than taking effect
only from that event onward, because `converge` refits from competitor state.
Repeating the same value is inert; supplying two *different* values for one
competitor within a single batch is `InferenceError::ConflictingCompetitorConfig`,
since events in a batch have no order. The scale must be finite and
non-negative; ingestion otherwise fails with `InferenceError::InvalidParameter`.
Note that the fluent `EventBuilder` (`h.event(t).team([...])`) sets weights but
not `drift_scale` or `prior`; those need the typed `Event` / `Team` / `Member`
shape shown above.
The fluent `EventBuilder` reaches this too: `.team([...])` is the common case
and leaves both unset, while `.members([...])` takes `Member` values directly,
so `h.event(t).members([Member::new("layout_7").with_drift_scale(0.0)])` is
equivalent to the typed shape above.
## Scored outcomes
+71
View File
@@ -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")`.
> **Not implemented. Deliberate deviation, decided 2026-09-08 (issue #5).**
>
> `rayon` ships **opt-in**: `Cargo.toml` has no `default = [...]` key. The
> measured speedups are 1.0x on realistic workloads and 1.3x on a pathological
> one (issue #4), because typical slices hold too few events to amortize
> rayon's task-spawn overhead. Default-on would hand every downstream user a
> thread pool and a dependency for approximately no gain.
>
> This section made the trade conditional on cross-slice dirty-bit skipping
> landing and changing the parallel story. It did not land: #4 was closed on
> 2026-08-27 by removing the inert `ConvergenceReport::slices_skipped` field
> rather than by implementing the mechanism, so the re-measurement this was
> waiting on will not arrive.
>
> The "Trade-offs" note below also cited an `unsafe` concurrent-write path
> through `SkillStore` as a cost of default-on. That cost does not exist: the
> crate is `#![forbid(unsafe_code)]`, and the compute/apply split on the
> internal `Event` is what lets a color group run in parallel without it. The
> case for opt-in rests on the measurements alone.
### Expected speedup ballpark
For 1000 players, 60 events/slice × 1000 slices, 30 convergence iterations:
@@ -521,7 +541,7 @@ These are pre-implementation estimates. Each tier validates with criterion.
- Color-group parallelism requires up-front graph coloring at ingestion. Cost: linear in events, run once per `add_events`. Cheap.
- Default = asynchronous EP (preserves current semantics). Synchronous opt-in only.
- Cross-slice sweep stays sequential; no speculative parallel sweeps.
- Rayon default-on but feature-gated.
- Rayon default-on but feature-gated. **Superseded — shipped opt-in; see the deviation note in Section 6.**
### Open question
+5 -2
View File
@@ -88,7 +88,9 @@ impl<K> Member<K> {
/// Set this competitor's starting skill estimate.
///
/// Captured at the competitor's first appearance; see the type docs.
/// Competitor configuration, not a per-event value: it applies for the
/// whole history and applies whenever it is supplied, including on a key
/// the history already knows. See the type docs.
pub fn with_prior(mut self, prior: Gaussian) -> Self {
self.prior = Some(prior);
self
@@ -104,7 +106,8 @@ impl<K> Member<K> {
/// shares a scale with moving competitors but should not itself move: a bot
/// at a known strength, a rating floor, a course difficulty.
///
/// Captured at the competitor's first appearance; see the type docs.
/// Applies for the whole history and whenever it is supplied, including on
/// a key the history already knows; see the type docs.
/// Must be finite and non-negative, or ingestion fails with
/// [`InferenceError::InvalidParameter`](crate::InferenceError::InvalidParameter).
pub fn with_drift_scale(mut self, scale: f64) -> Self {
+36
View File
@@ -50,6 +50,8 @@ where
}
/// Add a team by its member keys (weight 1.0 each, no prior overrides).
///
/// Use [`EventBuilder::members`] to set `prior` or `drift_scale`.
pub fn team<I: IntoIterator<Item = K>>(mut self, keys: I) -> Self {
let members: SmallVec<[Member<K>; 4]> = keys.into_iter().map(Member::new).collect();
self.event.teams.push(Team { members });
@@ -57,6 +59,40 @@ where
self
}
/// Add a team from fully-specified [`Member`] values.
///
/// [`EventBuilder::team`] is the common case and builds members with
/// `Member::new`, which leaves `prior` and `drift_scale` unset. This is the
/// escape hatch for when they matter:
///
/// ```
/// # use trueskill_tt::{Gaussian, History, Member};
/// # let mut h = History::builder().build();
/// h.event(0)
/// .team(["player"])
/// .members([Member::new("layout_7")
/// .with_drift_scale(0.0)
/// .with_prior(Gaussian::from_ms(0.0, 1.0))])
/// .ranking([0, 1])
/// .commit()?;
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
///
/// One method rather than a `priors` and a `drift_scales` setter beside
/// `weights`: those would have to grow a parallel array — and a parallel
/// length check — every time `Member` gains a field, and each one would be
/// a new way to get the lengths wrong. `Member`'s own builder already
/// expresses all of it.
///
/// `prior` and `drift_scale` are competitor configuration rather than
/// per-event values; see [`Member`] for what that means for a key the
/// history already knows.
pub fn members<I: IntoIterator<Item = Member<K>>>(mut self, members: I) -> Self {
self.event.teams.push(Team::with_members(members));
self.current_team_idx = Some(self.event.teams.len() - 1);
self
}
/// Set per-member weights for the most recently added team.
///
/// A length mismatch is recorded and returned by [`EventBuilder::commit`]
+466 -128
View File
@@ -202,6 +202,19 @@ pub(crate) struct CompetitorConfig {
drift_scale: Option<f64>,
}
/// The joint precision over a history's appearances, with the maps needed to
/// address a competitor either at their latest appearance or at a given slice.
struct TimeExpanded {
/// Row-major precision matrix over appearances.
lambda: Vec<f64>,
/// `(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 `lambda`.
width: usize,
}
/// A linear functional resolved against one time slice.
struct ResolvedTerms {
/// Coefficients over the slice's own competitors, in its ordering.
@@ -765,19 +778,104 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
Ok(crate::quality(&group_refs, self.beta))
}
/// Resolve `terms` into a contrast over the slice's competitor order, the
/// coefficients of any competitors the slice has never seen, and the mean.
/// The joint posterior precision over the whole history, time-expanded.
///
/// An unseen competitor shares no event with the slice, so it is
/// independent of everything in it by construction; keeping those
/// coefficients separate is what lets their variance be added rather than
/// solved for.
/// A competitor's skill is not one variable but one per appearance, linked
/// by drift. That is the point of Through Time, and it is why a joint over
/// a single slice answers almost nothing: competitors are each read at
/// *their own* last appearance, and in a history with per-day or per-event
/// slices those are different slices. A 76-slice history whose last slice
/// holds one competitor can answer no pairwise question at all.
///
/// Variables are `(competitor, appearance)`. Factors are the prior on a
/// first appearance, the drift between consecutive appearances, and the
/// within-slice event contrasts. Consecutive appearances with zero drift
/// variance are the same variable rather than two joined by an infinite
/// precision, which keeps the matrix positive-definite when a competitor is
/// pinned with `drift_scale = 0`.
///
/// Returns the matrix, each competitor's row at its latest appearance, and
/// the row at each `(competitor, slice)` for time-addressed queries.
fn time_expanded_joint(&self) -> TimeExpanded {
let mut latest: HashMap<Index, (usize, usize)> = HashMap::new();
let mut at_slice: HashMap<(Index, usize), usize> = HashMap::new();
// Row of a competitor's previous appearance, and the drift variance
// separating it from the current one.
let mut previous: HashMap<Index, usize> = HashMap::new();
let mut drift_links: Vec<(usize, usize, f64)> = Vec::new();
let mut first_rows: Vec<(usize, Index)> = Vec::new();
let mut n = 0usize;
for (slice_idx, slice) in self.time_slices.iter().enumerate() {
for (agent, elapsed) in slice.appearances() {
let rating = &self.agents[agent].rating;
let row = match previous.get(&agent) {
None => {
let row = n;
n += 1;
first_rows.push((row, agent));
row
}
Some(&prev) => {
let drift = rating.drift_variance_for_elapsed(elapsed);
if drift <= 0.0 {
// No drift: the same latent skill, not two.
prev
} else {
let row = n;
n += 1;
drift_links.push((prev, row, drift));
row
}
}
};
previous.insert(agent, row);
latest.insert(agent, (row, slice_idx));
at_slice.insert((agent, slice_idx), row);
}
}
let mut lambda = vec![0.0; n * n];
for (row, agent) in first_rows {
lambda[row * n + row] += 1.0 / self.agents[agent].rating.prior.sigma().powi(2);
}
for (a, b, drift) in drift_links {
lambda[a * n + a] += 1.0 / drift;
lambda[b * n + b] += 1.0 / drift;
lambda[a * n + b] -= 1.0 / drift;
lambda[b * n + a] -= 1.0 / drift;
}
for (slice_idx, slice) in self.time_slices.iter().enumerate() {
for (contrast, noise) in slice.scored_contrasts(&self.agents) {
for (ia, ca) in &contrast {
let ra = at_slice[&(*ia, slice_idx)];
for (ib, cb) in &contrast {
let rb = at_slice[&(*ib, slice_idx)];
lambda[ra * n + rb] += ca * cb / noise;
}
}
}
}
TimeExpanded {
lambda,
latest,
at_slice,
width: n,
}
}
/// Resolve `terms` into a contrast over the time-expanded rows, the
/// coefficients of competitors the history has never seen, and the mean.
///
/// `row_for` picks which appearance of a competitor the caller means —
/// their latest, or the one at a given time.
fn resolve_terms(
&self,
terms: &[(&K, f64)],
slice: &TimeSlice<T>,
row_of: &HashMap<Index, usize>,
width: usize,
row_for: impl Fn(Index) -> Option<(usize, usize)>,
) -> Result<ResolvedTerms, InferenceError>
where
K: std::fmt::Debug,
@@ -790,16 +888,16 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let located = self
.keys
.get(*key)
.and_then(|index| row_of.get(&index).map(|row| (index, *row)));
.and_then(|index| row_for(index).map(|located| (index, located)));
match located {
Some((index, row)) => {
Some((index, (row, slice_idx))) => {
contrast[row] += coefficient;
mean += coefficient
* slice
* self.time_slices[slice_idx]
.skills
.get(index)
.expect("index came from this slice")
.expect("row came from this slice")
.posterior()
.mu();
}
@@ -844,61 +942,63 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// The mean is the same combination of the marginal means, which message
/// passing already gets exactly right. Only the variance needs the joint.
///
/// # Which appearance each competitor is read at
///
/// Each competitor is read at *their own* latest appearance, which is where
/// [`History::current_skill`] reads them too, so the two agree about which
/// posterior they describe. That matters in a Through-Time history: with
/// 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.
///
/// # 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.
///
/// # Limitations
///
/// Currently exact only for a slice whose events are all scored, because a
/// scored likelihood is Gaussian and its factor can be rebuilt exactly. A
/// ranked outcome's truncation is approximated by EP, and reconstructing
/// those factors needs the converged messages, which inference does not
/// retain. Ranked slices return `JointUnavailable` rather than a plausible
/// wrong number.
/// Exact only for a history whose events are all scored, because a scored
/// likelihood is Gaussian and its factor can be rebuilt exactly. A ranked
/// outcome's truncation is approximated by EP, and reconstructing those
/// factors needs the converged messages, which inference does not retain —
/// so a history containing ranked events returns `JointUnavailable` rather
/// than a plausible wrong number.
///
/// # Errors
///
/// `UnknownKey` for a competitor absent from the latest slice, and
/// `JointUnavailable` if that slice contains ranked events or the system is
/// not positive-definite.
/// `UnknownKey` for a competitor the history has never seen, and
/// `JointUnavailable` for ranked events or a system that is not
/// positive-definite.
pub fn posterior_of(&self, terms: &[(&K, f64)]) -> Result<Gaussian, InferenceError>
where
K: std::fmt::Debug,
{
let slice = self
.time_slices
.last()
.ok_or(InferenceError::JointUnavailable {
reason: "the history has no events",
})?;
if !slice.all_scored() {
return Err(InferenceError::JointUnavailable {
reason: "the latest slice contains ranked events, whose EP factors \
are not retained after convergence",
});
self.joint()?.posterior_of(terms)
}
let (order, lambda) = slice.joint_precision(&self.agents);
let mut row_of = HashMap::with_capacity(order.len());
for (r, idx) in order.iter().enumerate() {
row_of.insert(*idx, r);
}
let ResolvedTerms {
contrast,
unseen,
mean,
} = self.resolve_terms(terms, slice, &row_of, order.len())?;
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`.
///
/// Each competitor is taken at their latest appearance at or before `time`,
/// which is the same reading [`History::learning_curve`] gives. Use this
/// when a comparison must be anchored to a moment — "how did these two
/// stand at the end of last season" — rather than to wherever each
/// competitor was last seen.
///
/// As with [`History::posterior_of`], this factorises the joint for one
/// question; [`History::joint`] amortises that across many.
///
/// # Errors
///
/// As [`History::posterior_of`], plus `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,
{
self.joint()?.posterior_of_at(time, terms)
}
/// How much observing this matchup would shrink the variance of `target`.
@@ -914,6 +1014,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
/// 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
///
/// Observing a scored event is a rank-one update to the precision matrix,
@@ -943,90 +1048,90 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
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(),
});
self.joint()?.expected_variance_reduction(teams, target)
}
let slice = self
.time_slices
.last()
.ok_or(InferenceError::JointUnavailable {
reason: "the history has no events",
})?;
if !slice.all_scored() {
/// 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() {
return Err(InferenceError::JointUnavailable {
reason: "the latest slice contains ranked events, whose EP factors \
are not retained after convergence",
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",
});
}
// 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 {
lambda,
latest,
at_slice,
width,
} = self.time_expanded_joint();
let (order, lambda) = slice.joint_precision(&self.agents);
let mut row_of = HashMap::with_capacity(order.len());
for (r, idx) in order.iter().enumerate() {
row_of.insert(*idx, r);
}
let target = self.resolve_terms(target, slice, &row_of, order.len())?;
let matchup = self.resolve_terms(&matchup, slice, &row_of, order.len())?;
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(
let cholesky = crate::joint::Cholesky::factor(lambda, width).ok_or(
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;
// Competitors outside the slice 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: 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))
Ok(Joint {
history: self,
cholesky,
latest,
at_slice,
width,
})
}
/// Predictive distribution of the score margin between two teams.
///
/// Answers "what will the gap be, and how wide is that interval" for a
@@ -1400,6 +1505,50 @@ 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,
});
}
}
}
}
// Chokepoint for tie validation: every ingestion route lands here,
// including `record_draw`, which builds its results directly rather
// than going through `Outcome`.
@@ -1805,6 +1954,195 @@ 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.
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, and it is not the competitor count — a
/// competitor contributes one variable per slice it appears in. Worth
/// checking before asking for a joint over a long history.
#[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)]
mod tests {
use approx::assert_ulps_eq;
+98 -45
View File
@@ -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
//! nothing a consumer publishes is one competitor: "can we tell these two
//! apart" is a difference, "what was this round worth" is a sum. Combining
//! marginals means assuming the competitors are independent, and they are
//! correlated through every event they share — which is the mechanism the model
//! exists to exploit.
//! Every question the joint answers is a *bilinear form* in the precision
//! matrix's inverse — the variance of a contrast is `c^T L^-1 c`, and the
//! covariance of two contrasts is `c^T L^-1 a`. None of them wants `L^-1 c`
//! itself, which is what makes the shape here worth stating explicitly.
//!
//! Measured on a five-competitor round robin, the exact correlation is +0.857,
//! so `sqrt(sa^2 + sb^2)` overstates the width of a difference by 2.6x.
//! Writing the precision as `A = L L^T`,
//!
//! ```text
//! c^T A^-1 a = c^T L^-T L^-1 a = (L^-1 c) . (L^-1 a)
//! ```
//!
//! so a single forward substitution per contrast answers everything, and the
//! back substitution a general solve would do is wasted work. That halves the
//! cost of a query, and it removes a failure mode: a variance computed as
//! `c . (A^-1 c)` is a difference of products that can round to a small
//! negative number, where the same quantity as `|L^-1 c|^2` is a sum of
//! squares and cannot.
//!
//! Factorising is `O(n^3)` and whitening is `O(n^2)`, so the split also
//! matters structurally: the expensive half depends only on the fit, and is
//! shared across every query a [`Joint`](crate::Joint) answers.
/// 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 {
/// Lower triangle of `L`, row-major `n * n`. The upper triangle is
/// leftover scratch from the factorisation and is never read.
l: Vec<f64>,
n: usize,
}
impl Cholesky {
/// Factorise `a` (row-major, `n * n`, symmetric) into `L L^T`.
///
/// `a` is row-major and is consumed as scratch.
/// `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 no prior and no
/// evidence.
pub(crate) fn solve_spd(mut a: Vec<f64>, b: &[f64]) -> Option<Vec<f64>> {
let n = b.len();
/// 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);
// In-place Cholesky: A = L L^T, lower triangle.
for j in 0..n {
let mut d = a[j * n + j];
for k in 0..j {
d -= a[j * n + k] * a[j * n + k];
}
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here too,
// and a negated comparison would let it through as "not positive".
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here
// too, and a negated comparison would let it through as "not
// positive".
if d.is_nan() || d <= 0.0 {
return None;
}
@@ -44,56 +64,89 @@ pub(crate) fn solve_spd(mut a: Vec<f64>, b: &[f64]) -> Option<Vec<f64>> {
}
}
// Forward substitution, then back substitution.
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(Self { l: a, n })
}
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)]
mod tests {
use super::*;
/// `[[4, 1], [1, 3]] z = [1, 2]` has `z = [1/11, 7/11]`, so the quadratic
/// form `b^T A^-1 b` is `1 * 1/11 + 2 * 7/11 = 15/11`.
#[test]
fn solves_a_known_system() {
// [[4, 1], [1, 3]] z = [1, 2] => z = [1/11, 7/11]
let a = vec![4.0, 1.0, 1.0, 3.0];
let z = solve_spd(a, &[1.0, 2.0]).unwrap();
assert!((z[0] - 1.0 / 11.0).abs() < 1e-12, "{z:?}");
assert!((z[1] - 7.0 / 11.0).abs() < 1e-12, "{z:?}");
fn reproduces_a_known_quadratic_form() {
let c = Cholesky::factor(vec![4.0, 1.0, 1.0, 3.0], 2).unwrap();
let y = c.whiten(&[1.0, 2.0]);
assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12);
}
/// Whitening `e_i` recovers the inverse's diagonal, which is the variance
/// of a single variable.
#[test]
fn recovers_the_inverse_diagonal() {
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
// [0.75, 1.0, 0.75].
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
let c = Cholesky::factor(a, 3).unwrap();
for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() {
let mut e = vec![0.0; 3];
e[i] = 1.0;
let z = solve_spd(a.clone(), &e).unwrap();
assert!((z[i] - expected).abs() < 1e-12, "row {i}: {z:?}");
let y = c.whiten(&e);
assert!((bilinear(&y, &y) - expected).abs() < 1e-12, "row {i}");
}
}
/// The off-diagonal bilinear form is symmetric and matches the inverse.
#[test]
fn recovers_an_off_diagonal_covariance() {
// Same A; (A^-1)_{0,1} = 0.5.
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
let c = Cholesky::factor(a, 3).unwrap();
let y0 = c.whiten(&[1.0, 0.0, 0.0]);
let y1 = c.whiten(&[0.0, 1.0, 0.0]);
assert!((bilinear(&y0, &y1) - 0.5).abs() < 1e-12);
assert!((bilinear(&y1, &y0) - 0.5).abs() < 1e-12);
}
/// A variance can never come out negative, because it is a sum of squares.
#[test]
fn a_quadratic_form_is_never_negative() {
let a = vec![1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12];
let c = Cholesky::factor(a, 2).unwrap();
let y = c.whiten(&[1.0, -1.0]);
assert!(bilinear(&y, &y) >= 0.0);
}
#[test]
fn rejects_a_non_positive_definite_matrix() {
// Singular: the second row is a multiple of the first.
let a = vec![1.0, 2.0, 2.0, 4.0];
assert!(solve_spd(a, &[1.0, 1.0]).is_none());
assert!(Cholesky::factor(vec![1.0, 2.0, 2.0, 4.0], 2).is_none());
}
}
+1 -1
View File
@@ -141,7 +141,7 @@ pub use event::{Event, Member, Team};
pub use event_builder::EventBuilder;
pub use game::{Game, GameOptions, OwnedGame};
pub use gaussian::Gaussian;
pub use history::{History, HistoryBuilder};
pub use history::{History, HistoryBuilder, Joint};
pub use key_table::KeyTable;
use matrix::Matrix;
pub use observer::{NullObserver, Observer};
+27 -42
View File
@@ -810,40 +810,26 @@ pub(crate) fn compute_elapsed<T: Time>(last: Option<&T>, current: &T) -> i64 {
}
impl<T: Time> TimeSlice<T> {
/// Precision matrix of the joint posterior over this slice's competitors.
/// This slice's scored event factors, as contrasts over competitors.
///
/// Message passing produces per-competitor marginals and throws the
/// correlation away — `Item::likelihood` is already the projection of an
/// event's factor down onto one competitor. So the joint has to be rebuilt
/// from the factor structure rather than recovered from the messages.
/// event's factor onto one competitor. So a joint has to be rebuilt from
/// the factor structure rather than recovered from the messages.
///
/// Usefully, a precision matrix depends only on *structure* — who played
/// whom, with what weights and what observation noise — and not on the
/// observed outcomes. The means are already exact (Gaussian belief
/// propagation gets those right even with cycles), so only the second
/// observed outcomes. The means are already exact, so only the second
/// moment needs rebuilding.
///
/// Returns the competitor order and the dense matrix in row-major order.
/// Only scored events contribute their factors exactly; see the caller.
pub(crate) fn joint_precision<D: Drift<T>>(
/// Each entry is a contrast and the observation variance that sits on it.
/// Ranked events contribute nothing: their truncation factors are EP
/// approximations that inference does not retain.
pub(crate) fn scored_contrasts<D: Drift<T>>(
&self,
agents: &CompetitorStore<T, D>,
) -> (Vec<Index>, Vec<f64>) {
let order: Vec<Index> = self.skills.keys().collect();
let n = order.len();
let mut row_of: HashMap<Index, usize> = HashMap::with_capacity(n);
for (r, idx) in order.iter().enumerate() {
row_of.insert(*idx, r);
}
let mut lambda = vec![0.0; n * n];
// Everything outside this slice enters as each competitor's forward and
// backward messages, which message passing treats as independent.
for (r, idx) in order.iter().enumerate() {
let skill = self.skills.get(*idx).expect("slice key has a skill");
lambda[r * n + r] += (skill.forward * skill.backward).pi();
}
) -> Vec<(Vec<(Index, f64)>, f64)> {
let mut out = Vec::new();
for event in &self.events {
let EventKind::Scored { score_sigma } = event.kind else {
@@ -851,49 +837,48 @@ impl<T: Time> TimeSlice<T> {
};
// Teams best-first, matching the diff chain inference builds.
let mut order_idx: Vec<usize> = (0..event.teams.len()).collect();
order_idx.sort_by(|&a, &b| {
let mut order: Vec<usize> = (0..event.teams.len()).collect();
order.sort_by(|&a, &b| {
event.teams[b]
.output
.partial_cmp(&event.teams[a].output)
.unwrap_or(std::cmp::Ordering::Equal)
});
for pair in order_idx.windows(2) {
for pair in order.windows(2) {
let (hi, lo) = (pair[0], pair[1]);
// Contrast vector, and the observation noise that sits on top
// of the skills: per-member performance noise plus the score
// noise itself.
let mut contrast: HashMap<usize, f64> = HashMap::new();
let mut contrast: Vec<(Index, f64)> = Vec::new();
let mut noise = score_sigma * score_sigma;
for (team, sign) in [(hi, 1.0), (lo, -1.0)] {
for (m, item) in event.teams[team].items.iter().enumerate() {
let w = event.weights[team][m];
let beta = agents[item.agent].rating.beta;
noise += w * w * beta * beta;
*contrast.entry(row_of[&item.agent]).or_insert(0.0) += sign * w;
noise += w * w * agents[item.agent].rating.beta.powi(2);
contrast.push((item.agent, sign * w));
}
}
for (&i, &ci) in &contrast {
for (&j, &cj) in &contrast {
lambda[i * n + j] += ci * cj / noise;
}
}
out.push((contrast, noise));
}
}
(order, lambda)
out
}
/// True when every event here is scored, so `joint_precision` is exact.
/// True when every event here is scored, so the joint is exact.
pub(crate) fn all_scored(&self) -> bool {
self.events
.iter()
.all(|e| matches!(e.kind, EventKind::Scored { .. }))
}
/// The competitors appearing in this slice, with the elapsed count since
/// each one's previous appearance.
pub(crate) fn appearances(&self) -> impl Iterator<Item = (Index, i64)> + '_ {
self.skills
.keys()
.map(|idx| (idx, self.skills.get(idx).expect("slice key").elapsed))
}
}
#[cfg(test)]
+3 -2
View File
@@ -170,8 +170,9 @@ fn event_builder_rejects_a_weights_length_mismatch() {
fn event_builder_weights_mismatch_leaves_the_history_untouched() {
let mut h = History::default();
// Two teams, so ingestion would otherwise succeed — a one-team event is
// rejected for an unrelated reason and would pass this vacuously.
// Two teams, so ingestion would otherwise succeed. A one-team event is
// rejected as `NotEnoughTeams` before the weights are ever examined, so
// building this with one team would pass vacuously.
let _ = h
.event(1)
.team(["a"])
+193
View File
@@ -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");
}
}
+147
View File
@@ -0,0 +1,147 @@
//! 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");
}
}
/// 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());
}
+220
View File
@@ -0,0 +1,220 @@
//! `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);
}
/// 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());
}
+296
View File
@@ -0,0 +1,296 @@
//! The joint must span slices, because Through Time reads each competitor at
//! their own last appearance.
//!
//! The exact posterior of a multi-slice scored history is still Gaussian: the
//! prior, the drift between appearances, and the scored likelihoods are all
//! Gaussian. So it can be written out by hand and compared against, which is
//! the check a single-slice fixture cannot make.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team, UnknownKeys,
};
const SIGMA0: f64 = 6.0;
const BETA: f64 = 1.0;
const SCORE_SIGMA: f64 = 2.0;
const GAMMA: f64 = 0.5;
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
fn history(gamma: f64) -> H {
History::builder()
.mu(0.0)
.sigma(SIGMA0)
.beta(BETA)
.score_sigma(SCORE_SIGMA)
.drift(ConstantDrift(gamma))
.unknown_keys(UnknownKeys::Reject)
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build()
}
fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> {
Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([sa, sb]),
}
}
fn inverse(mut a: Vec<Vec<f64>>) -> Vec<Vec<f64>> {
let n = a.len();
let mut inv: Vec<Vec<f64>> = (0..n)
.map(|i| (0..n).map(|j| f64::from(u8::from(i == j))).collect())
.collect();
for col in 0..n {
let mut piv = col;
for r in col + 1..n {
if a[r][col].abs() > a[piv][col].abs() {
piv = r;
}
}
a.swap(col, piv);
inv.swap(col, piv);
let d = a[col][col];
for j in 0..n {
a[col][j] /= d;
inv[col][j] /= d;
}
for r in 0..n {
if r == col {
continue;
}
let f = a[r][col];
for j in 0..n {
a[r][j] -= f * a[col][j];
inv[r][j] -= f * inv[col][j];
}
}
}
inv
}
/// Two competitors, two slices ten units apart, one duel in each.
///
/// The exact precision is written out explicitly here rather than obtained
/// from the crate, so this is an independent check rather than a restatement.
/// Variables are `[a0, b0, a1, b1]`.
#[test]
fn a_two_slice_joint_matches_the_exact_posterior() {
let mut h = history(GAMMA);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "b", 10, 4.0, 3.0),
])
.unwrap();
let report = h.converge().unwrap();
assert!(report.converged, "{:?}", report.final_step);
let prior_prec = 1.0 / (SIGMA0 * SIGMA0);
let drift_prec = 1.0 / (10.0 * GAMMA * GAMMA);
let obs_prec = 1.0 / (SCORE_SIGMA * SCORE_SIGMA + 2.0 * BETA * BETA);
let mut lambda = vec![vec![0.0; 4]; 4];
// priors on the first appearances
lambda[0][0] += prior_prec;
lambda[1][1] += prior_prec;
// drift a0-a1 and b0-b1
for (p, q) in [(0usize, 2usize), (1, 3)] {
lambda[p][p] += drift_prec;
lambda[q][q] += drift_prec;
lambda[p][q] -= drift_prec;
lambda[q][p] -= drift_prec;
}
// one duel per slice: contrast (+1, -1) on that slice's variables
for (p, q) in [(0usize, 1usize), (2, 3)] {
lambda[p][p] += obs_prec;
lambda[q][q] += obs_prec;
lambda[p][q] -= obs_prec;
lambda[q][p] -= obs_prec;
}
let cov = inverse(lambda);
// The crate reads each competitor at their latest appearance: a1, b1.
let exact_gap = (cov[2][2] + cov[3][3] - 2.0 * cov[2][3]).sqrt();
let got = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
assert!(
(got.sigma() - exact_gap).abs() / exact_gap < 1e-9,
"difference: got {} exact {exact_gap}",
got.sigma()
);
let exact_single = cov[2][2].sqrt();
let got_single = h.posterior_of(&[(&"a", 1.0)]).unwrap();
assert!(
(got_single.sigma() - exact_single).abs() / exact_single < 1e-9,
"single node: got {} exact {exact_single}",
got_single.sigma()
);
}
/// The case that motivated this: competitors read at *different* slices, with
/// the last slice holding only one of them. Under the old latest-slice joint
/// this was `UnknownKey`.
#[test]
fn competitors_last_seen_in_different_slices_are_comparable() {
let mut h = history(GAMMA);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "c", 10, 4.0, 3.0),
// the final slice holds one duel that does not involve b at all
duel("a", "c", 20, 6.0, 1.0),
])
.unwrap();
let _ = h.converge().unwrap();
// b last appeared at time 0; a and c at time 20. All three must resolve.
for (x, y) in [("a", "b"), ("b", "c"), ("a", "c")] {
let g = h
.posterior_of(&[(&x, 1.0), (&y, -1.0)])
.unwrap_or_else(|e| panic!("{x} - {y} should resolve across slices: {e}"));
assert!(g.sigma() > 0.0 && g.sigma().is_finite());
}
}
/// The mean must agree with what message passing reports, which is exact even
/// with cycles. Only the second moment needs the joint.
#[test]
fn means_agree_with_the_marginals() {
let mut h = history(GAMMA);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("b", "c", 5, 3.0, 1.0),
duel("a", "c", 10, 4.0, 2.0),
])
.unwrap();
let _ = h.converge().unwrap();
for k in ["a", "b", "c"] {
let marginal = h.current_skill(&k).unwrap().mu();
let joint = h.posterior_of(&[(&k, 1.0)]).unwrap().mu();
assert!(
(marginal - joint).abs() < 1e-9,
"{k}: marginal {marginal}, joint {joint}"
);
}
}
/// With zero drift a competitor has one latent skill however many slices it
/// appears in, so spreading the same events over time must not change the
/// answer. This exercises the appearance-merging path.
#[test]
fn zero_drift_makes_slice_layout_irrelevant() {
let spread = {
let mut h = history(0.0);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "b", 10, 4.0, 3.0),
duel("a", "b", 20, 6.0, 1.0),
])
.unwrap();
let _ = h.converge().unwrap();
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap()
};
let together = {
let mut h = history(0.0);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "b", 0, 4.0, 3.0),
duel("a", "b", 0, 6.0, 1.0),
])
.unwrap();
let _ = h.converge().unwrap();
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap()
};
assert!(
(spread.sigma() - together.sigma()).abs() < 1e-9,
"zero drift: spread {} vs together {}",
spread.sigma(),
together.sigma()
);
}
/// More drift means less is carried forward from old evidence, so a comparison
/// against a competitor last seen long ago must widen.
#[test]
fn drift_widens_a_comparison_across_time() {
let mut previous = 0.0;
for gamma in [0.0f64, 0.1, 0.5, 2.0] {
let mut h = history(gamma);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "c", 100, 4.0, 3.0),
])
.unwrap();
let _ = h.converge().unwrap();
// b was last seen at time 0; a at time 100.
let g = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
assert!(
g.sigma() > previous,
"gamma={gamma}: sigma {} did not exceed {previous}",
g.sigma()
);
previous = g.sigma();
}
}
/// `posterior_of_at` pins the reading to a moment, where `posterior_of` takes
/// each competitor wherever they were last seen.
#[test]
fn posterior_of_at_reads_as_of_a_time() {
let mut h = history(GAMMA);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "b", 10, 4.0, 3.0),
duel("a", "b", 20, 6.0, 1.0),
])
.unwrap();
let _ = h.converge().unwrap();
let early = h.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
let late = h.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
let latest = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
// Asking as of the final slice is the same as asking for the latest.
assert!((late.mu() - latest.mu()).abs() < 1e-9);
assert!((late.sigma() - latest.sigma()).abs() < 1e-9);
// Reading at time 0 is a different quantity, and the smoothed estimate
// there is informed by everything that came after.
assert!(
(early.mu() - late.mu()).abs() > 1e-6,
"as-of-0 and as-of-20 should differ: {} vs {}",
early.mu(),
late.mu()
);
// A time before any event has nothing to read.
assert!(h.posterior_of_at(-1, &[(&"a", 1.0)]).is_err());
}
/// Times between slices resolve to the latest appearance at or before them.
#[test]
fn a_time_between_slices_reads_the_previous_appearance() {
let mut h = history(GAMMA);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "b", 100, 4.0, 3.0),
])
.unwrap();
let _ = h.converge().unwrap();
let at_zero = h.posterior_of_at(0, &[(&"a", 1.0)]).unwrap();
let between = h.posterior_of_at(50, &[(&"a", 1.0)]).unwrap();
assert!((at_zero.mu() - between.mu()).abs() < 1e-12);
assert!((at_zero.sigma() - between.sigma()).abs() < 1e-12);
}