8 Commits
Author SHA1 Message Date
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
23 changed files with 858 additions and 173 deletions
+1
View File
@@ -7,3 +7,4 @@
NOTEPAD.md NOTEPAD.md
/.claude /.claude
proptest-regressions/
+5
View File
@@ -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
+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))
+23 -17
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 {
let elapsed_variance = match &self.last_time { Some(message) => {
Some(last) => self.rating.drift.variance_delta(last, now), let elapsed_variance = match &self.last_time {
None => 0.0, Some(last) => self.rating.drift.variance_delta(last, now),
}; None => 0.0,
self.message.forget(elapsed_variance) };
} else {
self.rating.prior message.forget(elapsed_variance)
}
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
@@ -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(),
+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(),
+146 -33
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;
@@ -635,18 +650,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 +676,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 +700,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 +733,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 +743,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 +785,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 +793,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 +818,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 +834,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 +858,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 +869,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 +885,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 +909,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 +923,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>>,
@@ -941,7 +1005,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 +1026,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(),
+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
}
} }
/// Whether a competitor is present in this slice. Test-only.
#[cfg(test)]
pub fn contains(&self, idx: Index) -> bool {
self.slots.contains_key(&idx)
}
/// 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)> { pub fn iter(&self) -> impl Iterator<Item = (Index, &Skill)> {
self.present.iter().enumerate().filter_map(|(i, &p)| { self.indices.iter().copied().zip(self.skills.iter())
if p {
Some((Index(i), &self.skills[i]))
} else {
None
}
})
} }
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);
}
} }
+82 -31
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,7 +69,7 @@ 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)
@@ -157,9 +164,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 +284,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 +304,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 +322,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 +333,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 +385,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 +424,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];
} }
} }
@@ -574,7 +596,7 @@ impl<T: Time> TimeSlice<T> {
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);
} }
@@ -627,7 +649,7 @@ impl<T: Time> TimeSlice<T> {
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,
); );
+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}"));
}
}
+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"),
}
}
}
}