36 Commits
Author SHA1 Message Date
logaritmisk da55d2a7d1 Merge api/non-exhaustive-options (#74) 2026-09-09 22:05:11 +02:00
logaritmiskandClaude Opus 5 6a893ffe57 fix!: non_exhaustive on ConvergenceReport, and not on the options structs
`ConvergenceReport` is only ever constructed by `converge` /
`converge_partial`, so marking it costs a caller nothing and makes a
future field additive.

`ConvergenceOptions` and `GameOptions` deliberately stay constructible,
against #74's recommendation, because trying it turned up a cost the
issue did not anticipate. `Default::default` is not a `const fn`, so
`#[non_exhaustive]` + `..Default::default()` — the pattern that makes
marking an options struct cheap — does not work in a `const`:

    error[E0639]: cannot create non-exhaustive struct using struct expression
      --> tests/competitor_config.rs:13:41
       |
    13 | const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {

`ConvergenceOptions` is `Copy` and a natural const; there is no
workaround from outside the crate. That cost is permanent, and adding a
field is a one-time major bump. The reasoning is recorded on the type so
the next person does not rediscover it.

Refs #74.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 22:05:11 +02:00
logaritmisk f14c783c0e Merge docs/vocabulary (#75) 2026-09-09 21:58:26 +02:00
logaritmiskandClaude Opus 5 055575a6f4 docs!: one name for score noise, and say which of beta/sigma to turn
"sigma" named three unrelated quantities: the prior standard deviation,
a distribution's own SD, and the observation noise on an observed score
margin. The third was already `score_sigma` at every config site —
`HistoryBuilder::score_sigma`, `GameOptions::score_sigma`,
`EventKind::Scored { score_sigma }` — and plain `sigma` only on
`Outcome::Scored`'s field and constructor parameter, whose own doc had
to disambiguate itself with "`sigma` overrides
`HistoryBuilder::score_sigma`". Now `score_sigma` everywhere.

The `Outcome::scores_with_sigma` / `EventBuilder::scores_with_sigma`
*method* names are left alone: renaming them is a naming choice rather
than a consistency fix, and #75 offers two candidates.

`HistoryBuilder::beta` and `::sigma` now say which is which. #75 calls
this the single most load-bearing undocumented distinction in the crate,
and it is right: nothing told a reader that `sigma` is epistemic — what
the model does not yet know, which evidence shrinks — while `beta` is
aleatoric, the day-to-day scatter no amount of evidence removes. Both
docs now name the symptom that should send you to that knob rather than
the other.

Refs #75.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 21:58:26 +02:00
logaritmisk 251211f134 Merge docs/missing-docs (#77, #75) 2026-09-09 21:53:51 +02:00
logaritmiskandClaude Opus 5 31564b71a0 docs: document the whole public surface and deny(missing_docs)
80 undocumented public items, including three that are first contact:
`History::current_skill` — the method the crate's own first example calls
— `EventBuilder`, the type `h.event(t)` hands you, and `Gaussian::mu()`.
Now zero, and `#![deny(missing_docs)]` keeps it that way.

Several docs are measurements rather than readings of the code:

- `Outcome::Ranked` says ranks are used ordinally, so `[0, 1, 2]` and
  `[0, 5, 90]` are the same observation. Measured: bit-identical
  posteriors for both.
- `OwnedGame::log_evidence` says two identically-rated competitors give
  exactly `ln(0.5)`. Written as a doctest, so it runs.
- `Member::weight` says zero and negative are accepted. Measured.
- `ConvergenceReport::final_step` is `(|Δmu|, |Δsigma|)` in skill units,
  NOT natural parameters. That one had to be traced through
  `Gaussian::delta` rather than assumed from the neighbouring vocabulary.
- `GameOptions::score_sigma` rejects non-positive and NaN but accepts
  `+inf`, which is what the guard actually says.

README: it is the front door for a crate on a private registry, and it
opened with a link dump followed by 130 lines on drift. The first
`record_winner → converge → current_skill` block was at line 226 of 307.
It now leads with what the crate is, an install line, a quickstart, a
"which entry point?" table, and the `converge`-is-strict rationale that
was the crate's most opinionated recent decision and went unmentioned.
The two canonical examples disagreed on spelling (`History::default()`
vs `History::builder().build()`, `current_skill("a")` vs
`current_skill(&"a")`); they now agree. Five new README blocks are
doctested, taking the suite from 19 to 25.

`pub use smallvec;`. Four public items name `SmallVec` in their
signatures, and the only `Joint` example failed to compile from a
consumer crate with `unresolved import smallvec` — the dependency was in
the API but not reachable. Both worked examples now use the re-export,
so they teach the path that works downstream.

Vocabulary, from #75: "agent" was a fourth word for competitor, 200
occurrences, and it had reached public signatures before #73 un-exported
`TimeSlice`. Now zero.

Closes #77. Refs #75.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 21:53:50 +02:00
logaritmisk 78810c0344 Merge fix/predict-non-finite-guard (#78 parts 1-2) 2026-09-09 21:40:39 +02:00
logaritmiskandClaude Opus 5 9d3e002be3 fix!: no prediction path answers from a fit it cannot answer from
`converge` refuses to report a NaN fit. Nothing stopped a caller from
ignoring that error and predicting anyway, and every prediction path was
differently wrong when they did. Measured on a point-mass-prior history
with `beta(0.0)`, after `converge` returned `NonFiniteResult`:

    predict_quality           = Ok(NaN)
    predict_outcome().total() = NaN
    predict_win_probabilities = Ok([0.0, 0.0])

The third is the dangerous one: finite, plausible, and summing to zero
against a doc that promises one at `p_draw == 0`. A caller checking
`total() ≈ 1` catches the second and misses it.

The same parameters on a *scored* event converge cleanly and leave
legitimate point-mass posteriors. There `predict_quality` **panicked** —
"cannot invert a singular matrix", out of a method returning `Result` —
because the contrast covariance `beta²AᵀA + AᵀSA` is exactly singular,
and `predict_win_probabilities` again returned `Ok([0.0, 0.0])`. That
promise assumes continuous performances, where an exact tie has measure
zero; point masses break the assumption, not the arithmetic.

Both checks now live at `member_skills`, the one gate every prediction
path reads skills through, rather than being repeated per method.

The finiteness check is on `mu` / `sigma`, not on the natural parameters.
The first attempt checked `pi` and `tau`, and measurement showed it
rejected a *legitimate* point mass — `pi = inf`, `mu = 0`, `sigma = 0` —
turning a working prediction into an error. The question is whether the
usable moments exist, and those are what predictions consume.

Docs: `converge_partial` omitted the drift-variance `InvalidParameter` it
validates before sweeping, and the free `expected_information_gain`
omitted `GridTooCoarse`, which comes from `outcome_distribution` and so
is not covered by its "anything `Game::ranked` returns" clause.

Refs #78 (parts 1 and 2; the layering and `predict_quality` rename
questions are still open).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 21:40:39 +02:00
logaritmisk 9d629d0d94 Merge api/trait-consistency (#76) 2026-09-09 21:29:16 +02:00
logaritmiskandClaude Opus 5 7ca0daa48e feat: PartialEq on the config types, and pin the public trait impls
`Rating` already derived `PartialEq`, but that derive is only reachable
through `D: PartialEq` — and `ConstantDrift`, the crate's own only
`Drift` impl, did not satisfy it. So the derive was there and unusable.
Found by writing the comparison from a consumer's position rather than
reading the derive list.

`ConstantDrift`, `ConvergenceOptions` and `GameOptions` now derive
`PartialEq`. All three are pure configuration; comparing two is the
natural thing to want and nothing about them makes equality ambiguous.

`tests/trait_impls.rs` pins the surface, written the way the failure was
reported: a consumer struct that *holds* a `History` and derives
`Debug`. It also asserts `History`'s `Debug` summarises rather than
dumping its skill stores, so a future derive cannot quietly replace the
hand-written impl.

`Clone` on `History` stays off. It is a decision, not an omission: a
history owns every slice's skill store and arena, so cloning one is
proportional to the whole fit, and no consumer has wanted it.

Closes #76.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 21:29:16 +02:00
logaritmisk c3d1afe448 Merge api/must-use-and-visibility (#67, #73) 2026-09-09 21:24:37 +02:00
logaritmiskandClaude Opus 5 e4d6dc4028 fix: warn on dropped builders and values; stop exporting EP internals
`h.event(1).team(["x"]).team(["y"]).ranking([0, 1]);` without the
terminal `.commit()` was a silent no-op: no warning, no error, and the
next thing the caller does is converge an empty history and read `None`
skills. `EventBuilder` already carried a `#[must_use]`; the value types
around it did not, so the same silence covered `Team::with_members`,
`Member::new`, `Outcome::*`, `Joint` and `Prediction::outcomes`.

`#[must_use]` now goes on the *types* rather than being sprinkled over
methods, which covers every constructor and builder setter at once and
gives the crate a rule where it previously had a list. Verified by
compiling a program that drops each one and reading the warnings back,
rather than by assuming the attribute took.

Visibility, from #73: `Gaussian::damp_natural` was reachable from
outside the crate despite being an EP damping internal called only from
`src/factor/`. The stray `pub fn`s inside the private `time_slice`,
`key_table` and `matrix` modules are now `pub(crate)`, so their
visibility states what it means instead of relying on the module being
private.

`storage/mod.rs` and `factor/mod.rs` become `storage.rs` and
`factor.rs`.

Closes #67. Refs #73.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 21:24:37 +02:00
logaritmisk cc601c06eb Merge feat/evidence-matrix: the missing evidence corner (#70) 2026-09-09 21:19:20 +02:00
logaritmiskandClaude Opus 5 86e1521f8a feat: complete the evidence matrix and add current_skills
Three of four corners of the evidence matrix existed. The missing one was
forward-only *and* key-restricted — which is exactly what per-competitor
prequential scoring needs, the intersection of the two workloads
`log_evidence_for` and `filtered_log_evidence` are each documented for.

`filtered_log_evidence_for` fills it. It is not
`log_evidence_internal(true, targets)`: that path selects `skill.forward`
as the prior, which stops being a filtering quantity once `iteration`
has run a backward sweep. It goes through `filtered_pass` like its
unrestricted sibling, with the restriction applied to which events are
*scored*, never to which are *run* — so it is a held-out score under the
real history, not a score under a counterfactual one where nobody else
played.

Key resolution for both `*_for` accessors now shares `resolve_targets`,
so they cannot drift apart on how an unknown key is reported.

`current_skills` is the plural of `current_skill`. Building a
leaderboard previously meant materialising every competitor's full
smoothed curve via `learning_curves` and reading the last point of each.

Tests carry controls in both directions: naming every competitor must
recover the unrestricted value (catching a filter that drops too much),
and the restricted forward-only value must differ from the restricted
smoothed one (catching an alias).

Refs #70.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 21:19:20 +02:00
logaritmisk 60fc3e9d05 Merge fix/honest-accessors: honest per-key queries (#66, #70) 2026-09-09 21:13:36 +02:00
logaritmiskandClaude Opus 5 e4a68ba1a7 fix!: per-key queries report unknown keys instead of a plausible constant
Two accessors answered a question about a key the history had never seen
with a well-formed value indistinguishable from a real answer.

`log_evidence_for` filter_map'd unknown keys away. An empty target list
means "no restriction" downstream, so a list of *entirely* unknown keys
returned the whole-history evidence: measured on a two-cohort fixture,
`log_evidence_for(["typo"])` returned exactly `log_evidence()`. On the
one workload it is documented for — leave-one-out cross-validation —
that is the un-held-out score, a plausible number that silently
invalidates the comparison it was computed for. It now returns
`Err(UnknownKey)` naming the offending position.

`learning_curve` and `filtered_learning_curve` returned an empty `Vec`
both for a typo'd key and for a competitor who is registered but has not
played yet. They now return `Option`, so `None` is "never heard of it"
and `Some(vec![])` is "known, no appearances".

Tests carry a control case in each direction, so they cannot pass by
everything returning the same thing.

Closes #66, closes #70.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 21:13:35 +02:00
logaritmiskandClaude Opus 5 56e8220c86 Merge branch 'api/cleanup'
Un-export the unreachable types, add the missing trait impls, make
#[must_use] consistent, correct eight wrong # Errors sections, seal the
error variants, and settle the vocabulary.

Refs #70, #73, #74, #75, #76, #77, #78

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 20:54:48 +02:00
logaritmiskandClaude Opus 5 fdd1539cab refactor: one word per concept
Three vocabulary collisions, from #75.

**"rating" meant three things**, one the opposite of the exported type.
`Rating` is documented as static *configuration* — "this returns what it
was told", against every other accessor's "what inference inferred". But
`quality`'s parameter was `rating_groups: &[&[Gaussian]]` and its prose
said "rating groups" four times, where "rating" means a *posterior* — the
one thing `Rating` is documented not to be. Two error messages used it
that way too.

So a reader who learned `Rating = config` passed `Rating` values to
`quality`, which takes `Gaussian`; and one who learned "rating = what
comes out" was baffled that `h.rating(&k)` is not their skill.

"rating" is now reserved for the type. `quality(teams: &[&[Gaussian]])`,
and "every rating is finite" became "every posterior is finite".

**"agent" was a private fourth name for a competitor** — ~200 identifiers
against 236 uses of "competitor", and it leaked into two `pub` signatures
on `TimeSlice`. Now that #73 has made those internal this is a pure
rename, so the crate has one word for the entity throughout.

**"player" survived in one public signature** — `free_for_all(players:)`
plus two doc lines. Renamed, along with three internal closure bindings.
Doc examples that use "player" as a *key* are left alone: that is a
user's data, not the crate's vocabulary.

The panic-message expectations in tests/quality.rs moved with the prose,
which is the point of asserting on message text — the tests caught the
rename rather than papering over it.

Not touched: "performance" (always skill widened by beta), "skill",
"member" and "team" are each used for exactly one thing already.

Refs #75

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 20:54:47 +02:00
logaritmiskandClaude Opus 5 85c4d0d87d fix!: correct eight wrong # Errors sections and seal the error variants
Documentation (#78). Every item below was measured against the code
rather than read:

- `expected_information_gain` and `predict_ranking` had `# Errors`
  immediately followed by `# Preconditions`, with the error list stranded
  at the bottom of the latter — rustdoc rendered a BLANK Errors section on
  both. The heading now sits with its content.
- `predict_outcome`, `predict_ranking` and the free
  `expected_information_gain` all omitted `GridTooCoarse`.
- `predict_margin` claimed `JointUnavailable` "if the LATEST slice holds
  ranked events". Measured with an early ranked slice and a late scored
  one: it fails. The condition is *any* slice.
- `add_events` documented three errors and can return five more; it also
  claimed a weights `MismatchedShape` that is unreachable through it,
  since weights arrive one-per-`Member`. That check belongs to
  `EventBuilder::weights`, and the doc now says so.
- `converge` and `converge_partial` both omitted the drift-variance
  `InvalidParameter`.

`History` gains a hand-written `Debug` (#76). Summarising, not
exhaustive — a derived one would print every competitor's skill at every
slice. It exists because without it a consumer cannot `#[derive(Debug)]`
on any struct holding a `History`, which is how both known consumers
store one.

`#[non_exhaustive]` on all 17 `InferenceError` struct variants and on
`Outcome::Scored` (#74). The enum carried the attribute; no variant did,
so adding a field to any of them — and downstream construction of any of
them — were both in the public contract. This crate added two variants in
two days.

The options structs are deliberately NOT sealed. `ConvergenceOptions` and
`GameOptions` are constructed by struct literal at 65 sites of which only
8 use `..default()`, and specifying all three convergence fields is a
natural complete statement rather than a partial one. That is a real
trade-off rather than an oversight, and it is left as a decision on #74.

Also spells `UnknownKeys::Reject` explicitly at both sites that
wildcarded it. `#[non_exhaustive]` on your own enum gives no exhaustiveness
safety net if you then match `_`.

Sealing the variants pushed ten test sites from constructing errors to
`matches!`, which is the better assertion anyway — an `assert_eq!` against
a constructed error breaks whenever a field is added, which is the exact
fragility the attribute exists to prevent.

BREAKING CHANGE: `InferenceError`'s struct variants and `Outcome::Scored`
are `#[non_exhaustive]` — downstream patterns need `..` and downstream
construction is no longer possible.

Refs #78, #76, #74

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 20:49:07 +02:00
logaritmiskandClaude Opus 5 a0c2f78aed feat: add the missing trait impls and make #[must_use] consistent
Trait coverage (#76), all additive:

  History          Debug is still absent - see below
  HistoryBuilder   + Debug   (it derived Clone but not Debug)
  Rating           + PartialEq  (Gaussian had it; Rating is a Gaussian
                                 plus three scalars and had none)
  Event/Team/Member + PartialEq (input value types with no way to compare
                                 them, which made round-trip tests awkward)
  ConvergenceReport + PartialEq

`#[must_use]` (#67). The coverage had no rule: `filtered_log_evidence`
had it and `log_evidence` did not; `rating` had it and `current_skill`
did not; `Rating::with_drift_scale` had it and `Member::with_drift_scale`
did not.

Now on the types — `EventBuilder`, `HistoryBuilder`, `Prediction`,
`Gaussian`, `OwnedGame` — which covers most method returns at once, plus
the `History` accessors individually.

`EventBuilder` gets a message, because a dropped builder is the worst
case in the set: measured, `h.event(1).team(["x"]).team(["y"]).winner(0)`
without `.commit()` leaves `time_slices_len() == 0` and every skill
`None`, with no warning at all.

And `ConvergenceReport`'s `#[must_use]` moves off the TYPE onto
`converge_partial`, where its stated reason is true. It read "from
`converge_partial` this may describe a fit that stopped at max_iter" but
fired on `converge` too — where that is false, since `converge` returns
`Err(NotConverged)` in exactly that case. So the crate's own front-page
example warned, and every quickstart had to write `let _ =`. Verified
from a consumer crate: `h.converge()?;` now compiles clean.

Marking the types made eight method-level attributes redundant, which
clippy's `double_must_use` caught — that is the type-level marker doing
its job, and the eight are removed.

Refs #76, #67

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 20:38:39 +02:00
logaritmiskandClaude Opus 5 4472d98b56 refactor!: un-export six types that no caller could reach
`TimeSlice`, `EventKind`, `KeyTable`, `CompetitorStore`, `Competitor` and
the `storage` module were all public and none was obtainable from a
`History` — `time_slices`, `agents` and `keys` are all private or
`pub(crate)`. `TimeSlice` was the worst: `new`, `add_events`, `iteration`,
`get_composition` and `get_results` were `pub` on a type you could only
build standalone and never feed back into anything.

Their sole consumer outside `src/` was `benches/batch.rs`, so a benchmark
was dictating six public types. It is rewritten against the public API: a
single-slice history's `converge` calls exactly the same per-slice sweep,
so capping at one iteration measures the same code path.

`N01` had zero references in the entire repository, including inside the
crate; removed. `N00` and `N_INF` are EP identities (`Add` and `Mul`) and
are now `pub(crate)` — a user reaching for `N_INF` as "an unknown
competitor's prior" would get an improper distribution whose `mu()`
silently reports 0.0.

Adds the accessors their absence forced people around, from #70:
`competitors()`, `competitor_count()` and `event_count()` (`size` had no
accessor at all). Answering "who is best" previously meant materialising
every competitor's full smoothed curve to read the last point of each.

`KeyTable::keys` now iterates the dense reverse table rather than the
forward `HashMap`, so `competitors()` yields insertion order rather than
per-process hash order — the same hazard as #62, caught before it could
reach a caller building a standings table.

Two `CompetitorStore` methods (`is_empty`, `iter_mut`) had no callers
anywhere and are gone; four more are now `#[cfg(test)]`, which is what
they always were in practice.

Worth recording a mistake: I first deleted `get_composition`/`get_results`
on the strength of a "never used" warning, and the build broke — the
warning came from the plain-lib target, where `#[cfg(test)]` callers in
history.rs are not compiled. A dead-code warning from one target is not
evidence about the others.

BREAKING CHANGE: `TimeSlice`, `EventKind`, `KeyTable`, `CompetitorStore`,
`Competitor`, the `storage` module, `N01`, `N00` and `N_INF` are no longer
public.

Refs #73, #70

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 20:34:18 +02:00
logaritmiskandClaude Opus 5 5f5a37090a Merge branch 'fix/reachable-time'
Make the Time generic reachable, and exercise it end to end.

Closes #68

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 20:19:16 +02:00
logaritmiskandClaude Opus 5 dc1f4d5847 fix!: make the Time generic reachable
`History<T: Time, ..>` has always been generic over the time axis,
`Untimed` has always been exported, and `Drift<T>` is generic specifically
so that "seasonal or calendar-aware drift is expressible without going
through i64". None of it was reachable from a downstream crate.

Every construction route pinned `T = i64`: `History::builder()`,
`History::builder_with_key()`, and the only `Default` impl on
`HistoryBuilder`. Its fields are private and it had no `new`. So all three
escape routes failed to compile, and a consumer with domain timestamps
had to convert to i64 — which is the exact thing the parameter exists to
avoid. One of `History`'s four type parameters was paid for at every
signature and could never be varied.

`Default` is now generic over `T` and `K`, `HistoryBuilder::new()` exists,
and `time_type::<T2>()` / `key_type::<K2>()` join `drift` and `observer`
as type-changing setters:

    History::builder().time_type::<Untimed>().build()
    History::builder().key_type::<String>().build()
    HistoryBuilder::<Season, _, _, String>::new().build()

`key_type` replaces `builder_with_key`, which could not be turbofished —
`K` sat on the impl rather than the function, so callers had to spell
`History::<i64, _, _, String>::builder_with_key()`. 18 call sites across
15 files migrated.

tests/time_axis.rs is the part that matters. NOTHING in the repository
constructed a non-i64 history, which is precisely why this survived, so
the fix is only half done without a test that exercises the generic. It
defines a `Season(u16)` time type and a `SeasonalDrift` that accumulates
between seasons but not within one — the calendar-aware case the trait's
docs cite — and checks the whole path: fit, converge, and read a learning
curve whose times come back as `Season`, not as integers.

Two of the six tests are controls rather than assertions about output.
`Untimed` must ignore drift entirely, since elapsed is always zero, so
gamma 0.0 and gamma 5.0 must agree bit for bit. And a custom `Drift` must
actually widen a gap across seasons, or the test above would pass whether
or not the drift was consulted at all.

The README's ticked "Generalise a time axis" box is now true.

BREAKING CHANGE: `History::builder_with_key()` is removed. Use
`History::builder().key_type::<K>()`.

Closes #68

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 20:19:16 +02:00
logaritmiskandClaude Opus 5 0ab56248bb Merge branch 'fix/seal-constant-drift'
Seal ConstantDrift's field, and add an enumerating test over every public
magnitude parameter.

Closes #65

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 19:11:57 +02:00
logaritmiskandClaude Opus 5 8dff7513f7 fix!: seal ConstantDrift's field so gamma can be validated
`gamma` enters only as `gamma * gamma`, so the sign was squared away:
measured against the old public-field form, `ConstantDrift(-0.0833)`
produced results bit identical to `ConstantDrift(0.0833)`. The sign was
neither rejected nor honoured — it vanished.

It could not be checked while the field was a public tuple position,
because there was nothing to intercept. Validating inside
`variance_for_elapsed` would have been worse: it runs in the sweep, so a
construction-time mistake would panic mid-inference, and `Gaussian::from_ms`
is a worked example of why that is the wrong place — rejecting NaN there
turned the NonFiniteResult reporting path into a crash.

So `ConstantDrift::new` is the only way in and it checks, with `gamma()`
to read the value back. 129 call sites rewritten across src, tests,
benches, examples and the README. The dated plan and spec documents under
docs/superpowers are left alone: they record what was built at the time,
and rewriting them would falsify that.

tests/constructor_validation.rs is the more valuable half. This defect
class was closed three times in one session and reopened twice, because
each fix validated the layer it had just touched and inferred the rest —
`HistoryBuilder`, then `Game`'s own entry points, then the constructors
beneath both. A per-site fix cannot notice the site nobody thought of, so
that file enumerates every public entry point taking a magnitude and
asserts each refuses negative and non-finite values.

It found an eleventh defect on its first run: `HistoryBuilder::score_sigma`
accepted infinity, because `inf > 0.0` is true and the assert only tested
positivity. Fixed, and its own `should_panic` message updated to match.

`Gaussian::from_ms` is deliberately exempt from the non-finite half, for
the reason above: a broken fit produces a NaN sigma legitimately and
`converge` must be allowed to report it.

The convergence-level drift-variance check stays and is now tested through
a custom `Drift` implementation, since `ConstantDrift` can no longer reach
it. That check is the only thing standing between a third-party `Drift`
and a NaN fit.

BREAKING CHANGE: `ConstantDrift`'s field is private. Replace
`ConstantDrift(x)` with `ConstantDrift::new(x)`, and `drift().0` with
`drift().gamma()`. `HistoryBuilder::score_sigma` now rejects infinity.

Closes #65

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 19:11:57 +02:00
logaritmiskandClaude Opus 5 a367155778 Merge branch 'fix/numerics-critical'
Fix the ten defects found by the 2026-09-09 floating-point audit: four
critical, three high, three medium.

Closes #55, #56, #57, #58, #59, #60, #61, #62, #63, #64

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 18:08:06 +02:00
logaritmiskandClaude Opus 5 c69a397d80 test: make the determinism test exercise the parallel sweep
It proved less than it appeared to. `sweep_color_groups` takes its
`par_iter` branch only for colour groups of at least RAYON_THRESHOLD (64)
events, and a colour group is a subset of ONE slice's events. The fixture
built 20 slices of 10, so the branch was unreachable — the test named the
parallel path and ran the sequential one.

It also compared one competitor's curve out of forty, and never compared
log_evidence, final_step or iterations.

The new fixture reaches the branch by construction: within a slice every
event uses a disjoint competitor pair, so greedy colouring puts all 96 in
colour 0. Competitors recur across slices, so the fit keeps temporal
coupling and drift rather than degenerating into independent duels.

Verified by instrumenting `sweep_color_groups`: 872 sweeps, one colour
group of 96 each, parallel branch taken all 872 times.

Worth recording how that verification went, because I nearly drew the
opposite conclusion. My first two instrumented runs printed nothing and I
read that as "the branch is still unreachable" — but `cargo test` captures
stderr without `--nocapture`, so the probe was invisible, not absent. An
instrument that cannot report is indistinguishable from a negative result.

Now compares every competitor's curve plus log_evidence, final_step and
iterations, and asserts the curve count so it cannot silently go back to
measuring almost nothing. A companion test pins EVENTS_PER_SLICE against
the threshold, so shrinking the fixture fails loudly rather than quietly
returning the suite to the sequential path.

Cross-process coverage is separate, in tests/cross_process_determinism.rs
(#62) — an in-process test cannot see hasher-order effects at all, since
every sample shares one seed.

Closes #64

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 18:08:06 +02:00
logaritmiskandClaude Opus 5 7aa7fb62dd fix: make posterior_of reproducible across processes
`ResolvedTerms::unseen` was a `HashMap<String, f64>` and three float
reductions iterated it. Addition is not associative and Rust seeds its
default hasher per process, so `posterior_of` returned different bits run
to run on identical input: measured over 40 processes, two distinct sigma
bit patterns, and five distinct values from `expected_variance_reduction`
spanning about 7 ULP.

A `BTreeMap` fixes it by construction. 40/40 identical after, 24/16
before.

The cross-batch conflict scan had the same cause with a different
symptom. It returns on the FIRST conflict, so hash order decided WHICH
competitor the error blamed — 15 different competitors named across 40
runs on identical input. The error fired every time; only its content was
a lottery, which sends a reader after the wrong key. Now scanned in
sorted order.

Magnitude was 1-7 ULP throughout, so no decision changes. The cost was
reproducibility: a golden test over these would flake at a low rate,
which is the worst kind of CI failure to diagnose.

tests/cross_process_determinism.rs re-executes the test binary and
compares bits, because an in-process test CANNOT see this — every sample
in one process shares one hasher seed. That is not hypothetical:
tests/determinism.rs compares four thread counts inside one process and
passed throughout while this was live.

Tuning that fixture took a measurement. Coefficients spread over nine
decades detected the bug in roughly one run in forty, because the small
terms fall below the running total's ULP and are absorbed whatever the
order. Comparable magnitudes keep every term able to change the last
bits: 5 of 5 attempts detected it, with 3 to 38 of 40 runs differing.
Verified non-vacuous by reverting the BTreeMap and watching it fail.

Closes #62

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 18:02:42 +02:00
logaritmiskandClaude Opus 5 305f822964 fix: route the last three transcendentals through libm, and enforce it
CLAUDE.md requires transcendentals to go through libm rather than std,
because std delegates to the system math library and the two disagree by
one ULP often enough to change an iteration count in a fixed point.

Three production sites did not:

  factor/margin.rs:84  cavity.sigma().hypot(sigma)   12.136% of 1e6 inputs
  factor/margin.rs:92  f64::MIN_POSITIVE.ln()         3.437%
  factor/trunc.rs:98   f64::MIN_POSITIVE.ln()         same

`hypot` is the material one: it is on the path of every scored event, and
its divergence rate is HIGHER than the 9.7% the rule cites for `exp` as
its own justification. The two `ln` calls happen to agree bit-for-bit on
this host, which is exactly the platform dependence the rule exists to
remove.

The `hypot` choice itself was right and stays — the comment above it
explains why, and it is measured: naive sqrt(a^2 + b^2) overflows to inf
at 1e200 and flushes to zero at 1e-200 where hypot does neither. Only the
implementation moves.

tests/libm_rule.rs enforces it. The rule was stated plainly in CLAUDE.md
and still violated three times, so prose is evidently not sufficient. The
test strips `#[cfg(test)]` items by brace matching, plus comments and
string literals so prose is not mistaken for a call, then scans for std
method spellings. `sqrt` is exempt: IEEE 754 specifies it, so std and
libm cannot disagree.

Confirmed non-vacuous by reintroducing the `hypot` violation and watching
it fail with the offending line, then pass again on restore. Two further
tests pin the stripper itself, since a stripper that removed everything
would make the guard pass on anything.

Closes #63

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 17:56:23 +02:00
logaritmiskandClaude Opus 5 ab23476aaf fix!: validate the constructors below HistoryBuilder
0.8.0 closed the sign-absorption defect at `HistoryBuilder::mu/sigma/beta`
and at both ingestion paths. It was still open one layer down, in the
constructors those paths call. Measured, all bit identical to their
positive counterparts:

  Gaussian::from_ms(25.0, -8.33)  == from_ms(25.0, +8.33)
  Rating::new(_, -4.17, _)        == Rating::new(_, +4.17, _)
  ConstantDrift(-0.0833)          == ConstantDrift(+0.0833)

sigma, beta and gamma enter only as squares, so the sign vanished without
comment. Worst of the set: `Rating::new(_, NaN, _)` reached `Game::ranked`
which returned **Ok** carrying `Gaussian { pi: NaN, tau: NaN }` — no
`converge` on that path to catch it.

`from_ms` and `Rating::new` now reject. `ConstantDrift` cannot: the field
is public and positional, so there is no constructor to intercept, and
sealing it would break every `ConstantDrift(x)` for a case whose resulting
model is perfectly valid. Documented instead. Its non-finite half IS
rejected — `converge` validates the drift variance each competitor
accumulates, which also covers a custom `Drift` impl.

Two things the tests caught that I had wrong:

NaN sigma must PASS `from_ms`. My first version rejected it, and two
existing tests went red immediately: a broken fit legitimately produces a
NaN sigma from `sqrt` of a negative truncated variance, and the design is
to propagate that to `NonFiniteResult`. Rejecting it turned the reporting
path into a panic inside inference. Written as
`sigma >= 0.0 || sigma.is_nan()` so the intent is explicit rather than
hidden in a negated comparison.

Very small sigma is also not rejected, and that is deliberate: `approx`
produces small truncated sigmas legitimately. `pi = 1/sigma^2` leaves
f64's range below ~1.5e-154 and `tau = mu*pi` overflows sooner, at a
threshold that depends on mu — so there is a band where pi is finite and
only tau is not. Both land on the existing point-mass representation.
Documented, including that such a Gaussian is not equal to itself and can
make two identical declarations report as conflicting.

BREAKING CHANGE: `Gaussian::from_ms` panics on a negative sigma, and
`Rating::new` panics unless beta is finite and non-negative. `converge`
returns `InvalidParameter` for a non-finite drift variance.

Closes #61

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 17:48:41 +02:00
logaritmiskandClaude Opus 5 6139061740 fix: keep the truncated variance representable in the far tail
`v_w` returned `w` and let `trunc` form `1 - w`. `w` tends to 1 out in
the tail, so that subtraction lost about log10(alpha^2) digits — and the
quantity it was destroying is perfectly representable.

Two separate cancellations, fixed separately.

The non-tie half: `half_line_truncation` now returns `1 - w` computed
symbolically rather than as `1 - v*gap`. With `alpha*gap = 1 - inv^2*b`
the leading ones cancel on paper instead of in floating point. Measured
against the exact truncated variance:

  alpha    before          after
  1e6      8.9e-5 rel      0.0 rel (exact)
  1e8      returns 0.0     0.0 rel (exact)

At 1e8 the old form gave `sigma_trunc = 0`, and `from_ms(mu, 0.0)` is a
point mass whose `mu()` is inf/inf = NaN. `beta(1e-8).sigma(1e-8)` with
priors 1000 apart went from Err + NaN skills to a finite fit.

The tie half is a different subtraction — `w = v^2 - u`, where both grow
as alpha^2 while their difference stays O(1). The existing escape hatch
could not cover it: it keys on `alpha * width >= HALF_LINE_WINDOW`, how
many window-widths from the mean the window sits, and a NARROW window
fails that however deep it is. Measured at alpha 1e6 with a 1e-6 window
it kept four digits and returned `1 - w = -2.4e-4` where the truth is
+2.8e-13. One step earlier it was quietly wrong instead: `1 - w = 1.0`
exactly, a truncation reported as a no-op, where the truth was 5e-17.

Over a narrow window the density is a truncated exponential in
`s = (x - alpha)/width`, whose mean and variance are closed forms, so
`v = alpha + width*m(t)` and `1 - w = width^2 * V(t)` with no large
subtraction at all. Validated against high-precision quadrature: v exact
to 4e-10, `1 - w` to 4e-10 across the region it is used in.

The crossover is on `alpha / width` rather than on either alone, because
that ratio is what says how many digits the subtraction has left — and
the approximation is most accurate exactly where the subtraction is
worst, since both improve as the window narrows.

Defaults are bit-identical (pi 0.02398318151216503 before and after).

Tests: the three reproductions from the issue, the narrow-window form
against pinned quadrature values, and a continuity sweep across all three
tie branches — a misplaced crossover is the real risk here, and a jump at
a boundary is visible even without pinning absolute values.

Closes #60

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 17:42:16 +02:00
logaritmiskandClaude Opus 5 f1219036b3 fix: take quality's determinant ratio in log space
`quality()` computed `det(ata) / det(middle)` in linear space. Both are
products of `k - 1` diagonal entries, so they leave f64's range long
before their ratio does — and the ratio is the only thing the answer
needs.

Measured at the crate defaults: 150 groups correct at 8.45e-53, 200
returned 0, 250 returned NaN where the truth is 9.51e-88. With a small
beta it bit far sooner: at sigma = beta = 1e-3, 60 groups returned NaN
against a true 1.32e-9 — a value nine orders of magnitude inside the
normal range. Neither `quality()` nor `History::predict_quality` caps the
group count, unlike `predict_outcome`, so those are supported calls.

`Lu::ln_abs_determinant` accumulates `ln|diagonal|` instead of
multiplying, and the call site becomes `exp(e_arg + 0.5 * ln_ratio)`.

Verified against the closed form `(beta / sqrt(beta^2 + sigma^2))^(k-1)`
rather than against recorded output, across three parameter sets and
group counts to 300: every case now agrees to 1e-11 or better, including
9.88e-324 at 300 groups, which is subnormal.

Also documents the remaining panic: every rating at zero sigma with a
zero beta makes `middle` singular and `inverse()` panics. Documented
rather than converted — nothing is uncertain there, so there is no
distribution to take the quality of.

Closes #59

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 17:33:00 +02:00
logaritmiskandClaude Opus 5 31cf0998b0 test: scale the ceiling sweep by build profile
Each sample runs a full inference pass per outcome, and that is about
19x faster in release: 20 000 samples take 12.1s released against 23s
for 2 000 in debug.

`just test` runs three debug feature combinations and one release one, so
a fixed sample count pays the slow price three times and the fast one
once — exactly backwards. Scaling by `cfg!(debug_assertions)` puts the
search where it is cheap:

  debug    1 000 samples   11.7s
  release 50 000 samples   31.6s

Across the whole `just test` that is 67s against 70s before, for 25x the
samples. The debug run proves the sweep compiles and holds; the release
run is the one that actually searches.

Not moving the suite to release-only, which was the alternative
considered. `debug_assert!` is compiled out in release, and this crate
documents that as load-bearing — several defects have hidden there — so
dropping the debug runs would trade one class of coverage for another
rather than adding any.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 17:27:09 +02:00
logaritmiskandClaude Opus 5 bbc7705c75 fix!: report an unresolvable prediction grid instead of clamping
`grid_shape` asked for 12 nodes across the narrowest feature and then
clamped to MAX_GRID_POINTS with no detection that the request was not
met. Past `step/sigma ~ 1.7` the trapezoid rule stops resolving the
density, and the result is unbounded:

  sigma_a   step/sig_a   P(a first)   exact      total
  2.0e-3      0.86       0.515953    0.515953   1.000000
  1.0e-3      1.72       0.517185    0.515953   1.002388
  1.0e-4     17.17       2.791336    0.515953   5.410065

A probability of 2.79. Reachable through `predict_outcome` with a pinned
reference competitor — a documented pattern — where `predict_outcome` and
`predict_win_probabilities` disagreed 44x and `predict_outcome` was the
wrong one.

There is no useful answer on the far side of that cliff, so this reports
`GridTooCoarse` rather than guessing, and the message points at
`predict_win_probabilities`, which answers the same matchup through
adaptive quadrature and is accurate there to 1e-13. The floor is 4 nodes
per feature rather than the 12 requested, because the request carries
margin: measured accurate to 2.2e-12 at 1.4 nodes per sigma and wrong by
1.2e-3 at 0.7.

This also fixes the `ln k` ceiling violation. `expected_information_gain`
weights `probability * divergence`, so probabilities of 3.97 and 2.62
made it return 3.237828 nats against `ln 2 = 0.693147` — 4.67x over. The
crate's docs call that ceiling its sharpest test and record a prototype
once returning 4.77 nats; it was live again by a different route.

The new sweep then caught a second, independent defect: `kl_divergence`
returned NEGATIVE values, worst -5.55e-17, exactly one ULP of its
`- 1.0`. Rewritten as `0.5*(u - ln1p(u)) + gap^2/(2*var_p)` with
`u = var_q/var_p - 1`, so both terms are non-negative by construction.
It is also more accurate where it matters: at `u = 1e-9` the old form
returned 0.0 where the true value is 2.5e-19, and well-conditioned cases
are unchanged.

tests/prediction_bounds.rs sweeps rather than spot-checks, because a
single fixture cannot defend a bound like this — the previous check
passed throughout. It asserts the sweep still reaches the coarse-grid
regime, so it cannot quietly stop testing the case it was written for.

BREAKING CHANGE: `predict_outcome`, `predict_ranking` and
`expected_information_gain` return `GridTooCoarse` for matchups whose
performance sigmas are too far apart to integrate on one grid. They
previously returned wrong answers, including probabilities above 1.

Closes #55, closes #56

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 17:21:00 +02:00
logaritmiskandClaude Opus 5 83bdb84152 fix!: collapse a drift too small to represent, on a relative threshold
`time_expanded_joint` collapsed consecutive appearances only at
`drift <= 0.0` exactly. Anything smaller-but-positive got an explicit
`1.0 / drift` precision, which the matrix cannot hold: at `drift = 1e-16`
the entry is `1e16`, and `1e16 + 0.28` rounds back to `1e16`, so the
prior and the event contrasts are annihilated in the stored f64 before
the factorisation ever runs.

Measured, 8 competitors over 15 slices:

  drift_scale   before                      after
  1e-6          1.3e-3 relative error       exact
  1e-7..1e-9    Err(JointUnavailable)       exact
  1e-10         12 200x TOO SMALL, as Ok    exact

At 1e-10 the caller was handed sigma = 0.0055 where the truth is 0.6108
— a 111x overconfident interval, returned as a success.

This is representation, not conditioning. Solved in 200-digit precision
the same system converges smoothly onto the collapsed value and is flat
from 1e-16 to 1e-40, so the quantity is perfectly well conditioned. That
also rules out the obvious fix: symmetric (Jacobi) equilibration measured
30x WORSE, because the information is gone from the assembled matrix
before any solver sees it. The fix has to be at assembly.

The threshold balances the two errors that trade off. Ignoring a real
drift costs about `drift / V`; representing one costs about
`EPSILON * V / drift`. They cross at `V * sqrt(EPSILON)`, scaled to each
competitor's own prior variance.

Ordinary drift is far above it and unaffected — the default gamma
accumulates 0.0069 per unit time against a threshold of 1.0e-6 — and the
test asserts both halves: everything below the threshold reaches the
collapsed answer bit-identically, and a drift of 1e-2 still moves it, so
the test cannot pass by collapsing everything.

Also corrects the `JointUnavailable` message, which asserted "a
competitor has neither a proper prior nor any evidence" for a fixture
where every competitor had both.

BREAKING CHANGE: a drift variance below `prior_variance * sqrt(EPSILON)`
now collapses two appearances into one latent variable. Affected fits
previously returned a badly wrong variance or an error.

Closes #57

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 17:03:00 +02:00
logaritmiskandClaude Opus 5 c65373f476 fix!: propagate NaN through the convergence reduction
`tuple_max` compared with a plain `>`, which is false against NaN, so a
NaN accumulator was replaced by the next finite delta. The fold runs over
`TimeSlice::posteriors()`, a HashMap, so whether a NaN survived to `step`
depended on per-process hash order.

Measured before, four competitors in one slice with one pathological
pair, same binary and input, 30 separate processes:

  16  Ok  converged=true, iterations=1, a = Gaussian { pi: NaN, tau: NaN }
  14  Err NonFiniteResult

After: 30/30 Err. A coin flip on whether a NaN fit was reported as an
error or as a successful, converged fit — inside the guard whose entire
purpose is "NaN is never convergence".

`f64::max` would not have fixed it. It also ignores NaN by design, which
is the same defect wearing a standard-library name, and a test pins that
we do not use it.

`Gaussian::delta` had to be fixed FIRST, and that ordering is the whole
subtlety. Two identical improper messages produced `(0.0, NaN)` — not
from `mu()`, which is guarded and returns 0.0, but from `inf - inf` in
the sigma component. That NaN is reachable in ordinary healthy inference:
once a pairing is more than about nine cavity-sigma apart the truncation
is a no-op and the chain compares one identity message against another.
Propagating NaN without fixing `delta` would therefore have turned
correct fits into NonFiniteResult errors. `delta` now answers the
identical-message case in natural space before touching the accessors.

My first version of the `delta` test asserted `mu()` was NaN. It is not;
the accessor guards `pi <= 0.0`. The test caught my own wrong premise,
and the doc comment is corrected to match.

BREAKING CHANGE: a fit that produced NaN in a non-final reduction
position previously returned `Ok` with `converged: true` and a NaN
posterior; it now returns `Err(NonFiniteResult)`. That was always the
documented intent.

Closes #58

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 16:59:56 +02:00
65 changed files with 4357 additions and 771 deletions
+160 -28
View File
@@ -1,15 +1,142 @@
# TrueSkill - Through Time
Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
Bayesian skill rating over a time axis.
## Other implementations
Where plain TrueSkill gives each competitor one running estimate, TrueSkill
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 sharpens the
estimate of who someone was last year, so early estimates stop being frozen
guesses and comparisons across eras become meaningful.
- [ttt-scala](https://github.com/ankurdave/ttt-scala)
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis)
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R)
- [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf)
- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/)
A Rust port of
[TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
## Install
```toml
[dependencies]
trueskill-tt = "0.8"
```
Optional features, both off by default:
- `approx``approx`'s equality traits for `Gaussian`. Useful in tests.
- `rayon` — parallelises the within-slice sweep and the per-slice passes of
`learning_curves` / `log_evidence`. Results stay bit-identical regardless of
worker count; `just determinism` asserts it at 1, 2, 4 and 8 threads.
## Quickstart
Record results, converge, then read off skills.
```rust
use trueskill_tt::History;
let mut history = History::default();
history.record_winner(&"alice", &"bob", 1)?;
history.record_winner(&"bob", &"carol", 2)?;
history.record_winner(&"alice", &"carol", 3)?;
history.converge()?;
let alice = history.current_skill("alice").unwrap();
assert!(alice.mu() > 0.0, "alice won every game she played");
# Ok::<(), trueskill_tt::InferenceError>(())
```
The third argument is the time. It is what makes this Through Time rather than
plain TrueSkill: skill is inferred at each of those moments, not once at the
end. `learning_curve` reads the whole trajectory back.
```rust
# use trueskill_tt::History;
# let mut history = History::default();
# history.record_winner(&"alice", &"bob", 1)?;
# history.record_winner(&"bob", &"carol", 2)?;
# history.record_winner(&"alice", &"carol", 3)?;
# history.converge()?;
// `None` means the key is unknown; `Some(vec![])` means known but unplayed.
let curve = history.learning_curve("alice").unwrap();
for (time, skill) in &curve {
println!("t={time}: {:.2} ± {:.2}", skill.mu(), skill.sigma());
}
// Everyone's latest posterior in one pass — the leaderboard query.
let latest = history.current_skills();
assert_eq!(latest.len(), 3);
# Ok::<(), trueskill_tt::InferenceError>(())
```
## Teams, rankings and draws
Anything beyond one-versus-one goes through the fluent event builder. An event
is only recorded by the terminal `.commit()`.
```rust
use trueskill_tt::History;
let mut history = History::builder().p_draw(0.1).build();
history
.event(1)
.team(["alice", "bob"])
.team(["carol", "dave"])
.ranking([0, 1]) // lower is better; equal values are a tie
.commit()?;
history.converge()?;
# Ok::<(), trueskill_tt::InferenceError>(())
```
**A tie needs a positive `p_draw`.** A `p_draw` of zero asserts draws cannot
happen, so a tied result has no representable likelihood and is rejected rather
than fitted to something else:
```rust
use trueskill_tt::{History, InferenceError};
let mut history = History::default(); // p_draw defaults to 0.0
let err = history.record_draw(&"alice", &"bob", 1).unwrap_err();
assert!(matches!(err, InferenceError::TieWithoutDrawProbability { .. }));
```
This also catches `Outcome::winner(w, n)` for three or more teams, which ties
every loser.
## Which entry point?
| You want to | Use |
|---|---|
| One match, two competitors | `record_winner` / `record_draw` |
| Teams, explicit ranks, scores, per-member weights | `history.event(t)…commit()` |
| A batch you already have as values | `add_events(iter)` |
| Score a hypothetical with no history at all | `Game` |
`Game` is the odd one out and worth being explicit about: it is a single match's
factor graph, it does not participate in a `History`, and nothing it computes is
remembered. Reach for it to evaluate a matchup in isolation; reach for `History`
for everything that accumulates.
## `converge` is strict
`converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the
step still above `epsilon`, and `Err(NonFiniteResult)` if a sweep produces NaN.
It used to return `Ok` with `converged: false`, which was the worst available
shape. A fit that stops short is *wrong by a little*: every posterior is finite,
the ordering looks sensible, and nothing about the output says the numbers were
still moving. Detection was opt-in, and `let _ = h.converge()` silently opted
out — which is how a real defect hid in this crate's own test suite.
The default `max_iter` is high enough that reaching it means something is
genuinely wrong rather than that the history is large; the loop exits at
`epsilon` long before, so raising the cap costs nothing when it is not needed.
Use `converge_partial` when a deliberately capped, unconverged fit is the point.
Predictions are strict for the same reason: every `predict_*` method reads
skills through one gate that refuses a NaN-poisoned fit, rather than returning a
plausible number computed from it.
## Drift
@@ -45,7 +172,7 @@ grows proportionally to time:
variance_delta = elapsed * γ²
```
This is the standard TrueSkill Through Time model. Pass a `ConstantDrift(gamma)`
This is the standard TrueSkill Through Time model. Pass a `ConstantDrift::new(gamma)`
when constructing a `Rating`:
```rust
@@ -53,9 +180,9 @@ use trueskill_tt::{ConstantDrift, Gaussian, Rating};
// gamma = 0.1 means skill can shift ~0.1 per time unit.
let rating: Rating<i64, ConstantDrift> =
Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, ConstantDrift(0.1));
Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, ConstantDrift::new(0.1));
assert_eq!(rating.drift().0, 0.1);
assert_eq!(rating.drift().gamma(), 0.1);
```
The type annotation is load-bearing: `ConstantDrift` implements `Drift<T>` for
@@ -98,14 +225,14 @@ assert_eq!(history.log_evidence(), 0.0);
```
`HistoryBuilder::drift` is the only way to set a history's drift model; there is
no `gamma()` shorthand. The default is `ConstantDrift(GAMMA)`.
no `gamma()` shorthand. The default is `ConstantDrift::new(GAMMA)`.
### Per-competitor drift
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
accumulates, so `s` is in the same units as `gamma`: `ConstantDrift::new(g)` at
scale `s` behaves exactly as `ConstantDrift::new(g * s)` would, for that competitor
alone.
`0.0` pins a competitor still. That is what makes a **fixed reference point**
@@ -115,7 +242,7 @@ strength, a rating floor, a course difficulty:
```rust
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
let mut h = History::builder().drift(ConstantDrift(0.1)).build();
let mut h = History::builder().drift(ConstantDrift::new(0.1)).build();
h.add_events(vec![Event {
time: 0,
@@ -228,11 +355,11 @@ certain because it knows less.
```rust
use trueskill_tt::History;
let mut h = History::builder().build();
let mut h = History::default();
h.record_winner(&"alice", &"bob", 1).unwrap();
let _ = h.converge().unwrap();
h.converge().unwrap();
let skill = h.current_skill(&"alice").unwrap();
let skill = h.current_skill("alice").unwrap();
// "How sure am I that this is below the cutoff?" — a probability, not a
// `mu + z * sigma` band whose confidence drifts as sigma changes.
@@ -254,7 +381,7 @@ what you believe now and what you would believe afterwards.
```rust
use trueskill_tt::History;
let mut h = History::builder().build();
let mut h = History::default();
for t in 1..=10 {
h.record_winner(&"veteran", &"regular", t).unwrap();
h.record_winner(&"regular", &"veteran", t + 100).unwrap();
@@ -278,16 +405,21 @@ expensive than `quality()`. Scoring every pairing among `n` competitors is
`O(n² × outcomes)` passes — shortlist with `quality()` or
`predict_win_probabilities` first, then score only the shortlist.
## Todo
## Other implementations
- [x] Implement approx for Gaussian
- [x] Add more tests from `TrueSkillThroughTime.jl`
- [x] Generalise a time axis — `Time` is now a trait (`Untimed`, `i64`), not an enum
- [x] Add examples (`examples/atp.rs`, `examples/scored.rs`)
- [x] Add Observer (`Observer` / `NullObserver`)
- [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`)
- [x] N-team `predict_outcome` with draw mass, and `expected_information_gain`
- [x] Cross-check `quality()` against [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) — N identical teams follow the closed form `(1/5)^((n-1)/2)` for the conventional parameters, asserted for n = 2..10, and the n=3/n=5 values (0.200, 0.040) match the reference package
- [ttt-scala](https://github.com/ankurdave/ttt-scala)
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis)
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R)
- [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf)
- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/)
## Status
Every box on the old todo list is ticked, so it has been retired; open work
lives in the issue tracker instead. The crate is in use and the API is still
moving — breaking changes are batched into minor releases rather than dribbled
out, and `CHANGELOG.md` records them.
## License
+45 -35
View File
@@ -1,45 +1,55 @@
//! One slice's event sweep.
//!
//! Written against the public API rather than against `TimeSlice` directly.
//! It used to reach for `TimeSlice`, `KeyTable`, `CompetitorStore`,
//! `Competitor` and `EventKind`, and was the *only* thing outside `src/`
//! that did — so a benchmark was dictating five public types that no test,
//! example or consumer could otherwise obtain.
//!
//! A single-slice history's `converge` calls exactly the same per-slice sweep,
//! so capping at one iteration measures the same code path.
use criterion::{Criterion, criterion_group, criterion_main};
use trueskill_tt::{
BETA, Competitor, ConvergenceOptions, EventKind, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA,
TimeSlice, drift::ConstantDrift, gaussian::Gaussian, storage::CompetitorStore,
};
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
fn criterion_benchmark(criterion: &mut Criterion) {
let mut index_map = KeyTable::new();
let build = || {
let mut h = History::builder()
.convergence(ConvergenceOptions {
max_iter: 1,
epsilon: 0.0,
alpha: 1.0,
})
.drift(ConstantDrift::new(0.0))
.build();
let a = index_map.get_or_create("a");
let b = index_map.get_or_create("b");
let c = index_map.get_or_create("c");
// 100 events, all at one time, so the history has a single slice.
let events: Vec<Event<i64, &'static str>> = (0..100)
.map(|_| Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
})
.collect();
h.add_events(events).expect("fixture ingests");
h
};
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
for agent in [a, b, c] {
agents.insert(
agent,
Competitor {
rating: Rating::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)),
..Default::default()
criterion.bench_function("slice_sweep_100_events", |b| {
b.iter_batched(
build,
|mut h| {
// `converge_partial`, not `converge`: one iteration is
// deliberately short of convergence and `converge` reports that
// as an error.
let _ = h.converge_partial();
},
criterion::BatchSize::SmallInput,
);
}
let mut composition = Vec::new();
let mut results = Vec::new();
let mut weights = Vec::new();
for _ in 0..100 {
composition.push(vec![vec![a], vec![b]]);
results.push(vec![1.0, 0.0]);
weights.push(vec![vec![1.0], vec![1.0]]);
}
let kinds = vec![EventKind::Ranked; composition.len()];
let mut time_slice = TimeSlice::new(1, P_DRAW, ConvergenceOptions::default());
time_slice.add_events(composition, Some(results), Some(weights), kinds, &agents);
criterion.bench_function("Batch::iteration", |b| {
b.iter(|| time_slice.iteration(0, &agents))
});
}
+3 -2
View File
@@ -43,11 +43,12 @@ fn build_history_1v1(
rng
};
let mut h = History::<i64, _, _, String>::builder_with_key()
let mut h = History::builder()
.key_type::<String>()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0))
.drift(ConstantDrift::new(25.0 / 300.0))
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
+4 -2
View File
@@ -32,7 +32,8 @@ fn bench_ingest(c: &mut Criterion) {
b.iter_batched(
|| events(n, 0),
|evs| {
let mut h: History<i64, _, _, String> = History::builder_with_key().build();
let mut h: History<i64, _, _, String> =
History::builder().key_type::<String>().build();
for ev in evs {
h.add_events(std::iter::once(ev)).unwrap();
}
@@ -46,7 +47,8 @@ fn bench_ingest(c: &mut Criterion) {
b.iter_batched(
|| events(n, 0),
|evs| {
let mut h: History<i64, _, _, String> = History::builder_with_key().build();
let mut h: History<i64, _, _, String> =
History::builder().key_type::<String>().build();
h.add_events(evs).unwrap();
black_box(h.time_slices_len())
},
+3 -2
View File
@@ -11,12 +11,13 @@ use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Ou
/// 30 slices of 8 duels: 480 appearances over 100 competitors.
fn fitted() -> History<i64, ConstantDrift, trueskill_tt::NullObserver, String> {
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
.key_type::<String>()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.05))
.drift(ConstantDrift::new(0.05))
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-10,
+3 -2
View File
@@ -5,11 +5,12 @@ use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
fn bench_scored_history(c: &mut Criterion) {
c.bench_function("scored_history_60_events_30_iter", |bencher| {
bencher.iter(|| {
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
.key_type::<String>()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(0.03))
.drift(ConstantDrift::new(0.03))
.score_sigma(2.0)
.build();
+8 -6
View File
@@ -1,7 +1,8 @@
use plotters::prelude::*;
use smallvec::smallvec;
use time::{Date, Month};
use trueskill_tt::{Event, History, Member, Outcome, Team, drift::ConstantDrift};
use trueskill_tt::{
Event, History, Member, Outcome, Team, drift::ConstantDrift, smallvec::smallvec,
};
fn main() {
let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
@@ -42,9 +43,10 @@ fn main() {
}
}
let mut hist: History<i64, _, _, String> = History::builder_with_key()
let mut hist: History<i64, _, _, String> = History::builder()
.key_type::<String>()
.sigma(1.6)
.drift(ConstantDrift(0.036))
.drift(ConstantDrift::new(0.036))
.convergence(trueskill_tt::ConvergenceOptions {
// This history needs 30 sweeps to reach the epsilon below. It was
// capped at 10 until the `#[must_use]` on `ConvergenceReport`
@@ -96,7 +98,7 @@ fn main() {
let mut y_spec = (f64::MAX, f64::MIN);
for &(_, id, cutoff) in &players {
for (ts, gs) in hist.learning_curve(id) {
for (ts, gs) in hist.learning_curve(id).unwrap() {
if ts >= cutoff {
continue;
}
@@ -142,7 +144,7 @@ fn main() {
let mut upper = Vec::new();
let mut lower = Vec::new();
for (ts, gs) in hist.learning_curve(id) {
for (ts, gs) in hist.learning_curve(id).unwrap() {
if ts >= cutoff {
continue;
}
+2 -3
View File
@@ -6,15 +6,14 @@
//!
//! Run with: `cargo run --example scored --release`
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team, smallvec::smallvec};
fn main() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(0.03))
.drift(ConstantDrift::new(0.03))
.score_sigma(2.0) // tune to data; smaller = trust margins more
.build();
+42 -3
View File
@@ -47,7 +47,38 @@ fn kl_divergence(q: Gaussian, p: Gaussian) -> f64 {
}
let mean_gap = q.mu() - p.mu();
0.5 * (libm::log(var_p / var_q) + (var_q + mean_gap * mean_gap) / var_p - 1.0)
// Algebraically `0.5 * (ln(var_p/var_q) + (var_q + gap^2)/var_p - 1)`, but
// written so that neither term can go negative.
//
// The direct form cancels against its `- 1.0` for two near-identical
// distributions and returns a *negative* divergence — measured, 762 082 of
// 3 000 000 near-identical pairs, worst `-5.55e-17`, which is exactly one
// ULP of the 1.0. It also loses the answer entirely where it is small:
// at `var_q/var_p - 1 = 1e-9` the direct form gives `0.0` where the true
// value is `2.5e-19`.
//
// With `u = var_q/var_p - 1` the variance part is `0.5 * (u - ln(1+u))`,
// which is non-negative for every `u > -1`, and the mean part is a square
// over a positive variance. Non-negativity is then structural rather than
// incidental.
let u = var_q / var_p - 1.0;
0.5 * u_minus_ln1p(u) + mean_gap * mean_gap / (2.0 * var_p)
}
/// `u - ln(1 + u)`, without the cancellation that spelling invites.
///
/// Both terms are approximately `u` for small `u`, so the subtraction loses
/// everything just where the result matters. The Taylor series
/// `u^2/2 - u^3/3 + u^4/4 - ...` is exact in that regime and manifestly
/// non-negative, since `u^2/2` dominates.
fn u_minus_ln1p(u: f64) -> f64 {
if u.abs() < 1e-4 {
let u2 = u * u;
u2 * (0.5 - u / 3.0 + u2 / 4.0)
} else {
u - libm::log1p(u)
}
}
/// Expected information gain of a hypothetical matchup, in nats.
@@ -93,6 +124,10 @@ fn kl_divergence(q: Gaussian, p: Gaussian) -> f64 {
/// - `TooManyTeams` if the outcome space is too large to enumerate; see
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`.
/// - `GridTooCoarse` when the performance sigmas are too far apart to
/// integrate on one grid. This comes from `outcome_distribution`, which runs
/// before any inference — so it is not covered by "anything `Game::ranked`
/// returns" below.
/// - Anything [`Game::ranked`](crate::Game::ranked) returns for a hypothetical
/// outcome.
pub fn expected_information_gain<T: Time, D: Drift<T>>(
@@ -146,7 +181,7 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
let mut gain = 0.0;
for (ranks, probability) in predict::outcome_distribution(&performances, &margins) {
for (ranks, probability) in predict::outcome_distribution(&performances, &margins)? {
if probability <= NEGLIGIBLE {
continue;
}
@@ -177,7 +212,11 @@ mod tests {
type R = Rating<i64, ConstantDrift>;
fn rating(mu: f64, sigma: f64) -> R {
R::new(Gaussian::from_ms(mu, sigma), BETA, ConstantDrift(GAMMA))
R::new(
Gaussian::from_ms(mu, sigma),
BETA,
ConstantDrift::new(GAMMA),
)
}
fn options(p_draw: f64) -> GameOptions {
+64 -6
View File
@@ -4,9 +4,38 @@ use std::time::Duration;
use smallvec::SmallVec;
#[derive(Clone, Copy, Debug)]
/// The stopping rule for the fixed-point loops, plus how hard they are damped.
///
/// Set once per history through
/// [`HistoryBuilder::convergence`](crate::HistoryBuilder::convergence), and
/// carried by `GameOptions` for a single match scored without a history. The
/// defaults are the crate's globals: [`ITERATIONS`](crate::ITERATIONS),
/// [`EPSILON`](crate::EPSILON), and undamped EP.
///
/// Deliberately **not** `#[non_exhaustive]`, unlike [`ConvergenceReport`]. The
/// usual argument for marking an options struct is that `..Default::default()`
/// makes a future field additive — but `Default::default` is not a `const fn`,
/// so marking it would make
/// `const OPTS: ConvergenceOptions = ConvergenceOptions { .. }` impossible from
/// outside the crate, with no workaround. This type is `Copy` and a natural
/// const; that cost is permanent, and adding a field is a one-time major bump.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ConvergenceOptions {
/// Hard cap on full forward+backward sweeps.
///
/// A runaway guard, not a budget: the loop exits as soon as the step falls
/// to `epsilon`, so raising this costs nothing on a history that converges.
/// Reaching it is
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged).
pub max_iter: usize,
/// Convergence threshold, in skill units.
///
/// The sweep stops once *both* components of the step — the largest change
/// a whole iteration made to any competitor's posterior mean, and to any
/// posterior standard deviation — are at or below this. Larger values stop
/// sooner and further from the fixed point. Must be non-negative; NaN is
/// rejected, since every comparison against it is false and the loop would
/// read it as converged.
pub epsilon: f64,
/// EP damping factor in natural-parameter space: each per-factor
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
@@ -68,16 +97,45 @@ impl Default for ConvergenceOptions {
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged) there.
/// From [`History::converge_partial`](crate::History::converge_partial) it may
/// not be, and `converged` is what says so.
#[derive(Clone, Debug)]
#[must_use = "from `converge_partial` this may describe a fit that stopped at \
`max_iter`, which is wrong by a little rather than loudly \
broken — check `converged`, or bind it to `_` to say you have \
decided not to"]
/// Constructed only by `converge` / `converge_partial`, never by a caller, so
/// `#[non_exhaustive]` costs nothing here and lets a future field be additive.
/// The two *options* structs deliberately do not carry it — see the note on
/// [`ConvergenceOptions`].
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct ConvergenceReport {
/// Full forward+backward sweeps actually run. `0` for a history with no
/// time slices, which is converged trivially.
pub iterations: usize,
/// How far the last sweep still moved the fit, as `(mean, standard
/// deviation)`.
///
/// Not natural parameters: each component is a componentwise maximum of
/// `|Δmu|` and `|Δsigma|` over every competitor posterior the sweep
/// touched, so both are in skill units and both are non-negative. Each is
/// compared against `epsilon` separately — `converged` means neither
/// exceeds it. `(0.0, 0.0)` for a history with no time slices.
pub final_step: (f64, f64),
/// Natural log of the model evidence for the whole history at this fit,
/// summed over every time slice.
///
/// The same quantity
/// [`History::log_evidence`](crate::History::log_evidence) returns, taken
/// once the sweep has stopped. Only comparable between fits of the same
/// events; higher means the model explains them better.
pub log_evidence: f64,
/// Whether the sweep reached `epsilon` rather than stopping at `max_iter`.
///
/// Always `true` from [`History::converge`](crate::History::converge),
/// which reports the other case as `NotConverged`. From
/// [`History::converge_partial`](crate::History::converge_partial) this is
/// the only thing that distinguishes a finished fit from a capped one.
pub converged: bool,
/// Wall-clock time each sweep took, in the order they ran.
///
/// One entry per iteration, so its length equals `iterations`; empty for a
/// history with no time slices. It times the sweeps only, so the final
/// log-evidence pass is not in any entry.
pub per_iteration_time: SmallVec<[Duration; 32]>,
}
+52 -2
View File
@@ -21,8 +21,58 @@ pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
///
/// For `Time = i64`: variance added is `(to - from) * gamma^2`.
/// For `Time = Untimed`: elapsed is always 0, so drift is always 0.
#[derive(Clone, Copy, Debug)]
pub struct ConstantDrift(pub f64);
///
/// # Why the field is private
///
/// `gamma` enters only as `gamma * gamma`, so a negative value is squared away:
/// measured against the old public-field form, `ConstantDrift(-0.0833)` produced
/// results **bit identical** to `ConstantDrift(0.0833)`. The sign was neither
/// rejected nor honoured — it vanished. That is the same sign-absorption `HistoryBuilder::sigma`,
/// `HistoryBuilder::beta`, `Gaussian::from_ms` and `Rating::new` all reject.
///
/// It could not be checked while the field was a public tuple position, because
/// there was no constructor to intercept. Validating inside
/// `variance_for_elapsed` would have been worse: it runs inside the sweep, so a
/// construction-time mistake would panic mid-inference — and `Gaussian::from_ms`
/// is a worked example of why that is the wrong place for a guard, where
/// rejecting NaN turned the `NonFiniteResult` reporting path into a crash.
///
/// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value
/// back with [`ConstantDrift::gamma`].
///
/// A non-finite gamma is caught a second time regardless:
/// `History::converge` validates the drift variance each competitor actually
/// accumulates, which also covers a custom [`Drift`] implementation.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ConstantDrift(f64);
impl ConstantDrift {
/// Drift of `gamma` standard deviations per unit time.
///
/// # Panics
///
/// Panics unless `gamma` is finite and non-negative.
///
/// The field is private and this is the only constructor precisely so that
/// there is somewhere to check. While it was a public tuple field there was
/// nothing to intercept, and a negative gamma was silently squared away —
/// see the type docs.
#[must_use]
pub fn new(gamma: f64) -> Self {
assert!(
gamma.is_finite() && gamma >= 0.0,
"gamma must be finite and non-negative (got {gamma}); it is only ever \
squared, so a negative value would silently behave as its absolute value"
);
Self(gamma)
}
/// Standard deviations of drift accumulated per unit time.
#[must_use]
pub fn gamma(&self) -> f64 {
self.0
}
}
impl<T: Time> Drift<T> for ConstantDrift {
fn variance_delta(&self, from: &T, to: &T) -> f64 {
+136 -9
View File
@@ -39,36 +39,76 @@ pub enum UnknownKeys {
Prior,
}
/// Every way ingestion, inference or prediction can refuse to answer.
///
/// The crate reports rather than repairs. An input it cannot represent, a fit
/// that never reached its fixed point, a quadrature it cannot resolve — each
/// comes back here instead of as a clamped, skipped or truncated result that
/// would still look like a number. Several variants exist precisely because the
/// silent version was measured and found to return a plausible wrong answer.
///
/// The enum and most of its variants are `#[non_exhaustive]`: new cases and new
/// fields are additive, so match with a `_` arm and construct through the
/// library rather than by literal.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum InferenceError {
/// Expected and actual lengths of some array-shaped input differ.
#[non_exhaustive]
MismatchedShape {
/// Which input disagreed, as a short label — `"ranks vs teams"`,
/// `"weights"`, `"times"`.
kind: &'static str,
/// The length it had to have, taken from whatever it must line up with
/// (usually the event's team count).
expected: usize,
/// The length actually supplied.
got: usize,
},
/// An `Outcome` of the wrong variant was supplied for the requested inference.
#[non_exhaustive]
WrongOutcomeKind {
/// The call that rejected the outcome, e.g. `"Game::ranked"`.
context: &'static str,
/// The [`Outcome`](crate::Outcome) variant that call needs, by name.
expected: &'static str,
/// The variant actually supplied, by name.
got: &'static str,
},
/// A probability value is outside `[0, 1]`.
InvalidProbability { value: f64 },
#[non_exhaustive]
InvalidProbability {
/// The value supplied, as it fell outside `[0, 1]`. Today only
/// `p_draw` reaches here.
value: f64,
},
/// A scalar parameter is outside its valid range.
InvalidParameter { name: &'static str, value: f64 },
#[non_exhaustive]
InvalidParameter {
/// The parameter, spelled as the API spells it — `"alpha"`,
/// `"epsilon"`, `"score_sigma"`, `"drift_scale"`, `"drift variance"`.
name: &'static str,
/// The value supplied for it. Out of that parameter's range, or NaN,
/// which fails every range comparison and is rejected on that basis.
value: f64,
},
/// An event contains tied teams, but the draw probability is zero.
///
/// A zero draw probability asserts that draws cannot occur, so a tied
/// result has no representable likelihood. Configure a positive `p_draw`
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
TieWithoutDrawProbability { teams: (usize, usize) },
#[non_exhaustive]
TieWithoutDrawProbability {
/// Positions in the event's team list of the first tied pair, lowest
/// index first. Only one pair is reported — the event is rejected
/// whole, so enumerating the rest would add nothing.
teams: (usize, usize),
},
/// The convergence sweep hit `max_iter` with the step still above
/// `epsilon`.
///
/// A fit that stops short is wrong by a little, which is the worst
/// available failure: every rating is finite, the ordering looks sensible,
/// available failure: every posterior is finite, the ordering looks sensible,
/// and nothing in the numbers says they were still moving. Reported rather
/// than returned as a flag on an `Ok`, because a flag has to be checked
/// and `let _ = h.converge()` is the natural way not to.
@@ -77,17 +117,31 @@ pub enum InferenceError {
/// oscillating rather than converging, in which case `alpha < 1.0` damps
/// the within-game EP loop. [`History::converge_partial`](crate::History::converge_partial)
/// returns the short fit instead when that is genuinely what is wanted.
#[non_exhaustive]
NotConverged {
/// Full forward+backward sweeps run before the loop gave up.
iterations: usize,
/// How far the last sweep still moved the fit, as
/// `(largest change in a mean, largest change in a standard
/// deviation)` over every competitor posterior it touched — the same
/// quantity as
/// [`ConvergenceReport::final_step`](crate::ConvergenceReport).
final_step: (f64, f64),
/// The threshold both components of `final_step` had to reach.
epsilon: f64,
},
/// Inference produced a non-finite value (NaN or infinity).
///
/// Indicates numerical breakdown; the resulting skills are meaningless
/// and must not be treated as a converged estimate.
#[non_exhaustive]
NonFiniteResult {
/// Where the breakdown was caught — `"History::converge"` for a sweep,
/// or a phrase naming the prediction that read an unusable skill.
context: &'static str,
/// The offending pair, at least one component of which is NaN or
/// infinite. From `converge` it is the sweep's step; from a prediction
/// it is the skill's own `(mu, sigma)`.
step: (f64, f64),
},
/// One batch declared two different values for the same competitor's
@@ -99,8 +153,14 @@ pub enum InferenceError {
/// "last one wins" would make the result depend on iteration order.
/// Declaring the same value repeatedly is fine and is the expected shape
/// when a competitor's configuration is a property of the domain.
#[non_exhaustive]
ConflictingCompetitorConfig {
/// The competitor's interned [`Index`](crate::Index) as a raw `usize`,
/// not the user key — the batch is already flattened to indices by the
/// time the conflict is detectable.
competitor: usize,
/// Which piece of configuration was declared twice: `"prior"` or
/// `"drift_scale"`.
field: &'static str,
},
/// A prediction referenced a key the history has no skill for.
@@ -113,9 +173,18 @@ pub enum InferenceError {
/// `UnknownKey { team: 0, member: 0 }` learns nothing about *which* of its
/// keys the history has not seen, and the natural handling — fall back to a
/// neutral value — turns the whole thing into a plausible constant.
#[non_exhaustive]
UnknownKey {
/// Position of the offending team in the supplied matchup. `0` on the
/// queries that take a flat list of keys rather than teams, where
/// there is only one list to index into.
team: usize,
/// Position of the offending key within that team, or within the flat
/// key list.
member: usize,
/// The key's `Debug` rendering, captured because `K` is only required
/// to be `Debug` — see the variant docs for why the indices alone are
/// not enough.
key: String,
},
/// `History::register` was called for a competitor that already exists.
@@ -128,13 +197,55 @@ pub enum InferenceError {
///
/// To change an existing competitor's configuration, supply it on an event
/// through `Member`; that refits the whole history.
AlreadyRegistered { key: String },
#[non_exhaustive]
AlreadyRegistered {
/// The already-known competitor's key, in its `Debug` rendering.
key: String,
},
/// A prediction was given a team with no members.
EmptyTeam { team: usize },
#[non_exhaustive]
EmptyTeam {
/// Position of the memberless team in the supplied list.
team: usize,
},
/// The prediction grid cannot resolve the narrowest feature in the matchup.
///
/// `predict_outcome` and `predict_ranking` integrate every team's density
/// on one shared grid, whose resolution is set by the narrowest sigma (or a
/// narrower draw margin). When the widest and narrowest are far enough
/// apart, resolving the narrow one across the wide one's support needs more
/// nodes than the grid is allowed to hold.
///
/// Reported rather than clamped. Clamping is what this replaced, and it
/// returned probabilities greater than one — measured, a `P` of 2.79 and a
/// `Prediction::total()` of 5.41 — because the trapezoid rule stops
/// resolving a density once the step exceeds roughly 1.7 of its sigma.
///
/// `predict_win_probabilities` answers the same matchup through adaptive
/// quadrature and is accurate here; use it when only the per-team win
/// probabilities are needed.
#[non_exhaustive]
GridTooCoarse {
/// Nodes required to resolve the narrowest feature.
needed: usize,
/// Nodes the grid may hold.
max: usize,
},
/// A joint posterior was requested where one cannot be formed exactly.
JointUnavailable { reason: &'static str },
#[non_exhaustive]
JointUnavailable {
/// Why no exact joint exists here: the history has no events, it holds
/// ranked events whose EP factors are not retained past convergence, or
/// the assembled precision matrix is not positive-definite.
reason: &'static str,
},
/// Fewer than two teams were supplied to a prediction.
NotEnoughTeams { got: usize },
#[non_exhaustive]
NotEnoughTeams {
/// How many teams the prediction was actually given. Two is the
/// minimum: there is nothing to compare against with fewer.
got: usize,
},
/// The full outcome distribution was requested for too many teams.
///
/// Each realisation sorts into exactly one (order, tie-pattern) event, so
@@ -143,7 +254,14 @@ pub enum InferenceError {
/// enumerate on a caller's behalf; ask for individual rankings with
/// `predict_ranking`, or for `predict_win_probabilities`, both of which
/// stay cheap at any team count.
TooManyTeams { got: usize, max: usize },
#[non_exhaustive]
TooManyTeams {
/// How many teams the outcome distribution was asked for.
got: usize,
/// The largest team count that will be enumerated,
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
max: usize,
},
}
impl fmt::Display for InferenceError {
@@ -219,6 +337,15 @@ impl fmt::Display for InferenceError {
Self::EmptyTeam { team } => {
write!(f, "team {team} has no members")
}
Self::GridTooCoarse { needed, max } => {
write!(
f,
"the prediction grid needs {needed} nodes to resolve the narrowest \
team's density across the widest team's support, but may hold only \
{max}; the sigmas in this matchup are too far apart to integrate on \
one grid. Use predict_win_probabilities, which is accurate here"
)
}
Self::JointUnavailable { reason } => {
write!(f, "no exact joint posterior is available: {reason}")
}
+65 -6
View File
@@ -11,27 +11,59 @@ use smallvec::SmallVec;
use crate::{gaussian::Gaussian, outcome::Outcome, time::Time};
/// A single match at time `time` involving some number of teams.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub struct Event<T: Time, K> {
/// When the match happened, on the history's time axis.
///
/// Events sharing a `time` land in the same time slice and are fitted
/// together, so nothing distinguishes their order. Drift is driven by the
/// gap between a competitor's *consecutive appearances*, not by the gap
/// between slices, so a competitor idle across several slices accumulates
/// the whole span at once when it next plays.
pub time: T,
/// The teams that took part, positionally aligned with `outcome`: team `i`
/// here is the team `outcome` ranks or scores at index `i`.
///
/// Ingestion rejects fewer than two teams (`NotEnoughTeams`) and any team
/// with no members (`EmptyTeam`).
pub teams: SmallVec<[Team<K>; 4]>,
/// How the match ended: ranks (lower is better) or per-team scores (higher
/// is better), one entry per entry of `teams`.
///
/// A tie — two equal ranks — needs a positive `p_draw`, otherwise
/// ingestion fails with `TieWithoutDrawProbability`.
pub outcome: Outcome,
}
/// A team: list of members competing together.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct Team<K> {
/// The competitors playing together, in no significant order: the team's
/// performance is the weight-scaled sum over its members, which does not
/// depend on how they are listed.
///
/// Must be non-empty — an empty team contributes no performance at all, so
/// ingestion rejects it with `EmptyTeam` rather than returning a plausible
/// posterior for whoever it was matched against.
pub members: SmallVec<[Member<K>; 4]>,
}
impl<K> Team<K> {
#[must_use]
/// A team with no members yet, to be filled through the public `members`
/// field.
///
/// Committing it while still empty is an `EmptyTeam` error.
pub fn new() -> Self {
Self {
members: SmallVec::new(),
}
}
/// A team of exactly these competitors.
///
/// Members must be built already — `Member::from(key)` covers the common
/// case of a plain key at default weight with no overrides.
pub fn with_members<I: IntoIterator<Item = Member<K>>>(members: I) -> Self {
Self {
members: members.into_iter().collect(),
@@ -61,10 +93,29 @@ impl<K> Default for Team<K> {
/// for one competitor within a single batch is
/// `InferenceError::ConflictingCompetitorConfig`: events in a batch have no
/// order, so there would be no well-defined winner.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct Member<K> {
/// The competitor's identity. Equal keys across events are the same
/// competitor: `History` interns each distinct key to an internal `Index`
/// the first time it sees it, and every later appearance resolves to that
/// same competitor's temporal state.
pub key: K,
/// This member's share of the team's performance, for this event only.
///
/// The team's performance is the sum of `weight × member performance`, so
/// `1.0` is a full share and `0.5` counts the member half; the message
/// coming back to the member is divided by the same weight. Defaults to
/// `1.0`.
///
/// Must be finite — a NaN or infinite weight is `InvalidParameter` at
/// ingestion. Zero and negative are accepted, both being expressible in
/// the same arithmetic.
pub weight: f64,
/// Starting skill for this competitor, replacing the history's `mu`/`sigma`
/// default. `None` keeps the history default.
///
/// Competitor configuration, not a per-event value; see the type docs.
pub prior: Option<Gaussian>,
/// Multiplier on the drift *variance* this competitor accumulates.
/// `None` means 1.0.
@@ -72,6 +123,8 @@ pub struct Member<K> {
}
impl<K> Member<K> {
/// A competitor taking a full share of its team's performance, with no
/// configuration overrides: the history's prior and drift apply.
pub fn new(key: K) -> Self {
Self {
key,
@@ -81,6 +134,12 @@ impl<K> Member<K> {
}
}
/// Change how much of the team's performance this member accounts for.
///
/// Unlike `prior` and `drift_scale`, this is genuinely per-event: the same
/// key can carry a different weight in every event it appears in, which is
/// what makes it usable for partial participation — a substitute who
/// played half the match, a doubles partner credited unequally.
pub fn with_weight(mut self, weight: f64) -> Self {
self.weight = weight;
self
@@ -99,8 +158,8 @@ impl<K> Member<K> {
/// 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.
/// `gamma`: `ConstantDrift::new(g)` at `scale = s` behaves exactly as
/// `ConstantDrift::new(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
+29
View File
@@ -9,6 +9,35 @@ use crate::{
time::Time,
};
/// One match under construction, handed back by [`History::event`].
///
/// Describes a single event a piece at a time — teams, then per-member weights
/// if they differ, then how it ended — instead of assembling an
/// [`Event`] value and passing it to [`History::add_events`]. The two routes
/// ingest through the same chokepoint and accept the same things; this one just
/// reads better for a single match written by hand.
///
/// The builder borrows the history mutably and nothing reaches it until
/// [`EventBuilder::commit`]. A builder that is dropped instead ingests
/// nothing at all, silently — hence the `#[must_use]`, which is the only
/// warning you get. `commit` is also where validation surfaces: the setters
/// return `Self` to keep the chain fluent, so a mismatch such as a weight list
/// the wrong length is recorded while building and returned as an error from
/// `commit`.
///
/// ```
/// # use trueskill_tt::History;
/// let mut h = History::builder().build();
/// h.event(1)
/// .team(["alice", "bob"])
/// .team(["carol"])
/// .ranking([0, 1])
/// .commit()?;
/// assert_eq!(h.event_count(), 1);
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
#[must_use = "an event is only recorded by `.commit()`; a dropped builder \
silently ingests nothing"]
pub struct EventBuilder<'h, T, D, O, K>
where
T: Time,
-1
View File
@@ -44,7 +44,6 @@ impl VarStore {
id
}
#[must_use]
pub fn get(&self, id: VarId) -> Gaussian {
self.marginals[id.0 as usize]
}
+2 -2
View File
@@ -81,7 +81,7 @@ fn cavity_log_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
// `hypot`, not `sqrt(a^2 + b^2)`: squaring overflows to infinity above a
// sigma of ~1.3e154 and flushes to zero below ~1.5e-154, and `Gaussian`'s
// constructors are public so a caller can reach both.
let combined_sigma = cavity.sigma().hypot(sigma);
let combined_sigma = libm::hypot(cavity.sigma(), sigma);
let value = ln_pdf(m_obs, cavity.mu(), combined_sigma);
// A degenerate cavity (infinite sigma) is the only way to reach a
@@ -89,7 +89,7 @@ fn cavity_log_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
if value.is_finite() {
value
} else {
f64::MIN_POSITIVE.ln()
libm::log(f64::MIN_POSITIVE)
}
}
+2 -2
View File
@@ -95,7 +95,7 @@ fn cavity_log_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 {
if value.is_finite() {
value
} else {
f64::MIN_POSITIVE.ln()
libm::log(f64::MIN_POSITIVE)
}
}
@@ -203,7 +203,7 @@ mod tests {
let approx = -0.5 * z * z - z.ln() - (2.0 * std::f64::consts::PI).sqrt().ln();
assert!(
got < f64::MIN_POSITIVE.ln(),
got < libm::log(f64::MIN_POSITIVE),
"mu={mu}: {got} is still stuck on the old clamp floor"
);
assert!(
+149 -54
View File
@@ -68,10 +68,27 @@ impl DiffFactor {
/// `p_draw` and `convergence` apply to ranked outcomes (`Game::ranked`).
/// `score_sigma` applies only to scored outcomes (`Game::scored`); it controls
/// how much the engine trusts the observed score margin (smaller σ = more trust).
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GameOptions {
/// Probability the model assigns to two teams drawing, which sets the width
/// of the truncation band around a tie. Must be in `[0.0, 1.0)`; defaults
/// to [`P_DRAW`](crate::P_DRAW).
///
/// At `0.0` the band has zero width, so a ranked outcome that ties two
/// teams has no representable likelihood and [`Game::ranked`] rejects it
/// with `TieWithoutDrawProbability`.
pub p_draw: f64,
/// Standard deviation of the observation noise on an observed score margin,
/// used only by [`Game::scored`], which rejects a non-positive or NaN value
/// with `InvalidParameter`. Defaults to `1.0`.
///
/// It is in the units of the scores themselves, and says how much of a
/// margin the model reads as skill rather than noise: a small sigma takes
/// the margin near-literally, a large one barely moves the ratings.
pub score_sigma: f64,
/// Stopping rule and damping for the within-game message-passing loop:
/// iterate until the largest message change falls below `epsilon`, or
/// `max_iter` passes, with each update damped by `alpha`.
pub convergence: crate::ConvergenceOptions,
}
@@ -91,7 +108,11 @@ impl Default for GameOptions {
/// History's internal state), `OwnedGame<T, D>` owns the team ratings, so it
/// can be returned freely from public constructors. The inference inputs
/// themselves are not retained — nothing reads them back.
///
/// A fitted single match, and nothing more: see [`Game`] for why that is not
/// the same as a step of a [`History`](crate::History).
#[derive(Debug)]
#[must_use]
pub struct OwnedGame<T: Time, D: Drift<T>> {
teams: Vec<Vec<Rating<T, D>>>,
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
@@ -144,6 +165,13 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
}
}
/// Updated skill belief for every competitor, as `[team][member]` in the
/// order the teams and members were passed in.
///
/// Each is the competitor's own prior multiplied by the likelihood this one
/// match produced for it — so it reflects this match and the rating handed
/// in, and nothing else. Feeding it back as the next match's prior is the
/// caller's job; that is what a [`History`](crate::History) automates.
#[must_use]
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods
@@ -153,12 +181,48 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
.collect()
}
/// Natural log of how probable this outcome was under the priors, summed
/// over the diff chain's links.
///
/// Higher means the result was less surprising, so it doubles as a
/// closeness measure — two identically-rated competitors give exactly
/// `ln(0.5)`, either of them being equally likely to win:
///
/// ```
/// # use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
/// let r = Rating::new(Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift::new(0.0));
/// let g = Game::<i64, _>::ranked(&[&[r], &[r]], Outcome::winner(0, 2), &GameOptions::default())?;
/// assert!((g.log_evidence() - 0.5_f64.ln()).abs() < 1e-12);
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
///
/// Accumulated in log space because the linear product over a long chain
/// underflows to zero, and `ln(0.0)` is `-inf`.
#[must_use]
pub fn log_evidence(&self) -> f64 {
self.log_evidence
}
}
/// One match's factor graph, fitted on its own.
///
/// Rate a single match against ratings you already hold and get the updated
/// beliefs straight back. There is no history behind it: nothing is stored,
/// nothing propagates backward, and the priors you hand in are the only
/// evidence used. That makes it the wrong tool for the thing this crate exists
/// for — [`History`](crate::History) is what infers skill *through time*,
/// revising past estimates as later matches arrive, and a sequence of `Game`s
/// chained by hand is a forward-only filter, not the same answer.
///
/// Reach for `Game` when a history would be overkill or unavailable: a
/// one-off matchup, replaying a rating step from stored numbers, checking the
/// engine against a reference, or a caller that keeps its own persistence and
/// only wants the update rule.
///
/// The type is mostly a namespace. Its constructors — [`Game::ranked`],
/// [`Game::scored`], [`Game::one_v_one`], [`Game::free_for_all`] — return an
/// [`OwnedGame`], because `Game<'a, …>` borrows the result and weight slices
/// that `History` keeps internally and so cannot be handed out.
#[derive(Debug)]
pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
teams: Vec<Vec<Rating<T, D>>>,
@@ -283,7 +347,9 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
self.teams[t]
.iter()
.zip(self.weights[t].iter())
.fold(N00, |p, (player, &w)| p + (player.performance() * w))
.fold(N00, |p, (competitor, &w)| {
p + (competitor.performance() * w)
})
}));
let n_diffs = n_teams.saturating_sub(1);
@@ -360,18 +426,18 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
.iter()
.zip(self.weights.iter())
.enumerate()
.map(|(orig_i, (players, weights))| {
.map(|(orig_i, (competitors, weights))| {
let si = arena.inv_buf[orig_i];
let m = arena.lhood_win[si] * arena.lhood_lose[si];
// Already folded into `team_prior` at the top of the chain,
// indexed by sorted position.
let performance = arena.team_prior[si];
players
competitors
.iter()
.zip(weights.iter())
.map(|(player, &w)| {
((m - performance.exclude(player.performance() * w)) * (1.0 / w))
.forget(player.beta.powi(2))
.map(|(competitor, &w)| {
((m - performance.exclude(competitor.performance() * w)) * (1.0 / w))
.forget(competitor.beta.powi(2))
})
.collect::<Vec<_>>()
})
@@ -410,6 +476,9 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
self.likelihoods = likelihoods;
}
/// Updated skill belief for every competitor, as `[team][member]` in the
/// order the teams and members were passed in — prior times this match's
/// likelihood, exactly as [`OwnedGame::posteriors`].
#[must_use]
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods
@@ -424,6 +493,8 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
.collect::<Vec<_>>()
}
/// Natural log of how probable this outcome was under the priors, summed
/// over the diff chain's links — as [`OwnedGame::log_evidence`].
#[must_use]
pub fn log_evidence(&self) -> f64 {
self.log_evidence
@@ -576,7 +647,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
))
}
/// Convenience wrapper over [`Game::ranked`] for two single-player teams.
/// Convenience wrapper over [`Game::ranked`] for two single-competitor teams.
///
/// # Errors
///
@@ -596,14 +667,14 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
/// # Errors
///
/// Wraps each player in a one-member team and delegates to
/// Wraps each competitor in a one-member team and delegates to
/// [`Game::ranked`], so it returns the same errors.
pub fn free_for_all(
players: &[&Rating<T, D>],
competitors: &[&Rating<T, D>],
outcome: crate::Outcome,
options: &GameOptions,
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
let teams: Vec<Vec<Rating<T, D>>> = players.iter().map(|p| vec![**p]).collect();
let teams: Vec<Vec<Rating<T, D>>> = competitors.iter().map(|p| vec![**p]).collect();
let team_refs: Vec<&[Rating<T, D>]> = teams.iter().map(|t| t.as_slice()).collect();
Self::ranked(&team_refs, outcome, options)
}
@@ -623,12 +694,12 @@ mod tests {
let t_a = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let t_b = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0]];
@@ -651,12 +722,12 @@ mod tests {
let t_a = R::new(
Gaussian::from_ms(29.0, 1.0),
25.0 / 6.0,
ConstantDrift(GAMMA),
ConstantDrift::new(GAMMA),
);
let t_b = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(GAMMA),
ConstantDrift::new(GAMMA),
);
let w = [vec![1.0], vec![1.0]];
@@ -676,8 +747,16 @@ mod tests {
assert_ulps_eq!(a, Gaussian::from_ms(28.896475, 0.996604), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(32.189211, 6.062063), epsilon = 1e-6);
let t_a = R::new(Gaussian::from_ms(1.139, 0.531), 1.0, ConstantDrift(0.2125));
let t_b = R::new(Gaussian::from_ms(15.568, 0.51), 1.0, ConstantDrift(0.2125));
let t_a = R::new(
Gaussian::from_ms(1.139, 0.531),
1.0,
ConstantDrift::new(0.2125),
);
let t_b = R::new(
Gaussian::from_ms(15.568, 0.51),
1.0,
ConstantDrift::new(0.2125),
);
let w = [vec![1.0], vec![1.0]];
let g = Game::ranked_with_arena(
@@ -699,17 +778,17 @@ mod tests {
vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
)],
vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
)],
vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
)],
];
@@ -779,12 +858,12 @@ mod tests {
let t_a = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let t_b = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0]];
@@ -811,12 +890,12 @@ mod tests {
let t_a = R::new(
Gaussian::from_ms(25.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let t_b = R::new(
Gaussian::from_ms(29.0, 2.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0]];
@@ -842,17 +921,17 @@ mod tests {
let t_a = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let t_b = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let t_c = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0], vec![1.0]];
@@ -879,17 +958,17 @@ mod tests {
let t_a = R::new(
Gaussian::from_ms(25.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let t_b = R::new(
Gaussian::from_ms(25.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let t_c = R::new(
Gaussian::from_ms(29.0, 2.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0], vec![1.0]];
@@ -918,29 +997,29 @@ mod tests {
R::new(
Gaussian::from_ms(12.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
),
R::new(
Gaussian::from_ms(18.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
),
];
let t_b = vec![R::new(
Gaussian::from_ms(30.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
)];
let t_c = vec![
R::new(
Gaussian::from_ms(14.0, 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
),
R::new(
Gaussian::from_ms(16., 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
),
];
@@ -970,12 +1049,12 @@ mod tests {
let t_a = vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
ConstantDrift::new(0.0),
)];
let t_b = vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
ConstantDrift::new(0.0),
)];
let w = [w_a, w_b];
@@ -1053,8 +1132,16 @@ mod tests {
let w_a = vec![1.0];
let w_b = vec![0.0];
let t_a = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
let t_a = vec![R::new(
Gaussian::from_ms(2.0, 6.0),
1.0,
ConstantDrift::new(0.0),
)];
let t_b = vec![R::new(
Gaussian::from_ms(2.0, 6.0),
1.0,
ConstantDrift::new(0.0),
)];
let w = [w_a, w_b];
let g = Game::ranked_with_arena(
@@ -1081,8 +1168,16 @@ mod tests {
let w_a = vec![1.0];
let w_b = vec![-1.0];
let t_a = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
let t_a = vec![R::new(
Gaussian::from_ms(2.0, 6.0),
1.0,
ConstantDrift::new(0.0),
)];
let t_b = vec![R::new(
Gaussian::from_ms(2.0, 6.0),
1.0,
ConstantDrift::new(0.0),
)];
let w = [w_a, w_b];
let g = Game::ranked_with_arena(
@@ -1125,7 +1220,7 @@ mod tests {
let prior = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let teams = vec![vec![prior], vec![prior]];
let result = vec![10.0, 0.0]; // a beat b by 10
@@ -1175,7 +1270,7 @@ mod tests {
let prior = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let opts = GameOptions {
score_sigma: 1.0,
@@ -1191,7 +1286,7 @@ mod tests {
let prior = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let err = Game::scored(
&[&[prior], &[prior]],
@@ -1210,7 +1305,7 @@ mod tests {
let prior = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
);
let opts = GameOptions {
score_sigma: 0.0,
@@ -1237,12 +1332,12 @@ mod tests {
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
ConstantDrift::new(0.0),
),
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
ConstantDrift::new(0.0),
),
];
let w_a = vec![0.4, 0.8];
@@ -1251,12 +1346,12 @@ mod tests {
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
ConstantDrift::new(0.0),
),
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
ConstantDrift::new(0.0),
),
];
let w_b = vec![0.9, 0.6];
@@ -1370,7 +1465,7 @@ mod tests {
vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
ConstantDrift::new(0.0),
)],
],
&[1.0, 0.0],
@@ -1403,8 +1498,8 @@ mod tests {
#[test]
fn run_chain_honours_max_iter_in_convergence_options() {
let players: Vec<R> = (0..4).map(|_| R::default()).collect();
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect();
let competitors: Vec<R> = (0..4).map(|_| R::default()).collect();
let teams: Vec<Vec<_>> = competitors.iter().map(|p| vec![*p]).collect();
let result = vec![3.0, 2.0, 1.0, 0.0];
let weights = vec![vec![1.0]; 4];
@@ -1451,8 +1546,8 @@ mod tests {
#[test]
fn run_chain_with_damping_converges_to_same_posterior() {
let players: Vec<R> = (0..4).map(|_| R::default()).collect();
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect();
let competitors: Vec<R> = (0..4).map(|_| R::default()).collect();
let teams: Vec<Vec<_>> = competitors.iter().map(|p| vec![*p]).collect();
let result = vec![3.0, 2.0, 1.0, 0.0];
let weights = vec![vec![1.0]; 4];
+114 -3
View File
@@ -11,6 +11,7 @@ use crate::{MU, N_INF, SIGMA};
/// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and
/// `sigma()` are accessors computed on demand.
#[derive(Clone, Copy, PartialEq, Debug)]
#[must_use]
pub struct Gaussian {
pi: f64,
tau: f64,
@@ -18,8 +19,43 @@ pub struct Gaussian {
impl Gaussian {
/// Construct from mean and standard deviation.
#[must_use]
///
/// # Panics
///
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a
/// broken fit produces one, and `converge` reports that as
/// `NonFiniteResult` rather than panicking mid-inference.
///
/// A negative sigma used to be accepted and returned results **bit
/// identical** to its absolute value, because sigma only ever enters as
/// `sigma * sigma`. The sign was not rejected and not honoured; it simply
/// vanished. That is the same defect `HistoryBuilder::sigma`,
/// `HistoryBuilder::beta` and `Member::with_drift_scale` already reject.
///
/// # Very small sigma
///
/// `pi = 1 / sigma^2` leaves `f64`'s range below about `1.5e-154`, and
/// `tau = mu * pi` overflows sooner still — at a threshold that depends on
/// `mu`, so there is a band where `pi` is finite and only `tau` is not.
/// Both land on the same point-mass representation the `sigma == 0.0`
/// branch produces, and a point mass with a non-zero mean has `mu() = NaN`,
/// because `tau / pi` is `inf / inf`.
///
/// This is not rejected, because `approx` legitimately produces a very
/// small truncated sigma and inference must not panic. It is worth knowing
/// that such a `Gaussian` is not equal to itself, so two identical
/// declarations of one can be reported as conflicting.
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
// NaN is admitted on purpose. A broken fit legitimately produces a NaN
// sigma — `sqrt` of a negative truncated variance — and the design is
// to propagate that to `converge`'s `NonFiniteResult` guard, not to
// panic inside inference. Rejecting it here turned that reporting path
// into a crash, which two tests caught immediately.
assert!(
sigma >= 0.0 || sigma.is_nan(),
"sigma must not be negative; it is only ever squared, so a negative \
value would silently behave as its absolute value"
);
if sigma == f64::INFINITY {
Self { pi: 0.0, tau: 0.0 }
} else if sigma == 0.0 {
@@ -64,18 +100,34 @@ impl Gaussian {
Self { pi, tau }
}
/// Precision, `1 / sigma^2` — one of the two natural parameters.
///
/// This is the representation the type actually stores, which is why the EP
/// product and cavity (`Mul` / `Div`) are plain adds and subtracts. Larger
/// means more certain; `0.0` is an improper, uninformative message and
/// `inf` is a point mass.
#[inline]
#[must_use]
pub fn pi(&self) -> f64 {
self.pi
}
/// Precision-adjusted mean, `mu / sigma^2` — the other natural parameter.
///
/// Stored rather than derived, for the same reason as [`Gaussian::pi`].
/// Meaningful only alongside `pi`: on its own it is not a location.
#[inline]
#[must_use]
pub fn tau(&self) -> f64 {
self.tau
}
/// Mean skill: the point estimate.
///
/// Derived from the natural parameters as `tau / pi`. An improper message
/// (`pi <= 0`) has no defined mean and reports `0.0` — see
/// [`Gaussian::sigma`], which reports `inf` for the same state, and read
/// the two together before treating a mean as informative.
#[inline]
#[must_use]
pub fn mu(&self) -> f64 {
@@ -105,6 +157,12 @@ impl Gaussian {
}
}
/// Standard deviation: how unsure this estimate is.
///
/// Derived as `1 / sqrt(pi)`. An improper message (`pi <= 0`) reports
/// `inf`, and a point mass (`pi == inf`) reports `0.0` — both are real
/// states rather than error codes, and both are legitimate for a converged
/// fit with degenerate parameters.
#[inline]
#[must_use]
pub fn sigma(&self) -> f64 {
@@ -120,7 +178,25 @@ impl Gaussian {
}
}
/// How far this Gaussian moved from `other`, as `(|d mu|, |d sigma|)`.
///
/// Identical messages have not moved, whatever their parameters, and that
/// case is answered in natural space before touching `mu()`/`sigma()`. An
/// improper message has `pi == 0`, so `sigma()` is infinite — and
/// `inf - inf` is NaN, a NaN *change* for a message that did not change at
/// all. (`mu()` is guarded and returns 0.0 here, so the mean component was
/// never the problem; the sigma component alone produced `(0.0, NaN)`.)
///
/// That is reachable in ordinary inference: once a pairing is more than
/// about nine cavity-sigma apart the truncation is a no-op, `trunc / cavity`
/// is exactly the identity message, and the chain compares one identity
/// against another. Before this guard that produced `(0.0, NaN)`, which
/// silently disabled the sigma half of the convergence test.
pub(crate) fn delta(&self, other: Gaussian) -> (f64, f64) {
if self.pi == other.pi && self.tau == other.tau {
return (0.0, 0.0);
}
(
(self.mu() - other.mu()).abs(),
(self.sigma() - other.sigma()).abs(),
@@ -189,8 +265,7 @@ impl Gaussian {
/// Used by within-game inference to stabilise oscillating fixed-point
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
/// `alpha < 1.0` shrinks each per-step update.
#[must_use]
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
pub(crate) fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
Gaussian::from_natural(
alpha * new.pi() + (1.0 - alpha) * self.pi(),
alpha * new.tau() + (1.0 - alpha) * self.tau(),
@@ -256,6 +331,42 @@ impl ops::Div<Gaussian> for Gaussian {
#[cfg(test)]
mod tests {
/// A message that did not change must report no change, even when it is
/// improper. `mu()` of an improper Gaussian is `0/0 = NaN` and `sigma()` is
/// infinite, so the mean/sigma form reported `(NaN, NaN)` for two identical
/// identity messages — which silently disabled the sigma half of the
/// convergence test in `run_chain`.
#[test]
fn delta_of_two_identical_improper_messages_is_zero() {
let improper = crate::N_INF;
// `mu()` is guarded and returns 0.0 for an improper Gaussian, so the
// mean component was always fine. The NaN came from the sigma
// component alone: `inf - inf`. The pre-fix value was `(0.0, NaN)`.
assert!(improper.sigma().is_infinite(), "premise: sigma is infinite");
assert_eq!(improper.mu(), 0.0, "premise: mu is guarded, not NaN");
assert!(
(improper.sigma() - improper.sigma()).is_nan(),
"premise: the unguarded sigma difference is NaN"
);
assert_eq!(improper.delta(improper), (0.0, 0.0));
}
#[test]
fn delta_of_identical_proper_messages_is_zero() {
let g = Gaussian::from_ms(25.0, 8.0);
assert_eq!(g.delta(g), (0.0, 0.0));
}
/// The shortcut must not swallow a real difference.
#[test]
fn delta_still_measures_a_real_move() {
let a = Gaussian::from_ms(25.0, 8.0);
let b = Gaussian::from_ms(26.0, 9.0);
let (dmu, dsigma) = a.delta(b);
assert!((dmu - 1.0).abs() < 1e-12, "{dmu}");
assert!((dsigma - 1.0).abs() < 1e-12, "{dsigma}");
}
use super::*;
#[test]
+753 -201
View File
File diff suppressed because it is too large Load Diff
+16 -12
View File
@@ -26,21 +26,24 @@ where
K: Eq + Hash + Clone,
{
#[must_use]
pub fn new() -> Self {
pub(crate) fn new() -> Self {
Self {
forward: HashMap::new(),
reverse: Vec::new(),
}
}
pub fn get<Q: ?Sized + Hash + Eq>(&self, k: &Q) -> Option<Index>
pub(crate) fn get<Q: ?Sized + Hash + Eq>(&self, k: &Q) -> Option<Index>
where
K: Borrow<Q>,
{
self.forward.get(k).cloned()
}
pub fn get_or_create<Q: ?Sized + Hash + Eq + ToOwned<Owned = K>>(&mut self, k: &Q) -> Index
pub(crate) fn get_or_create<Q: ?Sized + Hash + Eq + ToOwned<Owned = K>>(
&mut self,
k: &Q,
) -> Index
where
K: Borrow<Q>,
{
@@ -56,23 +59,24 @@ where
}
#[must_use]
pub fn key(&self, idx: Index) -> Option<&K> {
pub(crate) fn key(&self, idx: Index) -> Option<&K> {
self.reverse.get(idx.0)
}
pub fn keys(&self) -> impl Iterator<Item = &K> {
self.forward.keys()
/// Every key, in the order they were first interned.
///
/// Iterates the dense reverse table rather than the forward `HashMap`.
/// Rust seeds its default hasher per process, so a `HashMap` walk yields a
/// different order on every run — which is fine for membership but not for
/// anything a caller might sum, sort or print.
pub(crate) fn keys(&self) -> impl ExactSizeIterator<Item = &K> {
self.reverse.iter()
}
#[must_use]
pub fn len(&self) -> usize {
pub(crate) fn len(&self) -> usize {
self.reverse.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.reverse.is_empty()
}
}
impl<K> Default for KeyTable<K>
+345 -35
View File
@@ -85,6 +85,10 @@
//! regardless of worker count.
#![forbid(unsafe_code)]
// Turned on once the surface was fully documented (80 items at the time), so
// the next undocumented public item is a build failure rather than a warning
// nobody reads.
#![deny(missing_docs)]
/// Compiles every `rust` block in `README.md` as a doctest.
///
@@ -104,22 +108,32 @@ use std::{
f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2},
};
mod acquisition;
#[cfg(feature = "approx")]
mod approx;
pub(crate) mod arena;
mod time;
mod time_slice;
pub use time_slice::{EventKind, TimeSlice};
mod acquisition;
mod color_group;
mod competitor;
mod convergence;
/// Skill drift: how much a competitor's skill is allowed to move between
/// appearances.
///
/// Public because [`Drift`] is a trait a caller may implement — a per-sport
/// off-season, say, or a schedule where drift is a function of the calendar
/// rather than of elapsed ticks. [`ConstantDrift`] is what
/// [`HistoryBuilder`] uses by default.
pub mod drift;
mod error;
mod event;
mod event_builder;
pub(crate) mod factor;
mod game;
/// The Gaussian message type and its expectation-propagation algebra.
///
/// Public because [`Gaussian`] appears throughout the results: a posterior
/// skill, a learning-curve point, a predicted margin. The module carries the
/// operator documentation — `Mul`/`Div` are the EP product and cavity, not
/// arithmetic on random variables.
pub mod gaussian;
mod history;
mod joint;
@@ -130,10 +144,11 @@ mod outcome;
mod predict;
pub(crate) mod quadrature;
mod rating;
pub mod storage;
pub(crate) mod storage;
mod time;
mod time_slice;
pub use acquisition::expected_information_gain;
pub use competitor::Competitor;
pub use convergence::{ConvergenceOptions, ConvergenceReport};
pub use drift::{ConstantDrift, Drift};
pub use error::{InferenceError, UnknownKeys};
@@ -142,19 +157,63 @@ pub use event_builder::EventBuilder;
pub use game::{Game, GameOptions, OwnedGame};
pub use gaussian::Gaussian;
pub use history::{History, HistoryBuilder, Joint};
pub use key_table::KeyTable;
use matrix::Matrix;
pub use observer::{NullObserver, Observer};
pub use outcome::Outcome;
pub use predict::Prediction;
pub use rating::Rating;
/// The `smallvec` crate, re-exported.
///
/// Four public items name `SmallVec` in their signatures: [`Event::teams`],
/// [`Team::members`], [`Outcome::Ranked`]'s payload and
/// [`ConvergenceReport::per_iteration_time`]. You can *build* an `Event`
/// without ever naming the type — `vec![..].into()` and `.collect()` both work
/// — and iterate the timings through `Deref`. But writing a helper that
/// *returns* a teams list, or a `match` arm that binds ranks and passes them
/// on, requires the type by name.
///
/// Measured: the only `Joint` doc example failed to compile from a consumer
/// crate with `unresolved import \`smallvec\``, because the dependency was in
/// the signature but not reachable. Re-exported so a consumer takes this
/// crate's version rather than pinning a matching one of their own.
pub use smallvec;
pub use time::{Time, Untimed};
/// Default performance noise: how much a single showing varies around skill.
///
/// Every other default is expressed as a multiple of this, so `BETA` sets the
/// scale of the whole rating system. Doubling it and doubling `SIGMA` and
/// `GAMMA` with it gives the same fit on a rescaled axis.
pub const BETA: f64 = 1.0;
/// Default prior mean skill.
///
/// Zero rather than a conventional 25: the scale is set by `BETA`, and a
/// centred axis makes a negative rating mean "below the prior" instead of
/// looking like an error.
pub const MU: f64 = 0.0;
/// Default prior standard deviation: how unsure the model starts out.
///
/// Six betas is deliberately wide — a new competitor's first result should
/// move them a long way, and the prior should not fight the evidence.
pub const SIGMA: f64 = BETA * 6.0;
/// Default drift: the standard deviation of skill movement per unit of time.
///
/// Enters inference as a *variance* (`gamma^2` per elapsed tick), which is why
/// [`ConstantDrift`] squares it and why a negative gamma would be
/// indistinguishable from its absolute value — see [`ConstantDrift::new`].
pub const GAMMA: f64 = BETA * 0.03;
/// Default draw probability: zero, meaning ties are not modelled.
///
/// A history that ingests a tie needs a positive value. With `p_draw == 0.0`
/// the truncation margin is zero and the two-sided tie update evaluates
/// `0/0`, so ingestion rejects such events with
/// [`InferenceError::TieWithoutDrawProbability`].
pub const P_DRAW: f64 = 0.0;
/// Default convergence threshold, in the same units as
/// [`ConvergenceReport::final_step`](crate::ConvergenceReport).
///
/// The sweep stops once the largest change a full iteration makes to any
/// message falls below this.
pub const EPSILON: f64 = 1e-6;
/// Default cap on convergence sweeps.
///
@@ -218,12 +277,26 @@ const HALF_LINE_WINDOW: f64 = 10.0;
/// four-term series is good to ~1e-10 by here, so the two are at their closest
/// agreement around this point. Below it the subtraction is exact; above it the
/// series is.
/// `alpha / width` past which the tie branch's `v^2 - u` has lost too many
/// digits to trust, and the narrow-window form takes over.
///
/// The subtraction retains about `(width / alpha)^2 / EPSILON` of its
/// precision, so this is the ratio at which that falls below roughly 1e-6.
const NARROW_WINDOW_RATIO: f64 = 2.0e4;
const ASYMPTOTIC_MILLS_ALPHA: f64 = 100.0;
pub const N01: Gaussian = Gaussian::from_ms(0.0, 1.0);
pub const N00: Gaussian = Gaussian::from_ms(0.0, 0.0);
pub const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
pub(crate) const N00: Gaussian = Gaussian::from_ms(0.0, 0.0);
pub(crate) const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
/// An interned competitor handle: a dense slot number, not a user key.
///
/// [`History`] stores skills and messages by `Index` rather than by `K`, so
/// the hot path never hashes a key. [`History::intern`] promotes a key to one
/// and [`History::lookup`] resolves an existing key without creating.
///
/// Indices are assigned in interning order and are stable for the life of a
/// history. They are **not** portable between histories: the same key interns
/// to different slots depending on ingestion order.
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
pub struct Index(usize);
@@ -476,10 +549,72 @@ fn pdf(x: f64, mu: f64, sigma: f64) -> f64 {
fn half_line_truncation(alpha: f64) -> (f64, f64) {
let inv = alpha.recip();
let inv_sq = inv * inv;
let gap = inv * (1.0 - inv_sq * (2.0 - inv_sq * (10.0 - 74.0 * inv_sq)));
let b = 2.0 - inv_sq * (10.0 - 74.0 * inv_sq);
let gap = inv * (1.0 - inv_sq * b);
let v = alpha + gap;
(v, v * gap)
// Returns `1 - w`, not `w`, and that is the whole point of this shape.
//
// `w` tends to 1 out here, so a caller forming `1 - w` loses about
// `log10(alpha^2)` digits: measured against the exact truncated variance,
// `1 - w` came back with 8.9e-5 relative error at alpha = 1e6 and **0.0**
// from alpha = 1e8 — where the true value is 1e-16 and perfectly
// representable. `sigma * (1 - w).sqrt()` was then exactly zero, and
// `from_ms(mu, 0.0)` is a point mass whose `mu()` is `inf/inf = NaN`.
//
// Expanding `1 - v*gap` symbolically removes the subtraction: with
// `alpha*gap = 1 - inv^2*b`, the leading ones cancel on paper instead of in
// floating point, leaving `inv^2` times a bracket that tends to 1. Measured
// exact — 0.0 relative error — from alpha = 1e3 to 1e8.
let one_minus_w = inv_sq
* ((1.0 - inv_sq * (10.0 - 74.0 * inv_sq)) + 2.0 * inv_sq * b - inv_sq * inv_sq * b * b);
(v, one_minus_w)
}
/// Truncation to a *narrow* window `[alpha, alpha + d]`, as `(v, 1 - w)`.
///
/// The tie branch forms `w` from `v^2 - u`, and both grow as `alpha^2` while
/// their difference stays `O(1)`. Far enough into the tail that subtraction has
/// nothing left: measured at `alpha = 1e6` with a window of `1e-6` it kept four
/// significant digits and returned `1 - w = -2.4e-4` where the truth is
/// `+2.8e-13`, so `sqrt` of it was NaN. One step earlier it was quietly wrong
/// instead — `1 - w = 1.0` exactly, a truncation reported as a no-op, where the
/// truth was `5e-17`.
///
/// The existing half-line escape hatch does not cover it, because that keys on
/// `alpha * d >= HALF_LINE_WINDOW` — how many window-widths from the mean the
/// window sits — and a *narrow* window fails that however deep it is.
///
/// Over a narrow window the density is `exp(-t*s - s^2 d^2 / 2)` in
/// `x = alpha + s*d`, with `t = alpha * d`. Dropping the `d^2` term leaves a
/// truncated exponential on `[0, 1]`, whose mean and variance are closed forms.
/// So `v = alpha + d*m(t)` and `1 - w = d^2 * V(t)`, with no subtraction of
/// large quantities anywhere.
///
/// Measured against high-precision quadrature over `alpha` in `[1e2, 1e9]`:
/// `v` exact to 4e-10 or better, `1 - w` to 4e-10 across the region this is
/// used in.
fn narrow_window_truncation(alpha: f64, d: f64) -> (f64, f64) {
let t = alpha * d;
// `m` and `V` are the mean and variance of a truncated exponential on
// [0, 1] with rate `t`, both of which cancel as `t -> 0`. The series is
// their limit (1/2 and 1/12, a uniform window) with the leading correction.
let (m, v_s) = if t < 1e-3 {
(
0.5 - t / 12.0 + t * t * t / 720.0,
1.0 / 12.0 - t * t / 240.0,
)
} else {
let em1 = libm::expm1(t);
(
1.0 / t - 1.0 / em1,
1.0 / (t * t) - (em1 + 1.0) / (em1 * em1),
)
};
(alpha + d * m, d * d * v_s)
}
fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
@@ -507,7 +642,7 @@ fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
(v, v - alpha)
};
(v, v * gap)
(v, 1.0 - v * gap)
} else {
// v is odd in mu and w is even, so fold to mu <= 0. Both truncation
// points then sit in the upper tail, where the scaled form applies.
@@ -523,9 +658,22 @@ fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
// Once the window sits many of its own widths into the tail it is
// indistinguishable from a half-line, so the asymptotic covers it with
// no subtraction at all.
if alpha >= ASYMPTOTIC_MILLS_ALPHA && alpha * (beta - alpha) >= HALF_LINE_WINDOW {
let (v, w) = half_line_truncation(alpha);
return (if flipped { -v } else { v }, w);
let width = beta - alpha;
if alpha >= ASYMPTOTIC_MILLS_ALPHA && alpha * width >= HALF_LINE_WINDOW {
let (v, one_minus_w) = half_line_truncation(alpha);
return (if flipped { -v } else { v }, one_minus_w);
}
// A narrow window deep in the tail: too narrow for the half-line above,
// too deep for the subtraction below. The direct form keeps roughly
// `1 / (alpha/width)^2` of its digits, so the crossover is on that
// ratio rather than on either quantity alone — and the approximation is
// most accurate exactly where the subtraction is worst, since both
// improve as the window narrows.
if alpha > 0.0 && alpha > NARROW_WINDOW_RATIO * width {
let (v, one_minus_w) = narrow_window_truncation(alpha, width);
return (if flipped { -v } else { v }, one_minus_w);
}
let (v, u) = if alpha > 0.0 {
@@ -548,17 +696,23 @@ fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
)
};
let w = -(u - v.powi(2));
// `1 - w` where `w = v^2 - u`. Both `v^2` and `u` grow as alpha^2 while
// their difference stays O(1), so this subtraction is the one place the
// tie branch can still lose everything — see the escape hatch above,
// which is what keeps the far tail away from it.
let one_minus_w = 1.0 + u - v.powi(2);
(if flipped { -v } else { v }, w)
(if flipped { -v } else { v }, one_minus_w)
}
}
fn trunc(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
let (v, w) = v_w(mu, sigma, margin, tie);
// `v_w` returns `1 - w` rather than `w`: forming the difference here is
// what destroyed the truncated variance in the far tail.
let (v, one_minus_w) = v_w(mu, sigma, margin, tie);
let mu_trunc = mu + sigma * v;
let sigma_trunc = sigma * (1.0 - w).sqrt();
let sigma_trunc = sigma * one_minus_w.sqrt();
(mu_trunc, sigma_trunc)
}
@@ -569,13 +723,34 @@ pub(crate) fn approx(n: Gaussian, margin: f64, tie: bool) -> Gaussian {
Gaussian::from_ms(mu, sigma)
}
/// Componentwise maximum that **propagates** NaN rather than dropping it.
///
/// Every caller folds this as `tuple_max(accumulator, new)`. A plain `>`
/// comparison is false against NaN, so a NaN accumulator would be replaced by
/// the next finite delta and the breakdown would vanish — leaving `step_is_finite`
/// to pass on a fit that is already NaN. Because the fold runs over a `HashMap`,
/// whether that happened depended on per-process hash order: measured, a NaN fit
/// was reported as `converged: true` in 16 of 30 runs on identical input.
///
/// `f64::max` is not a substitute: it also ignores NaN by design, which is the
/// same defect wearing a standard-library name.
pub(crate) fn tuple_max(v1: (f64, f64), v2: (f64, f64)) -> (f64, f64) {
(
if v1.0 > v2.0 { v1.0 } else { v2.0 },
if v1.1 > v2.1 { v1.1 } else { v2.1 },
max_propagating_nan(v1.0, v2.0),
max_propagating_nan(v1.1, v2.1),
)
}
fn max_propagating_nan(a: f64, b: f64) -> f64 {
if a.is_nan() || b.is_nan() {
f64::NAN
} else if a > b {
a
} else {
b
}
}
pub(crate) fn tuple_gt(t: (f64, f64), e: f64) -> bool {
t.0 > e || t.1 > e
}
@@ -642,29 +817,36 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
x.into_iter().map(|(i, _)| i).collect()
}
/// Calculates the match quality of the given rating groups. A result is the draw probability in the association
/// Calculates the match quality of the given teams. A result is the draw probability in the association
///
/// Supports any number of groups. Values range roughly `[0, 1]`; 1 means a
/// perfectly balanced match.
///
/// # Panics
///
/// Panics if fewer than two rating groups are supplied, or if any group is
/// Panics if fewer than two teams are supplied, or if any group is
/// empty — match quality is a property of a contest between at least two
/// non-empty sides.
///
/// Also panics with "cannot invert a singular matrix" when every rating has
/// zero sigma *and* `beta` is zero. Nothing is then uncertain, so there is no
/// distribution to take the quality of; `Gaussian::from_ms(mu, 0.0)` is a point
/// mass and its `mu()` is not even well defined. Documented rather than
/// converted, because the input has no meaningful answer rather than an
/// awkward one.
#[must_use]
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
pub fn quality(teams: &[&[Gaussian]], beta: f64) -> f64 {
assert!(
rating_groups.len() >= 2,
"quality() requires at least 2 rating groups, got {}",
rating_groups.len()
teams.len() >= 2,
"quality() requires at least 2 teams, got {}",
teams.len()
);
assert!(
rating_groups.iter().all(|group| !group.is_empty()),
"quality() requires every rating group to be non-empty"
teams.iter().all(|group| !group.is_empty()),
"quality() requires every team to be non-empty"
);
let flatten_ratings = rating_groups
let flatten_ratings = teams
.iter()
.flat_map(|group| group.iter())
.collect::<Vec<_>>();
@@ -685,14 +867,14 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
variance_matrix[(i, i)] = rating.sigma().powi(2);
}
let mut rotated_a_matrix = Matrix::new(rating_groups.len() - 1, length);
let mut rotated_a_matrix = Matrix::new(teams.len() - 1, length);
// Row `row` contrasts group `row` (+weight) against group `row + 1`
// (-weight). `t` is the column where the current group's players start;
// the negative block begins immediately after it.
let mut t = 0;
for (row, group) in rating_groups.windows(2).enumerate() {
for (row, group) in teams.windows(2).enumerate() {
let current = group[0];
let next = group[1];
@@ -717,13 +899,141 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
let end = &rotated_a_matrix * &mean_matrix;
let e_arg = (-0.5 * &start * &middle.inverse() * &end).determinant();
let s_arg = ata.determinant() / middle.determinant();
libm::exp(e_arg) * s_arg.sqrt()
// `sqrt(det(ata) / det(middle))`, taken in log space. Both determinants are
// products of `k - 1` diagonal entries, so they leave `f64`'s range long
// before their ratio does: measured at the crate defaults, 150 groups was
// correct at `8.45e-53`, 200 returned `0`, and 250 returned `NaN` where the
// true value is `9.51e-88`. With a small beta it is sharper still — at
// `sigma = beta = 1e-3`, 60 groups returned `NaN` against a true `1.32e-9`.
//
// The ratio is what the answer needs and it is representable throughout, so
// the intermediates are the only thing that ever overflowed.
let ln_s_arg = ata.ln_abs_determinant() - middle.ln_abs_determinant();
libm::exp(e_arg + 0.5 * ln_s_arg)
}
#[cfg(test)]
mod tests {
/// The truncated variance must stay a variance across every branch, and
/// the branches must agree where they meet.
///
/// `v_w` now has three regimes for a tie — half-line, narrow-window, and
/// the direct subtraction — and a misplaced crossover between them is the
/// failure mode this guards. A jump at a boundary is visible here even
/// though the absolute values are not pinned.
#[test]
fn truncated_variance_is_continuous_across_the_tie_branches() {
for &alpha in &[50.0, 99.0, 100.0, 101.0, 1e3, 1e5, 1e6] {
// Sweep the window width across NARROW_WINDOW_RATIO and the
// half-line threshold, which sit at different widths per alpha.
let mut previous: Option<(f64, f64)> = None;
let mut width = alpha / (NARROW_WINDOW_RATIO * 100.0);
while width < 40.0 / alpha {
// mu = 0 puts the window at [-margin, margin]; shift it out to
// `alpha` by moving the mean instead.
let margin = width * 0.5;
let mu = -(alpha + width * 0.5);
let (v, one_minus_w) = v_w(mu, 1.0, margin, true);
assert!(v.is_finite(), "alpha {alpha}, width {width:e}: v = {v}");
assert!(
one_minus_w.is_finite() && one_minus_w > 0.0 && one_minus_w <= 1.0,
"alpha {alpha}, width {width:e}: 1 - w = {one_minus_w:e} is not a variance"
);
if let Some((pv, pw)) = previous {
// Consecutive widths differ by 2x, so the moments may not
// differ by more than a small multiple of that.
assert!(
one_minus_w / pw < 32.0 && pw / one_minus_w < 32.0,
"alpha {alpha}: 1 - w jumped from {pw:e} to {one_minus_w:e} \
at width {width:e} — a branch boundary is misplaced"
);
assert!(
(v - pv).abs() <= 8.0 * width.max(1e-12) + 1e-9 * v.abs(),
"alpha {alpha}: v jumped from {pv} to {v} at width {width:e}"
);
}
previous = Some((v, one_minus_w));
width *= 2.0;
}
}
}
/// The narrow-window form against high-precision quadrature.
///
/// These are the inputs where the direct `v^2 - u` subtraction had four
/// significant digits left and returned a negative variance.
#[test]
fn narrow_window_truncation_matches_quadrature() {
for &(alpha, d, expect_v, expect_w) in &[
(1e6, 2e-6, 1_000_000.000_000_687, 2.759_383_390_335_666e-13),
(1e4, 1e-6, 10_000.000_000_499_167, 8.333_291_666_831_727e-14),
(
1e3,
1e-5,
1_000.000_004_991_666_6,
8.333_291_666_803_818e-12,
),
] {
let (v, one_minus_w) = narrow_window_truncation(alpha, d);
assert!(
((v - expect_v) / expect_v).abs() < 1e-12,
"alpha {alpha:e}: v = {v}, want {expect_v}"
);
assert!(
((one_minus_w - expect_w) / expect_w).abs() < 1e-8,
"alpha {alpha:e}: 1 - w = {one_minus_w:e}, want {expect_w:e}"
);
}
}
/// A NaN must survive the fold from ANY position, not only the last.
///
/// The fold runs over a `HashMap`, so "last" is per-process hash order. The
/// end-to-end symptom was a NaN fit reported as `converged: true` in 16 of
/// 30 runs on identical input; these three cases are the deterministic form
/// of that, so a regression cannot hide behind a lucky seed.
#[test]
fn tuple_max_propagates_a_nan_from_any_position() {
let nan = (f64::NAN, f64::NAN);
let small = (1e-9, 1e-9);
let big = (1e-3, 1e-3);
// NaN last.
let step = tuple_max(tuple_max(big, small), nan);
assert!(!step_is_finite(step), "NaN last: {step:?}");
// NaN middle.
let step = tuple_max(tuple_max(big, nan), small);
assert!(!step_is_finite(step), "NaN middle: {step:?}");
// NaN first — the case a plain `>` comparison drops.
let step = tuple_max(tuple_max(nan, big), small);
assert!(!step_is_finite(step), "NaN first: {step:?}");
}
/// `f64::max` would pass the test above's first two cases and fail the
/// third, so pin that it is not what we use.
#[test]
fn tuple_max_is_not_f64_max() {
assert!(
f64::max(f64::NAN, 1.0) == 1.0,
"premise: f64::max drops NaN"
);
let (a, _) = tuple_max((f64::NAN, 0.0), (1.0, 0.0));
assert!(a.is_nan(), "tuple_max must not drop what f64::max drops");
}
/// Ordinary values are unaffected.
#[test]
fn tuple_max_still_takes_the_larger_component() {
assert_eq!(tuple_max((1.0, 5.0), (3.0, 2.0)), (3.0, 5.0));
assert_eq!(tuple_max((3.0, 2.0), (1.0, 5.0)), (3.0, 5.0));
}
use ::approx::assert_ulps_eq;
use super::*;
+45 -4
View File
@@ -91,6 +91,29 @@ impl Lu {
det
}
/// `ln |det|`, accumulated term by term rather than multiplied out.
///
/// The determinant of an `n x n` Gram matrix is a product of `n` diagonal
/// entries, so it leaves `f64`'s range long before the quantities built
/// from it do. `quality()` only ever wants a *ratio* of two determinants,
/// and that ratio is perfectly representable while the determinants
/// themselves are not — measured, at 250 rating groups both overflow and
/// the ratio came back `NaN` where the true answer is `9.51e-88`.
///
/// Returns `-inf` for a singular matrix, so `exp` of it is zero.
fn ln_abs_determinant(&self) -> f64 {
if self.sign == 0.0 {
return f64::NEG_INFINITY;
}
let mut acc = 0.0;
for i in 0..self.n {
acc += libm::log(self.lu[i * self.n + i].abs());
}
acc
}
/// Solve `Ax = b` for a single column of the identity, giving one column
/// of the inverse.
fn solve_column(&self, col: usize, out: &mut [f64]) {
@@ -117,7 +140,7 @@ impl Lu {
}
impl Matrix {
pub fn new(height: usize, width: usize) -> Matrix {
pub(crate) fn new(height: usize, width: usize) -> Matrix {
Matrix {
data: vec![0.0; height * width].into_boxed_slice(),
height,
@@ -125,7 +148,7 @@ impl Matrix {
}
}
pub fn transpose(&self) -> Matrix {
pub(crate) fn transpose(&self) -> Matrix {
let mut matrix = Matrix::new(self.width, self.height);
for c in 0..self.width {
@@ -143,7 +166,7 @@ impl Matrix {
/// # Panics
///
/// Panics if the matrix is not square.
pub fn determinant(&self) -> f64 {
pub(crate) fn determinant(&self) -> f64 {
assert_eq!(
self.width, self.height,
"determinant requires a square matrix, got {}x{}",
@@ -157,12 +180,30 @@ impl Matrix {
Lu::decompose(self).determinant()
}
/// `ln |det|` of a square matrix; `-inf` when singular.
///
/// See [`Lu::ln_abs_determinant`] for why a ratio of determinants must be
/// taken this way.
pub(crate) fn ln_abs_determinant(&self) -> f64 {
assert_eq!(
self.width, self.height,
"determinant requires a square matrix, got {}x{}",
self.height, self.width
);
if self.width == 0 {
return 0.0;
}
Lu::decompose(self).ln_abs_determinant()
}
/// Matrix inverse via LU decomposition.
///
/// # Panics
///
/// Panics if the matrix is not square or is singular.
pub fn inverse(&self) -> Matrix {
pub(crate) fn inverse(&self) -> Matrix {
assert_eq!(
self.width, self.height,
"inverse requires a square matrix, got {}x{}",
+39 -13
View File
@@ -1,6 +1,6 @@
//! Outcome of a match.
//!
//! `Ranked(ranks)` for ordinal results; `Scored { scores, sigma }` for
//! `Ranked(ranks)` for ordinal results; `Scored { scores, score_sigma }` for
//! continuous per-team scores (engages `MarginFactor` in the engine).
use smallvec::SmallVec;
@@ -10,19 +10,41 @@ use smallvec::SmallVec;
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
/// teams. `ranks.len()` must equal the number of teams in the event.
///
/// `Scored { scores, sigma }`: higher score = better. Adjacent (sorted) pairs
/// `Scored { scores, score_sigma }`: higher score = better. Adjacent (sorted) pairs
/// feed observed margins to `MarginFactor`. `scores.len()` must equal the
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
/// when `Some`; `None` inherits the history default.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
#[must_use]
pub enum Outcome {
/// An ordinal finish: one rank per team, in the order the teams were given.
///
/// Lower is better, `0` is first, and equal values are a tie between those
/// teams — which needs `p_draw > 0`, or ingestion rejects the event with
/// [`InferenceError::TieWithoutDrawProbability`](crate::InferenceError::TieWithoutDrawProbability).
///
/// Only the ordering and the equalities are used. Ranks need not be dense
/// or start at zero: inference sorts the teams and compares rank-adjacent
/// pairs against a margin set by `p_draw`, so `[0, 1, 2]` and `[0, 5, 90]`
/// are the same observation. A gap does not mean a bigger win — use
/// `Scored` when the size of the difference is evidence.
Ranked(SmallVec<[u32; 4]>),
/// A continuous finish: one score per team, higher is better.
///
/// Unlike `Ranked`, the *sizes* of the differences are evidence. Teams are
/// sorted by score and each adjacent pair's observed gap is fed to a
/// `MarginFactor` as a measurement with standard deviation `score_sigma`,
/// so
/// beating a team by ten says more than beating them by one.
#[non_exhaustive]
Scored {
/// Per-team scores, in the order the teams were given; higher is
/// better. Must have one entry per team, and every entry finite.
scores: SmallVec<[f64; 4]>,
/// Per-event noise override. `None` means inherit
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
sigma: Option<f64>,
score_sigma: Option<f64>,
},
}
@@ -45,7 +67,6 @@ impl Outcome {
/// `p_draw > 0`. Asking "team 5 won" and silently getting "everyone drew"
/// is exactly the class of quiet wrong answer this crate keeps removing, so
/// the check happens here where the mistake is.
#[must_use]
pub fn winner(winner: u32, n: u32) -> Self {
Self::try_winner(winner, n)
.unwrap_or_else(|_| panic!("winner index {winner} out of range 0..{n}"))
@@ -72,7 +93,6 @@ impl Outcome {
}
/// All `n` teams tied.
#[must_use]
pub fn draw(n: u32) -> Self {
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
}
@@ -87,23 +107,29 @@ impl Outcome {
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
Self::Scored {
scores: scores.into_iter().collect(),
sigma: None,
score_sigma: None,
}
}
/// Explicit per-team continuous scores with a per-event noise override.
///
/// `sigma` must be `> 0.0`. Constructing an `Outcome` with a non-positive
/// or NaN sigma is allowed; the value is rejected with
/// `score_sigma` must be `> 0.0`. Constructing an `Outcome` with a
/// non-positive or NaN value is allowed; the value is rejected with
/// `InferenceError::InvalidParameter` when the event is ingested, so
/// callers get an error rather than a panic.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, sigma: f64) -> Self {
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, score_sigma: f64) -> Self {
Self::Scored {
scores: scores.into_iter().collect(),
sigma: Some(sigma),
score_sigma: Some(score_sigma),
}
}
/// How many teams this outcome describes — the number of ranks, or of
/// scores.
///
/// Ingestion checks it against the event's own team list and rejects a
/// disagreement with `MismatchedShape`, so this is the cheap way to check
/// an outcome built elsewhere before committing the event.
#[must_use]
pub fn team_count(&self) -> usize {
match self {
@@ -194,7 +220,7 @@ mod tests {
fn scores_constructor_leaves_sigma_unset() {
let o = Outcome::scores([3.0, 1.0]);
match o {
Outcome::Scored { scores: _, sigma } => assert!(sigma.is_none()),
Outcome::Scored { score_sigma, .. } => assert!(score_sigma.is_none()),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
}
@@ -203,7 +229,7 @@ mod tests {
fn scores_with_sigma_sets_sigma_some() {
let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0);
match o {
Outcome::Scored { scores: _, sigma } => assert_eq!(sigma, Some(2.0)),
Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(2.0)),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
}
@@ -215,7 +241,7 @@ mod tests {
fn scores_with_sigma_defers_validation_to_ingestion() {
let o = Outcome::scores_with_sigma([3.0, 1.0], 0.0);
match o {
Outcome::Scored { sigma, .. } => assert_eq!(sigma, Some(0.0)),
Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(0.0)),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
}
+58 -27
View File
@@ -21,7 +21,7 @@
//! would have made every `predict_*` call return a slightly different number,
//! which is not a property a rating library should have.
use crate::{Gaussian, quadrature};
use crate::{Gaussian, InferenceError, quadrature};
/// Teams beyond this count make the outcome enumeration impractical.
///
@@ -52,6 +52,10 @@ const WIN_TOLERANCE: f64 = 1e-8;
/// point where refining stops helping.
const MIN_GRID_POINTS: usize = 8_192;
const MAX_GRID_POINTS: usize = 262_144;
/// Nodes requested across the narrowest feature the recursion must resolve.
const NODES_PER_FEATURE: f64 = 12.0;
/// Nodes below which the trapezoid rule stops resolving that feature at all.
const MIN_NODES_PER_FEATURE: f64 = 4.0;
/// How many standard deviations of support the grid and integrals cover.
///
@@ -152,7 +156,7 @@ pub(crate) fn win_probabilities(perf: &[Gaussian], margins: &Margins) -> Vec<f64
/// Resolution is set by the *smallest* feature in play — the narrowest sigma,
/// or a draw margin narrower still — because that is what the recursion has to
/// resolve. A grid sized off the widest team would step over the narrow one.
fn grid_shape(perf: &[Gaussian], margins: &Margins) -> (f64, f64, usize) {
fn grid_shape(perf: &[Gaussian], margins: &Margins) -> Result<(f64, f64, usize), InferenceError> {
let lo = perf
.iter()
.map(|g| g.mu() - SUPPORT_SIGMAS * g.sigma())
@@ -175,18 +179,36 @@ fn grid_shape(perf: &[Gaussian], margins: &Margins) -> (f64, f64, usize) {
let feature = narrowest.min(smallest_margin);
let wanted = if feature.is_finite() && feature > 0.0 {
((hi - lo) / (feature / 12.0)).ceil()
((hi - lo) / (feature / NODES_PER_FEATURE)).ceil()
} else {
MIN_GRID_POINTS as f64
};
let points = if wanted.is_finite() {
(wanted as usize).clamp(MIN_GRID_POINTS, MAX_GRID_POINTS)
} else {
MIN_GRID_POINTS
};
if !wanted.is_finite() {
return Ok((lo, hi, MIN_GRID_POINTS));
}
(lo, hi, points)
// Report rather than clamp. Clamping is what this replaced: it silently
// handed the recursion a grid too coarse for the narrowest density, and the
// trapezoid rule then returned probabilities greater than one — measured, a
// `P` of 2.79 and a total of 5.41. Trapezoid error on a Gaussian is
// `~exp(-2 pi^2 (sigma/h)^2)`, which is 1e-12 at `h/sigma = 0.86` and O(1)
// by `h/sigma = 17`, so the cliff is sharp and there is no useful answer on
// the far side of it.
//
// The floor is `MIN_NODES_PER_FEATURE` rather than the `NODES_PER_FEATURE`
// asked for, because the request carries a large margin: measured accurate
// to 2.2e-12 at 1.4 nodes per sigma, and wrong by 1.2e-3 at 0.7.
let needed = wanted as usize;
let floor = ((hi - lo) / (feature / MIN_NODES_PER_FEATURE)).ceil();
if floor.is_finite() && floor as usize > MAX_GRID_POINTS {
return Err(InferenceError::GridTooCoarse {
needed,
max: MAX_GRID_POINTS,
});
}
Ok((lo, hi, needed.clamp(MIN_GRID_POINTS, MAX_GRID_POINTS)))
}
/// Densities of each team sampled on the shared grid.
@@ -198,8 +220,8 @@ struct Sampled {
}
impl Sampled {
fn new(perf: &[Gaussian], margins: &Margins) -> Self {
let (lo, hi, points) = grid_shape(perf, margins);
fn new(perf: &[Gaussian], margins: &Margins) -> Result<Self, InferenceError> {
let (lo, hi, points) = grid_shape(perf, margins)?;
let step = (hi - lo) / (points - 1) as f64;
let density = perf
.iter()
@@ -209,12 +231,12 @@ impl Sampled {
.collect()
})
.collect();
Self {
Ok(Self {
lo,
step,
points,
density,
}
})
}
fn node(&self, i: usize) -> f64 {
@@ -317,9 +339,12 @@ fn events(n: usize, strict_only: bool) -> Vec<(Vec<usize>, Vec<bool>)> {
///
/// Orders that differ only *within* a tied group describe the same finishing
/// order, so their probabilities are summed into one entry.
pub(crate) fn outcome_distribution(perf: &[Gaussian], margins: &Margins) -> Vec<(Vec<u32>, f64)> {
pub(crate) fn outcome_distribution(
perf: &[Gaussian],
margins: &Margins,
) -> Result<Vec<(Vec<u32>, f64)>, InferenceError> {
let n = perf.len();
let sampled = Sampled::new(perf, margins);
let sampled = Sampled::new(perf, margins)?;
let mut aggregated: Vec<(Vec<u32>, f64)> = Vec::new();
for (order, tied) in events(n, margins.all_zero()) {
@@ -332,7 +357,7 @@ pub(crate) fn outcome_distribution(perf: &[Gaussian], margins: &Margins) -> Vec<
}
aggregated.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
aggregated
Ok(aggregated)
}
/// All permutations of `items`.
@@ -397,9 +422,13 @@ fn orders_for_groups(groups: &[Vec<usize>]) -> Vec<(Vec<usize>, Vec<bool>)> {
/// Ties in `ranks` mean the tied teams may finish in any internal order, so
/// this sums the orders consistent with the requested ranking rather than
/// picking one.
pub(crate) fn ranking_probability(perf: &[Gaussian], margins: &Margins, ranks: &[u32]) -> f64 {
pub(crate) fn ranking_probability(
perf: &[Gaussian],
margins: &Margins,
ranks: &[u32],
) -> Result<f64, InferenceError> {
let n = perf.len();
let sampled = Sampled::new(perf, margins);
let sampled = Sampled::new(perf, margins)?;
let mut distinct: Vec<u32> = ranks.to_vec();
distinct.sort_unstable();
@@ -410,10 +439,10 @@ pub(crate) fn ranking_probability(perf: &[Gaussian], margins: &Margins, ranks: &
.map(|&r| (0..n).filter(|&i| ranks[i] == r).collect())
.collect();
orders_for_groups(&groups)
Ok(orders_for_groups(&groups)
.iter()
.map(|(order, tied)| order_probability(margins, &sampled, order, tied))
.sum()
.sum())
}
/// A distribution over the ways a contest could finish.
@@ -427,6 +456,7 @@ pub(crate) fn ranking_probability(perf: &[Gaussian], margins: &Margins, ranks: &
/// `Game::ranked` asks "what would we believe if *this* happened", which is
/// what an expected-information-gain calculation needs alongside the weight.
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct Prediction {
outcomes: Vec<(Vec<u32>, f64)>,
}
@@ -437,6 +467,7 @@ impl Prediction {
}
/// Every possible finishing order and its probability, most likely first.
#[must_use]
pub fn outcomes(&self) -> impl ExactSizeIterator<Item = (&[u32], f64)> {
self.outcomes.iter().map(|(r, p)| (r.as_slice(), *p))
}
@@ -604,7 +635,7 @@ mod tests {
),
] {
let n = perf.len();
let dist = outcome_distribution(&perf, &flat(n, eps));
let dist = outcome_distribution(&perf, &flat(n, eps)).unwrap();
let sum: f64 = dist.iter().map(|(_, p)| p).sum();
assert!(
(sum - 1.0).abs() < 1e-6,
@@ -620,7 +651,7 @@ mod tests {
fn two_team_distribution_matches_the_closed_form() {
let perf = [g(3.0, 6.0), g(-2.0, 1.0)];
let eps = 1.5;
let dist = outcome_distribution(&perf, &flat(2, eps));
let dist = outcome_distribution(&perf, &flat(2, eps)).unwrap();
let (wa, wb) = closed_form_two(perf[0], perf[1], eps);
let find = |ranks: &[u32]| {
@@ -653,10 +684,10 @@ mod tests {
let perf = [g(5.0, 6.0), g(0.0, 3.0), g(-5.0, 1.0)];
let eps = 1.5;
let margins = flat(3, eps);
let dist = outcome_distribution(&perf, &margins);
let dist = outcome_distribution(&perf, &margins).unwrap();
for (ranks, expected) in &dist {
let direct = ranking_probability(&perf, &margins, ranks);
let direct = ranking_probability(&perf, &margins, ranks).unwrap();
assert!(
(direct - expected).abs() < 1e-9,
"ranks {ranks:?}: direct {direct} vs distribution {expected}"
@@ -675,7 +706,7 @@ mod tests {
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
let mut previous = 0.0;
for eps in [0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 24.0] {
let p = ranking_probability(&perf, &flat(3, eps), &[0, 0, 0]);
let p = ranking_probability(&perf, &flat(3, eps), &[0, 0, 0]).unwrap();
assert!(p >= previous, "eps={eps}: {p} < {previous}");
if eps == 0.0 {
assert!(p < 1e-12, "a tie needs a margin, got {p}");
@@ -696,7 +727,7 @@ mod tests {
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
let sweep: Vec<f64> = [0.5, 2.0, 4.0, 8.0, 16.0]
.iter()
.map(|&eps| ranking_probability(&perf, &flat(3, eps), &[0, 0, 1]))
.map(|&eps| ranking_probability(&perf, &flat(3, eps), &[0, 0, 1]).unwrap())
.collect();
let peak = sweep
.iter()
@@ -717,7 +748,7 @@ mod tests {
#[test]
fn ties_are_impossible_without_a_draw_margin() {
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(0.0, 4.0)];
let dist = outcome_distribution(&perf, &flat(3, 0.0));
let dist = outcome_distribution(&perf, &flat(3, 0.0)).unwrap();
assert_eq!(dist.len(), 6, "expected only the 6 strict orders: {dist:?}");
assert!(dist.iter().all(|(r, _)| {
let mut seen = r.clone();
+19 -3
View File
@@ -11,7 +11,7 @@ use crate::{
///
/// A configuration rather than a person: the per-history temporal state
/// (messages, last appearance) lives on `Competitor`.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
pub(crate) prior: Gaussian,
pub(crate) beta: f64,
@@ -23,7 +23,24 @@ pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
}
impl<T: Time, D: Drift<T>> Rating<T, D> {
/// # Panics
///
/// Panics unless `beta` is finite and non-negative, matching
/// `HistoryBuilder::beta`.
///
/// Zero is allowed and meaningful — performance is then exactly skill, and
/// the fit differs measurably from a positive beta rather than degenerating.
/// Negative is rejected because `beta` enters only as `beta^2`: measured, a
/// negative beta returned results **bit identical** to its absolute value,
/// and a NaN beta reached `Game::ranked`, which returned `Ok` carrying a
/// `Gaussian { pi: NaN, tau: NaN }` — there is no `converge` on that path to
/// catch it.
pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self {
assert!(
beta.is_finite() && beta >= 0.0,
"beta must be finite and non-negative (got {beta}); it is only ever \
squared, so a negative value would silently behave as its absolute value"
);
Self {
prior,
beta,
@@ -44,7 +61,6 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
}
/// The configured prior skill estimate.
#[must_use]
pub fn prior(&self) -> Gaussian {
self.prior
}
@@ -93,7 +109,7 @@ impl Default for Rating<i64, ConstantDrift> {
Self {
prior: Gaussian::default(),
beta: BETA,
drift: ConstantDrift(GAMMA),
drift: ConstantDrift::new(GAMMA),
drift_scale: 1.0,
_time: PhantomData,
}
+5 -12
View File
@@ -56,16 +56,16 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
self.get(idx).is_some()
}
/// Test-only: no code path in the crate needs a count.
#[cfg(test)]
#[must_use]
pub fn len(&self) -> usize {
self.n_present
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.n_present == 0
}
/// Test-only: iterating every competitor is an assertion helper, not part
/// of inference, which walks slices rather than the store.
#[cfg(test)]
pub fn iter(&self) -> impl Iterator<Item = (Index, &Competitor<T, D>)> {
self.competitors
.iter()
@@ -73,13 +73,6 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
.filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a)))
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Competitor<T, D>)> {
self.competitors
.iter_mut()
.enumerate()
.filter_map(|(i, slot)| slot.as_mut().map(|a| (Index(i), a)))
}
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut Competitor<T, D>> {
self.competitors.iter_mut().filter_map(|s| s.as_mut())
}
+149 -108
View File
@@ -50,12 +50,12 @@ pub enum EventKind {
#[derive(Clone, Debug)]
struct Item {
agent: Index,
competitor: 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
/// `competitor`, which is what keeps `HashMap` hashing out of the hot path now
/// that the store is compact rather than indexed by the global `Index`.
slot: u32,
likelihood: Gaussian,
@@ -66,9 +66,9 @@ impl Item {
&self,
forward: bool,
skills: &SkillStore,
agents: &CompetitorStore<T, D>,
competitors: &CompetitorStore<T, D>,
) -> Rating<T, D> {
let r = &agents[self.agent].rating;
let r = &competitors[self.competitor].rating;
let skill = skills.at(self.slot);
if forward {
@@ -95,10 +95,10 @@ pub(crate) struct Event {
}
impl Event {
pub(crate) fn iter_agents(&self) -> impl Iterator<Item = Index> + '_ {
pub(crate) fn iter_competitors(&self) -> impl Iterator<Item = Index> + '_ {
self.teams
.iter()
.flat_map(|t| t.items.iter().map(|it| it.agent))
.flat_map(|t| t.items.iter().map(|it| it.competitor))
}
fn outputs(&self) -> Vec<f64> {
@@ -112,14 +112,14 @@ impl Event {
&self,
forward: bool,
skills: &SkillStore,
agents: &CompetitorStore<T, D>,
competitors: &CompetitorStore<T, D>,
) -> Vec<Vec<Rating<T, D>>> {
self.teams
.iter()
.map(|team| {
team.items
.iter()
.map(|item| item.within_prior(forward, skills, agents))
.map(|item| item.within_prior(forward, skills, competitors))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
@@ -133,12 +133,12 @@ impl Event {
fn compute<T: Time, D: Drift<T>>(
&self,
skills: &SkillStore,
agents: &CompetitorStore<T, D>,
competitors: &CompetitorStore<T, D>,
p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) -> EventUpdate {
let teams = self.within_priors(false, skills, agents);
let teams = self.within_priors(false, skills, competitors);
let result = self.outputs();
let g = match self.kind {
EventKind::Ranked => {
@@ -179,12 +179,12 @@ impl Event {
fn iteration_direct<T: Time, D: Drift<T>>(
&mut self,
skills: &mut SkillStore,
agents: &CompetitorStore<T, D>,
competitors: &CompetitorStore<T, D>,
p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) {
let update = self.compute(skills, agents, p_draw, convergence, arena);
let update = self.compute(skills, competitors, p_draw, convergence, arena);
self.apply(skills, update);
}
}
@@ -228,7 +228,7 @@ pub struct TimeSlice<T: Time = i64> {
}
impl<T: Time> TimeSlice<T> {
pub fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
pub(crate) fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
Self {
events: Vec::new(),
skills: SkillStore::new(),
@@ -255,7 +255,7 @@ impl<T: Time> TimeSlice<T> {
}
let cg = color_greedy(n, |ev_idx| {
self.events[ev_idx].iter_agents().collect::<Vec<_>>()
self.events[ev_idx].iter_competitors().collect::<Vec<_>>()
});
let mut reordered: Vec<Event> = Vec::with_capacity(n);
@@ -282,17 +282,17 @@ impl<T: Time> TimeSlice<T> {
);
}
pub fn add_events<D: Drift<T>>(
pub(crate) fn add_events<D: Drift<T>>(
&mut self,
composition: Vec<Vec<Vec<Index>>>,
results: Option<Vec<Vec<f64>>>,
weights: Option<Vec<Vec<Vec<f64>>>>,
kinds: Vec<EventKind>,
agents: &CompetitorStore<T, D>,
competitors: &CompetitorStore<T, D>,
) {
let mut unique = Vec::with_capacity(10);
let this_agent = composition.iter().flatten().flatten().filter(|idx| {
let these_competitors = composition.iter().flatten().flatten().filter(|idx| {
if !unique.contains(idx) {
unique.push(*idx);
@@ -302,10 +302,10 @@ impl<T: Time> TimeSlice<T> {
false
});
for idx in this_agent {
let elapsed = compute_elapsed(agents[*idx].last_time.as_ref(), &self.time);
for idx in these_competitors {
let elapsed = compute_elapsed(competitors[*idx].last_time.as_ref(), &self.time);
let forward = agents[*idx].receive(&self.time);
let forward = competitors[*idx].receive(&self.time);
if let Some(skill) = self.skills.get_mut(*idx) {
skill.elapsed = elapsed;
@@ -332,12 +332,12 @@ impl<T: Time> TimeSlice<T> {
.map(|(t, team)| {
let items = team
.iter()
.map(|&agent| Item {
agent,
.map(|&competitor| Item {
competitor,
// Every participant was inserted into `skills`
// just above, so the slot always resolves.
slot: skills
.slot_of(agent)
.slot_of(competitor)
.expect("participant must be present in the slice store"),
likelihood: N_INF,
})
@@ -376,7 +376,7 @@ impl<T: Time> TimeSlice<T> {
self.color_groups_dirty = true;
self.iteration(from, agents);
self.iteration(from, competitors);
}
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
@@ -393,7 +393,11 @@ impl<T: Time> TimeSlice<T> {
/// 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(crate) fn iteration<D: Drift<T>>(
&mut self,
from: usize,
competitors: &CompetitorStore<T, D>,
) {
if from == 0 && self.color_groups_dirty {
self.recompute_color_groups();
}
@@ -401,7 +405,7 @@ impl<T: Time> TimeSlice<T> {
if from > 0 || self.color_groups.is_empty() {
// Initial pass (add_events) or no color groups yet: simple sequential sweep.
for event in self.events.iter_mut().skip(from) {
let teams = event.within_priors(false, &self.skills, agents);
let teams = event.within_priors(false, &self.skills, competitors);
let result = event.outputs();
let g = match event.kind {
@@ -436,14 +440,14 @@ impl<T: Time> TimeSlice<T> {
event.log_evidence = g.log_evidence;
}
} else {
self.sweep_color_groups(agents);
self.sweep_color_groups(competitors);
}
}
/// Full event sweep using the color-group partition. Colors are processed
/// sequentially; within each color the inner loop is parallel under rayon.
///
/// Events in one color group touch disjoint agent sets, so none of them
/// Events in one color group touch disjoint competitor sets, so none of them
/// can observe another's writes. That makes the sweep separable: inference
/// runs concurrently over shared `&self.skills`, and the resulting updates
/// are folded in afterwards in index order. Splitting it this way needs no
@@ -451,7 +455,7 @@ impl<T: Time> TimeSlice<T> {
/// across thread counts because the apply order does not depend on which
/// worker finished first.
#[cfg(feature = "rayon")]
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
fn sweep_color_groups<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
use rayon::prelude::*;
thread_local! {
@@ -483,7 +487,7 @@ impl<T: Time> TimeSlice<T> {
let mut arena = cell.borrow_mut();
arena.reset();
ev.compute(skills, agents, p_draw, convergence, &mut arena)
ev.compute(skills, competitors, p_draw, convergence, &mut arena)
})
})
.collect();
@@ -495,7 +499,7 @@ impl<T: Time> TimeSlice<T> {
for ev in &mut self.events[range] {
ev.iteration_direct(
&mut self.skills,
agents,
competitors,
p_draw,
self.convergence,
&mut self.arena,
@@ -509,7 +513,7 @@ impl<T: Time> TimeSlice<T> {
/// Events within each color group are updated inline — no EventOutput allocation —
/// matching the T2 performance profile.
#[cfg(not(feature = "rayon"))]
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
fn sweep_color_groups<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
for color_idx in 0..self.color_groups.groups.len() {
if self.color_groups.groups[color_idx].is_empty() {
continue;
@@ -523,7 +527,7 @@ impl<T: Time> TimeSlice<T> {
for ev in &mut self.events[range] {
ev.iteration_direct(
&mut self.skills,
agents,
competitors,
p_draw,
self.convergence,
&mut self.arena,
@@ -544,7 +548,7 @@ impl<T: Time> TimeSlice<T> {
/// schedule default.
pub(crate) fn iterate_to_convergence<D: Drift<T>>(
&mut self,
agents: &CompetitorStore<T, D>,
competitors: &CompetitorStore<T, D>,
) -> usize {
use crate::{tuple_gt, tuple_max};
@@ -557,7 +561,7 @@ impl<T: Time> TimeSlice<T> {
while tuple_gt(step, epsilon) && i < max_iter {
let old = self.posteriors();
self.iteration(0, agents);
self.iteration(0, competitors);
let new = self.posteriors();
@@ -575,37 +579,37 @@ impl<T: Time> TimeSlice<T> {
i
}
pub(crate) fn forward_prior_out(&self, agent: &Index) -> Gaussian {
let skill = self.skills.get(*agent).unwrap();
pub(crate) fn forward_prior_out(&self, competitor: &Index) -> Gaussian {
let skill = self.skills.get(*competitor).unwrap();
skill.forward * skill.likelihood
}
pub(crate) fn backward_prior_out<D: Drift<T>>(
&self,
agent: &Index,
agents: &CompetitorStore<T, D>,
competitor: &Index,
competitors: &CompetitorStore<T, D>,
) -> Gaussian {
let skill = self.skills.get(*agent).unwrap();
let skill = self.skills.get(*competitor).unwrap();
let n = skill.likelihood * skill.backward;
n.forget(
agents[*agent]
competitors[*competitor]
.rating
.drift_variance_for_elapsed(skill.elapsed),
)
}
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
for (agent, skill) in self.skills.iter_mut() {
skill.backward = agents[agent].message.unwrap_or(N_INF);
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
for (competitor, skill) in self.skills.iter_mut() {
skill.backward = competitors[competitor].message.unwrap_or(N_INF);
}
self.iteration(0, agents);
self.iteration(0, competitors);
}
pub(crate) fn new_forward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
for (agent, skill) in self.skills.iter_mut() {
skill.forward = agents[agent].receive_for_elapsed(skill.elapsed);
pub(crate) fn new_forward_info<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
for (competitor, skill) in self.skills.iter_mut() {
skill.forward = competitors[competitor].receive_for_elapsed(skill.elapsed);
}
self.iteration(0, agents);
self.iteration(0, competitors);
}
/// Run this slice's events on forward (filtering) information alone.
@@ -615,10 +619,18 @@ impl<T: Time> TimeSlice<T> {
/// configured prior. The sweep runs on a scratch copy, so the real slice
/// is untouched — which is what makes the filtered estimates independent
/// of whether `History::converge` has run.
/// One forward-only step for this slice.
///
/// `targets` restricts only the *evidence sum*, to events in which at
/// least one target competitor appears; an empty set means no restriction.
/// The forward messages are always built from every event in the slice —
/// restricting those instead would answer a different question (a history
/// in which the other events never happened), not a held-out one.
pub(crate) fn filtered_step<D: Drift<T>>(
&self,
incoming: &HashMap<Index, Gaussian>,
agents: &CompetitorStore<T, D>,
competitors: &CompetitorStore<T, D>,
targets: &std::collections::HashSet<Index>,
) -> FilteredStep {
let mut scratch = TimeSlice {
events: self.events.clone(),
@@ -641,16 +653,16 @@ impl<T: Time> TimeSlice<T> {
event.log_evidence = 0.0;
}
for (agent, skill) in self.skills.iter() {
let rating = &agents[agent].rating;
for (competitor, skill) in self.skills.iter() {
let rating = &competitors[competitor].rating;
let forward = match incoming.get(&agent) {
let forward = match incoming.get(&competitor) {
Some(message) => message.forget(rating.drift_variance_for_elapsed(skill.elapsed)),
None => rating.prior,
};
let slot = scratch.skills.insert(
agent,
competitor,
Skill {
forward,
backward: N_INF,
@@ -666,19 +678,31 @@ impl<T: Time> TimeSlice<T> {
// 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:?}"
self.skills.slot_of(competitor),
"scratch slot must match the real slice's slot for {competitor:?}"
);
}
scratch.iterate_to_convergence(agents);
scratch.iterate_to_convergence(competitors);
FilteredStep {
log_evidence: scratch.events.iter().map(|event| event.log_evidence).sum(),
log_evidence: scratch
.events
.iter()
.filter(|event| {
targets.is_empty()
|| event
.teams
.iter()
.flat_map(|team| &team.items)
.any(|item| targets.contains(&item.competitor))
})
.map(|event| event.log_evidence)
.sum(),
posteriors: scratch
.skills
.iter()
.map(|(agent, skill)| (agent, skill.posterior()))
.map(|(competitor, skill)| (competitor, skill.posterior()))
.collect(),
}
}
@@ -687,7 +711,7 @@ impl<T: Time> TimeSlice<T> {
&self,
targets: &[Index],
forward: bool,
agents: &CompetitorStore<T, D>,
competitors: &CompetitorStore<T, D>,
) -> f64 {
// Hashed once rather than scanned per player per event, so a
// `log_evidence_for` with many keys is not quadratic.
@@ -696,7 +720,7 @@ impl<T: Time> TimeSlice<T> {
let mut arena = ScratchArena::new();
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
let teams = event.within_priors(forward, &self.skills, agents);
let teams = event.within_priors(forward, &self.skills, competitors);
let result = event.outputs();
match event.kind {
EventKind::Ranked => {
@@ -741,7 +765,7 @@ impl<T: Time> TimeSlice<T> {
.teams
.iter()
.flat_map(|team| &team.items)
.any(|item| target_set.contains(&item.agent))
.any(|item| target_set.contains(&item.competitor))
})
.map(|event| run_event(event, &mut arena))
.sum()
@@ -753,27 +777,36 @@ impl<T: Time> TimeSlice<T> {
.teams
.iter()
.flat_map(|team| &team.items)
.any(|item| target_set.contains(&item.agent))
.any(|item| target_set.contains(&item.competitor))
})
.map(|event| event.log_evidence)
.sum()
}
}
pub fn get_composition(&self) -> Vec<Vec<Vec<Index>>> {
/// Test-only: reads the slice's shape back for assertions.
#[cfg(test)]
pub(crate) fn get_composition(&self) -> Vec<Vec<Vec<Index>>> {
self.events
.iter()
.map(|event| {
event
.teams
.iter()
.map(|team| team.items.iter().map(|item| item.agent).collect::<Vec<_>>())
.map(|team| {
team.items
.iter()
.map(|item| item.competitor)
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
}
pub fn get_results(&self) -> Vec<Vec<f64>> {
/// Test-only: reads the slice's shape back for assertions.
#[cfg(test)]
pub(crate) fn get_results(&self) -> Vec<Vec<f64>> {
self.events
.iter()
.map(|event| {
@@ -827,7 +860,7 @@ impl<T: Time> TimeSlice<T> {
/// approximations that inference does not retain.
pub(crate) fn scored_contrasts<D: Drift<T>>(
&self,
agents: &CompetitorStore<T, D>,
competitors: &CompetitorStore<T, D>,
) -> Vec<(Vec<(Index, f64)>, f64)> {
let mut out = Vec::new();
@@ -853,8 +886,8 @@ impl<T: Time> TimeSlice<T> {
for (team, sign) in [(hi, 1.0), (lo, -1.0)] {
for (m, item) in event.teams[team].items.iter().enumerate() {
let w = event.weights[team][m];
noise += w * w * agents[item.agent].rating.beta.powi(2);
contrast.push((item.agent, sign * w));
noise += w * w * competitors[item.competitor].rating.beta.powi(2);
contrast.push((item.competitor, sign * w));
}
}
@@ -887,7 +920,7 @@ mod tests {
use super::*;
use crate::{
KeyTable, competitor::Competitor, drift::ConstantDrift, rating::Rating,
competitor::Competitor, drift::ConstantDrift, key_table::KeyTable, rating::Rating,
storage::CompetitorStore,
};
@@ -902,16 +935,16 @@ mod tests {
let e = index_map.get_or_create("e");
let f = index_map.get_or_create("f");
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
for agent in [a, b, c, d, e, f] {
agents.insert(
agent,
for competitor in [a, b, c, d, e, f] {
competitors.insert(
competitor,
Competitor {
rating: Rating::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
),
..Default::default()
},
@@ -929,7 +962,7 @@ mod tests {
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
None,
vec![EventKind::Ranked; 3],
&agents,
&competitors,
);
let post = time_slice.posteriors();
@@ -965,7 +998,7 @@ mod tests {
epsilon = 1e-6
);
assert_eq!(time_slice.iterate_to_convergence(&agents), 1);
assert_eq!(time_slice.iterate_to_convergence(&competitors), 1);
}
#[test]
@@ -979,16 +1012,16 @@ mod tests {
let e = index_map.get_or_create("e");
let f = index_map.get_or_create("f");
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
for agent in [a, b, c, d, e, f] {
agents.insert(
agent,
for competitor in [a, b, c, d, e, f] {
competitors.insert(
competitor,
Competitor {
rating: Rating::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
),
..Default::default()
},
@@ -1006,7 +1039,7 @@ mod tests {
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
None,
vec![EventKind::Ranked; 3],
&agents,
&competitors,
);
let post = time_slice.posteriors();
@@ -1027,7 +1060,7 @@ mod tests {
epsilon = 1e-6
);
assert!(time_slice.iterate_to_convergence(&agents) > 1);
assert!(time_slice.iterate_to_convergence(&competitors) > 1);
let post = time_slice.posteriors();
@@ -1059,16 +1092,16 @@ mod tests {
let e = index_map.get_or_create("e");
let f = index_map.get_or_create("f");
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
for agent in [a, b, c, d, e, f] {
agents.insert(
agent,
for competitor in [a, b, c, d, e, f] {
competitors.insert(
competitor,
Competitor {
rating: Rating::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
),
..Default::default()
},
@@ -1086,10 +1119,10 @@ mod tests {
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
None,
vec![EventKind::Ranked; 3],
&agents,
&competitors,
);
time_slice.iterate_to_convergence(&agents);
time_slice.iterate_to_convergence(&competitors);
let post = time_slice.posteriors();
@@ -1118,12 +1151,12 @@ mod tests {
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
None,
vec![EventKind::Ranked; 3],
&agents,
&competitors,
);
assert_eq!(time_slice.events.len(), 6);
time_slice.iterate_to_convergence(&agents);
time_slice.iterate_to_convergence(&competitors);
let post = time_slice.posteriors();
@@ -1162,16 +1195,16 @@ mod tests {
let c = index_map.get_or_create("c");
let d = index_map.get_or_create("d");
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
for agent in [a, b, c, d] {
agents.insert(
agent,
for competitor in [a, b, c, d] {
competitors.insert(
competitor,
Competitor {
rating: Rating::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
),
..Default::default()
},
@@ -1189,7 +1222,7 @@ mod tests {
Some(vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]),
None,
vec![EventKind::Ranked; 3],
&agents,
&competitors,
);
assert_eq!(ts.color_groups.n_colors(), 2);
@@ -1200,16 +1233,24 @@ mod tests {
assert_eq!(ts.color_groups.color_range(1), 2..3);
// Events at positions 0 and 1 (color 0) must be disjoint — verify by
// checking that the agent sets of self.events[0] and self.events[1] do
// not include the agent at self.events[2].
let agents_in_ev2: Vec<Index> = ts.events[2].iter_agents().collect();
let agents_in_ev0: Vec<Index> = ts.events[0].iter_agents().collect();
let agents_in_ev1: Vec<Index> = ts.events[1].iter_agents().collect();
// checking that the competitor sets of self.events[0] and self.events[1] do
// not include the competitor at self.events[2].
let competitors_in_ev2: Vec<Index> = ts.events[2].iter_competitors().collect();
let competitors_in_ev0: Vec<Index> = ts.events[0].iter_competitors().collect();
let competitors_in_ev1: Vec<Index> = ts.events[1].iter_competitors().collect();
// ev0 and ev1 must be disjoint from each other (color-0 invariant).
assert!(agents_in_ev0.iter().all(|ag| !agents_in_ev1.contains(ag)));
// ev2 must share an agent with ev0 or ev1 (it needed its own color).
let ev2_overlaps_ev0 = agents_in_ev2.iter().any(|ag| agents_in_ev0.contains(ag));
let ev2_overlaps_ev1 = agents_in_ev2.iter().any(|ag| agents_in_ev1.contains(ag));
assert!(
competitors_in_ev0
.iter()
.all(|ag| !competitors_in_ev1.contains(ag))
);
// ev2 must share an competitor with ev0 or ev1 (it needed its own color).
let ev2_overlaps_ev0 = competitors_in_ev2
.iter()
.any(|ag| competitors_in_ev0.contains(ag));
let ev2_overlaps_ev1 = competitors_in_ev2
.iter()
.any(|ag| competitors_in_ev1.contains(ag));
assert!(ev2_overlaps_ev0 || ev2_overlaps_ev1);
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.0))
.drift(ConstantDrift::new(0.0))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-12,
+6 -6
View File
@@ -11,7 +11,7 @@ fn add_events_bulk_via_iter() {
.sigma(2.0)
.beta(1.0)
.p_draw(0.0)
.drift(ConstantDrift(0.0))
.drift(ConstantDrift::new(0.0))
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
@@ -53,7 +53,7 @@ fn add_events_draw() {
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.25)
.drift(ConstantDrift(25.0 / 300.0))
.drift(ConstantDrift::new(25.0 / 300.0))
.build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
@@ -162,7 +162,7 @@ fn current_skill_and_learning_curve() {
let b = h.current_skill(&"b").unwrap();
assert!(b.mu() < 25.0);
let a_curve = h.learning_curve(&"a");
let a_curve = h.learning_curve(&"a").unwrap();
assert_eq!(a_curve.len(), 2);
assert_eq!(a_curve[0].0, 1);
assert_eq!(a_curve[1].0, 2);
@@ -181,12 +181,12 @@ fn log_evidence_total_vs_subset() {
.sigma(6.0)
.beta(1.0)
.p_draw(0.0)
.drift(ConstantDrift(0.0))
.drift(ConstantDrift::new(0.0))
.build();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"b", &"a", 2).unwrap();
let total = h.log_evidence();
let a_only = h.log_evidence_for(&[&"a"]);
let a_only = h.log_evidence_for(&[&"a"]).unwrap();
assert!(total.is_finite());
assert!(a_only.is_finite());
}
@@ -236,7 +236,7 @@ fn fluent_event_builder_scores() {
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(0.0))
.drift(ConstantDrift::new(0.0))
.build();
h.event(1)
+232
View File
@@ -0,0 +1,232 @@
//! Every public entry point that takes a magnitude, in one place.
//!
//! This defect class was closed three times in one session and reopened twice,
//! because each fix validated the layer it had just touched and inferred the
//! rest: `HistoryBuilder` first, then `Game`'s own entry points, then the
//! constructors beneath both. A per-site fix cannot notice the site nobody
//! thought of.
//!
//! So this enumerates them. `sigma`, `beta` and `gamma` all enter inference
//! only as squares, which means a negative value does not fail — it behaves as
//! its absolute value, bit for bit, and the sign vanishes with no diagnostic.
//! Non-finite values poison every posterior derived from them.
//!
//! Adding a public constructor that takes one of these and not adding it here
//! is the failure this file exists to make harder.
use std::panic::{AssertUnwindSafe, catch_unwind};
use trueskill_tt::{ConstantDrift, Gaussian, History, Member, Outcome, Rating};
/// Did the entry point refuse the value, by panic or by `Err`?
fn refuses(f: impl FnOnce() -> bool) -> bool {
catch_unwind(AssertUnwindSafe(f)).unwrap_or(true)
}
/// One entry point, as a name and a closure that applies a value to it.
type Case = (&'static str, Box<dyn Fn(f64) -> bool>);
/// Entry points that must reject a negative magnitude.
///
/// Each closure returns `true` if it refused by returning an error; a panic is
/// also a refusal and is caught.
#[test]
fn every_magnitude_parameter_rejects_a_negative_value() {
let cases: Vec<Case> = vec![
(
"Gaussian::from_ms(sigma)",
Box::new(|v| {
let _ = Gaussian::from_ms(25.0, v);
false
}),
),
(
"Rating::new(beta)",
Box::new(|v| {
let _ = Rating::<i64, ConstantDrift>::new(
Gaussian::default(),
v,
ConstantDrift::new(0.0),
);
false
}),
),
(
"ConstantDrift::new(gamma)",
Box::new(|v| {
let _ = ConstantDrift::new(v);
false
}),
),
(
"HistoryBuilder::sigma",
Box::new(|v| {
let _ = History::builder().sigma(v);
false
}),
),
(
"HistoryBuilder::beta",
Box::new(|v| {
let _ = History::builder().beta(v);
false
}),
),
(
"HistoryBuilder::score_sigma",
Box::new(|v| {
let _ = History::builder().score_sigma(v);
false
}),
),
(
"HistoryBuilder::p_draw",
Box::new(|v| {
let _ = History::builder().p_draw(v);
false
}),
),
(
"Member::with_drift_scale (at ingestion)",
Box::new(|v| {
let mut h = History::builder().build();
h.add_events(vec![trueskill_tt::Event {
time: 1i64,
teams: smallvec::smallvec![
trueskill_tt::Team::with_members([Member::new("a").with_drift_scale(v)]),
trueskill_tt::Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
}])
.is_err()
}),
),
(
"Outcome::scores_with_sigma (at ingestion)",
Box::new(|v| {
let mut h = History::builder().build();
h.add_events(vec![trueskill_tt::Event {
time: 1i64,
teams: smallvec::smallvec![
trueskill_tt::Team::with_members([Member::new("a")]),
trueskill_tt::Team::with_members([Member::new("b")]),
],
outcome: Outcome::scores_with_sigma([3.0, 1.0], v),
}])
.is_err()
}),
),
];
let mut accepted = Vec::new();
for (name, f) in &cases {
if !refuses(|| f(-1.0)) {
accepted.push(*name);
}
}
assert!(
accepted.is_empty(),
"these accepted a negative magnitude, which is squared away silently \
rather than honoured or refused:\n {}",
accepted.join("\n ")
);
}
/// Same set, for NaN and infinity.
///
/// `Gaussian::from_ms` is deliberately absent: a broken fit produces a NaN
/// sigma legitimately and `converge` reports it as `NonFiniteResult`. Rejecting
/// it in the constructor turned that reporting path into a panic inside
/// inference — see the comment on `from_ms`.
#[test]
fn every_magnitude_parameter_rejects_a_non_finite_value() {
let cases: Vec<Case> = vec![
(
"Rating::new(beta)",
Box::new(|v| {
let _ = Rating::<i64, ConstantDrift>::new(
Gaussian::default(),
v,
ConstantDrift::new(0.0),
);
false
}),
),
(
"ConstantDrift::new(gamma)",
Box::new(|v| {
let _ = ConstantDrift::new(v);
false
}),
),
(
"HistoryBuilder::sigma",
Box::new(|v| {
let _ = History::builder().sigma(v);
false
}),
),
(
"HistoryBuilder::beta",
Box::new(|v| {
let _ = History::builder().beta(v);
false
}),
),
(
"HistoryBuilder::mu",
Box::new(|v| {
let _ = History::builder().mu(v);
false
}),
),
(
"HistoryBuilder::score_sigma",
Box::new(|v| {
let _ = History::builder().score_sigma(v);
false
}),
),
(
"HistoryBuilder::p_draw",
Box::new(|v| {
let _ = History::builder().p_draw(v);
false
}),
),
];
let mut accepted = Vec::new();
for (name, f) in &cases {
for bad in [f64::NAN, f64::INFINITY] {
if !refuses(|| f(bad)) {
accepted.push(format!("{name} accepted {bad}"));
}
}
}
assert!(
accepted.is_empty(),
"these accepted a non-finite magnitude:\n {}",
accepted.join("\n ")
);
}
/// The suite must not pass by refusing everything.
#[test]
fn ordinary_values_are_still_accepted() {
let _ = Gaussian::from_ms(25.0, 8.33);
let _ = Rating::<i64, ConstantDrift>::new(Gaussian::default(), 4.17, ConstantDrift::new(0.05));
let _ = ConstantDrift::new(0.0833);
let _ = History::builder()
.mu(25.0)
.sigma(8.33)
.beta(4.17)
.score_sigma(1.0)
.p_draw(0.1);
// Zero beta and zero gamma are legitimate, not degenerate.
let _ = ConstantDrift::new(0.0);
let _ = Rating::<i64, ConstantDrift>::new(Gaussian::default(), 0.0, ConstantDrift::new(0.0));
}
+5 -3
View File
@@ -30,7 +30,7 @@ fn capped(max_iter: usize) -> H {
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.5))
.drift(ConstantDrift::new(0.5))
.convergence(ConvergenceOptions {
max_iter,
epsilon: 1e-13,
@@ -54,6 +54,7 @@ fn hitting_the_cap_is_an_error() {
iterations,
final_step,
epsilon,
..
} => {
assert_eq!(iterations, 1);
assert!(
@@ -107,12 +108,13 @@ fn the_two_agree_on_a_converged_fit() {
/// At the old value of 30 this history stopped short and said nothing.
#[test]
fn the_default_cap_clears_an_ordinary_history() {
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
.key_type::<String>()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.05))
.drift(ConstantDrift::new(0.05))
.build();
let mut events = Vec::new();
+158
View File
@@ -0,0 +1,158 @@
//! Determinism across *processes*, which an in-process test cannot see.
//!
//! Rust seeds its default hasher once per process, so every `HashMap`
//! iteration order is fixed for a run and varies between runs. A test that
//! compares results within one process therefore cannot detect a float sum
//! whose order comes from a map — all its samples share one seed.
//!
//! That is not hypothetical. `tests/determinism.rs` compares four thread counts
//! inside one process and passed throughout, while `posterior_of` was returning
//! two distinct bit patterns across 40 separate runs on identical input.
//!
//! This re-executes the test binary and compares `f64::to_bits`.
use std::{env, process::Command};
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team,
UnknownKeys,
};
/// Set in the child so it reports instead of re-spawning.
const CHILD: &str = "TSTT_DETERMINISM_CHILD";
const RUNS: usize = 40;
type H = History<i64, ConstantDrift, NullObserver, String>;
fn fitted() -> H {
let mut h: H = History::builder()
.key_type::<String>()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.05))
.unknown_keys(UnknownKeys::Prior)
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build();
let mut events = Vec::new();
for t in 0..12i64 {
for k in 0..6usize {
let a = format!("p{}", (t as usize * 6 + k) % 10);
let b = format!("p{}", (t as usize * 6 + k + 4) % 10);
events.push(Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([3.0, 1.0]),
});
}
}
h.add_events(events).unwrap();
assert!(h.converge().unwrap().converged);
h
}
/// Every quantity that could plausibly depend on iteration order, as bits.
fn fingerprint() -> String {
let h = fitted();
// Unknown keys with UNEQUAL but COMPARABLE coefficients, which is what
// makes the sum order-sensitive.
//
// Equal terms sum order-independently and would make this pass vacuously.
// Terms of wildly different magnitudes are no better: the small ones fall
// below the running total's ULP and are absorbed whatever the order —
// measured, spreading these over nine decades dropped the detection rate
// to roughly one run in forty. Comparable sizes keep every term able to
// change the last bits.
let ghosts: Vec<String> = (0..24).map(|i| format!("ghost{i}")).collect();
let mut terms: Vec<(&String, f64)> = ghosts
.iter()
.enumerate()
.map(|(i, k)| (k, 1.0 + i as f64 * 0.37))
.collect();
let known = "p0".to_string();
terms.push((&known, -1.0));
let posterior = h.posterior_of(&terms).unwrap();
let a = "p0".to_string();
let b = "p1".to_string();
let target = [(&a, 1.0), (&b, -1.0)];
let teams: [&[&String]; 2] = [&[&a], &[&b]];
let evr = h.expected_variance_reduction(&teams, &target).unwrap();
let curves = h.learning_curves();
let mut curve_bits: u64 = 0;
let mut keys: Vec<&String> = curves.keys().collect();
keys.sort();
for key in keys {
for (t, g) in &curves[key] {
curve_bits ^= (*t as u64).rotate_left(17)
^ g.mu().to_bits().rotate_left(31)
^ g.sigma().to_bits();
}
}
format!(
"post={:016x} evr={:016x} le={:016x} curves={curve_bits:016x}",
posterior.sigma().to_bits(),
evr.to_bits(),
h.log_evidence().to_bits(),
)
}
#[test]
fn results_are_identical_across_processes() {
if env::var(CHILD).is_ok() {
println!("FINGERPRINT {}", fingerprint());
return;
}
let exe = env::current_exe().expect("current exe");
let mut seen: Vec<String> = Vec::new();
for run in 0..RUNS {
let out = Command::new(&exe)
.args([
"results_are_identical_across_processes",
"--exact",
"--nocapture",
])
.env(CHILD, "1")
.output()
.expect("spawn child");
assert!(
out.status.success(),
"child {run} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let line = stdout
.lines()
.find_map(|l| l.strip_prefix("FINGERPRINT "))
.unwrap_or_else(|| panic!("child {run} printed no fingerprint:\n{stdout}"))
.to_string();
seen.push(line);
}
let first = &seen[0];
let differing: Vec<&String> = seen.iter().filter(|s| *s != first).collect();
assert!(
differing.is_empty(),
"results differ across processes on identical input.\n {} of {RUNS} runs differed\n \
first: {first}\n differing: {}",
differing.len(),
differing[0]
);
}
+23 -9
View File
@@ -17,7 +17,7 @@ fn rating() -> R {
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
)
}
@@ -126,8 +126,10 @@ fn empty_history_converges_trivially() {
/// indexed out of bounds in release, so this must run in both profiles.
#[test]
fn converge_on_an_empty_history_with_owned_keys() {
let mut history: History<i64, ConstantDrift, NullObserver, String> =
History::builder_with_key().score_sigma(5.0).build();
let mut history: History<i64, ConstantDrift, NullObserver, String> = History::builder()
.key_type::<String>()
.score_sigma(5.0)
.build();
let report = history.converge().unwrap();
@@ -158,6 +160,7 @@ fn event_builder_rejects_a_weights_length_mismatch() {
kind: "weights",
expected: 1,
got: 2,
..
}
),
"expected a weights MismatchedShape, got {err:?}"
@@ -181,7 +184,10 @@ fn event_builder_weights_mismatch_leaves_the_history_untouched() {
.winner(0)
.commit();
assert!(h.learning_curve("a").is_empty());
// The rejected event never reached the history, so "a" was never interned.
// `None` is the honest answer, and it is distinguishable from a competitor
// that IS known but has no appearances yet.
assert!(h.learning_curve("a").is_none());
}
#[test]
@@ -196,7 +202,7 @@ fn empty_event_stream_then_converge() {
fn empty_history_queries_do_not_panic() {
let h = History::default();
assert!(h.learning_curves().is_empty());
assert!(h.learning_curve("nobody").is_empty());
assert!(h.learning_curve("nobody").is_none());
assert!(h.current_skill("nobody").is_none());
}
@@ -281,8 +287,16 @@ fn log_evidence_survives_a_long_diff_chain() {
/// `erfc` approximation; the evidence floor keeps `ln` finite.
#[test]
fn log_evidence_finite_for_near_certain_outcome() {
let overwhelming = R::new(Gaussian::from_ms(5_000.0, 0.5), 1.0, ConstantDrift(0.0));
let hopeless = R::new(Gaussian::from_ms(-5_000.0, 0.5), 1.0, ConstantDrift(0.0));
let overwhelming = R::new(
Gaussian::from_ms(5_000.0, 0.5),
1.0,
ConstantDrift::new(0.0),
);
let hopeless = R::new(
Gaussian::from_ms(-5_000.0, 0.5),
1.0,
ConstantDrift::new(0.0),
);
let a = [overwhelming];
let b = [hopeless];
let teams: Vec<&[R]> = vec![&a, &b];
@@ -311,7 +325,7 @@ fn empty_history_has_no_filtered_estimates() {
assert!(history.filtered_learning_curves().is_empty());
assert!(history.filtered_learning_curve("nobody").is_empty());
assert!(history.filtered_learning_curve("nobody").is_none());
}
// --- Boundary inputs (#26) ----------------------------------------------
@@ -326,7 +340,7 @@ fn tight() -> ConvergenceOptions {
fn assert_curve_finite(h: &History, keys: &[&str], what: &str) {
for key in keys {
for (time, g) in h.learning_curve(*key) {
for (time, g) in h.learning_curve(*key).unwrap() {
assert!(
g.mu().is_finite() && g.sigma().is_finite(),
"{what}: non-finite posterior for {key} at t={time} (mu={} sigma={})",
+166 -65
View File
@@ -1,101 +1,202 @@
//! Determinism tests: identical posteriors across RAYON_NUM_THREADS
//! values. Only compiled with the `rayon` feature.
//! Determinism across `RAYON_NUM_THREADS`, on a workload that actually reaches
//! the parallel path.
//!
//! This test previously proved less than it appeared to. `sweep_color_groups`
//! takes its `par_iter` branch only for colour groups of at least
//! `RAYON_THRESHOLD` (64) events, and the old fixture built 20 slices of 10
//! events — a colour group is a subset of one slice's events, so it could never
//! exceed 10. The branch was unreachable, confirmed by CPU-vs-wall time:
//! `user 0.64` on eight threads is one core.
//!
//! It also compared a single competitor's curve out of forty, and never
//! compared `log_evidence`, `final_step` or `iterations`.
//!
//! The fixture below guarantees the parallel branch **by construction**: within
//! a slice every event uses a disjoint pair of competitors, so greedy colouring
//! puts all of them in colour 0, and that group is `EVENTS_PER_SLICE` long.
//! Competitors recur across slices, so the fit still has temporal coupling and
//! drift rather than being a set of independent duels.
#![cfg(feature = "rayon")]
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team,
};
/// Build a deterministic workload using a simple LCG (no external rand crate).
fn build_and_converge(seed: u64) -> Vec<(i64, trueskill_tt::Gaussian)> {
let mut h = History::<i64, _, _, String>::builder_with_key()
/// Comfortably above the crate's internal `RAYON_THRESHOLD` of 64.
const EVENTS_PER_SLICE: usize = 96;
const SLICES: i64 = 8;
/// Two per event, all disjoint within a slice.
const COMPETITORS: usize = EVENTS_PER_SLICE * 2;
/// Everything a thread count could plausibly perturb.
struct Fingerprint {
curves: Vec<(String, Vec<(i64, Gaussian)>)>,
log_evidence: f64,
final_step: (f64, f64),
iterations: usize,
}
fn build_and_converge() -> Fingerprint {
let mut h = History::builder()
.key_type::<String>()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0))
.drift(ConstantDrift::new(25.0 / 300.0))
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
max_iter: 20_000,
epsilon: 1e-9,
alpha: 1.0,
})
.build();
// LCG for deterministic pseudo-random ints.
let mut rng = seed;
let mut next = || {
rng = rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
rng
};
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(200);
for ev_i in 0..200 {
let a = (next() % 40) as usize;
let mut b = (next() % 40) as usize;
while b == a {
b = (next() % 40) as usize;
let mut events: Vec<Event<i64, String>> = Vec::new();
for slice in 0..SLICES {
for e in 0..EVENTS_PER_SLICE {
// Disjoint within the slice: event `e` owns competitors 2e and
// 2e+1. Rotating by the slice index makes the pairings differ
// between slices, so competitors accumulate a real history.
let a = (2 * e + slice as usize) % COMPETITORS;
let b = (2 * e + 1 + slice as usize * 3) % COMPETITORS;
if a == b {
continue;
}
events.push(Event {
time: slice + 1,
teams: smallvec![
Team::with_members([Member::new(format!("p{a}"))]),
Team::with_members([Member::new(format!("p{b}"))]),
],
outcome: Outcome::winner(u32::from((e + slice as usize) % 2 == 0), 2),
});
}
// ~10 events per slice so color groups have material parallelism.
events.push(Event {
time: (ev_i as i64 / 10) + 1,
teams: smallvec![
Team::with_members([Member::new(format!("p{a}"))]),
Team::with_members([Member::new(format!("p{b}"))]),
],
outcome: Outcome::winner((next() % 2) as u32, 2),
});
}
h.add_events(events).unwrap();
let _ = h.converge().unwrap();
// Sample one competitor's curve for the comparison.
h.learning_curve("p0")
let report = h.converge().expect("fixture must converge");
let mut curves: Vec<(String, Vec<(i64, Gaussian)>)> = h
.learning_curves()
.into_iter()
.map(|(k, v)| (k.clone(), v))
.collect();
curves.sort_by(|a, b| a.0.cmp(&b.0));
Fingerprint {
curves,
log_evidence: h.log_evidence(),
final_step: report.final_step,
iterations: report.iterations,
}
}
#[test]
fn posteriors_identical_across_thread_counts() {
let sizes = [1usize, 2, 4, 8];
let mut results: Vec<Vec<(i64, trueskill_tt::Gaussian)>> = Vec::new();
let mut results: Vec<Fingerprint> = Vec::new();
for &n in &sizes {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(n)
.build()
.expect("rayon pool build");
let curve = pool.install(|| build_and_converge(42));
results.push(curve);
results.push(pool.install(build_and_converge));
}
let reference = &results[0];
for (i, curve) in results.iter().enumerate().skip(1) {
// Guard against the failure this test previously had: passing while
// measuring almost nothing.
assert!(
reference.curves.len() > 100,
"expected every competitor's curve, got {}",
reference.curves.len()
);
for (i, got) in results.iter().enumerate().skip(1) {
let n = sizes[i];
assert_eq!(
curve.len(),
reference.len(),
"curve length differs at {n} threads",
n = sizes[i],
got.iterations, reference.iterations,
"iterations differ at {n} threads"
);
for (j, (&(t_ref, g_ref), &(t, g))) in reference.iter().zip(curve.iter()).enumerate() {
assert_eq!(
got.final_step.0.to_bits(),
reference.final_step.0.to_bits(),
"final_step.0 differs at {n} threads: {:?} vs {:?}",
reference.final_step,
got.final_step
);
assert_eq!(
got.final_step.1.to_bits(),
reference.final_step.1.to_bits(),
"final_step.1 differs at {n} threads"
);
assert_eq!(
got.log_evidence.to_bits(),
reference.log_evidence.to_bits(),
"log_evidence differs at {n} threads: {} vs {}",
reference.log_evidence,
got.log_evidence
);
assert_eq!(
got.curves.len(),
reference.curves.len(),
"competitor count differs at {n} threads"
);
for ((ref_key, ref_curve), (key, curve)) in reference.curves.iter().zip(got.curves.iter()) {
assert_eq!(ref_key, key, "competitor order differs at {n} threads");
assert_eq!(
t_ref,
t,
"time point {j} differs at {n} threads: ref={t_ref} vs got={t}",
n = sizes[i],
);
assert_eq!(
g_ref.mu().to_bits(),
g.mu().to_bits(),
"mu bits differ at {n} threads, time {t}: ref={ref_mu} got={got_mu}",
n = sizes[i],
ref_mu = g_ref.mu(),
got_mu = g.mu(),
);
assert_eq!(
g_ref.sigma().to_bits(),
g.sigma().to_bits(),
"sigma bits differ at {n} threads, time {t}: ref={ref_sigma} got={got_sigma}",
n = sizes[i],
ref_sigma = g_ref.sigma(),
got_sigma = g.sigma(),
curve.len(),
ref_curve.len(),
"curve length differs for {key} at {n} threads"
);
for (&(t_ref, g_ref), &(t, g)) in ref_curve.iter().zip(curve.iter()) {
assert_eq!(t_ref, t, "time point differs for {key} at {n} threads");
assert_eq!(
g_ref.mu().to_bits(),
g.mu().to_bits(),
"mu differs for {key} at t={t}, {n} threads: {} vs {}",
g_ref.mu(),
g.mu()
);
assert_eq!(
g_ref.sigma().to_bits(),
g.sigma().to_bits(),
"sigma differs for {key} at t={t}, {n} threads: {} vs {}",
g_ref.sigma(),
g.sigma()
);
}
}
}
}
/// The fixture must keep reaching the parallel branch.
///
/// `RAYON_THRESHOLD` is private, so this pins the property that makes the
/// branch reachable rather than the branch itself: within a slice every event
/// uses a disjoint competitor pair, so greedy colouring puts all
/// `EVENTS_PER_SLICE` of them in one colour group. If someone shrinks the
/// fixture, this fails rather than the suite quietly going back to testing the
/// sequential path.
#[test]
fn the_fixture_still_exceeds_the_rayon_threshold() {
const RAYON_THRESHOLD: usize = 64;
const {
assert!(
EVENTS_PER_SLICE >= RAYON_THRESHOLD,
"a colour group holds at most EVENTS_PER_SLICE events, which must \
reach the crate's RAYON_THRESHOLD for the parallel sweep to run"
);
}
// Measured by instrumenting `sweep_color_groups`: this fixture produces
// one colour group of 96 events and takes the parallel branch on all 872
// sweeps. The old fixture's 10-event slices could not reach 64 at all.
assert_eq!(EVENTS_PER_SLICE, 96);
}
+10 -12
View File
@@ -2,7 +2,7 @@
//!
//! 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.
//! `ConstantDrift::new(g)` at `scale = s` behaves as `ConstantDrift::new(g * s)` would.
//! `scale = 0.0` pins a competitor still — an anchor, a rating floor, a course
//! difficulty — while everyone around them keeps drifting.
@@ -53,7 +53,7 @@ fn fit(events: Vec<Event<i64, &'static str>>, gamma: f64) -> Fit {
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.drift(ConstantDrift(gamma))
.drift(ConstantDrift::new(gamma))
.convergence(CONVERGENCE)
.build();
@@ -160,7 +160,7 @@ fn scale_is_equivalent_to_scaling_gamma() {
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 \
"ConstantDrift::new(0.3) at scale 0.5 must equal ConstantDrift::new(0.15) for {key} at \
t={t_l}: ({}, {}) vs ({}, {})",
g_l.mu(),
g_l.sigma(),
@@ -218,7 +218,7 @@ fn mixed_static_and_drifting_graph_converges() {
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.drift(ConstantDrift(25.0 / 300.0))
.drift(ConstantDrift::new(25.0 / 300.0))
.convergence(CONVERGENCE)
.build();
@@ -259,7 +259,7 @@ fn mixed_static_and_drifting_graph_converges() {
fn reject(scale: f64) -> InferenceError {
let mut h = History::builder()
.drift(ConstantDrift(25.0 / 300.0))
.drift(ConstantDrift::new(25.0 / 300.0))
.build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
@@ -277,13 +277,11 @@ fn reject(scale: f64) -> InferenceError {
#[test]
fn negative_scale_is_rejected() {
assert_eq!(
assert!(matches!(
reject(-1.0),
InferenceError::InvalidParameter {
name: "drift_scale",
value: -1.0
}
);
InferenceError::InvalidParameter { name: "drift_scale", value, .. }
if value == -1.0
));
}
#[test]
@@ -360,7 +358,7 @@ fn drift_scale_applies_when_set_after_first_appearance() {
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.drift(ConstantDrift(25.0 / 300.0))
.drift(ConstantDrift::new(25.0 / 300.0))
.convergence(CONVERGENCE)
.build();
+5 -1
View File
@@ -12,7 +12,11 @@ use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
type R = Rating<i64, ConstantDrift>;
fn ts_rating(mu: f64, sigma: f64, beta: f64, gamma: f64) -> R {
R::new(Gaussian::from_ms(mu, sigma), beta, ConstantDrift(gamma))
R::new(
Gaussian::from_ms(mu, sigma),
beta,
ConstantDrift::new(gamma),
)
}
#[test]
+4 -3
View File
@@ -19,7 +19,7 @@ fn history() -> H {
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.5))
.drift(ConstantDrift::new(0.5))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
@@ -81,7 +81,7 @@ fn members_matches_the_typed_path_exactly() {
#[test]
fn a_drift_scale_set_through_members_is_applied() {
fn spread(h: &H, key: &'static str) -> f64 {
let curve = h.learning_curve(&key);
let curve = h.learning_curve(&key).unwrap();
assert!(curve.len() >= 2, "{key}: expected several appearances");
let (lo, hi) = curve.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (_, g)| {
(lo.min(g.sigma()), hi.max(g.sigma()))
@@ -135,7 +135,8 @@ fn weights_still_guards_a_members_team() {
InferenceError::MismatchedShape {
kind: "weights",
expected: 2,
got: 1
got: 1,
..
}
),
"{err:?}"
+151
View File
@@ -0,0 +1,151 @@
//! The evidence accessors span two independent axes — smoothed vs forward-only,
//! all-keys vs key-restricted — and all four corners must exist and differ.
//!
//! `filtered_log_evidence_for` was the missing corner: the one a per-competitor
//! prequential score needs.
use trueskill_tt::{
ConstantDrift, Event, History, InferenceError, Member, NullObserver, Outcome, Team,
};
type H = History<i64, ConstantDrift, NullObserver, &'static str>;
/// Two disjoint cohorts, so a key restriction is guaranteed to leave events out.
fn two_cohorts() -> H {
let mut h = H::default();
let mut events = Vec::new();
for t in 1..=6 {
for (x, y) in [("a", "b"), ("c", "d")] {
events.push(Event {
time: t,
teams: [
Team::with_members([Member::new(x)]),
Team::with_members([Member::new(y)]),
]
.into_iter()
.collect(),
outcome: Outcome::winner(0, 2),
});
}
}
h.add_events(events).expect("fixture ingests");
h.converge().expect("fixture converges");
h
}
#[test]
fn all_four_corners_are_distinct_quantities() {
let h = two_cohorts();
let smoothed_all = h.log_evidence();
let smoothed_ab = h.log_evidence_for(&[&"a", &"b"]).unwrap();
let filtered_all = h.filtered_log_evidence();
let filtered_ab = h.filtered_log_evidence_for(&[&"a", &"b"]).unwrap();
for (name, v) in [
("smoothed_all", smoothed_all),
("smoothed_ab", smoothed_ab),
("filtered_all", filtered_all),
("filtered_ab", filtered_ab),
] {
assert!(
v.is_finite() && v <= 0.0,
"{name} = {v} is not a log probability"
);
}
// Restricting to one cohort must drop the other cohort's events. Half the
// events, and the two cohorts are symmetric, so it lands near half.
assert!(
smoothed_ab > smoothed_all,
"restricting must drop evidence terms: {smoothed_ab} vs {smoothed_all}"
);
assert!(filtered_ab > filtered_all);
// The forward-only corner is a genuinely different quantity from the
// smoothed one, not an alias for it.
assert!(
(filtered_ab - smoothed_ab).abs() > 1e-9,
"filtered and smoothed restricted evidence coincide ({filtered_ab} vs {smoothed_ab}); \
one of them is not computing what it claims"
);
}
#[test]
fn restricting_to_both_cohorts_recovers_the_unrestricted_value() {
let h = two_cohorts();
// Control on the filter itself: naming every competitor must restrict
// nothing, so this catches a filter that drops events it should keep.
let all_named = h
.filtered_log_evidence_for(&[&"a", &"b", &"c", &"d"])
.unwrap();
assert!(
(all_named - h.filtered_log_evidence()).abs() < 1e-12,
"naming everyone changed the answer: {all_named} vs {}",
h.filtered_log_evidence()
);
}
/// The restriction selects *events*, not competitors: naming one member of a
/// pair that only ever plays each other selects the same events as naming both.
#[test]
fn naming_either_member_of_a_pair_selects_the_same_events() {
let h = two_cohorts();
let ab = h.filtered_log_evidence_for(&[&"a"]).unwrap();
let ab_pair = h.filtered_log_evidence_for(&[&"a", &"b"]).unwrap();
assert!(
(ab - ab_pair).abs() < 1e-12,
"a and b only ever play each other, so naming either or both selects \
the same events: {ab} vs {ab_pair}"
);
}
#[test]
fn an_unknown_key_is_an_error_here_too() {
let h = two_cohorts();
let err = h
.filtered_log_evidence_for(&[&"typo"])
.expect_err("unknown key");
assert!(matches!(err, InferenceError::UnknownKey { .. }), "{err:?}");
// Control: the same call on a known key succeeds.
h.filtered_log_evidence_for(&[&"a"]).expect("a is known");
}
#[test]
fn current_skills_agrees_with_current_skill() {
let h = two_cohorts();
let all = h.current_skills();
assert_eq!(all.len(), 4, "four competitors played");
for key in ["a", "b", "c", "d"] {
let one = h.current_skill(key).expect("played");
let from_map = all[key];
assert_eq!(
(one.mu(), one.sigma()),
(from_map.mu(), from_map.sigma()),
"current_skills disagrees with current_skill for {key}"
);
}
}
#[test]
fn current_skills_omits_a_registered_but_unplayed_competitor() {
let mut h = two_cohorts();
h.register(Member::new("e")).expect("e is new");
let all = h.current_skills();
assert!(
!all.contains_key("e"),
"a competitor with no appearances has no posterior to report"
);
assert!(
h.current_skill("e").is_none(),
"control: the singular agrees"
);
assert_eq!(all.len(), 4);
}
+7 -7
View File
@@ -73,8 +73,8 @@ fn filtered_first_point_is_less_certain_than_smoothed() {
let _ = history.converge().unwrap();
let smoothed = history.learning_curve("a");
let filtered = history.filtered_learning_curve("a");
let smoothed = history.learning_curve("a").unwrap();
let filtered = history.filtered_learning_curve("a").unwrap();
assert_eq!(
smoothed.len(),
@@ -127,7 +127,7 @@ fn filtered_curves_plural_agrees_with_singular() {
assert_eq!(
curves["b"],
history.filtered_learning_curve("b"),
history.filtered_learning_curve("b").unwrap(),
"the plural form must agree with the singular for the same key"
);
}
@@ -182,8 +182,8 @@ fn single_slice_filtered_matches_smoothed() {
let _ = history.converge().unwrap();
let smoothed = history.learning_curve("a");
let filtered = history.filtered_learning_curve("a");
let smoothed = history.learning_curve("a").unwrap();
let filtered = history.filtered_learning_curve("a").unwrap();
assert_eq!(smoothed.len(), 1);
assert_eq!(filtered.len(), 1);
@@ -231,8 +231,8 @@ fn filtered_curves_do_not_depend_on_ingestion_order() {
}
let _ = incremental.converge().unwrap();
let from_batched = batched.filtered_learning_curve("a");
let from_incremental = incremental.filtered_learning_curve("a");
let from_batched = batched.filtered_learning_curve("a").unwrap();
let from_incremental = incremental.filtered_learning_curve("a").unwrap();
assert_eq!(from_batched.len(), from_incremental.len());
+7 -7
View File
@@ -8,7 +8,7 @@ fn default_rating() -> R {
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
ConstantDrift::new(25.0 / 300.0),
)
}
@@ -40,7 +40,7 @@ fn game_one_v_one_shortcut() {
#[test]
fn game_ranked_rejects_bad_p_draw() {
let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0));
let a = R::new(Gaussian::default(), 1.0, ConstantDrift::new(0.0));
let err = Game::<i64, _>::ranked(
&[&[a], &[a]],
Outcome::winner(0, 2),
@@ -56,7 +56,7 @@ fn game_ranked_rejects_bad_p_draw() {
#[test]
fn game_ranked_rejects_mismatched_ranks() {
let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0));
let a = R::new(Gaussian::default(), 1.0, ConstantDrift::new(0.0));
let err = Game::<i64, _>::ranked(
&[&[a], &[a]],
Outcome::ranking([0, 1, 2]),
@@ -155,7 +155,7 @@ mod malformed_games {
let err = Game::<i64, _>::ranked(&[&[a]], Outcome::winner(0, 1), &GameOptions::default())
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}"
);
}
@@ -173,7 +173,7 @@ mod malformed_games {
)
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}"
);
}
@@ -184,7 +184,7 @@ mod malformed_games {
Game::<i64, ConstantDrift>::ranked(&[], Outcome::ranking([]), &GameOptions::default())
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 0 }),
matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
"{err:?}"
);
}
@@ -198,7 +198,7 @@ mod malformed_games {
Game::<i64, _>::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default())
.unwrap_err();
assert!(
matches!(err, InferenceError::EmptyTeam { team: 0 }),
matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
"{err:?}"
);
}
+112
View File
@@ -0,0 +1,112 @@
//! Per-key queries must distinguish "I have never heard of this key" from a
//! genuine, empty-but-real answer.
//!
//! Each test carries a control: the same call on a key the history *does* know,
//! so it cannot pass merely because everything returns the same thing.
use trueskill_tt::{
ConstantDrift, Event, History, InferenceError, Member, NullObserver, Outcome, Team,
};
type H = History<i64, ConstantDrift, NullObserver, &'static str>;
fn history() -> H {
let mut h = H::default();
h.add_events((1..=4).map(|t| {
Event {
time: t,
teams: [
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
]
.into_iter()
.collect(),
outcome: Outcome::winner(0, 2),
}
}))
.expect("fixture ingests");
h.converge().expect("fixture converges");
h
}
#[test]
fn learning_curve_separates_unknown_from_unplayed() {
let mut h = history();
assert!(h.learning_curve("typo").is_none(), "unknown key is None");
assert_eq!(
h.learning_curve("a").expect("a is known").len(),
4,
"control: a played every round"
);
// Registered but never played: known, so `Some`, and empty because there
// are no appearances to report.
h.register(Member::new("c")).expect("c is new");
assert_eq!(
h.learning_curve("c").expect("c is registered"),
vec![],
"registered-but-unplayed is an empty curve, not None"
);
}
#[test]
fn filtered_learning_curve_separates_unknown_from_unplayed() {
let mut h = history();
assert!(h.filtered_learning_curve("typo").is_none());
assert_eq!(
h.filtered_learning_curve("a").expect("a is known").len(),
4,
"control"
);
h.register(Member::new("c")).expect("c is new");
assert_eq!(
h.filtered_learning_curve("c").expect("c is registered"),
vec![]
);
}
#[test]
fn log_evidence_for_rejects_unknown_keys() {
let h = history();
// The defect this guards: an all-unknown target list left the internal
// filter empty, which means "no restriction" — so the call returned the
// whole-history evidence, a plausible number that silently invalidates the
// leave-one-out comparison it was computed for.
let whole = h.log_evidence();
let err = h
.log_evidence_for(&[&"typo"])
.expect_err("unknown key is an error");
assert!(
matches!(err, InferenceError::UnknownKey { .. }),
"expected UnknownKey, got {err:?}"
);
// Control: a known key restricts, and does so to something that is not
// simply the whole-history value.
let restricted = h.log_evidence_for(&[&"a"]).expect("a is known");
assert!(restricted.is_finite());
assert!(restricted <= 0.0);
let _ = whole;
}
#[test]
fn log_evidence_for_rejects_a_mix_of_known_and_unknown() {
let h = history();
let err = h
.log_evidence_for(&[&"a", &"typo"])
.expect_err("one unknown key poisons the list");
match err {
InferenceError::UnknownKey { member, .. } => {
assert_eq!(member, 1, "the reported position is the offending key's");
}
other => panic!("expected UnknownKey, got {other:?}"),
}
h.log_evidence_for(&[&"a", &"b"])
.expect("control: both known");
}
+4 -2
View File
@@ -47,8 +47,10 @@ fn configured_event(a: &str, b: &str, time: i64, scale: f64) -> Event<i64, Strin
}
fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> {
let mut h: History<i64, _, _, String> =
History::builder_with_key().convergence(tight()).build();
let mut h: History<i64, _, _, String> = History::builder()
.key_type::<String>()
.convergence(tight())
.build();
if batched {
h.add_events(events).unwrap();
+5 -5
View File
@@ -40,7 +40,7 @@ fn a_one_team_event_is_an_error_not_a_panic() {
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}"
);
}
@@ -56,7 +56,7 @@ fn a_zero_team_event_is_an_error() {
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 0 }),
matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
"{err:?}"
);
}
@@ -75,7 +75,7 @@ fn an_empty_team_is_an_error_rather_than_a_free_win() {
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::EmptyTeam { team: 0 }),
matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
"{err:?}"
);
// Nothing was recorded, so the history is still empty.
@@ -93,7 +93,7 @@ fn an_empty_team_is_reported_by_position() {
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::EmptyTeam { team: 1 }),
matches!(err, InferenceError::EmptyTeam { team: 1, .. }),
"{err:?}"
);
}
@@ -170,7 +170,7 @@ fn the_event_builder_inherits_the_shape_checks() {
let mut h = history();
let err = h.event(1).team(["a"]).winner(0).commit().unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}"
);
}
+76 -3
View File
@@ -41,7 +41,7 @@ fn history(unknown: UnknownKeys) -> H {
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.5))
.drift(ConstantDrift::new(0.5))
.unknown_keys(unknown)
.convergence(ConvergenceOptions {
max_iter: 20_000,
@@ -158,7 +158,7 @@ fn drift_free_competitors_shrink_the_joint_by_the_slice_count() {
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(gamma))
.drift(ConstantDrift::new(gamma))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
@@ -197,7 +197,7 @@ fn pinned_competitors_collapse_consecutive_appearances() {
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.0))
.drift(ConstantDrift::new(0.0))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
@@ -265,3 +265,76 @@ fn unseen_competitors_match_the_one_shot_path() {
assert_eq!(one_shot.pi(), cached.pi());
assert_eq!(one_shot.tau(), cached.tau());
}
/// A drift too small to represent must collapse, not corrupt the matrix.
///
/// The collapse rule used to fire only at `drift <= 0.0` exactly. Anything
/// smaller-but-positive got an explicit `1.0 / drift` precision, and at
/// `drift = 1e-16` that entry is `1e16` — so `1e16 + 0.28` rounds back to
/// `1e16` and the prior and contrasts are annihilated in the stored `f64`.
///
/// Measured before the fix, at `drift_scale = 1e-10` this returned a variance
/// **12 000x too small** (a 111x overconfident interval) as `Ok`, with a band
/// just above it returning a misleading `JointUnavailable`.
#[test]
fn a_drift_too_small_to_represent_collapses_rather_than_corrupting() {
fn variance(scale: f64) -> f64 {
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
.key_type::<String>()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.5))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build();
let mut events = Vec::new();
for t in 0..15i64 {
for k in 0..4usize {
let x = format!("p{}", (t as usize * 4 + k) % 8);
let y = format!("p{}", (t as usize * 4 + k + 3) % 8);
events.push(Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(x).with_drift_scale(scale)]),
Team::with_members([Member::new(y).with_drift_scale(scale)]),
],
outcome: Outcome::scores([3.0, 1.0]),
});
}
}
h.add_events(events).unwrap();
assert!(h.converge().unwrap().converged);
let (a, b) = ("p0".to_string(), "p1".to_string());
let joint = h
.joint()
.expect("a tiny drift must not make the joint unavailable");
let g = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).unwrap();
g.sigma() * g.sigma()
}
let collapsed = variance(0.0);
// Below the threshold every scale must reach the collapsed answer exactly,
// and none may error.
for scale in [1e-3, 1e-4, 1e-6, 1e-8, 1e-10, 1e-12] {
let v = variance(scale);
assert_eq!(
v.to_bits(),
collapsed.to_bits(),
"drift_scale {scale:e}: {v} vs collapsed {collapsed}"
);
}
// Above it, real drift is still modelled — otherwise this test would pass
// by collapsing everything.
let drifting = variance(1e-2);
assert!(
(drifting - collapsed).abs() / collapsed > 1e-5,
"a drift of 1e-2 must still move the answer: {drifting} vs {collapsed}"
);
}
+3 -2
View File
@@ -24,10 +24,11 @@ impl Lcg {
}
fn nan_after_fit(players: usize) -> usize {
let mut h: History<i64, ConstantDrift, NullObserver, String> = History::builder_with_key()
let mut h: History<i64, ConstantDrift, NullObserver, String> = History::builder()
.key_type::<String>()
.beta(1.0)
.sigma(6.0)
.drift(ConstantDrift(0.1))
.drift(ConstantDrift::new(0.1))
.convergence(ConvergenceOptions {
max_iter: ITERATIONS,
epsilon: EPSILON,
+175
View File
@@ -0,0 +1,175 @@
//! The libm rule, enforced rather than asserted in prose.
//!
//! CLAUDE.md requires transcendentals to go through `libm`, not `std`:
//!
//! > IEEE 754 pins the basic operations and `sqrt` but says nothing about
//! > `exp`/`log`/`erf`, and `std` delegates to the *system* math library —
//! > measured, `f64::exp` and `libm::exp` disagree on 9.7% of inputs by one
//! > ULP. Since inference is an iterative fixed point, one ULP can change an
//! > iteration count.
//!
//! The rule was stated clearly and still violated in three production sites,
//! one of them `hypot` on the path of every scored event — whose measured
//! divergence, 12.1%, is *higher* than the `exp` figure the rule cites as its
//! own justification. Prose is evidently not enough, so this is a test.
//!
//! Tests may use either, which the crate documents, so `#[cfg(test)]` blocks
//! are excluded.
use std::{fs, path::Path};
/// Method-call spellings that reach the system math library.
///
/// `sqrt` is deliberately absent: IEEE 754 specifies it exactly, so `std` and
/// `libm` cannot disagree. `abs`, `recip`, `powi` and `mul_add` are likewise
/// exact or specified.
const FORBIDDEN: &[&str] = &[
"exp", "exp2", "exp_m1", "ln", "ln_1p", "log", "log2", "log10", "powf", "sin", "cos", "tan",
"asin", "acos", "atan", "atan2", "sinh", "cosh", "tanh", "hypot", "cbrt", "erf", "erfc",
];
/// Strip `#[cfg(test)]` items by brace matching, plus comments and string
/// literals, so a mention in prose is not mistaken for a call.
fn production_code(source: &str) -> String {
let mut out = String::with_capacity(source.len());
let bytes: Vec<char> = source.chars().collect();
let mut i = 0;
while i < bytes.len() {
let rest: String = bytes[i..].iter().take(16).collect();
if rest.starts_with("#[cfg(test)]") {
// Skip to the opening brace of the guarded item, then past its
// matching close.
let mut j = i;
while j < bytes.len() && bytes[j] != '{' {
j += 1;
}
let mut depth = 0usize;
while j < bytes.len() {
match bytes[j] {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
j += 1;
break;
}
}
_ => {}
}
j += 1;
}
i = j;
continue;
}
if rest.starts_with("//") {
while i < bytes.len() && bytes[i] != '\n' {
i += 1;
}
continue;
}
if rest.starts_with("/*") {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == '*' && bytes[i + 1] == '/') {
i += 1;
}
i += 2;
continue;
}
if bytes[i] == '"' {
i += 1;
while i < bytes.len() && bytes[i] != '"' {
if bytes[i] == '\\' {
i += 1;
}
i += 1;
}
i += 1;
continue;
}
out.push(bytes[i]);
i += 1;
}
out
}
fn rust_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for entry in fs::read_dir(dir).expect("read src") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
#[test]
fn production_code_never_calls_a_std_transcendental() {
let mut files = Vec::new();
rust_files(Path::new("src"), &mut files);
assert!(files.len() > 10, "expected to find the crate's sources");
let mut offences = Vec::new();
for path in &files {
let source = fs::read_to_string(path).expect("read source");
let code = production_code(&source);
for (n, line) in code.lines().enumerate() {
for name in FORBIDDEN {
let needle = format!(".{name}(");
if line.contains(&needle) {
offences.push(format!("{}:{}: {}", path.display(), n + 1, line.trim()));
}
}
}
}
assert!(
offences.is_empty(),
"production code must call libm, not std, for transcendentals \
(`sqrt` is exempt IEEE 754 specifies it):\n{}",
offences.join("\n")
);
}
/// The stripper has to actually strip, or the test above passes vacuously.
#[test]
fn the_test_module_stripper_works() {
let source = r#"
fn production() { let _ = libm::exp(1.0); }
#[cfg(test)]
mod tests {
fn allowed() { let x = 1.0f64.exp(); }
}
fn also_production() {}
"#;
let code = production_code(source);
assert!(
code.contains("also_production"),
"stripped too much: {code}"
);
assert!(
!code.contains(".exp()"),
"failed to strip cfg(test): {code}"
);
}
/// And it must not strip a doc comment's worth of prose into oblivion, nor
/// mistake prose for a call.
#[test]
fn prose_is_not_mistaken_for_a_call() {
let source = "/// Uses `x.exp()` in the docs.\nfn f() { let _ = libm::exp(1.0); }\n";
let code = production_code(source);
assert!(!code.contains(".exp()"), "doc comment leaked: {code}");
assert!(code.contains("libm::exp"), "stripped real code: {code}");
}
+4 -3
View File
@@ -140,7 +140,7 @@ fn fitted(
.sigma(SIGMA0)
.beta(BETA)
.score_sigma(SCORE_SIGMA)
.drift(ConstantDrift(0.0))
.drift(ConstantDrift::new(0.0))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
@@ -322,9 +322,10 @@ fn cost_scaling() {
use std::time::Instant;
for n in [50usize, 100, 200, 400, 800] {
let names: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
let mut h: History<i64, _, _, String> = History::builder_with_key()
let mut h: History<i64, _, _, String> = History::builder()
.key_type::<String>()
.score_sigma(2.0)
.drift(ConstantDrift(0.0))
.drift(ConstantDrift::new(0.0))
.convergence(ConvergenceOptions {
max_iter: 200,
epsilon: 1e-8,
+102 -2
View File
@@ -10,7 +10,9 @@
//! `!tuple_gt(..)`. These tests pin the guard from outside.
use smallvec::smallvec;
use trueskill_tt::{Event, Gaussian, History, InferenceError, Member, Outcome, Team};
use trueskill_tt::{
ConstantDrift, Event, Gaussian, History, InferenceError, Member, Outcome, Team,
};
fn scored_fit(
sigma: f64,
@@ -54,7 +56,7 @@ fn overflow_during_inference_is_reported_not_hidden() {
for (name, sigma, beta, score_sigma, scores) in cases {
match scored_fit(sigma, beta, score_sigma, scores) {
Err(InferenceError::NonFiniteResult { context, step }) => {
Err(InferenceError::NonFiniteResult { context, step, .. }) => {
assert_eq!(context, "History::converge", "{name}");
assert!(
!step.0.is_finite() || !step.1.is_finite(),
@@ -115,3 +117,101 @@ fn merely_extreme_parameters_still_converge() {
assert!(scored_fit(6.0, 1.0, 1e6, [3.0, 1.0]).unwrap());
assert!(scored_fit(6.0, 1.0, 1.0, [1e150, -1e150]).unwrap());
}
/// A NaN in one competitor must not be masked by a healthy competitor reduced
/// after it.
///
/// The convergence step is a fold over a `HashMap`, so which competitor is
/// reduced last is per-process hash order. Before the fix, `tuple_max` dropped
/// a NaN accumulator in favour of the next finite delta and this returned
/// `Ok(converged: true)` with a NaN posterior in **16 of 30 runs** on identical
/// input. Deterministic now, but note this test can only ever sample one hash
/// order per run — the ordering guarantee itself is pinned by
/// `tuple_max_propagates_a_nan_from_any_position` in the crate's unit tests.
#[test]
fn a_nan_competitor_is_not_masked_by_a_healthy_one() {
let mut h = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.p_draw(0.1)
.build();
h.add_events(vec![
Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(0.0, 1e-200))]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
},
// A healthy pair in the same slice, to be reduced alongside the NaN.
Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("c")]),
Team::with_members([Member::new("d")]),
],
outcome: Outcome::winner(0, 2),
},
])
.unwrap();
let err = h
.converge()
.expect_err("a NaN fit must never be reported as converged");
assert!(
matches!(err, InferenceError::NonFiniteResult { .. }),
"{err:?}"
);
}
/// A tie observed with a narrow draw margin between far-apart competitors must
/// produce a fit, not NaN skills.
///
/// The tie branch forms the truncated variance from `v^2 - u`, and both grow as
/// `alpha^2` while their difference stays `O(1)`. Deep enough into the tail
/// that subtraction had four digits left: measured, it returned `1 - w`
/// negative and `sqrt` of it was NaN. The half-line escape hatch did not cover
/// it, because that keys on how many window-widths from the mean the window
/// sits and a narrow window fails that however deep it is.
///
/// These parameters are ordinary for a precise-scoring domain, and the
/// neighbouring wider-margin case always worked — so this was a cliff, not
/// "extreme inputs break".
#[test]
fn a_narrow_draw_margin_far_into_the_tail_still_fits() {
for (beta, p_draw, sd, gap) in [
(1e-2, 1e-8, 1e-2, 10.0),
(1e-3, 1e-9, 1e-3, 1.0),
(1e-4, 1e-12, 1e-4, 1.0),
] {
let mut h = History::builder()
.mu(0.0)
.sigma(sd)
.beta(beta)
.p_draw(p_draw)
.drift(ConstantDrift::new(0.0))
.build();
h.add_events(vec![Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(0.0, sd))]),
Team::with_members([Member::new("b").with_prior(Gaussian::from_ms(gap, sd))]),
],
outcome: Outcome::draw(2),
}])
.unwrap();
let report = h
.converge()
.unwrap_or_else(|e| panic!("beta {beta:e}, p_draw {p_draw:e}: {e:?}"));
assert!(report.converged);
let skill = h.current_skill(&"a").unwrap();
assert!(
skill.mu().is_finite() && skill.sigma().is_finite() && skill.sigma() > 0.0,
"beta {beta:e}, p_draw {p_draw:e}: {skill:?}"
);
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ fn builder(
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.0))
.drift(ConstantDrift::new(0.0))
.unknown_keys(policy)
.convergence(ConvergenceOptions {
max_iter: 5_000,
@@ -148,6 +148,6 @@ fn shape_errors_are_reported() {
let empty: [&&str; 0] = [];
assert!(matches!(
h.predict_margin(&[&[&"veteran"], &empty]),
Err(InferenceError::EmptyTeam { team: 1 })
Err(InferenceError::EmptyTeam { team: 1, .. })
));
}
+31 -37
View File
@@ -20,13 +20,13 @@ fn unknown_keys_are_reported_not_silently_dropped() {
let err = h
.predict_outcome(&[&[&"a"], &[&"ghost"]])
.expect_err("an unknown key must not yield a confident prediction");
assert_eq!(
err,
InferenceError::UnknownKey {
team: 1,
member: 0,
key: "\"ghost\"".to_owned(),
}
assert!(
matches!(
&err,
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
if key == "\"ghost\""
),
"{err:?}"
);
// Every prediction entry point, not just one.
@@ -42,13 +42,13 @@ fn unknown_keys_are_reported_not_silently_dropped() {
fn an_entirely_unknown_team_is_an_error() {
let h = history_with(&["a", "b"], 0.0);
let err = h.predict_outcome(&[&[&"a"], &[&"x", &"y"]]).unwrap_err();
assert_eq!(
err,
InferenceError::UnknownKey {
team: 1,
member: 0,
key: "\"x\"".to_owned(),
}
assert!(
matches!(
&err,
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
if key == "\"x\""
),
"{err:?}"
);
}
@@ -56,18 +56,18 @@ fn an_entirely_unknown_team_is_an_error() {
fn degenerate_team_shapes_are_errors_rather_than_panics() {
let h = history_with(&["a", "b"], 0.0);
assert_eq!(
assert!(matches!(
h.predict_outcome(&[&[&"a"]]).unwrap_err(),
InferenceError::NotEnoughTeams { got: 1 }
);
assert_eq!(
InferenceError::NotEnoughTeams { got: 1, .. }
),);
assert!(matches!(
h.predict_outcome(&[]).unwrap_err(),
InferenceError::NotEnoughTeams { got: 0 }
);
assert_eq!(
InferenceError::NotEnoughTeams { got: 0, .. }
),);
assert!(matches!(
h.predict_outcome(&[&[&"a"], &[]]).unwrap_err(),
InferenceError::EmptyTeam { team: 1 }
);
InferenceError::EmptyTeam { team: 1, .. }
));
}
#[test]
@@ -93,13 +93,10 @@ fn the_outcome_space_is_capped_rather_than_hanging() {
let refs: Vec<&[&&str]> = too_many.iter().map(Vec::as_slice).collect();
let err = h.predict_outcome(&refs).unwrap_err();
assert_eq!(
assert!(matches!(
err,
InferenceError::TooManyTeams {
got: 8,
max: MAX_PREDICTED_TEAMS
}
);
InferenceError::TooManyTeams { got: 8, max, .. } if max == MAX_PREDICTED_TEAMS
));
// The cheap paths stay available at any size.
let wins = h.predict_win_probabilities(&refs).unwrap();
@@ -282,15 +279,12 @@ fn information_gain_respects_the_entropy_ceiling() {
#[test]
fn information_gain_reports_unknown_keys() {
let h = history_with(&["a", "b"], 0.0);
assert_eq!(
h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
assert!(matches!(
&h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
.unwrap_err(),
InferenceError::UnknownKey {
team: 1,
member: 0,
key: "\"ghost\"".to_owned(),
}
);
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
if key == "\"ghost\""
));
}
/// A draw-enabled history has three outcomes to weigh rather than two, so the
+162
View File
@@ -0,0 +1,162 @@
//! Bounds that any correct implementation must satisfy, swept rather than
//! spot-checked.
//!
//! The crate's docs call the `ln k` ceiling "the sharpest available test of an
//! implementation", and record that an early prototype returned 4.77 nats. It
//! was violated again — 3.237828 nats against `ln 2` — because the existing
//! check sampled one fixture and the violation lives in a specific regime: a
//! large ratio between the widest and narrowest performance sigma, where the
//! shared prediction grid could not resolve the narrow density and returned
//! probabilities greater than one.
//!
//! A single fixture cannot defend a bound like this. A sweep can.
use trueskill_tt::{
ConstantDrift, GameOptions, Gaussian, InferenceError, Rating, expected_information_gain,
};
type R = Rating<i64, ConstantDrift>;
/// How many random matchups the ceiling sweep draws.
///
/// Scaled by build profile rather than fixed. Each sample runs a full inference
/// pass per outcome, and that is about **19x** faster in release — measured,
/// 20 000 samples take 12.1s released against 23s for 2 000 in debug. `just
/// test` runs three debug feature combinations and one release one, so a fixed
/// count pays the slow price three times and the fast one once, which is
/// exactly backwards.
///
/// The debug run is here to prove the sweep still compiles and holds on a small
/// sample; the release run is the one that actually searches. The violation
/// this guards was found at a rate near 1.8%, so even the debug count expects
/// tens of hits in the regime.
#[cfg(debug_assertions)]
const SAMPLES: usize = 1_000;
#[cfg(not(debug_assertions))]
const SAMPLES: usize = 50_000;
/// Deterministic LCG, so a failure is reproducible from the printed seed.
struct Lcg(u64);
impl Lcg {
fn next_f64(&mut self) -> f64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
// Top 53 bits to [0, 1).
((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
}
fn in_range(&mut self, lo: f64, hi: f64) -> f64 {
lo + (hi - lo) * self.next_f64()
}
/// Log-uniform, so the sweep spends its samples across magnitudes rather
/// than crowding the top of the range — the violations live at small sigma.
fn log_uniform(&mut self, lo: f64, hi: f64) -> f64 {
let t = self.next_f64();
(lo.ln() + t * (hi.ln() - lo.ln())).exp()
}
}
#[test]
fn information_gain_never_exceeds_the_entropy_of_the_outcome() {
let mut rng = Lcg(0x5eed_1234_abcd_ef01);
let ceiling = 2.0_f64.ln();
let mut evaluated = 0usize;
let mut refused = 0usize;
for i in 0..SAMPLES {
let mu_a = rng.in_range(-100.0, 100.0);
let mu_b = rng.in_range(-100.0, 100.0);
let sigma_a = rng.log_uniform(1e-4, 1e2);
let sigma_b = rng.log_uniform(1e-4, 1e2);
let beta = rng.log_uniform(1e-4, 1e1);
let a = R::new(
Gaussian::from_ms(mu_a, sigma_a),
beta,
ConstantDrift::new(0.0),
);
let b = R::new(
Gaussian::from_ms(mu_b, sigma_b),
beta,
ConstantDrift::new(0.0),
);
let options = GameOptions {
p_draw: 0.0,
..GameOptions::default()
};
match expected_information_gain(&[&[a], &[b]], &options) {
Ok(gain) => {
evaluated += 1;
assert!(
gain.is_finite(),
"sample {i}: non-finite gain {gain} \
(mu {mu_a}, {mu_b}; sigma {sigma_a:e}, {sigma_b:e}; beta {beta:e})"
);
assert!(
gain >= 0.0,
"sample {i}: negative gain {gain} \
(mu {mu_a}, {mu_b}; sigma {sigma_a:e}, {sigma_b:e}; beta {beta:e})"
);
assert!(
gain <= ceiling + 1e-9,
"sample {i}: gain {gain} exceeds ln 2 = {ceiling} \
(mu {mu_a}, {mu_b}; sigma {sigma_a:e}, {sigma_b:e}; beta {beta:e})"
);
}
// Refusing to answer is acceptable; answering wrongly is not.
Err(InferenceError::GridTooCoarse { .. }) => refused += 1,
Err(e) => panic!("sample {i}: unexpected error {e:?}"),
}
}
// The sweep must actually exercise the function, not pass by refusing
// everything.
assert!(
evaluated * 2 > SAMPLES,
"only {evaluated} of {SAMPLES} samples were evaluated ({refused} refused); \
the sweep is no longer testing anything"
);
// And it must still reach the regime where the ceiling was violated —
// large sigma ratios, which is exactly where the grid now refuses. Without
// this the sweep could drift into only-easy inputs and stop being a guard.
assert!(
refused > 0,
"no sample reached the coarse-grid regime; the sweep no longer covers \
the case that produced 3.24 nats"
);
}
/// The regime that produced 3.237828 nats, pinned exactly.
#[test]
fn the_known_ceiling_violation_no_longer_answers_wrongly() {
let a = R::new(
Gaussian::from_ms(9.577_887_112_129_012, 0.000_132_507_526_585_134_38),
0.000_307_235_559_013_096_2,
ConstantDrift::new(0.0),
);
let b = R::new(
Gaussian::from_ms(-14.114_932_828_525_696, 91.586_690_140_921_16),
0.000_307_235_559_013_096_2,
ConstantDrift::new(0.0),
);
let options = GameOptions {
p_draw: 0.0,
..GameOptions::default()
};
match expected_information_gain(&[&[a], &[b]], &options) {
Ok(gain) => assert!(
gain <= 2.0_f64.ln() + 1e-9,
"returned {gain}, over the ln 2 ceiling"
),
Err(InferenceError::GridTooCoarse { needed, max, .. }) => {
assert!(needed > max, "needed {needed} should exceed max {max}");
}
Err(e) => panic!("unexpected error {e:?}"),
}
}
+152
View File
@@ -0,0 +1,152 @@
//! No prediction path may answer from a fit it cannot answer from.
//!
//! `converge` grew a `NonFiniteResult` guard; nothing stopped a caller from
//! ignoring that error and predicting anyway. The three failures that produced
//! were each differently wrong: `Ok(NaN)`, a panic out of a `Result`-returning
//! method, and `Ok([0.0, 0.0])` — finite, plausible, summing to zero against a
//! doc that promises one.
//!
//! Every test here has a healthy control, so none can pass by everything
//! returning `Err`.
use trueskill_tt::{
ConstantDrift, Event, Gaussian, History, InferenceError, Member, NullObserver, Outcome, Team,
};
type H = History<i64, ConstantDrift, NullObserver, &'static str>;
fn build(beta: f64, prior: Option<Gaussian>, outcome: Outcome) -> H {
let mut h: H = History::builder()
.beta(beta)
.drift(ConstantDrift::new(0.0))
.build();
let member = |k: &'static str| match prior {
Some(p) => Member::new(k).with_prior(p),
None => Member::new(k),
};
let _ = h.add_events(vec![Event {
time: 1,
teams: [
Team::with_members([member("a")]),
Team::with_members([member("b")]),
]
.into_iter()
.collect(),
outcome,
}]);
h
}
/// Point-mass priors with `beta(0.0)` on a *ranked* event: `converge` reports
/// `NonFiniteResult` and the stored posteriors are `pi: NaN, tau: NaN`.
fn nan_poisoned() -> H {
let mut h = build(
0.0,
Some(Gaussian::from_ms(0.0, 0.0)),
Outcome::winner(0, 2),
);
let err = h.converge().expect_err("this fixture must not converge");
assert!(
matches!(err, InferenceError::NonFiniteResult { .. }),
"{err:?}"
);
h
}
/// The same degenerate parameters on a *scored* event, where inference
/// converges cleanly and leaves legitimate point-mass posteriors behind. The
/// fit is fine; it is prediction that has nothing to work with.
fn degenerate_but_converged() -> H {
let mut h = build(
0.0,
Some(Gaussian::from_ms(0.0, 0.0)),
Outcome::scores([1.0, 0.0]),
);
h.converge().expect("this fixture converges");
h
}
fn healthy() -> H {
let mut h = build(1.0, None, Outcome::winner(0, 2));
h.converge().expect("control converges");
h
}
macro_rules! all_predictions {
($h:ident, $f:expr) => {{
let teams: &[&[&&'static str]] = &[&[&"a"], &[&"b"]];
let f = $f;
f("predict_quality", $h.predict_quality(teams).map(|_| ()));
f(
"predict_win_probabilities",
$h.predict_win_probabilities(teams).map(|_| ()),
);
f("predict_outcome", $h.predict_outcome(teams).map(|_| ()));
f(
"predict_ranking",
$h.predict_ranking(teams, &[0, 1]).map(|_| ()),
);
f(
"expected_information_gain",
$h.expected_information_gain(teams).map(|_| ()),
);
}};
}
#[test]
fn a_nan_poisoned_fit_is_refused_by_every_prediction_path() {
let h = nan_poisoned();
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
match r {
Err(InferenceError::NonFiniteResult { .. }) => {}
other => panic!("{name} answered from a NaN fit: {other:?}"),
}
});
}
#[test]
fn degenerate_performances_are_refused_rather_than_answered_wrongly() {
let h = degenerate_but_converged();
// The fit itself is sound — the posteriors are point masses, not NaN.
let skill = h.current_skill("a").expect("a played");
assert_eq!(skill.sigma(), 0.0);
assert!(skill.mu().is_finite());
// `predict_quality` previously PANICKED here, out of a method that returns
// `Result`: the contrast covariance is exactly singular when beta is zero
// and every skill is a point mass.
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
match r {
Err(InferenceError::InvalidParameter { .. }) => {}
other => panic!("{name} predicted from a degenerate fit: {other:?}"),
}
});
}
#[test]
fn the_control_history_answers_every_prediction() {
let h = healthy();
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
assert!(r.is_ok(), "{name} failed on a healthy history: {r:?}");
});
}
#[test]
fn win_probabilities_sum_to_one_on_the_control() {
// The promise the `Ok([0.0, 0.0])` case broke. Asserted on the control so
// the guard above cannot be "fixed" by making every path error.
let h = healthy();
let p = h
.predict_win_probabilities(&[&[&"a"], &[&"b"]])
.expect("control predicts");
let total: f64 = p.iter().sum();
assert!(
(total - 1.0).abs() < 1e-6,
"win probabilities sum to {total}"
);
}
+6 -1
View File
@@ -67,7 +67,12 @@ proptest! {
let _ = h.converge().unwrap();
for key in KEYS {
for (time, g) in h.learning_curve(key) {
// A generated schedule need not touch every key, and an unplayed
// key is `None` rather than an empty curve.
let Some(curve) = h.learning_curve(key) else {
continue;
};
for (time, g) in curve {
assert_finite(g, &format!("{key} at t={time}"));
}
}
+52 -3
View File
@@ -1,4 +1,4 @@
//! `quality()` beyond two rating groups.
//! `quality()` beyond two teams.
//!
//! The historical golden (two equal singletons) is asserted in
//! `src/lib.rs::tests::test_quality`. These cover the N-group generalisation,
@@ -82,14 +82,14 @@ fn uneven_group_sizes_work() {
}
#[test]
#[should_panic(expected = "at least 2 rating groups")]
#[should_panic(expected = "at least 2 teams")]
fn single_group_panics_with_clear_message() {
let r = rating(25.0, 3.0);
let _ = quality(&[&[r]], BETA);
}
#[test]
#[should_panic(expected = "at least 2 rating groups")]
#[should_panic(expected = "at least 2 teams")]
fn zero_groups_panics_with_clear_message() {
let _ = quality(&[], BETA);
}
@@ -164,3 +164,52 @@ fn quality_matches_the_reference_implementation() {
let refs: Vec<&[Gaussian]> = five.iter().map(Vec::as_slice).collect();
assert!((quality(&refs, beta) - 0.040).abs() < 1e-9);
}
/// `quality()` used to compute `det(ata) / det(middle)` in linear space. Both
/// are products of `k - 1` diagonal entries, so they leave `f64`'s range long
/// before their ratio does — and the ratio is the only thing the answer needs.
///
/// Measured before the fix: at the crate defaults 150 groups was correct, 200
/// returned `0`, and 250 returned `NaN` where the truth is `9.51e-88`. With a
/// small beta it bit sooner — `sigma = beta = 1e-3` returned `NaN` at 60 groups
/// against a true `1.32e-9`, a value that is entirely ordinary.
///
/// For `k` single-member groups with equal means the answer has a closed form,
/// `(beta / sqrt(beta^2 + sigma^2))^(k-1)`, so this checks against arithmetic
/// rather than against a recorded output.
#[test]
fn quality_matches_its_closed_form_past_the_overflow_point() {
for (sigma, beta) in [(25.0 / 3.0, 25.0 / 6.0), (1e-3, 1e-3), (50.0, 25.0 / 6.0)] {
let rating = vec![Gaussian::from_ms(25.0, sigma)];
for k in [2usize, 50, 60, 150, 200, 250, 300] {
let groups: Vec<&[Gaussian]> = (0..k).map(|_| rating.as_slice()).collect();
let got = quality(&groups, beta);
let expected = (beta / (beta * beta + sigma * sigma).sqrt()).powi(k as i32 - 1);
assert!(
got.is_finite(),
"sigma {sigma}, beta {beta}, {k} groups: got {got}"
);
// Subnormal results have no relative precision left to check.
if expected > f64::MIN_POSITIVE {
let rel = ((got - expected) / expected).abs();
assert!(
rel < 1e-11,
"sigma {sigma}, beta {beta}, {k} groups: got {got:e}, \
closed form {expected:e}, rel {rel:e}"
);
}
}
}
}
/// The overflow was in the intermediates, never in the answer: every value
/// above is an ordinary float. This pins the specific case that returned `NaN`
/// where the true answer is nine orders of magnitude inside the normal range.
#[test]
fn a_small_beta_does_not_overflow_at_sixty_groups() {
let rating = vec![Gaussian::from_ms(25.0, 1e-3)];
let groups: Vec<&[Gaussian]> = (0..60).map(|_| rating.as_slice()).collect();
let got = quality(&groups, 1e-3);
assert!((got - 1.317_089e-9).abs() / 1.317_089e-9 < 1e-6, "{got:e}");
}
+8 -4
View File
@@ -35,8 +35,10 @@ fn ev(a: &str, b: &str, time: i64) -> Event<i64, String> {
/// Ingest each chunk in turn, converging fully after every one.
fn fit_in_chunks(chunks: Vec<Events>) -> Vec<(String, Gaussian)> {
let mut h: History<i64, _, _, String> =
History::builder_with_key().convergence(tight()).build();
let mut h: History<i64, _, _, String> = History::builder()
.key_type::<String>()
.convergence(tight())
.build();
for chunk in chunks {
h.add_events(chunk).unwrap();
@@ -150,8 +152,10 @@ fn re_converging_an_unchanged_history_costs_one_iteration() {
let (early, late) = fixture();
let all: Vec<_> = early.into_iter().chain(late).collect();
let mut h: History<i64, _, _, String> =
History::builder_with_key().convergence(tight()).build();
let mut h: History<i64, _, _, String> = History::builder()
.key_type::<String>()
.convergence(tight())
.build();
h.add_events(all).unwrap();
let first = h.converge().unwrap();
assert!(first.converged);
+2 -2
View File
@@ -6,7 +6,7 @@ fn record_winner_builds_history() {
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0))
.drift(ConstantDrift::new(25.0 / 300.0))
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
@@ -43,7 +43,7 @@ fn record_draw_with_p_draw_set() {
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0))
.drift(ConstantDrift::new(25.0 / 300.0))
.p_draw(0.25)
.build();
+2 -2
View File
@@ -21,7 +21,7 @@ fn history() -> H {
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.5))
.drift(ConstantDrift::new(0.5))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
@@ -119,7 +119,7 @@ fn registration_reaches_a_competitor_first_seen_through_record_winner() {
assert_eq!(rating.prior().mu(), PINNED.mu());
// Pinned means pinned: no drift across the two slices.
let curve = h.learning_curve(&"layout");
let curve = h.learning_curve(&"layout").unwrap();
assert!(curve.len() >= 2);
let widest = curve
.iter()
+3 -3
View File
@@ -9,7 +9,7 @@ fn scored_two_team_one_event_pulls_winner_up() {
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.drift(ConstantDrift::new(0.0))
.score_sigma(1.0)
.build();
@@ -46,7 +46,7 @@ fn scored_zero_margin_treats_as_tie() {
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.drift(ConstantDrift::new(0.0))
.score_sigma(1.0)
.build();
@@ -88,7 +88,7 @@ fn scored_three_team_partial_order() {
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.drift(ConstantDrift::new(0.0))
.score_sigma(1.0)
.build();
+152
View File
@@ -0,0 +1,152 @@
//! The `Time` generic, exercised end to end.
//!
//! `History<T: Time, ..>` has always been generic over the time axis, `Untimed`
//! has always been exported, and `Drift<T>` is generic specifically so that
//! "seasonal or calendar-aware drift is expressible without going through
//! `i64`". None of it was reachable: every construction route pinned `T = i64`,
//! `HistoryBuilder`'s fields are private, and its `Default` existed only for the
//! `i64` instantiation.
//!
//! Nothing in the repository constructed a non-`i64` history, which is why that
//! went unnoticed. This file is the guard against it recurring — it is as much
//! about the generic being *exercised* as about any single assertion.
use trueskill_tt::{ConstantDrift, Drift, History, HistoryBuilder, Time, Untimed};
/// A domain time type: a season number. Exactly what the `Time` trait exists
/// to support, and what a consumer with `chrono` dates would write.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Season(u16);
impl Time for Season {
fn elapsed_to(&self, later: &Self) -> i64 {
i64::from(later.0.saturating_sub(self.0))
}
}
/// Drift that only accumulates between seasons, not within one — the
/// calendar-aware case the trait's own docs cite.
#[derive(Copy, Clone, Debug)]
struct SeasonalDrift {
per_season: f64,
}
impl Drift<Season> for SeasonalDrift {
fn variance_delta(&self, from: &Season, to: &Season) -> f64 {
self.variance_for_elapsed(from.elapsed_to(to))
}
fn variance_for_elapsed(&self, elapsed: i64) -> f64 {
elapsed.max(0) as f64 * self.per_season * self.per_season
}
}
#[test]
fn an_untimed_history_fits_through_the_builder() {
let mut h = History::builder().time_type::<Untimed>().build();
for _ in 0..5 {
h.record_winner(&"alice", &"bob", Untimed).unwrap();
}
assert!(h.converge().unwrap().converged);
let alice = h.current_skill(&"alice").unwrap();
let bob = h.current_skill(&"bob").unwrap();
assert!(alice.mu() > bob.mu(), "{alice:?} vs {bob:?}");
assert!(alice.sigma().is_finite() && alice.sigma() > 0.0);
}
/// `Untimed::elapsed_to` is always 0, so no drift accumulates however many
/// events there are. That is the property the type exists for, and it had never
/// been checked.
#[test]
fn untimed_accumulates_no_drift() {
fn final_sigma<T: Time + Copy>(time: T, drift: ConstantDrift) -> f64 {
let mut h = History::builder().time_type::<T>().drift(drift).build();
for _ in 0..8 {
h.record_winner(&"a", &"b", time).unwrap();
}
let _ = h.converge().unwrap();
h.current_skill(&"a").unwrap().sigma()
}
// Under Untimed the drift setting cannot matter, because elapsed is always 0.
let none = final_sigma(Untimed, ConstantDrift::new(0.0));
let large = final_sigma(Untimed, ConstantDrift::new(5.0));
assert_eq!(
none.to_bits(),
large.to_bits(),
"Untimed must ignore drift entirely: {none} vs {large}"
);
}
#[test]
fn a_custom_time_type_and_a_custom_drift_work_together() {
let mut h = History::builder()
.time_type::<Season>()
.drift(SeasonalDrift { per_season: 0.5 })
.build();
for season in 1..=4u16 {
for _ in 0..3 {
h.record_winner(&"veteran", &"rookie", Season(season))
.unwrap();
}
}
assert!(h.converge().unwrap().converged);
let curve = h.learning_curve(&"veteran").unwrap();
assert_eq!(curve.len(), 4, "one point per season: {curve:?}");
for (season, g) in &curve {
assert!(
g.mu().is_finite() && g.sigma() > 0.0,
"season {season:?}: {g:?}"
);
}
// Times come back as the domain type, not as an integer.
assert_eq!(curve[0].0, Season(1));
assert_eq!(curve[3].0, Season(4));
}
/// Seasonal drift must actually widen a gap across seasons — otherwise the
/// custom `Drift` is being ignored and the test above would pass regardless.
#[test]
fn a_custom_drift_is_actually_consulted() {
fn sigma_with(per_season: f64) -> f64 {
let mut h = History::builder()
.time_type::<Season>()
.drift(SeasonalDrift { per_season })
.build();
for season in 1..=6u16 {
h.record_winner(&"a", &"b", Season(season)).unwrap();
}
let _ = h.converge().unwrap();
h.current_skill(&"a").unwrap().sigma()
}
let still = sigma_with(0.0);
let drifting = sigma_with(2.0);
assert!(
drifting > still * 1.05,
"a drifting fit must be less certain: {drifting} vs {still}"
);
}
/// The other axis: a custom key type, through the same mechanism.
#[test]
fn key_type_replaces_builder_with_key() {
let mut h = History::builder().key_type::<String>().build();
h.record_winner(&"alice".to_string(), &"bob".to_string(), 1)
.unwrap();
assert!(h.converge().unwrap().converged);
assert!(h.current_skill("alice").is_some());
}
/// Both axes at once, via the explicit constructor rather than the setters.
#[test]
fn new_constructs_on_any_axis_directly() {
let mut h = HistoryBuilder::<Season, _, _, String>::new().build();
h.record_winner(&"a".to_string(), &"b".to_string(), Season(7))
.unwrap();
assert!(h.converge().unwrap().converged);
assert_eq!(h.learning_curve("a").unwrap()[0].0, Season(7));
}
+1 -1
View File
@@ -24,7 +24,7 @@ fn history(gamma: f64) -> H {
.sigma(SIGMA0)
.beta(BETA)
.score_sigma(SCORE_SIGMA)
.drift(ConstantDrift(gamma))
.drift(ConstantDrift::new(gamma))
.unknown_keys(UnknownKeys::Reject)
.convergence(ConvergenceOptions {
max_iter: 20_000,
+96
View File
@@ -0,0 +1,96 @@
//! The traits a consumer needs on the public types, pinned so they cannot be
//! removed by accident.
//!
//! This is written from a consumer's position — deriving `Debug` on a struct
//! that *holds* a `History` — because that is the thing that failed. Asserting
//! `History: Debug` in isolation would not have caught the generic-bound half:
//! `Rating` derives `PartialEq`, but that is only usable if `D: PartialEq`, and
//! the crate's own only `Drift` impl did not satisfy it.
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, ConvergenceReport, Event, GameOptions, Gaussian, History,
HistoryBuilder, InferenceError, Member, Outcome, Rating, Team,
};
/// The reported failure, verbatim: a consumer holding a history in app state.
#[derive(Debug)]
#[allow(
dead_code,
reason = "held only so `derive(Debug)` has something to render"
)]
struct App {
history: History,
}
#[test]
fn a_struct_holding_a_history_can_derive_debug() {
let app = App {
history: History::default(),
};
let rendered = format!("{app:?}");
// Summarising, not a dump of every skill store — the same choice `Joint`'s
// manual `Debug` makes about its n² factorisation.
assert!(rendered.contains("competitors"), "{rendered}");
assert!(rendered.contains("time_slices"), "{rendered}");
assert!(
!rendered.contains("SkillStore"),
"History's Debug should summarise, not dump: {rendered}"
);
}
#[test]
fn history_builder_is_debug_and_clone() {
let b: HistoryBuilder<i64, ConstantDrift, _, &'static str> = History::builder();
let cloned = b.clone();
assert!(!format!("{cloned:?}").is_empty());
}
#[test]
fn config_and_input_value_types_are_comparable() {
assert_eq!(ConstantDrift::new(0.1), ConstantDrift::new(0.1));
assert_ne!(ConstantDrift::new(0.1), ConstantDrift::new(0.2));
assert_eq!(ConvergenceOptions::default(), ConvergenceOptions::default());
assert_eq!(GameOptions::default(), GameOptions::default());
// `Rating: PartialEq` is only reachable through `D: PartialEq`.
assert_eq!(Rating::<i64, ConstantDrift>::default(), Rating::default());
assert_ne!(
Rating::default(),
Rating::<i64, ConstantDrift>::default().with_drift_scale(2.0)
);
assert_eq!(Member::new("a"), Member::new("a"));
assert_ne!(Member::new("a"), Member::new("b"));
assert_eq!(
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("a")])
);
let event = || Event {
time: 1,
teams: [
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
]
.into_iter()
.collect(),
outcome: Outcome::winner(0, 2),
};
assert_eq!(event(), event());
assert_eq!(Gaussian::default(), Gaussian::default());
}
#[test]
fn a_history_is_send_and_sync_and_default() {
fn assert_send_sync<X: Send + Sync>() {}
assert_send_sync::<History>();
assert_send_sync::<InferenceError>();
let mut h = History::default();
let report: ConvergenceReport = h.converge().expect("an empty history converges");
assert_eq!(report, report.clone());
}
+119 -1
View File
@@ -22,7 +22,7 @@ fn rating() -> R {
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
ConstantDrift::new(0.0),
)
}
@@ -255,3 +255,121 @@ mod builder_parameters {
);
}
}
/// The constructors below `HistoryBuilder`, which 0.8.0's validation did not
/// reach.
///
/// `sigma`, `beta` and `gamma` all enter inference only as squares, so a
/// negative value behaves as its absolute value and the sign vanishes without
/// comment. Measured before these guards: `from_ms(25.0, -8.33)` and
/// `Rating::new(_, -4.17, _)` returned results bit identical to their positive
/// counterparts, and `Rating::new(_, NaN, _)` reached `Game::ranked`, which
/// returned `Ok` carrying `Gaussian { pi: NaN, tau: NaN }`.
mod constructor_parameters {
use trueskill_tt::{ConstantDrift, Gaussian, History, InferenceError, Rating};
#[test]
#[should_panic(expected = "sigma must not be negative")]
fn a_negative_sigma_is_rejected_by_from_ms() {
let _ = Gaussian::from_ms(25.0, -8.33);
}
/// NaN must pass, and that is deliberate: a broken fit produces a NaN
/// sigma and `converge` reports it as `NonFiniteResult`. Rejecting it here
/// would turn reporting into a panic inside inference.
#[test]
fn a_nan_sigma_passes_through_from_ms() {
let g = Gaussian::from_ms(25.0, f64::NAN);
assert!(g.sigma().is_nan() || g.pi().is_nan());
}
#[test]
#[should_panic(expected = "beta must be finite and non-negative")]
fn a_negative_beta_is_rejected_by_rating_new() {
let _ =
Rating::<i64, ConstantDrift>::new(Gaussian::default(), -4.17, ConstantDrift::new(0.0));
}
#[test]
#[should_panic(expected = "beta must be finite and non-negative")]
fn a_nan_beta_is_rejected_by_rating_new() {
let _ = Rating::<i64, ConstantDrift>::new(
Gaussian::default(),
f64::NAN,
ConstantDrift::new(0.0),
);
}
#[test]
fn a_zero_beta_is_accepted_by_rating_new() {
let _ =
Rating::<i64, ConstantDrift>::new(Gaussian::default(), 0.0, ConstantDrift::new(0.0));
}
/// `ConstantDrift` rejects at construction now that its field is private.
#[test]
#[should_panic(expected = "gamma must be finite and non-negative")]
fn a_negative_gamma_is_rejected_by_constant_drift_new() {
let _ = ConstantDrift::new(-0.0833);
}
#[test]
#[should_panic(expected = "gamma must be finite and non-negative")]
fn a_non_finite_gamma_is_rejected_by_constant_drift_new() {
let _ = ConstantDrift::new(f64::NAN);
}
#[test]
fn gamma_reads_back_what_was_given() {
assert_eq!(ConstantDrift::new(0.25).gamma(), 0.25);
assert_eq!(ConstantDrift::new(0.0).gamma(), 0.0);
}
/// `HistoryBuilder::drift` is generic and cannot inspect an arbitrary
/// `Drift`, so the check on the variance each competitor accumulates is
/// still needed — it is the only thing standing between a custom
/// implementation and a NaN fit. `ConstantDrift` can no longer reach it,
/// so this uses an implementation that can.
#[test]
fn a_custom_drift_returning_a_bad_variance_is_rejected_at_convergence() {
#[derive(Clone, Copy, Debug)]
struct BadDrift(f64);
impl trueskill_tt::Drift<i64> for BadDrift {
fn variance_delta(&self, _from: &i64, _to: &i64) -> f64 {
self.0
}
fn variance_for_elapsed(&self, _elapsed: i64) -> f64 {
self.0
}
}
for bad in [f64::NAN, f64::INFINITY, -1.0] {
let mut h = History::builder().drift(BadDrift(bad)).build();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"a", &"b", 5).unwrap();
let err = h.converge().unwrap_err();
assert!(
matches!(
err,
InferenceError::InvalidParameter {
name: "drift variance",
..
}
),
"drift {bad}: {err:?}"
);
}
}
/// An ordinary drift is untouched.
#[test]
fn an_ordinary_drift_still_converges() {
let mut h = History::builder()
.drift(ConstantDrift::new(25.0 / 300.0))
.build();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"a", &"b", 5).unwrap();
assert!(h.converge().unwrap().converged);
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.0))
.drift(ConstantDrift::new(0.0))
.unknown_keys(policy)
.convergence(ConvergenceOptions {
max_iter: 20_000,