11 Commits
Author SHA1 Message Date
logaritmisk b2a7ade10c chore: Release trueskill-tt version 0.3.0 2026-09-01 06:34:31 +02:00
logaritmiskandClaude Opus 5 617bc07f6f feat: allow drift to vary per competitor via Member::with_drift_scale
Drift was a property of the History, so every competitor drifted at the
same rate and a fixed reference point could not share a graph with moving
competitors. A bot at a known strength, a rating floor, a course
difficulty — all of them drifted along with the players.

Member::with_drift_scale(s) multiplies the drift *variance* a competitor
accumulates, so s is in the same units as gamma: ConstantDrift(g) at
scale s behaves exactly as ConstantDrift(g * s) would for that competitor.
A scalar rather than a per-competitor Drift keeps History's single D type
parameter untouched and stays Copy. 0.0 pins a competitor still.

The scale lives on Rating, beside the drift it scales, and is applied
only through Rating::drift_variance_delta / drift_variance_for_elapsed.
Making those the sole entry points means a caller cannot reach the raw
drift and silently skip a competitor's scale — the filtered pass was
exactly that bug during development, caught because its test was written
before the wiring.

Like with_prior, the scale is competitor configuration captured at first
appearance rather than a per-event override; a competitor that is static
is static, and a scale that changed between events would make the skill
trajectory hard to interpret. Member's docs claimed prior was a per-event
override, which the code has never done — corrected here.

A negative scale is rejected rather than squared into its absolute value,
and a non-finite one rejected outright, both as InvalidParameter.

None means 1.0, so no existing call site changes and no existing fit
moves. Adding a public field to Member does break struct-literal
construction downstream.

Closes #34

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014b6wy2q8rnFK8U8GPJVQNU
2026-09-01 06:29:02 +02:00
logaritmiskandClaude Opus 5 1a88678384 refactor!: remove ConvergenceReport::slices_skipped
Closes #33. The field was public, hardcoded to 0 at both construction sites,
and had no route to ever being non-zero.

It was added in T3 as the reporting surface for dirty-bit slice skipping. That
feature is #4, closed as unworkable — the ceiling measured at ~6% against a
projected 5-50x, on top of three independent soundness blockers. #32, which
reattributed the cost to ingestion, is closed too: the re-convergence is
necessary work rather than waste, because appending one event genuinely moves
the involved competitors ~1.2 sigma across their whole history. Nothing left
would ever populate it.

This is the same defect class as #19, where this arc started: a public surface
that looks implemented, reports a plausible value, and is inert. A caller
reading `slices_skipped: 0` reasonably concludes "no slices were skipped this
run", not "this feature does not exist".

Removed rather than documented as reserved. Its only value was as a hook for a
plan that no longer exists, and keeping it preserves the shape of that plan.
Breaking, but ConvergenceReport is returned rather than constructed by callers,
so the only breakage is code reading a constant zero.

Also added a test asserting every remaining field carries real information —
iterations non-zero, final_step finite, log_evidence a finite negative log
probability, and per_iteration_time holding one duration per iteration.
Mutation-proved: pinning per_iteration_time to an empty SmallVec fails it. The
next always-constant member now has to survive an assertion rather than just a
reviewer's attention, which is the actual lesson of #19 and #33.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-28 08:07:41 +02:00
logaritmisk 5f46296671 chore: ignore proptest regression seed files
proptest writes tests/*.proptest-regressions when a property fails, seeding a
replay of that exact case. Useful locally; noise in the repo when the failure
came from a deliberate mutation rather than a real defect.
2026-08-27 18:06:26 +02:00
logaritmiskandClaude Opus 5 2745fbb622 test: add property-based tests, a shared finiteness helper, and boundary inputs
Most of what remained on #26.

**Property tests (`tests/properties.rs`, proptest as a dev-dependency).** Four
invariants over generated 1v1 schedules rather than hand-written fixtures,
which is where this crate's shipped defects actually hid — a linear evidence
product that underflowed only past ~1000 teams, and a batching path no golden
exercised because every golden ingests in one call:

- converged posteriors are always finite with positive sigma
- log-evidence, batch and filtered, is finite and never above zero
- filtered evidence is invariant to whether `converge` has run
- one-at-a-time ingestion reaches the same fixed point as batched

The invariance property was mutation-proved: making `filtered_step` read
`skill.forward` instead of the carried message fails it with
`-1.1038430064192069 -> -1.1135747072822761`.

**Shared finiteness helper (`tests/common/mod.rs`).** `assert_finite` was local
to `degenerate_inputs.rs`. It now also rejects a non-positive sigma, which the
old version let through — `Gaussian::sigma` reports a non-positive precision as
improper rather than trapping, so a collapsed posterior would have passed a
finite-only check.

**Boundary inputs.** Zero and negative weights, out-of-order timestamps, and
extreme beta/sigma combinations. Worth recording that zero weight reaches
`(m - performance.exclude(..)) * (1.0 / w)` — a division by zero — and the
posterior comes out finite anyway; the test pins that rather than asserting
what ought to happen. The weight tests `expect()` the commit rather than
returning early on error, because an early return would have made them vacuous
the moment validation changed. I checked that specifically by turning the
return into a failure and confirming it did not fire.

Not done, and left on #26: benchmark regression gating. Nothing fails on a
regression today; making it fail needs a threshold chosen against how noisy the
shared runner is, which is a policy call rather than a mechanical one.

60 test binaries, up from 56. MSRV 1.85 verified with proptest in the graph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 18:06:26 +02:00
logaritmiskandClaude Opus 5 6d2573b92e perf: make the per-slice SkillStore compact instead of dense
Closes #17. Each `TimeSlice` owned a `Vec<Skill>` indexed by the GLOBAL
`Index.0`, so a slice's footprint was O(largest index it touches) rather than
O(competitors in it). Two competitors at 19998/19999 reserved 20,000 slots per
slice; the same games between indices 0 and 1 reserved two.

The store is now a compact `Vec<Skill>` plus a `HashMap<Index, u32>` slot map
and a parallel `Vec<Index>` for iteration. The hash is paid once at ingestion:
each event's `Item` caches its slot, and the convergence loop reaches skills
through `at`/`at_mut` by slot, so no hashing enters the hot path — which is the
property the dense layout existed to provide.

Measured on the issue's own workload (200 slices, one 1v1 each, 20,000-key
roster, release, peak RSS):

    indices 0 / 1          52 MB  ->  5.55 MB
    indices 19998 / 19999  309 MB ->  8.39 MB

The 257 MB gap is now 2.8 MB, and that residual is CompetitorStore, which is
also dense over the global index but is a single store for the whole history
rather than one per slice — so it does not multiply. Left alone deliberately.

Benchmarks, against the pre-change code:

    Batch::iteration        +2.4%   (regressed)
    history_converge x3     -18.8%, -21.6%, -21.7%  (improved)

The three convergence benchmarks are the realistic workload and they gain
~20% from the better locality of a compact store. The micro-benchmark loses
2.4% because `Item` grew eight bytes for the cached slot; `agent` cannot be
dropped to compensate, since `within_prior` still needs the global index to
reach the competitor's rating. I judged 2.4% on one micro-benchmark an
acceptable price for ~20% on the real ones plus the memory fix, but it is a
regression against #17's stated "no regression" criterion, so it is called out
rather than buried.

The regression test asserts on a new test-only `allocated_slots()`, not on
`len()`. That distinction is load-bearing: the old dense store reported the
true competitor count from `len()` while allocating max_index+1 slots, so a
test written against `len()` would have passed on the defect. Mutation-proved
by re-adding the dense padding, which fails it.

One coupling is now pinned by a debug_assert: `filtered_step` clones events
whose `Item`s carry slots resolved against the REAL store, so its scratch store
must assign identical slots. It does, because `iter()` yields slot order and
`insert` allocates in call order — but that is an invariant across two types,
so it is asserted rather than left to be rediscovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 18:01:29 +02:00
logaritmiskandClaude Opus 5 1ac3b21db5 fix: enforce EventBuilder weight/team length in release
Part of #18. `EventBuilder::weights` guarded the length match with a
`debug_assert!`, so release builds accepted a mismatch, silently dropped the
weights, and ingested the event anyway. That is the exact shape #18 is about:
validation that exists only where it is least needed.

The setters return `Self` to keep the chain fluent, so they cannot return a
`Result`. The builder now records the first failure and `commit` returns it as
`MismatchedShape`. The weights are not applied on mismatch either, so a
partially-weighted team cannot reach the history by another route.

Two tests in tests/degenerate_inputs.rs, whose CI job runs in release — which
is the only place the old behaviour differed.

The second test needed strengthening before it was worth anything. As first
written it committed a ONE-team event, which ingestion rejects for an unrelated
reason, so it passed under a mutation that disabled the whole check. It now
uses two teams, so ingestion would otherwise succeed and the assertion is
actually load-bearing. Both tests were then mutation-proved together: disabling
the error path in `commit` fails both in release.

#18 stays open. The remaining debug_asserts live in `ranked_with_arena` and
`scored_with_arena`, and promoting those means threading `Result` up through
`Event::compute`, `TimeSlice::iteration`, `log_evidence` and `filtered_step` —
which lands on the public API as `log_evidence() -> Result<f64>` and
`filtered_learning_curve() -> Result<...>`. That is a trade-off about what the
query API should look like, not a mechanical change, so it is not mine to
decide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:53:37 +02:00
logaritmiskandClaude Opus 5 4e043364fd perf: stop cloning inference inputs in OwnedGame and ingestion
The last two items on #23.

`OwnedGame::new` and `new_scored` cloned the whole team structure to hand one
copy to `Game` and keep another. But `Game` takes the teams by value and is
dropped at the end of the constructor, so the vec can simply be taken back out
of it — the clone existed only because nobody looked at the lifetime.

`add_events_with_prior` deep-cloned each event's composition, results and
weights when chunking events into per-timestamp groups. Nothing reads those
three after the chunking loop (the agent-collection pass and the tie pre-check
both run before it), so the elements are now moved out with `mem::take`.

That soundness argument rests entirely on `o` being a permutation: visiting an
index twice would take an already-emptied vec and silently produce an event
with no teams rather than failing. Since that would be invisible, there is now
a debug_assert checking the permutation property directly, next to the comment
explaining why the code depends on it.

Verified on 1.85.0 as well as the local toolchain — an MSRV break in this
change would otherwise only surface in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:50:39 +02:00
logaritmiskandClaude Opus 5 aff3fb948d refactor!: replace emptiness-as-sentinel with Option for results and weights
Third of the five remaining items on #23.

`add_events_with_prior` and `TimeSlice::add_events` took `Vec<Vec<f64>>` and
`Vec<Vec<Vec<f64>>>` where an empty vec meant "not supplied" — so an empty
outer vec and a genuinely empty event list were the same value, and every
reader had to know which. Both are now `Option`, and the "not supplied"
branches read as `None` arms rather than `is_empty()` checks.

Two things fell out of the change that a sentinel would have hidden:

The tie pre-check iterated `results` directly. Under `Option` it needs
`.iter().flatten()`, which makes explicit that a `None` results list has no
ties to reject — previously an empty vec silently skipped the same loop and
looked identical to "checked, found nothing".

`MismatchedShape.got` could no longer be `results.len()`, because at the point
of the error there may be no vec to take a length from. It is now computed as
`map_or(0, Vec::len)` before the error is built.

MSRV note: my first draft used let-chains for the two validations. Those need
Rust 1.88 and this crate pins 1.85 — it compiled locally on 1.98 and would
have failed only in the MSRV CI job. Rewritten with `is_some_and`, and
verified by installing 1.85.0 and building against it rather than by assuming
the removal was complete.

Breaking: `TimeSlice::add_events` is public.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:48:50 +02:00
logaritmiskandClaude Opus 5 06ed24b240 refactor!: make Competitor::message an Option, and compute_elapsed loud
Two of the five remaining items on #23.

`Competitor.message` was a `Gaussian` using the improper `N_INF` as an "unset"
sentinel, so `message != N_INF` meant "has a message" and every reader had to
know that convention. It is now `Option<Gaussian>`, which makes "no message
yet" and "a legitimately improper message" distinguishable at the type level
instead of by float comparison.

Worth noting what the change surfaced: switching the type turned every read
site into a compile error, and there were eight — two in the convergence sweep,
five in ingestion, one in new_backward_info. The last is the interesting one:
`skill.backward = agents[agent].message` needed `unwrap_or(N_INF)` rather than
an unwrap, because an absent message genuinely does mean the improper identity
there. A sentinel-based refactor would have had to find that by reading.

This is a breaking change: `message` is a public field. It rides the next
minor bump.

`compute_elapsed` clamped a negative elapsed to zero silently. Negative elapsed
means slices are being visited out of time order, which would otherwise make
drift *reduce* uncertainty. Release still clamps, so a bad timestamp degrades
to "no drift" rather than corrupting a posterior, but debug now trips — getting
there is a slice-ordering bug, not something callers can cause with ordinary
data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:46:12 +02:00
logaritmiskandClaude Opus 5 9b2c2b38c8 docs: complete the public API documentation contract
Closes the last open item in #25. `cargo clippy -W missing_errors_doc
-W missing_panics_doc -W must_use_candidate -W doc_markdown` went from 56
warnings to zero.

The 13 hand-written sections name the actual variants each function returns
rather than gesturing at "an error". Establishing that meant reading the error
paths — `Game::ranked` alone returns four distinct variants, and `record_draw`
can hit TieWithoutDrawProbability where `record_winner` provably cannot, since
a two-team decisive outcome has nothing to tie. Documenting those as
interchangeable would have been worse than leaving them undocumented, because
a reader would trust it.

Two existing doc comments already described panics in prose but not under a
`# Panics` heading, so neither rustdoc nor clippy surfaced them:
`Outcome::winner` and `EventBuilder::weights`. Both now carry the heading, and
`Outcome::winner` gained the note that it ties every loser, so `n >= 3` needs a
positive p_draw — the crate's easiest error to hit by accident.

The 43 mechanical fixes (31 `#[must_use]` on pure accessors, 11 missing
backticks) were applied with `cargo clippy --fix`. `#[must_use]` on Gaussian's
arithmetic and on `posteriors()` matters: discarding those results is always a
bug, and until now nothing said so.

Also documented why `[profile.release] debug = true` exists — cargo-flamegraph
needs the symbols, and library profile settings are ignored downstream, so it
reads as an oversight without the note.

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