46 Commits
Author SHA1 Message Date
logaritmisk 2cba3d10f6 chore: Release trueskill-tt version 0.9.0 2026-09-10 07:50:43 +02:00
logaritmisk 5d9501307e Merge chore/release-0.9.0: migration guide and changelog cleanup 2026-09-10 07:47:20 +02:00
logaritmiskandClaude Opus 5 327324c411 docs: add a migration guide for 0.9.0, and drop merge noise from the changelog
Twenty-one breaking changes in one release, with a live consumer. The
changelog lists them; `MIGRATING.md` says what to do about them, leading
with the three that change what an existing, *compiling* call returns —
unknown keys, predictions from a broken fit, and `Gaussian`'s operators
— since those are the ones the compiler will not find for you.

Every "after" snippet was compiled, not written from memory, and doing
so caught three errors in my own guide:

- `log_evidence_for(&[&"alice"])` does not compile at `K = String`. The
  right spelling is `&["alice"]`, which works at *both* key types —
  checked, because a guide that is right for half its readers is worse
  than no guide.
- the same for `filtered_log_evidence_for`
- the `Analysis<'h> { joint: Joint<'h> }` example needs a history at the
  default key type; pairing it with a `History<String>` does not compile

git-cliff skips merge commits now. Every branch lands with `--no-ff`, so
a release's merges outnumber its real commits and say nothing the merged
ones do not — 0.9.0's changelog had fourteen lines of them under "Other
(unconventional)". `ci:` commits get a group instead of falling through
to that catch-all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-10 07:47:19 +02:00
logaritmisk 61da3aca33 Merge api/typed-errors (#74) 2026-09-10 07:31:31 +02:00
logaritmiskandClaude Opus 5 061c481aad refactor!: typed discriminators for InferenceError
Six of fifteen variants carried a `&'static str` discriminator, about
thirty magic strings between them, and the only thing a caller could do
with one was print it. Four new enums replace them:

    Parameter        13 variants, replacing 9 strings in InvalidParameter
    Shape             4 variants, replacing 10 in MismatchedShape
    OutcomeKind       2 variants, replacing WrongOutcomeKind's three fields
    CompetitorField   2 variants, replacing ConflictingCompetitorConfig's

`InvalidProbability` folds into `InvalidParameter` as
`Parameter::PDraw`. It was a bespoke variant for one scalar while every
other scalar shared `InvalidParameter`, and it omitted the parameter
name — so the same parameter had two mechanisms.

`JointUnavailable { reason: &'static str }` splits into `EmptyHistory`,
`JointRequiresScoredEvents` and `NotPositiveDefinite`. The three are
conditions a caller branches on differently — add events, use
`predict_win_probabilities`, or reconsider the priors — and telling them
apart used to mean string-matching English. One test already proved the
distinction was load-bearing: the blanket conversion mapped the
empty-history case onto the ranked one and `an_empty_history_has_no_joint`
caught it immediately.

`NonFiniteResult` splits into `NonFiniteStep { context, step }` and
`NonFiniteSkill { mu, sigma }`. One `step: (f64, f64)` field was
carrying a sweep step from `converge` and a skill's own moments from a
prediction — two situations in one variant, and a field name that could
only be right for one of them.

`InvalidParameter { name: "beta with point-mass skills" }` becomes
`NoPerformanceVariance`. It was never a parameter out of range: both
values are individually valid and it is their combination that leaves
nothing varying.

Three `Display` impls did not meet the standard the others set, and the
typed data is what makes fixing them possible:

    before  drift variance is invalid: NaN
    after   drift variance must be finite and non-negative (got NaN)

    before  kinds: expected length 3, got 2
    after   the outcome describes a different number of teams than the
            event has: expected 3, got 2

    before  Game::ranked: expected Outcome::Ranked, got Outcome::Scored
    after   expected Outcome::Ranked, got Outcome::Scored; call
            Game::scored for a scored outcome

`Parameter::range()` states each parameter's actual bounds, which no
`&'static str` name could have. `error::message_tests` renders every one
and asserts each is a sentence rather than a label, and that the three
above now carry a range or a next step.

The four internal `MismatchedShape` kinds — `results`, `times`, `kinds`,
and the weights array — collapse to `Shape::Internal`, whose `Display`
says plainly that reaching it is a bug in this crate. They are checks on
`add_events_with_prior`'s own parallel arrays and are unreachable
through the public API; they stay checked rather than becoming
`debug_assert!`s, because release is where this crate's defects hide.

Closes #74.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-10 07:31:31 +02:00
logaritmisk 0b9997354d Merge feat/rating-rule (#53) 2026-09-10 07:17:12 +02:00
logaritmiskandClaude Opus 5 c4194b0051 feat: HistoryBuilder::default_rating_for, a rule instead of a roll call
`register` states configuration for one competitor, which needs the key
set up front. A consumer ingesting an event stream generally does not
have it — and "every layout is static" is a rule, not a list. This makes
it one statement that cannot be forgotten on an ingestion path.

    History::builder()
        .default_rating_for(|key: &&str| {
            key.starts_with("layout_")
                .then(|| StartingPoint::new().drift_scale(0.0))
        })
        .build()

A fifth type parameter, defaulted to `NoRule`, so it costs a caller who
does not use one exactly nothing: `History<String>` still spells out.

Two deviations from #53, both because implementing it exposed something
the issue could not have known.

**A trait, not a bare `Fn` bound.** #53's option 1 was a raw
`R: Fn(&K) -> Option<Rating<T, D>>`. A closure's type cannot be written
down, and the motivating consumer holds its `History` in application
state — so it has to name the type in a struct field, and option 1 makes
that impossible. `RatingRule<K>` is implementable on a named type;
`tests/rating_rule.rs` has the struct-field case that would not have
compiled otherwise. `default_rating_for` still takes a closure for the
common case, via `FnRule`.

**The rule returns a `StartingPoint`, not a `Rating`.** A `Rating` also
carries `beta` and the drift model, which describe the *history* rather
than one competitor — a rule that could vary them would be describing a
different model per competitor. What the create branch actually applies
is the prior and the drift scale, the same pair a `Member` may carry, so
that is what the rule supplies. It also keeps `RatingRule<K>` free of
`T` and `D`: with `Rating<T, D>` in the signature, `drift` and
`time_type` stop compiling after a rule is set, because
`R: RatingRule<K, T, D>` does not imply `R: RatingRule<K, T, D2>`.

**Precedence, which #53 left open: explicit beats the rule, field by
field.** The alternative — `ConflictingCompetitorConfig` — would make a
single exceptional competitor incompatible with having any rule at all.
Two *explicit* declarations that disagree stay an error, because neither
is more specific than the other, and a test pins that they still do.

`key_type` resets the rule to `NoRule`: a `RatingRule<K>` cannot answer
questions about `K2`.

Every test carries a control, and one of them corrected me. I first
asserted that a non-matching competitor's *posterior* was untouched.
It is not, and should not be: alice plays the pinned layout, and what
she learns from beating it depends on how sure the model is about it.
The control is her configuration.

Closes #53.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-10 07:17:12 +02:00
logaritmisk 1629176199 Merge perf/sparse-joint (#52) 2026-09-10 07:08:18 +02:00
logaritmiskandClaude Opus 5 695bb822ef perf!: sparse Cholesky with an AMD ordering for the joint
745 ms -> 1.11 ms on the fixture #52 was opened about.

The joint precision matrix is 0.19% dense at scale and gets sparser as
the history grows. We allocated all n^2 entries — 31 MB at n = 1976,
128 MB at ustat's ~4000 appearances — filled 99.8% of it with zeros, and
ran an O(n^3) factorisation over the whole thing.

Two measurements shaped the fix, and the first killed the plan #52
proposed.

**Ordering alone does nothing to a dense factorisation.** Its inner
loops run over every k whether the entry is zero or not, so a
permutation changes which entries are zero and not how many
multiplications happen. A 700x700 banded matrix at 0.43% density:
30.196 ms in band order, 29.544 ms under a scramble that destroyed the
band. Identical, as the flop count says it must be. #52's step 1 —
"reorder with AMD, keep our own Cholesky, and measure" — could not have
worked, and measuring said so before any of it was written.

**Sparsity and AMD together are worth four orders of magnitude.**
Symbolic factorisation on the n = 1976 fixture, against 2.572e9 dense
flops: sparse in natural order needs 5.597e7 (46x), sparse under AMD
needs 8.656e4 — 29,710x. AMD is worth 646x on top of sparsity and
nothing without it. Natural order fills in badly for exactly the reason
#52 predicted about bandwidth: nnz(L) is 292,437 against A's 7,504,
because a competitor idle from slice 0 to slice 75 links across the
whole matrix.

Measured end to end, factorising through `History::joint`:

    n =  480     215 us   (bench: 9.11 ms -> 167 us, 54x)
    n = 1976    1.112 ms  (was ~745 ms, 670x)
    n = 7800    4.616 ms  (dense would be 1.58e11 flops)

Scaling is near-linear now rather than cubic: 16x the variables costs
21x the time, where dense would cost 4096x.

The factorisation is the up-looking sparse Cholesky of Davis's *Direct
Methods for Sparse Linear Systems*, written here rather than taken from
a crate. The scouting in #52 still holds and got one addition: `feral`
itself pulls `pulp`, so it has the same runtime CPU-dispatch problem
that ruled out `faer` — results could differ between an AVX-512 host and
an AVX2 one, the drift the libm-over-std decision was made to avoid.
`sprs-ldl` is still LGPL and `nalgebra-sparse` still disclaims
fill-reduction in its own docs. Only the ordering is a dependency:
`feral-amd`, two crates, both `#![forbid(unsafe_code)]`.

The matrix is accumulated into a `BTreeMap`, not a hash map: the
iteration order becomes the summation order, and a hash map's varies per
process. `tests/cross_process_determinism.rs` exists because that has
bitten before.

`whiten` returns its result in the permuted order and leaves it there —
a dot product does not care, as long as both operands were permuted the
same way — so `bilinear` is unchanged.

Correctness: the existing analytic goldens are 2x2 and 3x3, too small to
permute or fill in, so they could not have caught a symbolic-pass bug.
`agrees_with_a_dense_reference_on_random_sparse_systems` checks every
bilinear form against a deliberately naive dense factorisation that
shares no code with the thing it is checking, on chain-plus-long-range
matrices up to n = 60.

Closes #52.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-10 07:08:18 +02:00
logaritmisk d36d125e52 Merge infra/bench-variance (#54) 2026-09-10 06:55:25 +02:00
logaritmiskandClaude Opus 5 0801acebd1 ci: measure the runner's own benchmark variance, and fix the joint bench
#54 asks whether benchmark regressions can be gated. The threshold is
the whole problem — too tight and CI goes red on noise, which trains the
reflex to re-run until green; too loose and it never fires — and which
of those is possible depends on a number nobody has measured. This adds
a manually-triggered job that runs one unchanged benchmark ten times and
reports min/median/max/mean and the spread.

`joint_factorise_480_appearances` is the probe: ~9 ms, long enough not
to be dominated by timer overhead, and the measurement this crate most
wants protected — it is the dense factorisation #52 is about replacing.

`benches/joint.rs` did not run at all. Its fixture asked for
`epsilon: 1e-10` within `max_iter: 30` and never got there, so once
`converge` stopped returning short fits silently it panicked:

    NotConverged { iterations: 30, final_step: (4.5e-4, 0.0), epsilon: 1e-10 }

It now uses the default `ITERATIONS` cap. Measuring a factorisation on
an unconverged fit would have been measuring something nobody runs. The
other four benchmarks were checked and are fine.

Two things in the report step were got wrong first and fixed by running
them, not by reading them:

- `asort` is a gawk extension and the runner's `awk` is mawk. Sorting
  goes through `sort -n` instead.
- Criterion picks a unit per run, so a mixed batch would compare 9 ms
  against 9 us as though they were the same number. The job refuses to
  report a spread unless every run agrees on the unit.

Refs #54.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-10 06:55:25 +02:00
logaritmisk 1ad789cf40 Merge api/param-reorder (#72) 2026-09-10 06:49:23 +02:00
logaritmiskandClaude Opus 5 b553c630f5 refactor!: K comes first in History, HistoryBuilder and Joint
`K` is the one type parameter people change, and it was last. Naming a
history in a struct field meant writing all four to say one thing:

    struct Ladder { history: History<i64, ConstantDrift, NullObserver, String> }
    struct Analysis<'h> { joint: Joint<'h, i64, ConstantDrift, NullObserver, &'static str> }

Now:

    struct Ladder { history: History<String> }
    struct Analysis<'h> { joint: Joint<'h> }

`History<K, T, D, O>`, all four defaulted. Bounds may reference later
parameters, so `D: Drift<T> = ConstantDrift` is legal in third position.
`Joint` gains the same defaults, so `Joint<'h, String>` spells it.

72 call sites swapped, and the reorder makes most of them shorter: 18
now read `History<String>` and the `&'static str` ones read `History`.
The two turbofished builders shrink from
`HistoryBuilder::<Untimed, _, _, String>::new()` to
`HistoryBuilder::<String, Untimed>::new()`.

`Joint` keeps `O` structurally, defaulted rather than removed. #72 notes
it never touches the observer, which is true — but it borrows the whole
`&'h History<K, T, D, O>` and calls `History::resolve_terms`, so dropping
the parameter means either a view type or moving that method off
`History`. The default already buys the entire user-visible benefit,
which was the spelling.

Refs #72.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-10 06:49:22 +02:00
logaritmisk d2ab4446ef Merge api/joint-layering (#78) 2026-09-10 06:40:31 +02:00
logaritmiskandClaude Opus 5 e72bf3894c refactor!: the joint is reached through Joint, not mirrored on History
`posterior_of`, `posterior_of_at` and `expected_variance_reduction`
existed twice: once on `Joint`, and once on `History` as one-shot
wrappers whose whole body was `self.joint()?.<same>(..)`.

The wrappers re-factorised on every call — their own docs said so,
warning the reader to take a `Joint` instead — and they were what
smuggled the scored-only precondition onto the flat surface. A user
following the quickstart builds a ranked history, sees `posterior_of` in
the method list, and it never works. `h.joint()?.posterior_of(..)` is
one call longer and tells the truth: you need a joint, and a joint needs
a scored history.

That leaves three tiers instead of a flat surface with a hidden
precondition: `History` fits and reads, `predict_*` forecasts, `Joint`
answers exact joint questions.

`predict_margin` was itself calling `self.posterior_of`; it goes through
`self.joint()?` directly now.

The `Joint` methods' docs referred back to the wrappers for their real
content ("Identical to `History::posterior_of`, without re-paying the
factorisation"), so they now carry it: what a linear functional means,
which appearance each competitor is read at, and why
`expected_variance_reduction` belongs on the handle.

`tests/joint_handle.rs` had three tests comparing the wrapper against
the handle. That comparison is gone, but the property behind it is not —
they now compare a *reused* joint against a *fresh* one per question,
which is the actual correctness claim behind caching the factorisation
(#51), without the wrapper in the middle.

Closes #78.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-10 06:40:31 +02:00
logaritmisk 56193609f7 Merge api/renames (#75, #78) 2026-09-09 23:23:12 +02:00
logaritmiskandClaude Opus 5 13a395fdc9 refactor!: scores_with_noise, and History::quality
Two names that described the wrong thing.

`scores_with_sigma(scores, sigma)` reads as "these scores have prior
sigma 2.0". The quantity is observation noise on the score *margin*, in
the units of the scores, and it is spelled `score_sigma` at every config
site — `HistoryBuilder::score_sigma`, `GameOptions::score_sigma`,
`EventKind::Scored { score_sigma }` — so this was the one place the
crate used a third meaning of "sigma" for it. Its own doc had to
disambiguate itself: "`sigma` overrides `HistoryBuilder::score_sigma`".
`scores_with_noise(scores, score_sigma)` on both `Outcome` and
`EventBuilder`.

`predict_quality` predicts nothing. Its own doc says it answers "is this
matchup *fair*", not "what will happen", and the `predict_*` family is
otherwise exactly the methods returning a probability or a distribution
over outcomes. `History::quality` also makes the free/method pair
consistent: free `quality` pairs with `History::quality` the way free
`expected_information_gain` already pairs with
`History::expected_information_gain`. The rule that was already being
followed and never stated — a free function scores a hypothetical from
explicit parameters, the same-named method asks it against the fit — is
now written on the method.

Closes #75. Refs #78 (part 4).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 23:23:12 +02:00
logaritmisk 6e2ce69728 Merge api/retire-index (#73) 2026-09-09 23:18:54 +02:00
logaritmiskandClaude Opus 5 faa25fb3b1 refactor!: retire Index, intern and lookup
`Index` was public, `History::intern` and `History::lookup` returned
one, and no public method anywhere accepted one. It was a handle with
nowhere to go — and `key_table.rs` advertised the hot-path story it was
meant to enable ("power users can promote `&K` to `Index` and skip the
lookup"), which was never reachable through the public API.

It also shadowed `std::ops::Index`, which `CompetitorStore` implements,
so `use trueskill_tt::*` alongside `use std::ops::*` collided.

All three are `pub(crate)` now. `intern` stays internal because
ingestion needs it; `lookup` is gone entirely, since `current_skill`,
`rating` and `learning_curve` already answer "does this history know
this key" and all three take a borrowed key.

The three tests that used them asserted things a caller cannot observe.
They now assert what the interning bought:

- `record_winner_creates_two_competitors` compares posteriors instead of
  comparing two opaque indices for inequality.
- `intern_is_idempotent` becomes `a_repeated_key_is_one_competitor` — a
  key appearing in two events gives one competitor with a two-point
  learning curve, which is the observable form of the same claim.
- `lookup_returns_none_for_missing` becomes `an_unknown_key_is_unknown`.

Closes #73.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 23:18:54 +02:00
logaritmisk ddbac87744 Merge api/gaussian-operators (#71) 2026-09-09 23:13:10 +02:00
logaritmiskandClaude Opus 5 076a7ded8c feat!: Gaussian's EP operations stop wearing arithmetic's clothes
`Gaussian` publicly implemented `Mul`, `Div`, `Add` and `Sub`. They were
the EP product, cavity and variance-space convolutions, and every one of
them lies to a reader who takes the operator at face value:

    a = N(10, 2)   b = N(4, 3)   c = N(1, 1)

    a * b        N(8.15, 1.66)   not 40
    a - b        sigma GREW, 2 -> sqrt(4 + 9)
    a * N(1, 0)  mu = NaN        "multiply by one"
    a / c        pi = -0.75      mu() prints a confident 0

The last is this crate's signature defect on a public operator. `Div` is
the cavity and can legitimately leave a negative precision, which is not
a distribution — and `mu()`/`sigma()` guard `pi <= 0` and report `0.0`
and `inf`, so it comes back as a plausible number with no panic, no
`Debug` marker and nothing to test against.

The four impls are now `pub(crate)` inherent methods that say what they
do: `ep_product`, `cavity`, `convolve`, `convolve_diff`, plus `scale`
for the one operation that genuinely is arithmetic. Nothing in a user's
workflow needed operator syntax; inference did, and it still has it.

`pi()` and `tau()` follow. Storing natural parameters is a performance
decision — it makes message passing two adds — not a contract. The
public surface is now exactly: `from_ms`, `from_mv`, `mu`, `sigma`,
`variance`, `probability_below`, `probability_above`. `from_mv` and
`variance` are promoted from `pub(crate)`; they are the honest pair for
callers who already hold a variance and should not pay a round trip
through the square root.

Four integration tests asserted bit-identity on `(pi, tau)`. They assert
it on `(mu, variance)` instead — still `assert_eq!`, still exact, and
`1/pi` and `tau/pi` are deterministic, so bit-equal natural parameters
give bit-equal moments. `a_nan_sigma_passes_through_from_ms` drops its
`|| g.pi().is_nan()` half: `sigma()` substitutes for `pi <= 0` and
`pi == inf`, so NaN survives to it only from a NaN precision.

`benches/gaussian.rs` is deleted. It timed two f64 additions through the
public operators, and keeping those public solely to feed it is the same
thing #73 objected to when a benchmark was dictating five public types.
The paths it covered are exercised by `batch` and `history_converge`
through the real call chain.

Closes #71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 23:13:10 +02:00
logaritmisk 8e34410db0 Merge api/game-rename (#69) 2026-09-09 22:55:05 +02:00
logaritmiskandClaude Opus 5 92d690d0f8 feat!: Game is the type you get, and one_v_one returns one
`lib.rs` advertised `Game` in "Core types" as "one match in isolation".
It had no public constructor: every `Game::*` returned `OwnedGame`, so
`let g: Game = Game::ranked(..)?` did not compile.

Names swapped. The public type is the owned one — `Game<T, D>`, no
lifetime — and the borrowing form is `pub(crate) GameRef<'a, T, D>`,
which is what it always was: an implementation detail about whether the
result and weight slices are borrowed from `History`'s storage. That
distinction meant nothing to someone scoring one match, and it showed
the module's surface twice in rustdoc, since both types carried
`posteriors()` / `log_evidence()`.

`one_v_one` returned `(Gaussian, Gaussian)` while every sibling returned
a game, making it the one constructor you could not ask for
`log_evidence()`. It returns `Self` now; `.posteriors()` recovers the old
shape, and the test that covers it now also asserts the evidence of two
identical ratings is exactly `ln(0.5)`.

`ranked`, `scored` and `free_for_all` had `# Errors` as their entire
doc, so rustdoc's index rendered the error list as the summary. They
have summary lines, and `Game` has a worked example — it was advertised
as a core type with none anywhere in the crate.

Closes #69.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 22:55:05 +02:00
logaritmisk 4c2e98c54b Merge api/ergonomics (#72) 2026-09-09 22:11:25 +02:00
logaritmiskandClaude Opus 5 92ae5fca17 feat!: prediction and joint queries take borrowed keys
`&[&[&K]]` was the worst shape in the API. At `K = String` — the
realistic case, where names arrive owned from a database or CSV — a
string literal was *impossible*, and asking "who wins" cost six lines
and four allocations of temporaries that all had to outlive the call:

    let ta = vec![a.to_string()];
    let ra: Vec<&String> = ta.iter().collect();
    ...
    self.history.predict_win_probabilities(&teams)

All seven `predict_*` / `expected_*` methods, `posterior_of`,
`posterior_of_at` and the `Joint` mirrors are now generic over the
borrowed key, the same way `current_skill` and `learning_curve` already
were. `member_skills` and `resolve_terms` only ever did two things with
a key — `keys.get` and `format!("{key:?}")` — and neither needed `K`.

    h.predict_win_probabilities(&[&["alice"], &["bob"]])   // K = String
    h.predict_win_probabilities(&[&["alice"], &["bob"]])   // K = &'static str
    h.posterior_of(&[("alice", 1.0), ("bob", -1.0)])

One spelling for both key types, and `K: Debug` becomes `Q: Debug`, so a
key type no longer has to be `Debug` to run a prediction. The old
`&[&[&"a"]]` spelling still compiles at the default key type, where `Q`
infers to `&str` and the two shapes coincide.

The one cost: `predict_outcome(&[])` can no longer infer `Q` — nothing
in an empty slice names it. It needs an annotation, and only on that
degenerate call.

`lookup` carried `ToOwned<Owned = K>`, copy-pasted from `intern`, which
genuinely needs it to create the entry. `lookup` never creates, and its
five neighbours all accept `h.f("alice")` already. Dropping the bound
strictly widens what compiles.

`HistoryBuilder::gamma` is shorthand for `.drift(ConstantDrift::new(g))`.
Drift is the most-tuned parameter after `sigma` and `GAMMA` is a public
constant, but setting it meant first discovering `ConstantDrift`, a type
a caller has no other reason to name. On the `ConstantDrift` builder
only, since `gamma` is that model's parameter rather than something
every `Drift` has, and rejecting a negative value for the same reason as
`sigma` and `beta`: it enters squared.

Refs #72 (items 2 and 3, plus the gamma shorthand; the type-parameter
reorder in item 1 is still open).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 22:11:25 +02:00
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
74 changed files with 4799 additions and 1350 deletions
+91
View File
@@ -0,0 +1,91 @@
# Measure the CI runner's own benchmark variance.
#
# #54 asks whether benchmark regressions can be gated. The threshold is the
# whole problem: too tight and CI goes red on noise, which trains people to
# re-run until green; too loose and it never fires. Which of those is possible
# depends on a number nobody has measured — how much this runner's results move
# between identical runs.
#
# So: run one unchanged benchmark ten times and report the spread. If it is
# ~15%, a fixed-threshold gate is dead and the answer is a tracker; if it is
# ~2%, a gate at 10% is meaningful.
#
# Manual only. It takes ten benchmark runs and answers a question that is asked
# once, not every push.
name: Benchmark variance
on:
workflow_dispatch:
inputs:
runs:
description: How many repeats
required: false
default: "10"
jobs:
variance:
name: runner variance on one benchmark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# `joint_factorise_480_appearances` is the right probe: ~9 ms, so it is
# long enough not to be dominated by timer overhead, and it is the
# measurement this crate most wants protected — the dense factorisation
# #52 is about replacing.
- name: Warm up
run: cargo bench --bench joint -- joint_factorise_480_appearances --warm-up-time 1 --measurement-time 3
- name: Repeat the same benchmark
run: |
set -euo pipefail
for i in $(seq 1 "${{ inputs.runs || '10' }}"); do
echo "== run $i =="
cargo bench --bench joint -- \
joint_factorise_480_appearances --warm-up-time 1 --measurement-time 3 \
2>&1 | tee -a raw.txt
done
- name: Report the spread
run: |
set -euo pipefail
# Criterion prints `time: [lo mid hi]` with a unit after each. Take
# the midpoints. `sort -n` rather than awk's `asort`, which is a gawk
# extension the runner's mawk does not have — that failed on the
# first try here.
grep -oE 'time:[[:space:]]+\[[^]]+\]' raw.txt \
| sed -E 's/.*\[[^ ]+ [^ ]+ ([0-9.]+) ([^ ]+).*/\1 \2/' > mids.txt
echo "--- midpoints ---"
cat mids.txt
# Criterion picks a unit per run, so mixed units would have us
# comparing 9 ms against 9 us as if they were the same number — the
# plausible-looking wrong answer this crate keeps removing. Refuse.
if [ "$(cut -d' ' -f2 mids.txt | sort -u | wc -l)" -ne 1 ]; then
echo "runs reported different units; the spread would be meaningless"
cut -d' ' -f2 mids.txt | sort | uniq -c
exit 1
fi
sort -n mids.txt | awk '{ v[NR]=$1; u=$2; s+=$1 }
END {
if (NR == 0) { print "no samples parsed - see the raw.txt artifact"; exit 1 }
printf "n = %d\n", NR
printf "min = %.4f %s\n", v[1], u
printf "median = %.4f %s\n", v[int((NR+1)/2)], u
printf "max = %.4f %s\n", v[NR], u
printf "mean = %.4f %s\n", s/NR, u
printf "spread = %.2f%% (max-min)/min\n", 100*(v[NR]-v[1])/v[1]
print ""
print "Read it against #54: a spread near 15% kills both"
print "fixed-threshold options and the answer is a tracker;"
print "a spread near 2% makes a gate at 10% meaningful."
}'
- uses: actions/upload-artifact@v4
if: always()
with:
name: bench-variance-raw
path: |
raw.txt
mids.txt
+61 -10
View File
@@ -2,6 +2,65 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 0.9.0 - 2026-09-10
### Breaking Changes
- fix!: propagate NaN through the convergence reduction
- fix!: collapse a drift too small to represent, on a relative threshold
- fix!: report an unresolvable prediction grid instead of clamping
- fix!: validate the constructors below HistoryBuilder
- fix!: seal ConstantDrift's field so gamma can be validated
- fix!: make the Time generic reachable
- refactor!: un-export six types that no caller could reach
- fix!: correct eight wrong `# Errors` sections and seal the error variants
- fix!: per-key queries report unknown keys instead of a plausible constant
- fix!: no prediction path answers from a fit it cannot answer from
- docs!: one name for score noise, and say which of beta/sigma to turn
- fix!: non_exhaustive on ConvergenceReport, and not on the options structs
- feat!: prediction and joint queries take borrowed keys
- feat!: Game is the type you get, and one_v_one returns one
- feat!: Gaussian's EP operations stop wearing arithmetic's clothes
- refactor!: retire Index, intern and lookup
- refactor!: scores_with_noise, and History::quality
- refactor!: the joint is reached through Joint, not mirrored on History
- refactor!: K comes first in History, HistoryBuilder and Joint
- perf!: sparse Cholesky with an AMD ordering for the joint
- refactor!: typed discriminators for InferenceError
### Bug Fixes
- fix: take quality's determinant ratio in log space
- fix: keep the truncated variance representable in the far tail
- fix: route the last three transcendentals through libm, and enforce it
- fix: make posterior_of reproducible across processes
- fix: warn on dropped builders and values; stop exporting EP internals
### CI
- ci: measure the runner's own benchmark variance, and fix the joint bench
### Documentation
- docs: document the whole public surface and deny(missing_docs)
- docs: add a migration guide for 0.9.0, and drop merge noise from the changelog
### Features
- feat: add the missing trait impls and make `#[must_use]` consistent
- feat: complete the evidence matrix and add current_skills
- feat: PartialEq on the config types, and pin the public trait impls
- feat: HistoryBuilder::default_rating_for, a rule instead of a roll call
### Refactor
- refactor: one word per concept
### Testing
- test: scale the ceiling sweep by build profile
- test: make the determinism test exercise the parallel sweep
## 0.8.0 - 2026-09-08 ## 0.8.0 - 2026-09-08
### Breaking Changes ### Breaking Changes
@@ -25,13 +84,9 @@ All notable changes to this project will be documented in this file.
- feat: add EventBuilder::members for per-member configuration - feat: add EventBuilder::members for per-member configuration
### Other (unconventional) ### Miscellaneous Tasks
- Merge branch 'fix/ingestion-shape' - chore: Release trueskill-tt version 0.8.0
- Merge branch 'feat/convergence-strictness'
- Merge branch 'fix/non-finite-weights'
- Merge branch 'test/close-coverage-gaps'
- Merge branch 'fix/game-boundary'
### Testing ### Testing
@@ -47,10 +102,6 @@ All notable changes to this project will be documented in this file.
- chore: Release trueskill-tt version 0.7.0 - chore: Release trueskill-tt version 0.7.0
### Other (unconventional)
- Merge branch 'feat/joint-handle'
## 0.6.0 - 2026-09-08 ## 0.6.0 - 2026-09-08
### Breaking Changes ### Breaking Changes
+4 -5
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "trueskill-tt" name = "trueskill-tt"
version = "0.8.0" version = "0.9.0"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing" description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
@@ -33,10 +33,6 @@ bench = false
name = "batch" name = "batch"
harness = false harness = false
[[bench]]
name = "gaussian"
harness = false
[[bench]] [[bench]]
name = "history_converge" name = "history_converge"
harness = false harness = false
@@ -51,12 +47,15 @@ harness = false
[dependencies] [dependencies]
approx = { version = "0.5.1", optional = true } approx = { version = "0.5.1", optional = true }
feral-amd = "0.2"
libm = "0.2.16" libm = "0.2.16"
rayon = { version = "1", optional = true } rayon = { version = "1", optional = true }
smallvec = "1" smallvec = "1"
[features] [features]
approx = ["dep:approx"] approx = ["dep:approx"]
# Exposes the joint sparsity pattern for the #52 measurement. Test-only.
measure-sparsity = []
rayon = ["dep:rayon"] rayon = ["dep:rayon"]
[dev-dependencies] [dev-dependencies]
+204
View File
@@ -0,0 +1,204 @@
# Migrating
## 0.8.0 → 0.9.0
Twenty-one breaking changes. Nearly all of them are mechanical, and the
compiler finds every one — nothing here changes behaviour silently.
Three exceptions are worth reading before you start, because they change
what an existing, compiling call *returns*: [unknown keys](#unknown-keys-are-reported-not-skipped),
[predictions from a broken fit](#predictions-refuse-a-fit-they-cannot-answer-from),
and [`Gaussian`'s operators](#gaussians-operators-are-gone).
### Type parameters: `K` comes first
`K` was last, so naming a history meant writing all four parameters to change
the one that matters.
```rust
// before
struct Ladder { history: History<i64, ConstantDrift, NullObserver, String> }
struct Analysis<'h> { joint: Joint<'h, i64, ConstantDrift, NullObserver, &'static str> }
// after
struct Ladder { history: History<String> }
struct Analysis<'h> { joint: Joint<'h> }
```
`History<K, T, D, O, R>` — key, time, drift, observer, rating rule — all
defaulted. `HistoryBuilder` matches. There is a fifth parameter now (`R`), and
you will never write it unless you use `default_rating_for`.
`HistoryBuilder::<Untimed, _, _, String>::new()` becomes
`HistoryBuilder::<String, Untimed>::new()`.
### Predictions and joint queries take borrowed keys
At `K = String` a string literal used to be impossible, and asking "who wins"
cost four allocations of temporaries that all had to outlive the call.
```rust
// before, at K = String
let ta = vec![a.to_string()];
let ra: Vec<&String> = ta.iter().collect();
let tb = vec![b.to_string()];
let rb: Vec<&String> = tb.iter().collect();
let teams: Vec<&[&String]> = vec![&ra, &rb];
h.predict_win_probabilities(&teams)?;
// after, at either key type
h.predict_win_probabilities(&[&["alice"], &["bob"]])?;
h.posterior_of(&[("alice", 1.0), ("bob", -1.0)])?;
```
At `K = &'static str` the old `&[&[&"a"]]` spelling still compiles — `Q` infers
to `&str` and the two shapes coincide — so this is only a break for owned keys,
where nothing compiled before.
One cost: `predict_outcome(&[])` can no longer infer the key type. Annotate it,
`let none: &[&[&str]] = &[];`. It bites only on that degenerate call.
### Unknown keys are reported, not skipped
**Read this one.** `log_evidence_for` used to `filter_map` unknown keys away,
and an empty target list means *no restriction* downstream — so a list of
entirely unknown keys returned the **whole-history** value. Measured:
`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.
```rust
let e = h.log_evidence_for(&["alice"])?; // now Result
let curve = h.learning_curve("alice"); // now Option
```
Note `&["alice"]`, not `&[&"alice"]`. These take borrowed keys like the
prediction methods, so one spelling works at both key types.
`learning_curve` and `filtered_learning_curve` return `Option`: `None` is "never
heard of this key", `Some(vec![])` is "known, has not played". They used to be
the same empty `Vec`.
### Predictions refuse a fit they cannot answer from
**Read this one too.** `converge` already refused to report a NaN fit, but
nothing stopped a caller ignoring that error and predicting anyway. On a
NaN-poisoned fit, `quality` returned `Ok(NaN)`, `predict_outcome().total()` was
`NaN`, and `predict_win_probabilities` returned `Ok([0.0, 0.0])` — finite,
plausible, and summing to zero against a doc promising one.
Every `predict_*` path now returns `Err(NonFiniteSkill { .. })` there, and
`Err(NoPerformanceVariance)` when `beta` is zero and every skill is a point
mass. If you were ignoring `converge`'s error, you will start seeing these.
### `Gaussian`'s operators are gone
`Mul`, `Div`, `Add` and `Sub` were the EP product, cavity and variance-space
convolutions, not arithmetic — `N(10,2) * N(4,3)` is `N(8.15, 1.66)`, and
`a / c` could leave a negative precision whose `mu()` printed a confident `0`.
They are `pub(crate)` inherent methods now. The public surface is `from_ms`,
`from_mv`, `mu`, `sigma`, `variance`, `probability_below`, `probability_above`;
`pi()` and `tau()` are internal. If you compared fits bit-for-bit on
`(pi, tau)`, compare `(mu, variance)` — same information, still exact.
### `Game` is the type you get
`Game::ranked` returned an `OwnedGame`, so `let g: Game = Game::ranked(..)?` did
not compile. Names swapped: `Game<T, D>` is public, `OwnedGame` is gone.
`one_v_one` returns a `Game` rather than `(Gaussian, Gaussian)`, so it can be
asked for `log_evidence()` like its siblings. For the old shape:
```rust
let post = Game::one_v_one(&a, &b, outcome, &opts)?.posteriors();
let (a_post, b_post) = (post[0][0], post[1][0]);
```
### The joint is reached through `Joint`
`History::posterior_of`, `posterior_of_at` and `expected_variance_reduction`
were one-shot wrappers that re-factorised on every call. They are gone.
```rust
// before — pays for the factorisation twice
let a = h.posterior_of(&terms)?;
let b = h.posterior_of(&other)?;
// after — pays once, and the borrow says so
let joint = h.joint()?;
let a = joint.posterior_of(&terms)?;
let b = joint.posterior_of(&other)?;
```
### `InferenceError` is typed
Six variants carried `&'static str` discriminators. Four enums replace them:
`Parameter`, `Shape`, `OutcomeKind`, `CompetitorField`.
```rust
// before
InferenceError::InvalidParameter { name: "drift_scale", value }
InferenceError::MismatchedShape { kind: "ranks vs teams", .. }
InferenceError::WrongOutcomeKind { context, expected, got } // three &str
// after
InferenceError::InvalidParameter { parameter: Parameter::DriftScale, value }
InferenceError::MismatchedShape { shape: Shape::OutcomeVsTeams, .. }
InferenceError::WrongOutcomeKind { expected: OutcomeKind::Ranked, got }
```
Variants that split or merged:
| before | after |
|---|---|
| `InvalidProbability { value }` | `InvalidParameter { parameter: Parameter::PDraw, value }` |
| `JointUnavailable { reason }` | `EmptyHistory`, `JointRequiresScoredEvents`, `NotPositiveDefinite` |
| `NonFiniteResult { context, step }` | `NonFiniteStep { context, step }` (convergence), `NonFiniteSkill { mu, sigma }` (prediction) |
Every struct variant is `#[non_exhaustive]`, so `match` with a `..` and
construct through the library.
### Renames
| before | after |
|---|---|
| `History::predict_quality` | `History::quality` |
| `Outcome::scores_with_sigma` | `Outcome::scores_with_noise` |
| `EventBuilder::scores_with_sigma` | `EventBuilder::scores_with_noise` |
| `Outcome::Scored { sigma }` | `Outcome::Scored { score_sigma }` |
| `OwnedGame` | `Game` |
### Removed
`History::intern`, `History::lookup` and `Index`. Nothing public ever accepted
an `Index`, so there was nothing to do with one. `current_skill`, `rating` and
`learning_curve` answer "does this history know this key" and all take a
borrowed key.
`TimeSlice`, `EventKind`, `KeyTable`, `CompetitorStore`, `Competitor` and `N01`
are no longer exported. None was obtainable from a `History`.
### Warnings, not errors
`#[must_use]` now sits on the value types, so a dropped `EventBuilder` — an
event you forgot to `.commit()`, previously a silent no-op — warns. So do
dropped `Team`, `Member`, `Outcome` and `Joint` values. A `-D warnings` build
will need updating.
### Nothing to do, but worth knowing
The joint factorisation is sparse with an AMD fill-reducing ordering:
**745 ms → 1.11 ms** on a 1976-appearance fixture, and near-linear scaling where
it was cubic. Results are unchanged; `feral-amd` is a new dependency (two
crates, both `#![forbid(unsafe_code)]`).
`HistoryBuilder::gamma(f64)` is shorthand for
`.drift(ConstantDrift::new(gamma))`.
`History::current_skills()` is the leaderboard query — every competitor's latest
posterior in one pass, rather than a full smoothed curve each.
`History::filtered_log_evidence_for(&["alice"])` completes the evidence matrix:
forward-only *and* key-restricted, which is what per-competitor prequential
scoring needs.
+155 -22
View File
@@ -1,15 +1,142 @@
# TrueSkill - Through Time # 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) A Rust port of
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis) [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R) ## Install
- [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/) ```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(NonFiniteStep)` 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 ## Drift
@@ -203,7 +330,7 @@ stay available at any size:
Unknown keys are an error by default, not a silent omission: a team the history Unknown keys are an error by default, not a silent omission: a team the history
has never seen cannot produce a confident-looking probability. The error names has never seen cannot produce a confident-looking probability. The error names
the key, and every key must already be known — pre-filter with `lookup` or the key, and every key must already be known — pre-filter with
`current_skill` if your caller cannot guarantee that. `current_skill` if your caller cannot guarantee that.
If predicting for competitors you have never seen is the point rather than a If predicting for competitors you have never seen is the point rather than a
@@ -228,11 +355,11 @@ certain because it knows less.
```rust ```rust
use trueskill_tt::History; use trueskill_tt::History;
let mut h = History::builder().build(); let mut h = History::default();
h.record_winner(&"alice", &"bob", 1).unwrap(); 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 // "How sure am I that this is below the cutoff?" — a probability, not a
// `mu + z * sigma` band whose confidence drifts as sigma changes. // `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 ```rust
use trueskill_tt::History; use trueskill_tt::History;
let mut h = History::builder().build(); let mut h = History::default();
for t in 1..=10 { for t in 1..=10 {
h.record_winner(&"veteran", &"regular", t).unwrap(); h.record_winner(&"veteran", &"regular", t).unwrap();
h.record_winner(&"regular", &"veteran", t + 100).unwrap(); h.record_winner(&"regular", &"veteran", t + 100).unwrap();
@@ -278,16 +405,22 @@ expensive than `quality()`. Scoring every pairing among `n` competitors is
`O(n² × outcomes)` passes — shortlist with `quality()` or `O(n² × outcomes)` passes — shortlist with `quality()` or
`predict_win_probabilities` first, then score only the shortlist. `predict_win_probabilities` first, then score only the shortlist.
## Todo ## Other implementations
- [x] Implement approx for Gaussian - [ttt-scala](https://github.com/ankurdave/ttt-scala)
- [x] Add more tests from `TrueSkillThroughTime.jl` - [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis)
- [x] Generalise a time axis — `Time` is now a trait (`Untimed`, `i64`), not an enum - [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
- [x] Add examples (`examples/atp.rs`, `examples/scored.rs`) - [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R)
- [x] Add Observer (`Observer` / `NullObserver`) - [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf)
- [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`) - [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/)
- [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 ## 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. `CHANGELOG.md` lists them and [`MIGRATING.md`](MIGRATING.md) explains what
to do about them.
## License ## License
+45 -39
View File
@@ -1,49 +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 criterion::{Criterion, criterion_group, criterion_main};
use trueskill_tt::{ use smallvec::smallvec;
BETA, Competitor, ConvergenceOptions, EventKind, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA, use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
TimeSlice, drift::ConstantDrift, gaussian::Gaussian, storage::CompetitorStore,
};
fn criterion_benchmark(criterion: &mut Criterion) { 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"); // 100 events, all at one time, so the history has a single slice.
let b = index_map.get_or_create("b"); let events: Vec<Event<i64, &'static str>> = (0..100)
let c = index_map.get_or_create("c"); .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(); criterion.bench_function("slice_sweep_100_events", |b| {
b.iter_batched(
for agent in [a, b, c] { build,
agents.insert( |mut h| {
agent, // `converge_partial`, not `converge`: one iteration is
Competitor { // deliberately short of convergence and `converge` reports that
rating: Rating::new( // as an error.
Gaussian::from_ms(MU, SIGMA), let _ = h.converge_partial();
BETA,
ConstantDrift::new(GAMMA),
),
..Default::default()
}, },
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))
}); });
} }
-53
View File
@@ -1,53 +0,0 @@
use criterion::{Criterion, criterion_group, criterion_main};
use trueskill_tt::gaussian::Gaussian;
fn benchmark_gaussian_arithmetic(criterion: &mut Criterion) {
// Define test Gaussians
let g1 = Gaussian::from_ms(25.0, 25.0 / 3.0);
let g2 = Gaussian::from_ms(0.0, 1.0);
let g3 = Gaussian::from_ms(1.0, 1.0);
// Benchmark addition
criterion.bench_function("Gaussian::add", |bencher| {
bencher.iter(|| g1 + g2);
});
// Benchmark subtraction
criterion.bench_function("Gaussian::sub", |bencher| {
bencher.iter(|| g1 - g3);
});
// Benchmark multiplication
criterion.bench_function("Gaussian::mul", |bencher| {
bencher.iter(|| g1 * g2);
});
// Benchmark division
// NOTE: numerator must have higher precision (smaller sigma) than the
// denominator in this representation; g2 (sigma=1) / g1 (sigma=8.33) is
// well-defined, whereas g1 / g2 underflows and panics in mu_sigma.
criterion.bench_function("Gaussian::div", |bencher| {
bencher.iter(|| g2 / g1);
});
// Benchmark natural parameter conversions
criterion.bench_function("Gaussian::pi", |bencher| {
bencher.iter(|| g1.pi());
});
criterion.bench_function("Gaussian::tau", |bencher| {
bencher.iter(|| g1.tau());
});
// Benchmark combined pi/tau operations (used in mul/div)
criterion.bench_function("Gaussian::pi_tau_combined", |bencher| {
bencher.iter(|| {
let pi = g1.pi();
let tau = g1.tau();
(pi, tau)
});
});
}
criterion_group!(benches, benchmark_gaussian_arithmetic);
criterion_main!(benches);
+2 -4
View File
@@ -25,16 +25,14 @@
use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
use smallvec::smallvec; use smallvec::smallvec;
use trueskill_tt::{ use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team,
};
fn build_history_1v1( fn build_history_1v1(
n_events: usize, n_events: usize,
n_competitors: usize, n_competitors: usize,
events_per_slice: usize, events_per_slice: usize,
seed: u64, seed: u64,
) -> History<i64, ConstantDrift, NullObserver, String> { ) -> History<String> {
let mut rng = seed; let mut rng = seed;
let mut next = || { let mut next = || {
rng = rng rng = rng
+2 -4
View File
@@ -32,8 +32,7 @@ fn bench_ingest(c: &mut Criterion) {
b.iter_batched( b.iter_batched(
|| events(n, 0), || events(n, 0),
|evs| { |evs| {
let mut h: History<i64, _, _, String> = let mut h: History<String> = History::builder().key_type::<String>().build();
History::builder().key_type::<String>().build();
for ev in evs { for ev in evs {
h.add_events(std::iter::once(ev)).unwrap(); h.add_events(std::iter::once(ev)).unwrap();
} }
@@ -47,8 +46,7 @@ fn bench_ingest(c: &mut Criterion) {
b.iter_batched( b.iter_batched(
|| events(n, 0), || events(n, 0),
|evs| { |evs| {
let mut h: History<i64, _, _, String> = let mut h: History<String> = History::builder().key_type::<String>().build();
History::builder().key_type::<String>().build();
h.add_events(evs).unwrap(); h.add_events(evs).unwrap();
black_box(h.time_slices_len()) black_box(h.time_slices_len())
}, },
+13 -4
View File
@@ -10,16 +10,22 @@ use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team}; use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
/// 30 slices of 8 duels: 480 appearances over 100 competitors. /// 30 slices of 8 duels: 480 appearances over 100 competitors.
fn fitted() -> History<i64, ConstantDrift, trueskill_tt::NullObserver, String> { fn fitted() -> History<String> {
let mut h: History<i64, ConstantDrift, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.mu(0.0) .mu(0.0)
.sigma(6.0) .sigma(6.0)
.beta(1.0) .beta(1.0)
.score_sigma(2.0) .score_sigma(2.0)
.drift(ConstantDrift::new(0.05)) .drift(ConstantDrift::new(0.05))
// `max_iter: 30` was here, and this fixture needs more: `converge`
// reported `NotConverged { iterations: 30, final_step: (4.5e-4, 0.0) }`
// once it stopped returning short fits silently. The benchmark measures
// the factorisation, whose cost depends on the fit's *shape* rather
// than its exactness — but measuring it on an unconverged fit is still
// measuring something nobody would run.
.convergence(ConvergenceOptions { .convergence(ConvergenceOptions {
max_iter: 30, max_iter: trueskill_tt::ITERATIONS,
epsilon: 1e-10, epsilon: 1e-10,
alpha: 1.0, alpha: 1.0,
}) })
@@ -58,8 +64,11 @@ fn bench_joint(c: &mut Criterion) {
bencher.iter(|| std::hint::black_box(h.joint().unwrap().variables())); bencher.iter(|| std::hint::black_box(h.joint().unwrap().variables()));
}); });
// Factorise-and-query, the cost the deleted `History::posterior_of`
// wrapper paid on every call. Kept as the baseline the cached query below
// is measured against.
c.bench_function("posterior_of_one_shot_480_appearances", |bencher| { c.bench_function("posterior_of_one_shot_480_appearances", |bencher| {
bencher.iter(|| std::hint::black_box(h.posterior_of(&terms).unwrap())); bencher.iter(|| std::hint::black_box(h.joint().unwrap().posterior_of(&terms).unwrap()));
}); });
let joint = h.joint().unwrap(); let joint = h.joint().unwrap();
+1 -1
View File
@@ -5,7 +5,7 @@ use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
fn bench_scored_history(c: &mut Criterion) { fn bench_scored_history(c: &mut Criterion) {
c.bench_function("scored_history_60_events_30_iter", |bencher| { c.bench_function("scored_history_60_events_30_iter", |bencher| {
bencher.iter(|| { bencher.iter(|| {
let mut h: History<i64, ConstantDrift, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.mu(25.0) .mu(25.0)
.sigma(25.0 / 3.0) .sigma(25.0 / 3.0)
+5
View File
@@ -58,6 +58,11 @@ commit_parsers = [
{ message = "^test", group = "Testing" }, { message = "^test", group = "Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true }, { message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore", group = "Miscellaneous Tasks" }, { message = "^chore", group = "Miscellaneous Tasks" },
{ message = "^ci", group = "CI" },
# Every branch lands with `--no-ff`, so a release's merge commits outnumber
# its real ones and say nothing the merged commits do not. They were
# filling an "Other (unconventional)" section with 14 lines of noise.
{ message = "^Merge ", skip = true },
{ body = ".*security", group = "Security" }, { body = ".*security", group = "Security" },
{ body = ".*", group = "Other (unconventional)" }, { body = ".*", group = "Other (unconventional)" },
] ]
+6 -5
View File
@@ -1,7 +1,8 @@
use plotters::prelude::*; use plotters::prelude::*;
use smallvec::smallvec;
use time::{Date, Month}; 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() { fn main() {
let mut csv = csv::Reader::open("examples/atp.csv").unwrap(); let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
@@ -42,7 +43,7 @@ fn main() {
} }
} }
let mut hist: History<i64, _, _, String> = History::builder() let mut hist: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.sigma(1.6) .sigma(1.6)
.drift(ConstantDrift::new(0.036)) .drift(ConstantDrift::new(0.036))
@@ -97,7 +98,7 @@ fn main() {
let mut y_spec = (f64::MAX, f64::MIN); let mut y_spec = (f64::MAX, f64::MIN);
for &(_, id, cutoff) in &players { 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 { if ts >= cutoff {
continue; continue;
} }
@@ -143,7 +144,7 @@ fn main() {
let mut upper = Vec::new(); let mut upper = Vec::new();
let mut lower = 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 { if ts >= cutoff {
continue; continue;
} }
+1 -2
View File
@@ -6,8 +6,7 @@
//! //!
//! Run with: `cargo run --example scored --release` //! Run with: `cargo run --example scored --release`
use smallvec::smallvec; use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team, smallvec::smallvec};
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
fn main() { fn main() {
let mut h = History::builder() let mut h = History::builder()
+12 -4
View File
@@ -123,7 +123,11 @@ fn u_minus_ln1p(u: f64) -> f64 {
/// - `EmptyTeam` if any team has no members. /// - `EmptyTeam` if any team has no members.
/// - `TooManyTeams` if the outcome space is too large to enumerate; see /// - `TooManyTeams` if the outcome space is too large to enumerate; see
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS). /// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`. /// - `InvalidParameter` for a `p_draw` 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 /// - Anything [`Game::ranked`](crate::Game::ranked) returns for a hypothetical
/// outcome. /// outcome.
pub fn expected_information_gain<T: Time, D: Drift<T>>( pub fn expected_information_gain<T: Time, D: Drift<T>>(
@@ -140,7 +144,8 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
}); });
} }
if !(0.0..1.0).contains(&options.p_draw) { if !(0.0..1.0).contains(&options.p_draw) {
return Err(InferenceError::InvalidProbability { return Err(InferenceError::InvalidParameter {
parameter: crate::Parameter::PDraw,
value: options.p_draw, value: options.p_draw,
}); });
} }
@@ -155,7 +160,7 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
.iter() .iter()
.map(|team| { .map(|team| {
team.iter() team.iter()
.fold(crate::N00, |acc, rating| acc + rating.performance()) .fold(crate::N00, |acc, rating| acc.convolve(rating.performance()))
}) })
.collect(); .collect();
@@ -363,7 +368,10 @@ mod tests {
)); ));
assert!(matches!( assert!(matches!(
expected_information_gain(&[&a, &a], &options(1.5)), expected_information_gain(&[&a, &a], &options(1.5)),
Err(InferenceError::InvalidProbability { .. }) Err(InferenceError::InvalidParameter {
parameter: crate::Parameter::PDraw,
..
})
)); ));
} }
+66 -8
View File
@@ -4,9 +4,38 @@ use std::time::Duration;
use smallvec::SmallVec; 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 { 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, 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, pub epsilon: f64,
/// EP damping factor in natural-parameter space: each per-factor /// EP damping factor in natural-parameter space: each per-factor
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is /// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
@@ -37,13 +66,13 @@ impl ConvergenceOptions {
pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> { pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> {
if !(self.alpha > 0.0 && self.alpha <= 1.0) { if !(self.alpha > 0.0 && self.alpha <= 1.0) {
return Err(crate::InferenceError::InvalidParameter { return Err(crate::InferenceError::InvalidParameter {
name: "alpha", parameter: crate::Parameter::Alpha,
value: self.alpha, value: self.alpha,
}); });
} }
if self.epsilon.is_nan() || self.epsilon < 0.0 { if self.epsilon.is_nan() || self.epsilon < 0.0 {
return Err(crate::InferenceError::InvalidParameter { return Err(crate::InferenceError::InvalidParameter {
name: "epsilon", parameter: crate::Parameter::Epsilon,
value: self.epsilon, value: self.epsilon,
}); });
} }
@@ -68,16 +97,45 @@ impl Default for ConvergenceOptions {
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged) there. /// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged) there.
/// From [`History::converge_partial`](crate::History::converge_partial) it may /// From [`History::converge_partial`](crate::History::converge_partial) it may
/// not be, and `converged` is what says so. /// not be, and `converged` is what says so.
#[derive(Clone, Debug)] /// Constructed only by `converge` / `converge_partial`, never by a caller, so
#[must_use = "from `converge_partial` this may describe a fit that stopped at \ /// `#[non_exhaustive]` costs nothing here and lets a future field be additive.
`max_iter`, which is wrong by a little rather than loudly \ /// The two *options* structs deliberately do not carry it — see the note on
broken — check `converged`, or bind it to `_` to say you have \ /// [`ConvergenceOptions`].
decided not to"] #[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct ConvergenceReport { pub struct ConvergenceReport {
/// Full forward+backward sweeps actually run. `0` for a history with no
/// time slices, which is converged trivially.
pub iterations: usize, 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), 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, 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, 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]>, pub per_iteration_time: SmallVec<[Duration; 32]>,
} }
+2 -2
View File
@@ -35,7 +35,7 @@ pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
/// `variance_for_elapsed` would have been worse: it runs inside the sweep, so a /// `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` /// 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 /// 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. /// rejecting NaN turned the `NonFiniteStep` reporting path into a crash.
/// ///
/// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value /// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value
/// back with [`ConstantDrift::gamma`]. /// back with [`ConstantDrift::gamma`].
@@ -43,7 +43,7 @@ pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
/// A non-finite gamma is caught a second time regardless: /// A non-finite gamma is caught a second time regardless:
/// `History::converge` validates the drift variance each competitor actually /// `History::converge` validates the drift variance each competitor actually
/// accumulates, which also covers a custom [`Drift`] implementation. /// accumulates, which also covers a custom [`Drift`] implementation.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct ConstantDrift(f64); pub struct ConstantDrift(f64);
impl ConstantDrift { impl ConstantDrift {
+441 -38
View File
@@ -39,36 +39,241 @@ pub enum UnknownKeys {
Prior, Prior,
} }
/// Which scalar an [`InferenceError::InvalidParameter`] is about.
///
/// A typed discriminator rather than a `&'static str`, so a caller can branch
/// on it and `Display` can state each parameter's actual valid range. Nine
/// distinct strings used to flow through this position, and the only thing a
/// caller could do with one was print it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Parameter {
/// Prior mean skill. Must be finite.
Mu,
/// Prior standard deviation. Must be finite and strictly positive.
Sigma,
/// Performance noise. Must be finite and non-negative.
Beta,
/// Draw probability. Must be in `[0.0, 1.0)`.
PDraw,
/// Observation noise on a score margin. Must be finite and strictly
/// positive.
ScoreSigma,
/// EP damping factor. Must be in `(0.0, 1.0]`.
Alpha,
/// Convergence threshold. Must be non-negative and not NaN.
Epsilon,
/// A competitor's multiplier on the drift variance. Must be finite and
/// non-negative.
DriftScale,
/// The variance a [`Drift`](crate::Drift) implementation actually produced
/// for a span. Must be finite and non-negative — checked because a custom
/// implementation is the one thing no constructor can validate up front.
DriftVariance,
/// A per-member weight on an event. Must be finite.
Weight,
/// A team's score on a scored event. Must be finite.
Score,
/// A team's rank on a ranked event. Must be finite.
Rank,
/// The winning team's index, as given to `Outcome::winner`. Must be less
/// than the team count.
WinnerIndex,
}
impl Parameter {
/// The range this parameter must lie in, for the `Display` message.
fn range(self) -> &'static str {
match self {
Self::Mu => "must be finite",
Self::Sigma => "must be finite and strictly positive",
Self::Beta => "must be finite and non-negative",
Self::PDraw => "must be in [0.0, 1.0)",
Self::ScoreSigma => "must be finite and strictly positive",
Self::Alpha => "must be in (0.0, 1.0]",
Self::Epsilon => "must be non-negative and not NaN",
Self::DriftScale => "must be finite and non-negative",
Self::DriftVariance => "must be finite and non-negative",
Self::Weight => "must be finite",
Self::Score => "must be finite",
Self::Rank => "must be finite",
Self::WinnerIndex => "must be less than the number of teams",
}
}
}
impl std::fmt::Display for Parameter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
Self::Mu => "mu",
Self::Sigma => "sigma",
Self::Beta => "beta",
Self::PDraw => "p_draw",
Self::ScoreSigma => "score_sigma",
Self::Alpha => "alpha",
Self::Epsilon => "epsilon",
Self::DriftScale => "drift_scale",
Self::DriftVariance => "drift variance",
Self::Weight => "weight",
Self::Score => "score",
Self::Rank => "rank",
Self::WinnerIndex => "winner index",
};
f.write_str(name)
}
}
/// Which two lengths an [`InferenceError::MismatchedShape`] found disagreeing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Shape {
/// The outcome describes a different number of teams than the event has.
OutcomeVsTeams,
/// A per-member weight list does not match the team's membership.
Weights,
/// A call that takes a fixed number of teams got a different number.
Teams,
/// One of `add_events_with_prior`'s parallel arrays disagreed with the
/// others.
///
/// Not reachable through the public API — the arrays are built together at
/// the ingestion chokepoint. Kept as a checked error rather than a
/// `debug_assert!` so it also holds in release, which is where this
/// crate's defects have tended to hide.
Internal,
}
impl std::fmt::Display for Shape {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let what = match self {
Self::OutcomeVsTeams => {
"the outcome describes a different number of teams than the event has"
}
Self::Weights => "the weight list does not match the team's membership",
Self::Teams => "this call takes a fixed number of teams",
Self::Internal => {
"an internal array disagreed with its siblings (this is a bug in trueskill-tt)"
}
};
f.write_str(what)
}
}
/// Which [`Outcome`](crate::Outcome) variant a call found or wanted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum OutcomeKind {
/// [`Outcome::Ranked`](crate::Outcome::Ranked): an ordinal finish.
Ranked,
/// [`Outcome::Scored`](crate::Outcome::Scored): continuous scores.
Scored,
}
impl OutcomeKind {
/// The call that takes this kind, for the `Display` message.
fn constructor(self) -> &'static str {
match self {
Self::Ranked => "Game::ranked",
Self::Scored => "Game::scored",
}
}
/// The adjective form, for prose.
fn adjective(self) -> &'static str {
match self {
Self::Ranked => "ranked",
Self::Scored => "scored",
}
}
}
impl std::fmt::Display for OutcomeKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Ranked => "Outcome::Ranked",
Self::Scored => "Outcome::Scored",
})
}
}
/// Which piece of per-competitor configuration was declared twice.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CompetitorField {
/// The starting skill distribution.
Prior,
/// The multiplier on the drift variance.
DriftScale,
}
impl std::fmt::Display for CompetitorField {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Prior => "prior",
Self::DriftScale => "drift_scale",
})
}
}
/// 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)] #[derive(Debug, Clone, PartialEq)]
#[non_exhaustive] #[non_exhaustive]
pub enum InferenceError { pub enum InferenceError {
/// Expected and actual lengths of some array-shaped input differ. /// Expected and actual lengths of some array-shaped input differ.
#[non_exhaustive]
MismatchedShape { MismatchedShape {
kind: &'static str, /// Which pair of lengths disagreed.
shape: Shape,
/// The length it had to have, taken from whatever it must line up with
/// (usually the event's team count).
expected: usize, expected: usize,
/// The length actually supplied.
got: usize, got: usize,
}, },
/// An `Outcome` of the wrong variant was supplied for the requested inference. /// An `Outcome` of the wrong variant was supplied for the requested inference.
#[non_exhaustive]
WrongOutcomeKind { WrongOutcomeKind {
context: &'static str, /// The variant the call needs.
expected: &'static str, expected: OutcomeKind,
got: &'static str, /// The variant actually supplied.
got: OutcomeKind,
}, },
/// A probability value is outside `[0, 1]`.
InvalidProbability { value: f64 },
/// A scalar parameter is outside its valid range. /// A scalar parameter is outside its valid range.
InvalidParameter { name: &'static str, value: f64 }, #[non_exhaustive]
InvalidParameter {
/// Which parameter. `Display` states its valid range.
parameter: Parameter,
/// The value supplied for it: outside that 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. /// An event contains tied teams, but the draw probability is zero.
/// ///
/// A zero draw probability asserts that draws cannot occur, so a tied /// A zero draw probability asserts that draws cannot occur, so a tied
/// result has no representable likelihood. Configure a positive `p_draw` /// result has no representable likelihood. Configure a positive `p_draw`
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties. /// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
TieWithoutDrawProbability { teams: (usize, usize) }, #[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 /// The convergence sweep hit `max_iter` with the step still above
/// `epsilon`. /// `epsilon`.
/// ///
/// A fit that stops short is wrong by a little, which is the worst /// 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 /// 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 /// 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. /// and `let _ = h.converge()` is the natural way not to.
@@ -77,19 +282,57 @@ pub enum InferenceError {
/// oscillating rather than converging, in which case `alpha < 1.0` damps /// oscillating rather than converging, in which case `alpha < 1.0` damps
/// the within-game EP loop. [`History::converge_partial`](crate::History::converge_partial) /// the within-game EP loop. [`History::converge_partial`](crate::History::converge_partial)
/// returns the short fit instead when that is genuinely what is wanted. /// returns the short fit instead when that is genuinely what is wanted.
#[non_exhaustive]
NotConverged { NotConverged {
/// Full forward+backward sweeps run before the loop gave up.
iterations: usize, 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), final_step: (f64, f64),
/// The threshold both components of `final_step` had to reach.
epsilon: f64, epsilon: f64,
}, },
/// Inference produced a non-finite value (NaN or infinity). /// A convergence sweep produced a non-finite step.
/// ///
/// Indicates numerical breakdown; the resulting skills are meaningless /// EP has broken down; the resulting skills are meaningless and must not
/// and must not be treated as a converged estimate. /// be treated as a converged estimate. Further iterations cannot recover,
NonFiniteResult { /// so the loop stops rather than reporting a NaN step as convergence.
#[non_exhaustive]
NonFiniteStep {
/// Where the breakdown was caught, e.g. `"History::converge"`.
context: &'static str, context: &'static str,
/// The offending step as `(|d mu|, |d sigma|)`, at least one component
/// of which is NaN or infinite.
step: (f64, f64), step: (f64, f64),
}, },
/// A prediction read a skill with no usable mean or variance.
///
/// Split from `NonFiniteStep` (#74), which used to carry both under one
/// `step: (f64, f64)` field — a sweep step from `converge` and a skill's
/// own moments from a prediction. One field name cannot be right for both.
///
/// Reaching this means a previous `converge` failed and its error was
/// ignored: predicting from a NaN fit produced `Ok(NaN)` on some paths and
/// a plausible-looking `Ok([0.0, 0.0])` on others.
#[non_exhaustive]
NonFiniteSkill {
/// The skill's mean, which may itself be finite while `sigma` is not.
mu: f64,
/// The skill's standard deviation.
sigma: f64,
},
/// Every skill in the matchup is a point mass and `beta` is zero, so
/// there is no performance distribution to predict from.
///
/// Not `InvalidParameter`: both values are individually in range, and it
/// is their combination that leaves nothing varying. Every prediction is a
/// statement about how performances vary, and in this configuration
/// nothing does — `quality` would divide by a singular contrast covariance
/// and `predict_win_probabilities` would report zeros that sum to zero.
NoPerformanceVariance,
/// One batch declared two different values for the same competitor's /// One batch declared two different values for the same competitor's
/// configuration. /// configuration.
/// ///
@@ -99,9 +342,14 @@ pub enum InferenceError {
/// "last one wins" would make the result depend on iteration order. /// "last one wins" would make the result depend on iteration order.
/// Declaring the same value repeatedly is fine and is the expected shape /// Declaring the same value repeatedly is fine and is the expected shape
/// when a competitor's configuration is a property of the domain. /// when a competitor's configuration is a property of the domain.
#[non_exhaustive]
ConflictingCompetitorConfig { ConflictingCompetitorConfig {
/// The competitor's interned slot as a raw `usize`,
/// not the user key — the batch is already flattened to indices by the
/// time the conflict is detectable.
competitor: usize, competitor: usize,
field: &'static str, /// Which piece of configuration was declared twice.
field: CompetitorField,
}, },
/// A prediction referenced a key the history has no skill for. /// A prediction referenced a key the history has no skill for.
/// ///
@@ -113,9 +361,18 @@ pub enum InferenceError {
/// `UnknownKey { team: 0, member: 0 }` learns nothing about *which* of its /// `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 /// keys the history has not seen, and the natural handling — fall back to a
/// neutral value — turns the whole thing into a plausible constant. /// neutral value — turns the whole thing into a plausible constant.
#[non_exhaustive]
UnknownKey { 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, team: usize,
/// Position of the offending key within that team, or within the flat
/// key list.
member: usize, 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, key: String,
}, },
/// `History::register` was called for a competitor that already exists. /// `History::register` was called for a competitor that already exists.
@@ -128,9 +385,17 @@ pub enum InferenceError {
/// ///
/// To change an existing competitor's configuration, supply it on an event /// To change an existing competitor's configuration, supply it on an event
/// through `Member`; that refits the whole history. /// 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. /// 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. /// The prediction grid cannot resolve the narrowest feature in the matchup.
/// ///
/// `predict_outcome` and `predict_ranking` integrate every team's density /// `predict_outcome` and `predict_ranking` integrate every team's density
@@ -147,16 +412,46 @@ pub enum InferenceError {
/// `predict_win_probabilities` answers the same matchup through adaptive /// `predict_win_probabilities` answers the same matchup through adaptive
/// quadrature and is accurate here; use it when only the per-team win /// quadrature and is accurate here; use it when only the per-team win
/// probabilities are needed. /// probabilities are needed.
#[non_exhaustive]
GridTooCoarse { GridTooCoarse {
/// Nodes required to resolve the narrowest feature. /// Nodes required to resolve the narrowest feature.
needed: usize, needed: usize,
/// Nodes the grid may hold. /// Nodes the grid may hold.
max: usize, max: usize,
}, },
/// A joint posterior was requested where one cannot be formed exactly. /// A joint posterior was requested from a history with no events.
JointUnavailable { reason: &'static str }, ///
/// Split out of a single `JointUnavailable { reason: &str }` (#74): the
/// three reasons are conditions a caller branches on differently, and
/// distinguishing them used to mean matching on English prose. This one
/// means "add events".
EmptyHistory,
/// A joint posterior was requested from a history containing ranked
/// events.
///
/// Exact only for an all-scored history: a scored likelihood is Gaussian
/// and its factor can be rebuilt exactly, while a ranked outcome's
/// truncation is approximated by EP and reconstructing those factors needs
/// the converged messages, which inference does not retain.
///
/// [`History::predict_win_probabilities`](crate::History::predict_win_probabilities)
/// answers the comparable question on a ranked history.
JointRequiresScoredEvents,
/// The assembled precision matrix is not positive-definite.
///
/// The usual cause is a competitor with neither a proper prior nor any
/// evidence, but an extreme prior or drift can also make the assembled
/// matrix indefinite in floating point. Unlike its two siblings this one
/// is numerical rather than structural — the same history may factorise
/// under different parameters.
NotPositiveDefinite,
/// Fewer than two teams were supplied to a prediction. /// 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. /// The full outcome distribution was requested for too many teams.
/// ///
/// Each realisation sorts into exactly one (order, tie-pattern) event, so /// Each realisation sorts into exactly one (order, tie-pattern) event, so
@@ -165,28 +460,33 @@ pub enum InferenceError {
/// enumerate on a caller's behalf; ask for individual rankings with /// enumerate on a caller's behalf; ask for individual rankings with
/// `predict_ranking`, or for `predict_win_probabilities`, both of which /// `predict_ranking`, or for `predict_win_probabilities`, both of which
/// stay cheap at any team count. /// 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 { impl fmt::Display for InferenceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::MismatchedShape { Self::MismatchedShape {
kind, shape,
expected, expected,
got, got,
} => { } => {
write!(f, "{kind}: expected length {expected}, got {got}") write!(f, "{shape}: expected {expected}, got {got}")
} }
Self::WrongOutcomeKind { Self::WrongOutcomeKind { expected, got } => {
context, write!(
expected, f,
got, "expected {expected}, got {got}; call {} for a {} outcome",
} => { got.constructor(),
write!(f, "{context}: expected {expected}, got {got}") got.adjective()
} )
Self::InvalidProbability { value } => {
write!(f, "probability must be in [0, 1]; got {value}")
} }
Self::TieWithoutDrawProbability { teams } => { Self::TieWithoutDrawProbability { teams } => {
write!( write!(
@@ -207,14 +507,23 @@ impl fmt::Display for InferenceError {
alpha < 1.0 if it is oscillating" alpha < 1.0 if it is oscillating"
) )
} }
Self::NonFiniteResult { context, step } => { Self::NonFiniteStep { context, step } => {
write!( write!(
f, f,
"{context}: inference produced a non-finite result (step = {step:?})" "{context}: inference produced a non-finite step {step:?}; EP has \
broken down and further iterations cannot recover"
) )
} }
Self::InvalidParameter { name, value } => { Self::NonFiniteSkill { mu, sigma } => {
write!(f, "{name} is invalid: {value}") write!(
f,
"a prediction read a skill with no usable mean or variance \
(mu = {mu}, sigma = {sigma}); the fit did not converge, and \
`converge` reports that"
)
}
Self::InvalidParameter { parameter, value } => {
write!(f, "{parameter} {} (got {value})", parameter.range())
} }
Self::ConflictingCompetitorConfig { competitor, field } => { Self::ConflictingCompetitorConfig { competitor, field } => {
write!( write!(
@@ -227,7 +536,7 @@ impl fmt::Display for InferenceError {
f, f,
"team {team}, member {member}: no skill recorded for key {key} \ "team {team}, member {member}: no skill recorded for key {key} \
(every key must already be known to the history; pre-filter \ (every key must already be known to the history; pre-filter \
with `lookup` or `current_skill` if that is not guaranteed)" with `current_skill` if that is not guaranteed)"
) )
} }
Self::AlreadyRegistered { key } => { Self::AlreadyRegistered { key } => {
@@ -250,9 +559,25 @@ impl fmt::Display for InferenceError {
one grid. Use predict_win_probabilities, which is accurate here" one grid. Use predict_win_probabilities, which is accurate here"
) )
} }
Self::JointUnavailable { reason } => { Self::EmptyHistory => {
write!(f, "no exact joint posterior is available: {reason}") f.write_str("no exact joint posterior is available: the history has no events")
} }
Self::JointRequiresScoredEvents => f.write_str(
"no exact joint posterior is available: the history contains ranked \
events, whose EP factors are not retained after convergence. Use \
predict_win_probabilities for a ranked history",
),
Self::NotPositiveDefinite => f.write_str(
"the joint precision matrix is not positive-definite; the usual cause \
is a competitor with neither a proper prior nor any evidence, but an \
extreme prior or drift can also make the assembled matrix indefinite \
in floating point",
),
Self::NoPerformanceVariance => f.write_str(
"beta is zero and every skill in this matchup is a point mass, so \
there is no performance distribution to predict from; give beta a \
positive value, or a competitor a prior with positive sigma",
),
Self::NotEnoughTeams { got } => { Self::NotEnoughTeams { got } => {
write!(f, "prediction needs at least 2 teams, got {got}") write!(f, "prediction needs at least 2 teams, got {got}")
} }
@@ -268,3 +593,81 @@ impl fmt::Display for InferenceError {
} }
impl std::error::Error for InferenceError {} impl std::error::Error for InferenceError {}
#[cfg(test)]
mod message_tests {
use super::*;
/// Every message must name the problem *and* what to do, which is the
/// standard the good ones set and the three #74 called out did not meet.
#[test]
fn messages_are_actionable() {
let cases = [
InferenceError::InvalidParameter {
parameter: Parameter::Alpha,
value: 0.0,
},
InferenceError::InvalidParameter {
parameter: Parameter::DriftVariance,
value: f64::NAN,
},
InferenceError::InvalidParameter {
parameter: Parameter::PDraw,
value: 1.5,
},
InferenceError::MismatchedShape {
shape: Shape::OutcomeVsTeams,
expected: 3,
got: 2,
},
InferenceError::WrongOutcomeKind {
expected: OutcomeKind::Ranked,
got: OutcomeKind::Scored,
},
InferenceError::EmptyHistory,
InferenceError::JointRequiresScoredEvents,
InferenceError::NotPositiveDefinite,
InferenceError::NoPerformanceVariance,
InferenceError::NonFiniteSkill {
mu: f64::NAN,
sigma: f64::NAN,
},
];
for case in &cases {
let rendered = case.to_string();
eprintln!("{rendered}");
// `InvalidParameter` used to render `drift variance is invalid: NaN`
// — no range, no remedy, no location. Every message must at least
// be a sentence.
assert!(
rendered.len() > 30,
"message is too terse to act on: {rendered}"
);
assert!(!rendered.contains("is invalid:"), "{rendered}");
}
// The three that #74 singled out now state a range or a next step.
assert!(
InferenceError::InvalidParameter {
parameter: Parameter::DriftVariance,
value: f64::NAN,
}
.to_string()
.contains("must be finite and non-negative")
);
assert!(
InferenceError::WrongOutcomeKind {
expected: OutcomeKind::Ranked,
got: OutcomeKind::Scored,
}
.to_string()
.contains("Game::scored")
);
assert!(
InferenceError::JointRequiresScoredEvents
.to_string()
.contains("predict_win_probabilities")
);
}
}
+63 -4
View File
@@ -11,27 +11,59 @@ use smallvec::SmallVec;
use crate::{gaussian::Gaussian, outcome::Outcome, time::Time}; use crate::{gaussian::Gaussian, outcome::Outcome, time::Time};
/// A single match at time `time` involving some number of teams. /// A single match at time `time` involving some number of teams.
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq)]
pub struct Event<T: Time, K> { 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, 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]>, 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, pub outcome: Outcome,
} }
/// A team: list of members competing together. /// A team: list of members competing together.
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct Team<K> { 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]>, pub members: SmallVec<[Member<K>; 4]>,
} }
impl<K> Team<K> { 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 { pub fn new() -> Self {
Self { Self {
members: SmallVec::new(), 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 { pub fn with_members<I: IntoIterator<Item = Member<K>>>(members: I) -> Self {
Self { Self {
members: members.into_iter().collect(), members: members.into_iter().collect(),
@@ -61,10 +93,29 @@ impl<K> Default for Team<K> {
/// for one competitor within a single batch is /// for one competitor within a single batch is
/// `InferenceError::ConflictingCompetitorConfig`: events in a batch have no /// `InferenceError::ConflictingCompetitorConfig`: events in a batch have no
/// order, so there would be no well-defined winner. /// order, so there would be no well-defined winner.
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct Member<K> { 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, 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, 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>, pub prior: Option<Gaussian>,
/// Multiplier on the drift *variance* this competitor accumulates. /// Multiplier on the drift *variance* this competitor accumulates.
/// `None` means 1.0. /// `None` means 1.0.
@@ -72,6 +123,8 @@ pub struct Member<K> {
} }
impl<K> 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 { pub fn new(key: K) -> Self {
Self { Self {
key, 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 { pub fn with_weight(mut self, weight: f64) -> Self {
self.weight = weight; self.weight = weight;
self self
+51 -12
View File
@@ -9,14 +9,44 @@ use crate::{
time::Time, time::Time,
}; };
pub struct EventBuilder<'h, T, D, O, K> /// 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, R>
where where
T: Time, T: Time,
D: Drift<T>, D: Drift<T>,
O: Observer<T>, O: Observer<T>,
K: Eq + std::hash::Hash + Clone, K: Eq + std::hash::Hash + Clone,
R: crate::RatingRule<K>,
{ {
history: &'h mut History<T, D, O, K>, history: &'h mut History<K, T, D, O, R>,
event: Event<T, K>, event: Event<T, K>,
current_team_idx: Option<usize>, current_team_idx: Option<usize>,
/// First validation failure seen while building, surfaced by `commit`. /// First validation failure seen while building, surfaced by `commit`.
@@ -29,14 +59,15 @@ where
error: Option<InferenceError>, error: Option<InferenceError>,
} }
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K> impl<'h, T, D, O, K, R> EventBuilder<'h, T, D, O, K, R>
where where
T: Time, T: Time,
D: Drift<T>, D: Drift<T>,
O: Observer<T>, O: Observer<T>,
K: Eq + std::hash::Hash + Clone, K: Eq + std::hash::Hash + Clone,
R: crate::RatingRule<K>,
{ {
pub(crate) fn new(history: &'h mut History<T, D, O, K>, time: T) -> Self { pub(crate) fn new(history: &'h mut History<K, T, D, O, R>, time: T) -> Self {
Self { Self {
history, history,
event: Event { event: Event {
@@ -113,7 +144,7 @@ where
if ws.len() != team.members.len() { if ws.len() != team.members.len() {
self.error.get_or_insert(InferenceError::MismatchedShape { self.error.get_or_insert(InferenceError::MismatchedShape {
kind: "weights", shape: crate::Shape::Weights,
expected: team.members.len(), expected: team.members.len(),
got: ws.len(), got: ws.len(),
}); });
@@ -142,13 +173,21 @@ where
/// Set explicit per-team continuous scores with a per-event noise override. /// Set explicit per-team continuous scores with a per-event noise override.
/// ///
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only. /// `score_sigma` is the observation noise on the *score margin*, not a
/// Must be `> 0.0`. Constructing the outcome with a non-positive or NaN /// skill sigma, and it overrides `HistoryBuilder::score_sigma` for this
/// sigma is allowed; the value is rejected with /// event only. A small value takes the margin near-literally; a large one
/// `InferenceError::InvalidParameter` when the event is ingested, so /// barely moves the ratings.
/// callers get an error from `commit` rather than a panic. ///
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self { /// Must be `> 0.0`. Building the outcome with a non-positive or NaN value
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma); /// is allowed; it is rejected with `InferenceError::InvalidParameter` when
/// the event is ingested, so callers get an error from `commit` rather
/// than a panic.
pub fn scores_with_noise<I: IntoIterator<Item = f64>>(
mut self,
scores: I,
score_sigma: f64,
) -> Self {
self.event.outcome = crate::Outcome::scores_with_noise(scores, score_sigma);
self self
} }
-1
View File
@@ -44,7 +44,6 @@ impl VarStore {
id id
} }
#[must_use]
pub fn get(&self, id: VarId) -> Gaussian { pub fn get(&self, id: VarId) -> Gaussian {
self.marginals[id.0 as usize] self.marginals[id.0 as usize]
} }
+2 -2
View File
@@ -39,7 +39,7 @@ impl MarginFactor {
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`. /// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) { pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
let marginal = vars.get(self.diff); let marginal = vars.get(self.diff);
let cavity = marginal / self.msg; let cavity = marginal.cavity(self.msg);
if self.log_evidence_cached.is_none() { if self.log_evidence_cached.is_none() {
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.m_obs, self.sigma)); self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.m_obs, self.sigma));
@@ -49,7 +49,7 @@ impl MarginFactor {
let damped = self.msg.damp_natural(new_msg, alpha); let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg; let old_msg = self.msg;
self.msg = damped; self.msg = damped;
vars.set(self.diff, cavity * damped); vars.set(self.diff, cavity.ep_product(damped));
old_msg.delta(damped) old_msg.delta(damped)
} }
+3 -3
View File
@@ -41,14 +41,14 @@ impl TruncFactor {
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`. /// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) { pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
let marginal = vars.get(self.diff); let marginal = vars.get(self.diff);
let cavity = marginal / self.msg; let cavity = marginal.cavity(self.msg);
if self.log_evidence_cached.is_none() { if self.log_evidence_cached.is_none() {
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.margin, self.tie)); self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.margin, self.tie));
} }
let trunc = approx(cavity, self.margin, self.tie); let trunc = approx(cavity, self.margin, self.tie);
let new_msg = trunc / cavity; let new_msg = trunc.cavity(cavity);
let damped = self.msg.damp_natural(new_msg, alpha); let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg; let old_msg = self.msg;
@@ -57,7 +57,7 @@ impl TruncFactor {
// marginal_new = cavity * stored_msg. With alpha = 1.0 this equals // marginal_new = cavity * stored_msg. With alpha = 1.0 this equals
// `trunc` (since cavity * new_msg = trunc by construction); with // `trunc` (since cavity * new_msg = trunc by construction); with
// alpha < 1.0 it reflects the partially-applied update. // alpha < 1.0 it reflects the partially-applied update.
vars.set(self.diff, cavity * damped); vars.set(self.diff, cavity.ep_product(damped));
old_msg.delta(damped) old_msg.delta(damped)
} }
+221 -103
View File
@@ -68,10 +68,27 @@ impl DiffFactor {
/// `p_draw` and `convergence` apply to ranked outcomes (`Game::ranked`). /// `p_draw` and `convergence` apply to ranked outcomes (`Game::ranked`).
/// `score_sigma` applies only to scored outcomes (`Game::scored`); it controls /// `score_sigma` applies only to scored outcomes (`Game::scored`); it controls
/// how much the engine trusts the observed score margin (smaller σ = more trust). /// how much the engine trusts the observed score margin (smaller σ = more trust).
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct GameOptions { 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, 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, 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, pub convergence: crate::ConvergenceOptions,
} }
@@ -85,20 +102,51 @@ impl Default for GameOptions {
} }
} }
/// Owned variant of `Game` returned by public constructors. /// One match, fitted on its own.
/// ///
/// Unlike `Game<'a, T, D>` (which borrows its result/weights slices from /// Rate a single match against ratings you already hold and read the updated
/// History's internal state), `OwnedGame<T, D>` owns the team ratings, so it /// beliefs straight back. There is no history behind it: nothing is stored,
/// can be returned freely from public constructors. The inference inputs /// nothing propagates backward, and the priors you hand in are the only
/// themselves are not retained — nothing reads them back. /// 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 it 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.
///
/// ```
/// use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
///
/// let strong: Rating = Rating::new(Gaussian::from_ms(30.0, 3.0), 1.0, ConstantDrift::new(0.0));
/// let weak: Rating = Rating::new(Gaussian::from_ms(20.0, 3.0), 1.0, ConstantDrift::new(0.0));
///
/// // The underdog wins.
/// let game = Game::ranked(
/// &[&[weak], &[strong]],
/// Outcome::winner(0, 2),
/// &GameOptions::default(),
/// )?;
///
/// let posteriors = game.posteriors();
/// assert!(posteriors[0][0].mu() > weak.prior().mu(), "the winner gained");
/// assert!(posteriors[1][0].mu() < strong.prior().mu(), "the loser lost");
///
/// // An upset is improbable, and `log_evidence` says so.
/// assert!(game.log_evidence() < 0.5_f64.ln());
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
#[derive(Debug)] #[derive(Debug)]
pub struct OwnedGame<T: Time, D: Drift<T>> { #[must_use]
pub struct Game<T: Time, D: Drift<T>> {
teams: Vec<Vec<Rating<T, D>>>, teams: Vec<Vec<Rating<T, D>>>,
pub(crate) likelihoods: Vec<Vec<Gaussian>>, pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) log_evidence: f64, pub(crate) log_evidence: f64,
} }
impl<T: Time, D: Drift<T>> OwnedGame<T, D> { impl<T: Time, D: Drift<T>> Game<T, D> {
pub(crate) fn new( pub(crate) fn new(
teams: Vec<Vec<Rating<T, D>>>, teams: Vec<Vec<Rating<T, D>>>,
result: Vec<f64>, result: Vec<f64>,
@@ -110,7 +158,8 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
// `Game` takes the teams by value and is dropped here, so take the vec // `Game` takes the teams by value and is dropped here, so take the vec
// back out of it rather than handing it a clone. // back out of it rather than handing it a clone.
let g = Game::ranked_with_arena(teams, &result, &weights, p_draw, convergence, &mut arena); let g =
GameRef::ranked_with_arena(teams, &result, &weights, p_draw, convergence, &mut arena);
Self { Self {
teams: g.teams, teams: g.teams,
@@ -128,7 +177,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
) -> Self { ) -> Self {
let mut arena = ScratchArena::new(); let mut arena = ScratchArena::new();
let g = Game::scored_with_arena( let g = GameRef::scored_with_arena(
teams, teams,
&scores, &scores,
&weights, &weights,
@@ -144,23 +193,59 @@ 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] #[must_use]
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> { pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods self.likelihoods
.iter() .iter()
.zip(self.teams.iter()) .zip(self.teams.iter())
.map(|(l, t)| l.iter().zip(t.iter()).map(|(&l, r)| l * r.prior).collect()) .map(|(l, t)| {
l.iter()
.zip(t.iter())
.map(|(&l, r)| l.ep_product(r.prior))
.collect()
})
.collect() .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] #[must_use]
pub fn log_evidence(&self) -> f64 { pub fn log_evidence(&self) -> f64 {
self.log_evidence self.log_evidence
} }
} }
/// The borrowing form of [`Game`], used only inside the crate.
///
/// `History` keeps each event's result and weight slices in its own storage
/// and sweeps them thousands of times, so the inference core borrows them
/// rather than copying. That borrow is the whole difference between this and
/// [`Game`]; it is why this type cannot be handed to a caller, and why it is
/// not part of the public API.
#[derive(Debug)] #[derive(Debug)]
pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> { pub(crate) struct GameRef<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
teams: Vec<Vec<Rating<T, D>>>, teams: Vec<Vec<Rating<T, D>>>,
result: &'a [f64], result: &'a [f64],
weights: &'a [Vec<f64>], weights: &'a [Vec<f64>],
@@ -170,7 +255,7 @@ pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
pub(crate) log_evidence: f64, pub(crate) log_evidence: f64,
} }
impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> { impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
pub(crate) fn ranked_with_arena( pub(crate) fn ranked_with_arena(
teams: Vec<Vec<Rating<T, D>>>, teams: Vec<Vec<Rating<T, D>>>,
result: &'a [f64], result: &'a [f64],
@@ -283,7 +368,9 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
self.teams[t] self.teams[t]
.iter() .iter()
.zip(self.weights[t].iter()) .zip(self.weights[t].iter())
.fold(N00, |p, (player, &w)| p + (player.performance() * w)) .fold(N00, |p, (competitor, &w)| {
p.convolve(competitor.performance().scale(w))
})
})); }));
let n_diffs = n_teams.saturating_sub(1); let n_diffs = n_teams.saturating_sub(1);
@@ -302,28 +389,28 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
step = (0.0_f64, 0.0_f64); step = (0.0_f64, 0.0_f64);
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() { for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
let pw = arena.team_prior[e] * arena.lhood_lose[e]; let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1]; let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
let raw = pw - pl; let raw = pw.convolve_diff(pl);
arena.vars.set(lf.diff(), raw * lf.msg()); arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
let d = lf.propagate(&mut arena.vars, alpha); let d = lf.propagate(&mut arena.vars, alpha);
step = tuple_max(step, d); step = tuple_max(step, d);
let new_ll = pw - lf.msg(); let new_ll = pw.convolve_diff(lf.msg());
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll)); step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
arena.lhood_lose[e + 1] = new_ll; arena.lhood_lose[e + 1] = new_ll;
} }
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() { for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
let e = n_diffs - 1 - rev_i; let e = n_diffs - 1 - rev_i;
let pw = arena.team_prior[e] * arena.lhood_lose[e]; let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1]; let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
let raw = pw - pl; let raw = pw.convolve_diff(pl);
arena.vars.set(lf.diff(), raw * lf.msg()); arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
let d = lf.propagate(&mut arena.vars, alpha); let d = lf.propagate(&mut arena.vars, alpha);
step = tuple_max(step, d); step = tuple_max(step, d);
let new_lw = pl + lf.msg(); let new_lw = pl.convolve(lf.msg());
step = tuple_max(step, arena.lhood_win[e].delta(new_lw)); step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
arena.lhood_win[e] = new_lw; arena.lhood_win[e] = new_lw;
} }
@@ -333,18 +420,21 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
// Special case: exactly 1 diff (2-team game); loop body was empty. // Special case: exactly 1 diff (2-team game); loop body was empty.
if n_diffs == 1 { if n_diffs == 1 {
let raw = (arena.team_prior[0] * arena.lhood_lose[0]) let raw = arena.team_prior[0]
- (arena.team_prior[1] * arena.lhood_win[1]); .ep_product(arena.lhood_lose[0])
arena.vars.set(links[0].diff(), raw * links[0].msg()); .convolve_diff(arena.team_prior[1].ep_product(arena.lhood_win[1]));
arena
.vars
.set(links[0].diff(), raw.ep_product(links[0].msg()));
links[0].propagate(&mut arena.vars, alpha); links[0].propagate(&mut arena.vars, alpha);
} }
// Boundary updates: close the chain at both ends. // Boundary updates: close the chain at both ends.
if n_diffs > 0 { if n_diffs > 0 {
let pl1 = arena.team_prior[1] * arena.lhood_win[1]; let pl1 = arena.team_prior[1].ep_product(arena.lhood_win[1]);
arena.lhood_win[0] = pl1 + links[0].msg(); arena.lhood_win[0] = pl1.convolve(links[0].msg());
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2]; let pw_last = arena.team_prior[n_teams - 2].ep_product(arena.lhood_lose[n_teams - 2]);
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg(); arena.lhood_lose[n_teams - 1] = pw_last.convolve_diff(links[n_diffs - 1].msg());
} }
let log_evidence: f64 = links.iter().map(DiffFactor::log_evidence).sum(); let log_evidence: f64 = links.iter().map(DiffFactor::log_evidence).sum();
@@ -360,18 +450,19 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
.iter() .iter()
.zip(self.weights.iter()) .zip(self.weights.iter())
.enumerate() .enumerate()
.map(|(orig_i, (players, weights))| { .map(|(orig_i, (competitors, weights))| {
let si = arena.inv_buf[orig_i]; let si = arena.inv_buf[orig_i];
let m = arena.lhood_win[si] * arena.lhood_lose[si]; let m = arena.lhood_win[si].ep_product(arena.lhood_lose[si]);
// Already folded into `team_prior` at the top of the chain, // Already folded into `team_prior` at the top of the chain,
// indexed by sorted position. // indexed by sorted position.
let performance = arena.team_prior[si]; let performance = arena.team_prior[si];
players competitors
.iter() .iter()
.zip(weights.iter()) .zip(weights.iter())
.map(|(player, &w)| { .map(|(competitor, &w)| {
((m - performance.exclude(player.performance() * w)) * (1.0 / w)) m.convolve_diff(performance.exclude(competitor.performance().scale(w)))
.forget(player.beta.powi(2)) .scale(1.0 / w)
.forget(competitor.beta.powi(2))
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
@@ -410,27 +501,26 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
self.likelihoods = likelihoods; self.likelihoods = likelihoods;
} }
#[must_use] /// As [`Game::posteriors`].
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> { ///
/// Test-only: inference reads `likelihoods` directly, and `GameRef` is not
/// public, so the only callers are this module's own goldens.
#[cfg(test)]
pub(crate) fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods self.likelihoods
.iter() .iter()
.zip(self.teams.iter()) .zip(self.teams.iter())
.map(|(l, t)| { .map(|(l, t)| {
l.iter() l.iter()
.zip(t.iter()) .zip(t.iter())
.map(|(&l, p)| l * p.prior) .map(|(&l, p)| l.ep_product(p.prior))
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
} }
#[must_use]
pub fn log_evidence(&self) -> f64 {
self.log_evidence
}
} }
impl<T: Time, D: Drift<T>> Game<'_, T, D> { impl<T: Time, D: Drift<T>> Game<T, D> {
/// Reject the team shapes inference cannot represent. /// Reject the team shapes inference cannot represent.
/// ///
/// `run_chain` builds one diff link per adjacent pair of teams, so fewer /// `run_chain` builds one diff link per adjacent pair of teams, so fewer
@@ -454,12 +544,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
Ok(()) Ok(())
} }
/// Fit one match from an ordinal result.
///
/// `teams` is `[team][member]`, and `outcome` ranks those teams in the
/// same order. Read the result with [`posteriors`](Game::posteriors) and
/// [`log_evidence`](Game::log_evidence).
///
/// # Errors /// # Errors
/// ///
/// - `InvalidParameter` if `options.convergence` is out of range — an /// - `InvalidParameter` if `options.convergence` is out of range — an
/// `alpha` of zero would leave every EP update unapplied and silently /// `alpha` of zero would leave every EP update unapplied and silently
/// return the priors. /// return the priors.
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`. /// - `InvalidParameter` for a `p_draw` outside `[0.0, 1.0)`.
/// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`. /// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`.
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`. /// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
/// - `TieWithoutDrawProbability` if the outcome ties two teams while /// - `TieWithoutDrawProbability` if the outcome ties two teams while
@@ -471,17 +567,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
teams: &[&[Rating<T, D>]], teams: &[&[Rating<T, D>]],
outcome: crate::Outcome, outcome: crate::Outcome,
options: &GameOptions, options: &GameOptions,
) -> Result<OwnedGame<T, D>, crate::InferenceError> { ) -> Result<Self, crate::InferenceError> {
options.convergence.validate()?; options.convergence.validate()?;
Self::validate_teams(teams)?; Self::validate_teams(teams)?;
if !(0.0..1.0).contains(&options.p_draw) { if !(0.0..1.0).contains(&options.p_draw) {
return Err(crate::InferenceError::InvalidProbability { return Err(crate::InferenceError::InvalidParameter {
parameter: crate::Parameter::PDraw,
value: options.p_draw, value: options.p_draw,
}); });
} }
if outcome.team_count() != teams.len() { if outcome.team_count() != teams.len() {
return Err(crate::InferenceError::MismatchedShape { return Err(crate::InferenceError::MismatchedShape {
kind: "outcome ranks vs teams", shape: crate::Shape::OutcomeVsTeams,
expected: teams.len(), expected: teams.len(),
got: outcome.team_count(), got: outcome.team_count(),
}); });
@@ -490,9 +587,8 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
let ranks = outcome let ranks = outcome
.as_ranks() .as_ranks()
.ok_or(crate::InferenceError::WrongOutcomeKind { .ok_or(crate::InferenceError::WrongOutcomeKind {
context: "Game::ranked", expected: crate::OutcomeKind::Ranked,
expected: "Outcome::Ranked", got: crate::OutcomeKind::Scored,
got: "Outcome::Scored",
})?; })?;
let tied = if options.p_draw == 0.0 { let tied = if options.p_draw == 0.0 {
@@ -510,7 +606,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect(); let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect(); let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
Ok(OwnedGame::new( Ok(Self::new(
teams_owned, teams_owned,
result, result,
weights, weights,
@@ -519,6 +615,12 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
)) ))
} }
/// Fit one match from continuous scores.
///
/// Unlike [`ranked`](Game::ranked), the *size* of each adjacent gap is
/// evidence: beating a team by ten says more than beating them by one.
/// How much more is set by `options.score_sigma`.
///
/// # Errors /// # Errors
/// ///
/// - `InvalidParameter` if `options.score_sigma` is not strictly positive /// - `InvalidParameter` if `options.score_sigma` is not strictly positive
@@ -531,18 +633,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
teams: &[&[Rating<T, D>]], teams: &[&[Rating<T, D>]],
outcome: crate::Outcome, outcome: crate::Outcome,
options: &GameOptions, options: &GameOptions,
) -> Result<OwnedGame<T, D>, crate::InferenceError> { ) -> Result<Self, crate::InferenceError> {
options.convergence.validate()?; options.convergence.validate()?;
Self::validate_teams(teams)?; Self::validate_teams(teams)?;
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() { if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
return Err(crate::InferenceError::InvalidParameter { return Err(crate::InferenceError::InvalidParameter {
name: "score_sigma", parameter: crate::Parameter::ScoreSigma,
value: options.score_sigma, value: options.score_sigma,
}); });
} }
if outcome.team_count() != teams.len() { if outcome.team_count() != teams.len() {
return Err(crate::InferenceError::MismatchedShape { return Err(crate::InferenceError::MismatchedShape {
kind: "outcome scores vs teams", shape: crate::Shape::OutcomeVsTeams,
expected: teams.len(), expected: teams.len(),
got: outcome.team_count(), got: outcome.team_count(),
}); });
@@ -550,9 +652,8 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
let scores = outcome let scores = outcome
.as_scores() .as_scores()
.ok_or(crate::InferenceError::WrongOutcomeKind { .ok_or(crate::InferenceError::WrongOutcomeKind {
context: "Game::scored", expected: crate::OutcomeKind::Scored,
expected: "Outcome::Scored", got: crate::OutcomeKind::Ranked,
got: "Outcome::Ranked",
})? })?
.to_vec(); .to_vec();
// A non-finite score poisons the chain rather than failing it. Ranks // A non-finite score poisons the chain rather than failing it. Ranks
@@ -560,14 +661,14 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
for value in &scores { for value in &scores {
if !value.is_finite() { if !value.is_finite() {
return Err(crate::InferenceError::InvalidParameter { return Err(crate::InferenceError::InvalidParameter {
name: "score", parameter: crate::Parameter::Score,
value: *value, value: *value,
}); });
} }
} }
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect(); let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect(); let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
Ok(OwnedGame::new_scored( Ok(Self::new_scored(
teams_owned, teams_owned,
scores, scores,
weights, weights,
@@ -576,7 +677,24 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
)) ))
} }
/// Convenience wrapper over [`Game::ranked`] for two single-player teams. /// Two single-competitor teams: the common case, without the nesting.
///
/// Returns a `Game` like every other constructor. It used to return
/// `(Gaussian, Gaussian)` — the posteriors alone — which made it the one
/// member of the family you could not ask for
/// [`log_evidence`](Game::log_evidence). Call `.posteriors()` for the old
/// shape:
///
/// ```
/// # use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
/// # let a: Rating = Rating::new(Gaussian::from_ms(25.0, 8.0), 4.0, ConstantDrift::new(0.0));
/// # let b = a;
/// let game = Game::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default())?;
/// let post = game.posteriors();
/// let (a_post, b_post) = (post[0][0], post[1][0]);
/// # let _ = (a_post, b_post);
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
/// ///
/// # Errors /// # Errors
/// ///
@@ -588,22 +706,22 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
b: &Rating<T, D>, b: &Rating<T, D>,
outcome: crate::Outcome, outcome: crate::Outcome,
options: &GameOptions, options: &GameOptions,
) -> Result<(Gaussian, Gaussian), crate::InferenceError> { ) -> Result<Self, crate::InferenceError> {
let game = Self::ranked(&[&[*a], &[*b]], outcome, options)?; Self::ranked(&[&[*a], &[*b]], outcome, options)
let post = game.posteriors();
Ok((post[0][0], post[1][0]))
} }
/// A free-for-all: every competitor is their own one-member team.
///
/// # Errors /// # 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. /// [`Game::ranked`], so it returns the same errors.
pub fn free_for_all( pub fn free_for_all(
players: &[&Rating<T, D>], competitors: &[&Rating<T, D>],
outcome: crate::Outcome, outcome: crate::Outcome,
options: &GameOptions, options: &GameOptions,
) -> Result<OwnedGame<T, D>, crate::InferenceError> { ) -> Result<Self, 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(); let team_refs: Vec<&[Rating<T, D>]> = teams.iter().map(|t| t.as_slice()).collect();
Self::ranked(&team_refs, outcome, options) Self::ranked(&team_refs, outcome, options)
} }
@@ -632,7 +750,7 @@ mod tests {
); );
let w = [vec![1.0], vec![1.0]]; let w = [vec![1.0], vec![1.0]];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b]], vec![vec![t_a], vec![t_b]],
&[0.0, 1.0], &[0.0, 1.0],
&w, &w,
@@ -660,7 +778,7 @@ mod tests {
); );
let w = [vec![1.0], vec![1.0]]; let w = [vec![1.0], vec![1.0]];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b]], vec![vec![t_a], vec![t_b]],
&[0.0, 1.0], &[0.0, 1.0],
&w, &w,
@@ -688,7 +806,7 @@ mod tests {
); );
let w = [vec![1.0], vec![1.0]]; let w = [vec![1.0], vec![1.0]];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b]], vec![vec![t_a], vec![t_b]],
&[0.0, 1.0], &[0.0, 1.0],
&w, &w,
@@ -722,7 +840,7 @@ mod tests {
]; ];
let w = [vec![1.0], vec![1.0], vec![1.0]]; let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
teams.clone(), teams.clone(),
&[1.0, 2.0, 0.0], &[1.0, 2.0, 0.0],
&w, &w,
@@ -739,7 +857,7 @@ mod tests {
assert_ulps_eq!(b, Gaussian::from_ms(31.311358, 6.698818), epsilon = 1e-6); assert_ulps_eq!(b, Gaussian::from_ms(31.311358, 6.698818), epsilon = 1e-6);
let w = [vec![1.0], vec![1.0], vec![1.0]]; let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
teams.clone(), teams.clone(),
&[2.0, 1.0, 0.0], &[2.0, 1.0, 0.0],
&w, &w,
@@ -756,7 +874,7 @@ mod tests {
assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6); assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6);
let w = [vec![1.0], vec![1.0], vec![1.0]]; let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
teams, teams,
&[1.0, 2.0, 0.0], &[1.0, 2.0, 0.0],
&w, &w,
@@ -796,7 +914,7 @@ mod tests {
); );
let w = [vec![1.0], vec![1.0]]; let w = [vec![1.0], vec![1.0]];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b]], vec![vec![t_a], vec![t_b]],
&[0.0, 0.0], &[0.0, 0.0],
&w, &w,
@@ -828,7 +946,7 @@ mod tests {
); );
let w = [vec![1.0], vec![1.0]]; let w = [vec![1.0], vec![1.0]];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b]], vec![vec![t_a], vec![t_b]],
&[0.0, 0.0], &[0.0, 0.0],
&w, &w,
@@ -864,7 +982,7 @@ mod tests {
); );
let w = [vec![1.0], vec![1.0], vec![1.0]]; let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b], vec![t_c]], vec![vec![t_a], vec![t_b], vec![t_c]],
&[0.0, 0.0, 0.0], &[0.0, 0.0, 0.0],
&w, &w,
@@ -901,7 +1019,7 @@ mod tests {
); );
let w = [vec![1.0], vec![1.0], vec![1.0]]; let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b], vec![t_c]], vec![vec![t_a], vec![t_b], vec![t_c]],
&[0.0, 0.0, 0.0], &[0.0, 0.0, 0.0],
&w, &w,
@@ -953,7 +1071,7 @@ mod tests {
]; ];
let w = [vec![1.0, 1.0], vec![1.0], vec![1.0, 1.0]]; let w = [vec![1.0, 1.0], vec![1.0], vec![1.0, 1.0]];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![t_a, t_b, t_c], vec![t_a, t_b, t_c],
&[1.0, 0.0, 0.0], &[1.0, 0.0, 0.0],
&w, &w,
@@ -987,7 +1105,7 @@ mod tests {
)]; )];
let w = [w_a, w_b]; let w = [w_a, w_b];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![t_a.clone(), t_b.clone()], vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
@@ -1012,7 +1130,7 @@ mod tests {
let w_b = vec![0.7]; let w_b = vec![0.7];
let w = [w_a, w_b]; let w = [w_a, w_b];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![t_a.clone(), t_b.clone()], vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
@@ -1037,7 +1155,7 @@ mod tests {
let w_b = vec![0.7]; let w_b = vec![0.7];
let w = [w_a, w_b]; let w = [w_a, w_b];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![t_a, t_b], vec![t_a, t_b],
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
@@ -1073,7 +1191,7 @@ mod tests {
)]; )];
let w = [w_a, w_b]; let w = [w_a, w_b];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![t_a, t_b], vec![t_a, t_b],
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
@@ -1109,7 +1227,7 @@ mod tests {
)]; )];
let w = [w_a, w_b]; let w = [w_a, w_b];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![t_a, t_b], vec![t_a, t_b],
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
@@ -1155,7 +1273,7 @@ mod tests {
let result = vec![10.0, 0.0]; // a beat b by 10 let result = vec![10.0, 0.0]; // a beat b by 10
let weights = [vec![1.0], vec![1.0]]; let weights = [vec![1.0], vec![1.0]];
let mut arena = ScratchArena::new(); let mut arena = ScratchArena::new();
let g = Game::scored_with_arena( let g = GameRef::scored_with_arena(
teams, teams,
&result, &result,
&weights, &weights,
@@ -1175,7 +1293,7 @@ mod tests {
// Tighter score_sigma should produce a stronger update. // Tighter score_sigma should produce a stronger update.
let mut arena2 = ScratchArena::new(); let mut arena2 = ScratchArena::new();
let g_tight = Game::scored_with_arena( let g_tight = GameRef::scored_with_arena(
vec![vec![prior], vec![prior]], vec![vec![prior], vec![prior]],
&result, &result,
&weights, &weights,
@@ -1249,7 +1367,7 @@ mod tests {
assert!(matches!( assert!(matches!(
err, err,
crate::InferenceError::InvalidParameter { crate::InferenceError::InvalidParameter {
name: "score_sigma", parameter: crate::Parameter::ScoreSigma,
.. ..
} }
)); ));
@@ -1286,7 +1404,7 @@ mod tests {
let w_b = vec![0.9, 0.6]; let w_b = vec![0.9, 0.6];
let w = [w_a, w_b]; let w = [w_a, w_b];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![t_a.clone(), t_b.clone()], vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
@@ -1321,7 +1439,7 @@ mod tests {
let w_b = vec![0.7, 0.4]; let w_b = vec![0.7, 0.4];
let w = [w_a, w_b]; let w = [w_a, w_b];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![t_a.clone(), t_b.clone()], vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
@@ -1356,7 +1474,7 @@ mod tests {
let w_b = vec![0.7, 2.4]; let w_b = vec![0.7, 2.4];
let w = [w_a, w_b]; let w = [w_a, w_b];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![t_a.clone(), t_b.clone()], vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
@@ -1388,7 +1506,7 @@ mod tests {
); );
let w = [vec![1.0, 1.0], vec![1.0]]; let w = [vec![1.0, 1.0], vec![1.0]];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![ vec![
t_a.clone(), t_a.clone(),
vec![R::new( vec![R::new(
@@ -1409,7 +1527,7 @@ mod tests {
let w_b = vec![1.0, 0.0]; let w_b = vec![1.0, 0.0];
let w = [w_a, w_b]; let w = [w_a, w_b];
let g = Game::ranked_with_arena( let g = GameRef::ranked_with_arena(
vec![t_a, t_b.clone()], vec![t_a, t_b.clone()],
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
@@ -1427,14 +1545,14 @@ mod tests {
#[test] #[test]
fn run_chain_honours_max_iter_in_convergence_options() { fn run_chain_honours_max_iter_in_convergence_options() {
let players: Vec<R> = (0..4).map(|_| R::default()).collect(); let competitors: Vec<R> = (0..4).map(|_| R::default()).collect();
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect(); let teams: Vec<Vec<_>> = competitors.iter().map(|p| vec![*p]).collect();
let result = vec![3.0, 2.0, 1.0, 0.0]; let result = vec![3.0, 2.0, 1.0, 0.0];
let weights = vec![vec![1.0]; 4]; let weights = vec![vec![1.0]; 4];
// Capped at 1 iteration: cannot fully propagate down a 4-team chain. // Capped at 1 iteration: cannot fully propagate down a 4-team chain.
let mut arena = ScratchArena::new(); let mut arena = ScratchArena::new();
let g_capped = Game::ranked_with_arena( let g_capped = GameRef::ranked_with_arena(
teams.clone(), teams.clone(),
&result, &result,
&weights, &weights,
@@ -1449,7 +1567,7 @@ mod tests {
// Same inputs, plenty of iterations: fully converged. // Same inputs, plenty of iterations: fully converged.
let mut arena = ScratchArena::new(); let mut arena = ScratchArena::new();
let g_full = Game::ranked_with_arena( let g_full = GameRef::ranked_with_arena(
teams, teams,
&result, &result,
&weights, &weights,
@@ -1475,13 +1593,13 @@ mod tests {
#[test] #[test]
fn run_chain_with_damping_converges_to_same_posterior() { fn run_chain_with_damping_converges_to_same_posterior() {
let players: Vec<R> = (0..4).map(|_| R::default()).collect(); let competitors: Vec<R> = (0..4).map(|_| R::default()).collect();
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect(); let teams: Vec<Vec<_>> = competitors.iter().map(|p| vec![*p]).collect();
let result = vec![3.0, 2.0, 1.0, 0.0]; let result = vec![3.0, 2.0, 1.0, 0.0];
let weights = vec![vec![1.0]; 4]; let weights = vec![vec![1.0]; 4];
let mut arena = ScratchArena::new(); let mut arena = ScratchArena::new();
let g_undamped = Game::ranked_with_arena( let g_undamped = GameRef::ranked_with_arena(
teams.clone(), teams.clone(),
&result, &result,
&weights, &weights,
@@ -1493,7 +1611,7 @@ mod tests {
// alpha=0.5 with extra iterations: should reach the same fixed point. // alpha=0.5 with extra iterations: should reach the same fixed point.
let mut arena = ScratchArena::new(); let mut arena = ScratchArena::new();
let g_damped = Game::ranked_with_arena( let g_damped = GameRef::ranked_with_arena(
teams, teams,
&result, &result,
&weights, &weights,
+112 -69
View File
@@ -1,5 +1,3 @@
use std::ops;
use crate::{MU, N_INF, SIGMA}; use crate::{MU, N_INF, SIGMA};
/// A Gaussian distribution stored in natural parameters. /// A Gaussian distribution stored in natural parameters.
@@ -11,6 +9,7 @@ use crate::{MU, N_INF, SIGMA};
/// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and /// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and
/// `sigma()` are accessors computed on demand. /// `sigma()` are accessors computed on demand.
#[derive(Clone, Copy, PartialEq, Debug)] #[derive(Clone, Copy, PartialEq, Debug)]
#[must_use]
pub struct Gaussian { pub struct Gaussian {
pi: f64, pi: f64,
tau: f64, tau: f64,
@@ -23,7 +22,7 @@ impl Gaussian {
/// ///
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a /// Panics if `sigma` is negative. NaN is deliberately allowed through: a
/// broken fit produces one, and `converge` reports that as /// broken fit produces one, and `converge` reports that as
/// `NonFiniteResult` rather than panicking mid-inference. /// `NonFiniteStep` rather than panicking mid-inference.
/// ///
/// A negative sigma used to be accepted and returned results **bit /// A negative sigma used to be accepted and returned results **bit
/// identical** to its absolute value, because sigma only ever enters as /// identical** to its absolute value, because sigma only ever enters as
@@ -44,11 +43,10 @@ impl Gaussian {
/// small truncated sigma and inference must not panic. It is worth knowing /// small truncated sigma and inference must not panic. It is worth knowing
/// that such a `Gaussian` is not equal to itself, so two identical /// that such a `Gaussian` is not equal to itself, so two identical
/// declarations of one can be reported as conflicting. /// declarations of one can be reported as conflicting.
#[must_use]
pub const fn from_ms(mu: f64, sigma: f64) -> Self { pub const fn from_ms(mu: f64, sigma: f64) -> Self {
// NaN is admitted on purpose. A broken fit legitimately produces a NaN // NaN is admitted on purpose. A broken fit legitimately produces a NaN
// sigma — `sqrt` of a negative truncated variance — and the design is // sigma — `sqrt` of a negative truncated variance — and the design is
// to propagate that to `converge`'s `NonFiniteResult` guard, not to // to propagate that to `converge`'s `NonFiniteStep` guard, not to
// panic inside inference. Rejecting it here turned that reporting path // panic inside inference. Rejecting it here turned that reporting path
// into a crash, which two tests caught immediately. // into a crash, which two tests caught immediately.
assert!( assert!(
@@ -75,11 +73,12 @@ impl Gaussian {
/// Construct from mean and *variance*, skipping the square-root round trip. /// Construct from mean and *variance*, skipping the square-root round trip.
/// ///
/// `from_ms(mu, var.sqrt())` immediately squares the root away again to /// `from_ms(mu, var.sqrt())` immediately squares the root away again to
/// recover `pi = 1/var`. Variance-combining operations (`Add`, `Sub`, /// recover `pi = 1/var`. Variance-combining operations work in variance
/// `exclude`, `forget`) work in variance space throughout, so they go /// space throughout, so they go through here instead and never take a
/// through here instead and never take a root. /// root. Use it whenever you already hold a variance —
/// [`variance`](Gaussian::variance) is its inverse.
#[inline] #[inline]
pub(crate) fn from_mv(mu: f64, var: f64) -> Self { pub fn from_mv(mu: f64, var: f64) -> Self {
if var == f64::INFINITY { if var == f64::INFINITY {
Self { pi: 0.0, tau: 0.0 } Self { pi: 0.0, tau: 0.0 }
} else if var == 0.0 { } else if var == 0.0 {
@@ -100,18 +99,32 @@ impl Gaussian {
Self { pi, tau } 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] #[inline]
#[must_use] pub(crate) fn pi(&self) -> f64 {
pub fn pi(&self) -> f64 {
self.pi 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] #[inline]
#[must_use] pub(crate) fn tau(&self) -> f64 {
pub fn tau(&self) -> f64 {
self.tau 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] #[inline]
#[must_use] #[must_use]
pub fn mu(&self) -> f64 { pub fn mu(&self) -> f64 {
@@ -126,12 +139,14 @@ impl Gaussian {
} }
} }
/// Variance, `1 / pi`, without the root-and-square of `sigma().powi(2)`. /// Variance, without the root-and-square of `sigma().powi(2)`.
/// ///
/// Mirrors `sigma()`'s treatment of the improper (`pi <= 0`) and point-mass /// Mirrors [`sigma`](Gaussian::sigma)'s treatment of the improper
/// (`pi == inf`) cases. /// (infinite) and point-mass (zero) cases, and is the inverse of
/// [`from_mv`](Gaussian::from_mv).
#[inline] #[inline]
pub(crate) fn variance(&self) -> f64 { #[must_use]
pub fn variance(&self) -> f64 {
if self.pi <= 0.0 { if self.pi <= 0.0 {
f64::INFINITY f64::INFINITY
} else if self.pi.is_infinite() { } else if self.pi.is_infinite() {
@@ -141,6 +156,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] #[inline]
#[must_use] #[must_use]
pub fn sigma(&self) -> f64 { pub fn sigma(&self) -> f64 {
@@ -243,8 +264,7 @@ impl Gaussian {
/// Used by within-game inference to stabilise oscillating fixed-point /// Used by within-game inference to stabilise oscillating fixed-point
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly; /// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
/// `alpha < 1.0` shrinks each per-step update. /// `alpha < 1.0` shrinks each per-step update.
#[must_use] pub(crate) fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
Gaussian::from_natural( Gaussian::from_natural(
alpha * new.pi() + (1.0 - alpha) * self.pi(), alpha * new.pi() + (1.0 - alpha) * self.pi(),
alpha * new.tau() + (1.0 - alpha) * self.tau(), alpha * new.tau() + (1.0 - alpha) * self.tau(),
@@ -258,34 +278,65 @@ impl Default for Gaussian {
} }
} }
impl ops::Add<Gaussian> for Gaussian { impl Gaussian {
type Output = Gaussian; /// The EP factor **product**: multiply two messages about the same
/// Variance addition: (mu1 + mu2, sqrt(σ1² + σ2²)). /// variable.
/// Used for combining performance and noise; rare relative to mul/div. ///
fn add(self, rhs: Gaussian) -> Self::Output { /// Two natural-parameter additions and no square root, which is why the
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance()) /// type stores `pi` and `tau` rather than `mu` and `sigma`. This is the
} /// hot path.
} ///
/// Not arithmetic — `N(10, 2).ep_product(N(4, 3))` is `N(8.15, 1.66)`,
impl ops::Sub<Gaussian> for Gaussian { /// nowhere near 40. It used to be spelled `a * b`, on a public `Mul` impl,
type Output = Gaussian; /// where that was a trap rather than a shorthand.
/// (mu1 - mu2, sqrt(σ1² + σ2²)). Same sigma combination as Add. #[inline]
fn sub(self, rhs: Gaussian) -> Self::Output { pub(crate) fn ep_product(self, rhs: Gaussian) -> Gaussian {
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
}
}
impl ops::Mul<Gaussian> for Gaussian {
type Output = Gaussian;
/// Factor product: nat-param add. Hot path — two f64 additions, no sqrt.
fn mul(self, rhs: Gaussian) -> Self::Output {
Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau) Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau)
} }
}
impl ops::Mul<f64> for Gaussian { /// The EP **cavity**: divide out a message this belief already absorbed.
type Output = Gaussian; ///
fn mul(self, scalar: f64) -> Self::Output { /// The inverse of [`ep_product`](Gaussian::ep_product), and two
/// subtractions rather than two additions.
///
/// **May return an improper result.** Cancelling a message that carried
/// most of the precision leaves `pi <= 0`, which is not a distribution.
/// `mu()` reports `0.0` and `sigma()` reports `inf` for such a value —
/// both are the accessors' policy for "undefined", not answers. Measured:
/// `N(10, 2).cavity(N(1, 1))` has `pi = -0.75`, and its `mu()` prints a
/// confident `0`. That is why this is not a public operator.
#[inline]
pub(crate) fn cavity(self, rhs: Gaussian) -> Gaussian {
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
}
/// Convolve two independent Gaussians: `N(mu1 + mu2, sqrt(v1 + v2))`.
///
/// The distribution of a *sum* of independent variables, so the variances
/// add — the result is always wider than either input. Used to combine a
/// skill with performance noise. Goes through `from_mv` and takes no root.
#[inline]
pub(crate) fn convolve(self, rhs: Gaussian) -> Gaussian {
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
}
/// Convolve a *difference*: `N(mu1 - mu2, sqrt(v1 + v2))`.
///
/// The means subtract and the variances still **add**, because a
/// difference of independent variables is no more certain than a sum. That
/// is the half that made the old `Sub` impl misleading: `a - b` grew the
/// sigma from 2 to `sqrt(4 + 9)`.
#[inline]
pub(crate) fn convolve_diff(self, rhs: Gaussian) -> Gaussian {
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
}
/// Scale by a constant: `mu` by `scalar`, `sigma` by `|scalar|`.
///
/// The one operation that *is* ordinary arithmetic — it is the
/// distribution of `scalar * X`. Used for per-member weights.
#[inline]
pub(crate) fn scale(self, scalar: f64) -> Gaussian {
if !scalar.is_finite() { if !scalar.is_finite() {
return N_INF; return N_INF;
} }
@@ -300,14 +351,6 @@ impl ops::Mul<f64> for Gaussian {
} }
} }
impl ops::Div<Gaussian> for Gaussian {
type Output = Gaussian;
/// Cavity: nat-param sub. Hot path — two f64 subtractions, no sqrt.
fn div(self, rhs: Gaussian) -> Self::Output {
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// A message that did not change must report no change, even when it is /// A message that did not change must report no change, even when it is
@@ -365,64 +408,64 @@ mod tests {
// Subtracting such a message must not produce NaN (the original failure path). // Subtracting such a message must not produce NaN (the original failure path).
let proper = Gaussian::from_ms(9.75, 1.256); let proper = Gaussian::from_ms(9.75, 1.256);
let diff = proper - tiny_neg; let diff = proper.convolve_diff(tiny_neg);
assert!(diff.pi().is_finite() && !diff.pi().is_nan()); assert!(diff.pi().is_finite() && !diff.pi().is_nan());
assert!(diff.tau().is_finite() && !diff.tau().is_nan()); assert!(diff.tau().is_finite() && !diff.tau().is_nan());
} }
#[test] #[test]
fn test_add() { fn convolve_adds_variances() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0); let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(0.0, 1.0); let m = Gaussian::from_ms(0.0, 1.0);
let r = n + m; let r = n.convolve(m);
assert!((r.mu() - 25.0).abs() < 1e-12); assert!((r.mu() - 25.0).abs() < 1e-12);
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10); assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
} }
#[test] #[test]
fn test_sub() { fn convolve_diff_subtracts_means_and_adds_variances() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0); let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(1.0, 1.0); let m = Gaussian::from_ms(1.0, 1.0);
let r = n - m; let r = n.convolve_diff(m);
assert!((r.mu() - 24.0).abs() < 1e-12); assert!((r.mu() - 24.0).abs() < 1e-12);
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10); assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
} }
#[test] #[test]
fn test_mul() { fn ep_product_is_not_arithmetic() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0); let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(0.0, 1.0); let m = Gaussian::from_ms(0.0, 1.0);
let r = n * m; let r = n.ep_product(m);
assert!((r.mu() - 0.35488958990536273).abs() < 1e-10); assert!((r.mu() - 0.35488958990536273).abs() < 1e-10);
assert!((r.sigma() - 0.992876838486922).abs() < 1e-10); assert!((r.sigma() - 0.992876838486922).abs() < 1e-10);
} }
#[test] #[test]
fn test_div() { fn cavity_undoes_a_product() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0); let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(0.0, 1.0); let m = Gaussian::from_ms(0.0, 1.0);
let r = m / n; let r = m.cavity(n);
assert!((r.mu() - (-0.3652597402597402)).abs() < 1e-10); assert!((r.mu() - (-0.3652597402597402)).abs() < 1e-10);
assert!((r.sigma() - 1.0072787050317253).abs() < 1e-10); assert!((r.sigma() - 1.0072787050317253).abs() < 1e-10);
} }
#[test] #[test]
fn test_n00_is_add_identity() { fn test_n00_is_add_identity() {
// N00 (sigma=0) is the additive identity for the variance-convolution Add op. // N00 (sigma=0) is the identity for `convolve`.
// N_INF (sigma=inf) is the identity for the EP-product Mul op. // N_INF (sigma=inf) is the identity for `ep_product`.
let g = Gaussian::from_ms(3.0, 2.0); let g = Gaussian::from_ms(3.0, 2.0);
let n00 = Gaussian::from_ms(0.0, 0.0); let n00 = Gaussian::from_ms(0.0, 0.0);
let r = n00 + g; let r = n00.convolve(g);
assert!((r.mu() - g.mu()).abs() < 1e-12); assert!((r.mu() - g.mu()).abs() < 1e-12);
assert!((r.sigma() - g.sigma()).abs() < 1e-12); assert!((r.sigma() - g.sigma()).abs() < 1e-12);
} }
#[test] #[test]
fn test_mul_is_factor_product() { fn ep_product_adds_natural_parameters() {
// n * m in nat-params should be pi_n + pi_m, tau_n + tau_m // `ep_product` in nat-params should be pi_n + pi_m, tau_n + tau_m
let n = Gaussian::from_ms(2.0, 3.0); let n = Gaussian::from_ms(2.0, 3.0);
let m = Gaussian::from_ms(1.0, 2.0); let m = Gaussian::from_ms(1.0, 2.0);
let r = n * m; let r = n.ep_product(m);
let expected_pi = n.pi() + m.pi(); let expected_pi = n.pi() + m.pi();
let expected_tau = n.tau() + m.tau(); let expected_tau = n.tau() + m.tau();
assert!((r.pi() - expected_pi).abs() < 1e-15); assert!((r.pi() - expected_pi).abs() < 1e-15);
@@ -430,10 +473,10 @@ mod tests {
} }
#[test] #[test]
fn test_div_is_cavity() { fn cavity_subtracts_natural_parameters() {
let n = Gaussian::from_ms(2.0, 1.0); let n = Gaussian::from_ms(2.0, 1.0);
let m = Gaussian::from_ms(1.0, 2.0); let m = Gaussian::from_ms(1.0, 2.0);
let r = n / m; let r = n.cavity(m);
let expected_pi = n.pi() - m.pi(); let expected_pi = n.pi() - m.pi();
let expected_tau = n.tau() - m.tau(); let expected_tau = n.tau() - m.tau();
assert!((r.pi() - expected_pi).abs() < 1e-15); assert!((r.pi() - expected_pi).abs() < 1e-15);
+906 -457
View File
File diff suppressed because it is too large Load Diff
+406 -48
View File
@@ -1,4 +1,4 @@
//! Cholesky factorisation of a joint precision matrix. //! Sparse Cholesky factorisation of a joint precision matrix.
//! //!
//! Every question the joint answers is a *bilinear form* in the precision //! Every question the joint answers is a *bilinear form* in the precision
//! matrix's inverse — the variance of a contrast is `c^T L^-1 c`, and the //! matrix's inverse — the variance of a contrast is `c^T L^-1 c`, and the
@@ -18,72 +18,324 @@
//! negative number, where the same quantity as `|L^-1 c|^2` is a sum of //! negative number, where the same quantity as `|L^-1 c|^2` is a sum of
//! squares and cannot. //! squares and cannot.
//! //!
//! Factorising is `O(n^3)` and whitening is `O(n^2)`, so the split also //! # Why this is sparse (#52)
//! matters structurally: the expensive half depends only on the fit, and is //!
//! shared across every query a [`Joint`](crate::Joint) answers. //! A time-expanded joint is *extremely* sparse and gets sparser as the history
//! grows: a row couples only to its own previous and next appearance through
//! the drift link, and to whoever co-appeared in its slice. Measured on a
//! 76-slice, 988-duel, 200-competitor history: `n = 1976`, `nnz = 7504`,
//! **0.19% dense**.
//!
//! This used to store all `n^2` entries and run a dense `O(n^3)` factorisation
//! over them. Two measurements decided the replacement:
//!
//! - **Ordering alone does nothing to a dense factorisation.** Its inner loops
//! run over every `k` whether or not the entry is zero. A 700x700 banded
//! matrix at 0.43% density factorised in 30.196 ms in band order and
//! 29.544 ms under a scramble that destroyed the band — identical, as the
//! flop count says it must be. Fill-reducing order is worth nothing until
//! the factorisation skips zeros.
//! - **Together they are worth four orders of magnitude.** On that `n = 1976`
//! fixture, against `n^3/3 = 2.572e9` flops dense: sparse in the natural
//! order needs `5.597e7` (46x better), and sparse under an AMD fill-reducing
//! order needs `8.656e4` — **29,710x**. AMD is worth 646x *on top of*
//! sparsity and nothing without it.
//!
//! Natural ordering fills in badly here for the reason #52 predicted: a
//! competitor who appears in slice 0 and not again until slice 75 creates a
//! drift link spanning nearly the whole matrix. `nnz(L)` is 292,437 under the
//! natural order against 11,583 under AMD, from an `A` with 7,504.
//!
//! The ordering comes from `feral-amd`. The factorisation is the up-looking
//! sparse Cholesky of Davis's *Direct Methods for Sparse Linear Systems*,
//! written here rather than taken from a crate: the sparse solvers on
//! crates.io either pull SIMD dispatch (`faer`, and `feral` itself, both
//! through `pulp`), which would make results differ between an AVX-512 host
//! and an AVX2 one — the same class of drift the `libm`-over-`std` decision
//! was made to avoid — or are LGPL, or disclaim fill-reduction in their own
//! docs.
use std::collections::BTreeMap;
/// A symmetric matrix accumulated entry by entry, before factorisation.
///
/// A `BTreeMap` rather than a hash map because the iteration order becomes the
/// factorisation's summation order, and a hash map's order varies per process.
/// `tests/cross_process_determinism.rs` exists because that has bitten before.
#[derive(Default)]
pub(crate) struct SymmetricBuilder {
entries: BTreeMap<(usize, usize), f64>,
}
impl SymmetricBuilder {
pub(crate) fn new() -> Self {
Self::default()
}
/// Add `value` to entry `(row, col)`. Both triangles must be supplied.
pub(crate) fn add(&mut self, row: usize, col: usize, value: f64) {
*self.entries.entry((row, col)).or_insert(0.0) += value;
}
/// The `(row, col)` positions that hold a nonzero. For the #52 measurement.
#[cfg(feature = "measure-sparsity")]
pub(crate) fn pattern(&self) -> impl Iterator<Item = (usize, usize)> + '_ {
self.entries
.iter()
.filter(|(_, v)| **v != 0.0)
.map(|(&rc, _)| rc)
}
}
/// A factorised symmetric positive-definite matrix, reusable across queries. /// A factorised symmetric positive-definite matrix, reusable across queries.
pub(crate) struct Cholesky { pub(crate) struct Cholesky {
/// Lower triangle of `L`, row-major `n * n`. The upper triangle is
/// leftover scratch from the factorisation and is never read.
l: Vec<f64>,
n: usize, n: usize,
/// `inv[old] = new`: where each original row sits after the AMD reorder.
inv: Vec<usize>,
/// `L` in compressed-column form, permuted. Within a column the diagonal
/// is first and the rest ascend by row.
col_ptr: Vec<usize>,
row_idx: Vec<usize>,
val: Vec<f64>,
} }
impl Cholesky { impl Cholesky {
/// Factorise `a` (row-major, `n * n`, symmetric) into `L L^T`. /// Factorise the accumulated matrix into `L L^T`, under a fill-reducing
/// /// permutation.
/// `a` is consumed as scratch.
/// ///
/// Returns `None` if the matrix is not positive-definite, which for a /// Returns `None` if the matrix is not positive-definite, which for a
/// precision matrix means the model is improper — a competitor with /// precision matrix means the model is improper — a competitor with
/// neither a proper prior nor any evidence. /// neither a proper prior nor any evidence — or if the ordering fails.
pub(crate) fn factor(mut a: Vec<f64>, n: usize) -> Option<Self> { pub(crate) fn factor(built: SymmetricBuilder, n: usize) -> Option<Self> {
debug_assert_eq!(a.len(), n * n); if n == 0 {
return Some(Self {
for j in 0..n { n: 0,
let mut d = a[j * n + j]; inv: Vec::new(),
for k in 0..j { col_ptr: vec![0],
d -= a[j * n + k] * a[j * n + k]; row_idx: Vec::new(),
val: Vec::new(),
});
} }
let inv = Self::amd_permutation(n, &built)?;
// Upper triangle of the permuted matrix, column-major: column `c`
// holds the rows `r <= c`. Exactly one of a symmetric pair survives
// the `r <= c` filter, so nothing is double-counted.
let mut cols: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
for (&(old_r, old_c), &v) in &built.entries {
if v == 0.0 {
continue;
}
let (r, c) = (inv[old_r], inv[old_c]);
if r <= c {
cols[c].push((r, v));
}
}
let mut a_ptr = Vec::with_capacity(n + 1);
let mut a_row = Vec::new();
let mut a_val = Vec::new();
a_ptr.push(0usize);
for col in &mut cols {
col.sort_unstable_by_key(|&(r, _)| r);
for &(r, v) in col.iter() {
a_row.push(r);
a_val.push(v);
}
a_ptr.push(a_row.len());
}
let parent = Self::etree(n, &a_ptr, &a_row);
// Symbolic pass: how many entries each column of L will hold. Running
// `ereach` per column costs O(nnz(L)) in total, which is the same order
// as the numeric pass it sizes.
let mut counts = vec![0usize; n];
let mut stack = vec![0usize; n];
let mut mark = vec![false; n];
for k in 0..n {
let top = Self::ereach(k, &a_ptr, &a_row, &parent, &mut stack, &mut mark);
for &i in &stack[top..] {
counts[i] += 1;
}
counts[k] += 1; // the diagonal
}
let mut col_ptr = Vec::with_capacity(n + 1);
col_ptr.push(0usize);
for &c in &counts {
col_ptr.push(col_ptr[col_ptr.len() - 1] + c);
}
let nnz = col_ptr[n];
let mut row_idx = vec![0usize; nnz];
let mut val = vec![0.0f64; nnz];
// `next[i]` is the slot column `i` will fill next. Column `i`'s
// diagonal lands first, at `col_ptr[i]`, because nothing is written to
// a column before its own iteration.
let mut next: Vec<usize> = col_ptr[..n].to_vec();
let mut x = vec![0.0f64; n];
for k in 0..n {
let top = Self::ereach(k, &a_ptr, &a_row, &parent, &mut stack, &mut mark);
for p in a_ptr[k]..a_ptr[k + 1] {
if a_row[p] <= k {
x[a_row[p]] = a_val[p];
}
}
let mut d = x[k];
x[k] = 0.0;
for &i in &stack[top..] {
let lki = x[i] / val[col_ptr[i]];
x[i] = 0.0;
for p in col_ptr[i] + 1..next[i] {
x[row_idx[p]] -= val[p] * lki;
}
d -= lki * lki;
let p = next[i];
next[i] += 1;
row_idx[p] = k;
val[p] = lki;
}
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here // Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here
// too, and a negated comparison would let it through as "not // too, and a negated comparison would let it through as "not
// positive". // positive".
if d.is_nan() || d <= 0.0 { if d.is_nan() || d <= 0.0 {
return None; return None;
} }
let d = d.sqrt(); let p = next[k];
a[j * n + j] = d; next[k] += 1;
row_idx[p] = k;
for i in j + 1..n { val[p] = d.sqrt();
let mut s = a[i * n + j];
for k in 0..j {
s -= a[i * n + k] * a[j * n + k];
}
a[i * n + j] = s / d;
}
} }
Some(Self { l: a, n }) Some(Self {
n,
inv,
col_ptr,
row_idx,
val,
})
} }
/// Whiten a contrast: `y = L^-1 b`. /// AMD fill-reducing order, as `inv[old] = new`.
fn amd_permutation(n: usize, built: &SymmetricBuilder) -> Option<Vec<usize>> {
let mut cols: Vec<Vec<i32>> = vec![Vec::new(); n];
for (&(r, c), &v) in &built.entries {
if v != 0.0 {
cols[c].push(i32::try_from(r).ok()?);
}
}
let mut col_ptr = Vec::with_capacity(n + 1);
let mut row_idx = Vec::new();
col_ptr.push(0i32);
for (j, col) in cols.iter_mut().enumerate() {
col.push(i32::try_from(j).ok()?);
col.sort_unstable();
col.dedup();
row_idx.extend_from_slice(col);
col_ptr.push(i32::try_from(row_idx.len()).ok()?);
}
let pattern = feral_amd::CscPattern::new(n, &col_ptr, &row_idx)?;
// `perm[new] = old`; we want the inverse.
let perm = feral_amd::amd_order(&pattern).ok()?;
let mut inv = vec![0usize; n];
for (new, &old) in perm.iter().enumerate() {
inv[usize::try_from(old).ok()?] = new;
}
Some(inv)
}
/// Elimination tree of the upper-triangular pattern. `usize::MAX` is "no
/// parent", i.e. a root.
fn etree(n: usize, col_ptr: &[usize], row_idx: &[usize]) -> Vec<usize> {
let mut parent = vec![usize::MAX; n];
let mut ancestor = vec![usize::MAX; n];
for k in 0..n {
for &row in &row_idx[col_ptr[k]..col_ptr[k + 1]] {
let mut i = row;
while i != usize::MAX && i < k {
let next = ancestor[i];
ancestor[i] = k;
if next == usize::MAX {
parent[i] = k;
}
i = next;
}
}
}
parent
}
/// Nonzero pattern of row `k` of `L`, written into `stack[top..n]` in
/// topological order. Returns `top`.
///
/// `stack` is used from both ends — a scratch region from `0` while walking
/// each path up the tree, and the result from `n` downwards. They cannot
/// collide because every node is pushed at most once across the whole call.
fn ereach(
k: usize,
col_ptr: &[usize],
row_idx: &[usize],
parent: &[usize],
stack: &mut [usize],
mark: &mut [bool],
) -> usize {
let n = mark.len();
let mut top = n;
mark[k] = true;
for &row in &row_idx[col_ptr[k]..col_ptr[k + 1]] {
let mut i = row;
if i > k {
continue;
}
let mut len = 0usize;
while i != usize::MAX && !mark[i] {
stack[len] = i;
len += 1;
mark[i] = true;
i = parent[i];
}
// Reverse the path onto the output end, so the result stays in
// topological order overall.
while len > 0 {
len -= 1;
top -= 1;
stack[top] = stack[len];
}
}
for &i in &stack[top..] {
mark[i] = false;
}
mark[k] = false;
top
}
/// Whiten a contrast: `y = L^-1 P b`.
/// ///
/// The point of the result is the dot product, not the vector: for two /// The point of the result is the dot product, not the vector: for two
/// contrasts `b` and `b'`, `y . y'` is `b^T A^-1 b'`. See the module docs. /// contrasts `b` and `b'`, `y . y'` is `b^T A^-1 b'`. See the module docs.
///
/// The result is in the permuted order, and stays there — a dot product
/// does not care, as long as both operands were permuted the same way.
pub(crate) fn whiten(&self, b: &[f64]) -> Vec<f64> { pub(crate) fn whiten(&self, b: &[f64]) -> Vec<f64> {
debug_assert_eq!(b.len(), self.n); debug_assert_eq!(b.len(), self.n);
let n = self.n; let n = self.n;
let mut y = b.to_vec(); let mut y = vec![0.0f64; n];
for i in 0..n { for (old, &v) in b.iter().enumerate() {
// Folded from `y[i]` rather than summed and subtracted once, so the y[self.inv[old]] = v;
// accumulation order matches a plain substitution loop exactly. }
let row = &self.l[i * n..i * n + i]; for j in 0..n {
let s = row y[j] /= self.val[self.col_ptr[j]];
.iter() let yj = y[j];
.zip(&y[..i]) for p in self.col_ptr[j] + 1..self.col_ptr[j + 1] {
.fold(y[i], |acc, (l, v)| acc - l * v); y[self.row_idx[p]] -= self.val[p] * yj;
y[i] = s / self.l[i * n + i]; }
} }
y y
} }
@@ -98,11 +350,24 @@ pub(crate) fn bilinear(y: &[f64], y_prime: &[f64]) -> f64 {
mod tests { mod tests {
use super::*; use super::*;
/// Factorise a dense row-major matrix, for the goldens below.
fn dense(a: &[f64], n: usize) -> Option<Cholesky> {
let mut b = SymmetricBuilder::new();
for i in 0..n {
for j in 0..n {
if a[i * n + j] != 0.0 {
b.add(i, j, a[i * n + j]);
}
}
}
Cholesky::factor(b, n)
}
/// `[[4, 1], [1, 3]] z = [1, 2]` has `z = [1/11, 7/11]`, so the quadratic /// `[[4, 1], [1, 3]] z = [1, 2]` has `z = [1/11, 7/11]`, so the quadratic
/// form `b^T A^-1 b` is `1 * 1/11 + 2 * 7/11 = 15/11`. /// form `b^T A^-1 b` is `1 * 1/11 + 2 * 7/11 = 15/11`.
#[test] #[test]
fn reproduces_a_known_quadratic_form() { fn reproduces_a_known_quadratic_form() {
let c = Cholesky::factor(vec![4.0, 1.0, 1.0, 3.0], 2).unwrap(); let c = dense(&[4.0, 1.0, 1.0, 3.0], 2).unwrap();
let y = c.whiten(&[1.0, 2.0]); let y = c.whiten(&[1.0, 2.0]);
assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12); assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12);
} }
@@ -113,8 +378,8 @@ mod tests {
fn recovers_the_inverse_diagonal() { fn recovers_the_inverse_diagonal() {
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is // A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
// [0.75, 1.0, 0.75]. // [0.75, 1.0, 0.75].
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0]; let a = [2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
let c = Cholesky::factor(a, 3).unwrap(); let c = dense(&a, 3).unwrap();
for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() { for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() {
let mut e = vec![0.0; 3]; let mut e = vec![0.0; 3];
e[i] = 1.0; e[i] = 1.0;
@@ -127,8 +392,8 @@ mod tests {
#[test] #[test]
fn recovers_an_off_diagonal_covariance() { fn recovers_an_off_diagonal_covariance() {
// Same A; (A^-1)_{0,1} = 0.5. // Same A; (A^-1)_{0,1} = 0.5.
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0]; let a = [2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
let c = Cholesky::factor(a, 3).unwrap(); let c = dense(&a, 3).unwrap();
let y0 = c.whiten(&[1.0, 0.0, 0.0]); let y0 = c.whiten(&[1.0, 0.0, 0.0]);
let y1 = c.whiten(&[0.0, 1.0, 0.0]); let y1 = c.whiten(&[0.0, 1.0, 0.0]);
assert!((bilinear(&y0, &y1) - 0.5).abs() < 1e-12); assert!((bilinear(&y0, &y1) - 0.5).abs() < 1e-12);
@@ -138,15 +403,108 @@ mod tests {
/// A variance can never come out negative, because it is a sum of squares. /// A variance can never come out negative, because it is a sum of squares.
#[test] #[test]
fn a_quadratic_form_is_never_negative() { fn a_quadratic_form_is_never_negative() {
let a = vec![1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12]; let a = [1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12];
let c = Cholesky::factor(a, 2).unwrap(); let c = dense(&a, 2).unwrap();
let y = c.whiten(&[1.0, -1.0]); let y = c.whiten(&[1.0, -1.0]);
assert!(bilinear(&y, &y) >= 0.0); assert!(bilinear(&y, &y) >= 0.0);
} }
/// Against an independent dense reference, on random sparse SPD matrices.
///
/// The goldens above are 2x2 and 3x3 — small enough that AMD does nothing
/// and no fill-in occurs, so they cannot catch a symbolic-pass bug. This
/// builds matrices big enough to permute and fill in, and checks every
/// bilinear form against a textbook dense factorisation of the *same*
/// matrix in its original order.
#[test]
fn agrees_with_a_dense_reference_on_random_sparse_systems() {
/// Dense Cholesky and quadratic form, deliberately naive: this is the
/// reference, so it must not share code with what it is checking.
fn dense_quadratic_form(a: &[f64], n: usize, b: &[f64], c: &[f64]) -> f64 {
let mut l = a.to_vec();
for j in 0..n {
let mut d = l[j * n + j];
for k in 0..j {
d -= l[j * n + k] * l[j * n + k];
}
let d = d.sqrt();
l[j * n + j] = d;
for i in j + 1..n {
let mut sum = l[i * n + j];
for k in 0..j {
sum -= l[i * n + k] * l[j * n + k];
}
l[i * n + j] = sum / d;
}
}
let solve = |rhs: &[f64]| -> Vec<f64> {
let mut y = rhs.to_vec();
for i in 0..n {
for k in 0..i {
y[i] -= l[i * n + k] * y[k];
}
y[i] /= l[i * n + i];
}
y
};
let (yb, yc) = (solve(b), solve(c));
yb.iter().zip(&yc).map(|(x, y)| x * y).sum()
}
// A cheap deterministic generator; no dependency, and reproducible.
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut rand = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
};
for n in [7usize, 23, 60] {
let mut a = vec![0.0f64; n * n];
// A chain plus scattered long-range couplings: the shape of a
// time-expanded joint, where a competitor's drift link can span
// the whole matrix.
for i in 0..n {
a[i * n + i] = 4.0 + rand();
if i + 1 < n {
let v = -(0.5 + rand() * 0.5);
a[i * n + i + 1] = v;
a[(i + 1) * n + i] = v;
}
}
for step in 0..n / 3 {
let i = (step * 7) % n;
let j = (step * 29 + 3) % n;
if i != j {
let v = -(0.1 + rand() * 0.2);
a[i * n + j] = v;
a[j * n + i] = v;
// Keep it diagonally dominant, hence positive-definite.
a[i * n + i] += 0.6;
a[j * n + j] += 0.6;
}
}
let sparse = dense(&a, n).expect("spd");
for trial in 0..8 {
let b: Vec<f64> = (0..n).map(|_| rand() * 2.0 - 1.0).collect();
let c: Vec<f64> = (0..n).map(|_| rand() * 2.0 - 1.0).collect();
let got = bilinear(&sparse.whiten(&b), &sparse.whiten(&c));
let want = dense_quadratic_form(&a, n, &b, &c);
assert!(
(got - want).abs() <= 1e-10 * want.abs().max(1.0),
"n={n} trial={trial}: sparse {got} vs dense {want}"
);
}
}
}
/// A permutation must not change which matrices are rejected.
#[test] #[test]
fn rejects_a_non_positive_definite_matrix() { fn rejects_a_non_positive_definite_matrix() {
// Singular: the second row is a multiple of the first. // Singular: the second row is a multiple of the first.
assert!(Cholesky::factor(vec![1.0, 2.0, 2.0, 4.0], 2).is_none()); assert!(dense(&[1.0, 2.0, 2.0, 4.0], 2).is_none());
} }
} }
+16 -12
View File
@@ -26,21 +26,24 @@ where
K: Eq + Hash + Clone, K: Eq + Hash + Clone,
{ {
#[must_use] #[must_use]
pub fn new() -> Self { pub(crate) fn new() -> Self {
Self { Self {
forward: HashMap::new(), forward: HashMap::new(),
reverse: Vec::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 where
K: Borrow<Q>, K: Borrow<Q>,
{ {
self.forward.get(k).cloned() 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 where
K: Borrow<Q>, K: Borrow<Q>,
{ {
@@ -56,23 +59,24 @@ where
} }
#[must_use] #[must_use]
pub fn key(&self, idx: Index) -> Option<&K> { pub(crate) fn key(&self, idx: Index) -> Option<&K> {
self.reverse.get(idx.0) self.reverse.get(idx.0)
} }
pub fn keys(&self) -> impl Iterator<Item = &K> { /// Every key, in the order they were first interned.
self.forward.keys() ///
/// 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] #[must_use]
pub fn len(&self) -> usize { pub(crate) fn len(&self) -> usize {
self.reverse.len() self.reverse.len()
} }
#[must_use]
pub fn is_empty(&self) -> bool {
self.reverse.is_empty()
}
} }
impl<K> Default for KeyTable<K> impl<K> Default for KeyTable<K>
+101 -29
View File
@@ -9,6 +9,10 @@
//! This is a Rust port of //! This is a Rust port of
//! [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py). //! [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
//! //!
//! Upgrading? `MIGRATING.md` in the repository root covers every breaking
//! change, with the ones that alter what an existing call *returns* called out
//! first.
//!
//! # Getting started //! # Getting started
//! //!
//! Record results, converge, then read off skills: //! Record results, converge, then read off skills:
@@ -85,6 +89,10 @@
//! regardless of worker count. //! regardless of worker count.
#![forbid(unsafe_code)] #![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. /// Compiles every `rust` block in `README.md` as a doctest.
/// ///
@@ -104,22 +112,32 @@ use std::{
f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2}, f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2},
}; };
mod acquisition;
#[cfg(feature = "approx")] #[cfg(feature = "approx")]
mod approx; mod approx;
pub(crate) mod arena; pub(crate) mod arena;
mod time;
mod time_slice;
pub use time_slice::{EventKind, TimeSlice};
mod acquisition;
mod color_group; mod color_group;
mod competitor; mod competitor;
mod convergence; 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; pub mod drift;
mod error; mod error;
mod event; mod event;
mod event_builder; mod event_builder;
pub(crate) mod factor; pub(crate) mod factor;
mod game; 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; pub mod gaussian;
mod history; mod history;
mod joint; mod joint;
@@ -130,31 +148,78 @@ mod outcome;
mod predict; mod predict;
pub(crate) mod quadrature; pub(crate) mod quadrature;
mod rating; mod rating;
pub mod storage; pub mod rating_rule;
pub(crate) mod storage;
mod time;
mod time_slice;
pub use acquisition::expected_information_gain; pub use acquisition::expected_information_gain;
pub use competitor::Competitor;
pub use convergence::{ConvergenceOptions, ConvergenceReport}; pub use convergence::{ConvergenceOptions, ConvergenceReport};
pub use drift::{ConstantDrift, Drift}; pub use drift::{ConstantDrift, Drift};
pub use error::{InferenceError, UnknownKeys}; pub use error::{CompetitorField, InferenceError, OutcomeKind, Parameter, Shape, UnknownKeys};
pub use event::{Event, Member, Team}; pub use event::{Event, Member, Team};
pub use event_builder::EventBuilder; pub use event_builder::EventBuilder;
pub use game::{Game, GameOptions, OwnedGame}; pub use game::{Game, GameOptions};
pub use gaussian::Gaussian; pub use gaussian::Gaussian;
pub use history::{History, HistoryBuilder, Joint}; pub use history::{History, HistoryBuilder, Joint};
pub use key_table::KeyTable;
use matrix::Matrix; use matrix::Matrix;
pub use observer::{NullObserver, Observer}; pub use observer::{NullObserver, Observer};
pub use outcome::Outcome; pub use outcome::Outcome;
pub use predict::Prediction; pub use predict::Prediction;
pub use rating::Rating; pub use rating::Rating;
pub use rating_rule::{FnRule, NoRule, RatingRule, StartingPoint};
/// 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}; 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; 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; 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; 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; 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; 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; pub const EPSILON: f64 = 1e-6;
/// Default cap on convergence sweeps. /// Default cap on convergence sweeps.
/// ///
@@ -226,20 +291,27 @@ const HALF_LINE_WINDOW: f64 = 10.0;
const NARROW_WINDOW_RATIO: f64 = 2.0e4; const NARROW_WINDOW_RATIO: f64 = 2.0e4;
const ASYMPTOTIC_MILLS_ALPHA: f64 = 100.0; const ASYMPTOTIC_MILLS_ALPHA: f64 = 100.0;
pub const N01: Gaussian = Gaussian::from_ms(0.0, 1.0); pub(crate) const N00: Gaussian = Gaussian::from_ms(0.0, 0.0);
pub const N00: Gaussian = Gaussian::from_ms(0.0, 0.0); pub(crate) const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
pub 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. Indices are assigned in interning order and
/// are stable for the life of a history; they are not portable between
/// histories, since the same key interns to a different slot under a different
/// ingestion order.
///
/// Crate-internal. It was public, along with `History::intern` and
/// `History::lookup` that produced one — and **nothing public ever accepted
/// one**, so it was a handle with nowhere to go. It also shadowed
/// `std::ops::Index`, which `CompetitorStore` implements. See #73.
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)] #[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
pub struct Index(usize); pub(crate) struct Index(usize);
impl Index { impl Index {
/// The underlying slot number. /// The underlying slot number.
/// pub(crate) fn get(self) -> usize {
/// Indices are dense and assigned in interning order, so this is usable as
/// a key into a caller-side side table.
#[must_use]
pub fn get(self) -> usize {
self.0 self.0
} }
} }
@@ -750,14 +822,14 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
x.into_iter().map(|(i, _)| i).collect() 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 /// Supports any number of groups. Values range roughly `[0, 1]`; 1 means a
/// perfectly balanced match. /// perfectly balanced match.
/// ///
/// # Panics /// # 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 /// empty — match quality is a property of a contest between at least two
/// non-empty sides. /// non-empty sides.
/// ///
@@ -768,18 +840,18 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
/// converted, because the input has no meaningful answer rather than an /// converted, because the input has no meaningful answer rather than an
/// awkward one. /// awkward one.
#[must_use] #[must_use]
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 { pub fn quality(teams: &[&[Gaussian]], beta: f64) -> f64 {
assert!( assert!(
rating_groups.len() >= 2, teams.len() >= 2,
"quality() requires at least 2 rating groups, got {}", "quality() requires at least 2 teams, got {}",
rating_groups.len() teams.len()
); );
assert!( assert!(
rating_groups.iter().all(|group| !group.is_empty()), teams.iter().all(|group| !group.is_empty()),
"quality() requires every rating group to be non-empty" "quality() requires every team to be non-empty"
); );
let flatten_ratings = rating_groups let flatten_ratings = teams
.iter() .iter()
.flat_map(|group| group.iter()) .flat_map(|group| group.iter())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -800,14 +872,14 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
variance_matrix[(i, i)] = rating.sigma().powi(2); 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` // Row `row` contrasts group `row` (+weight) against group `row + 1`
// (-weight). `t` is the column where the current group's players start; // (-weight). `t` is the column where the current group's players start;
// the negative block begins immediately after it. // the negative block begins immediately after it.
let mut t = 0; 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 current = group[0];
let next = group[1]; let next = group[1];
+5 -5
View File
@@ -140,7 +140,7 @@ impl Lu {
} }
impl Matrix { impl Matrix {
pub fn new(height: usize, width: usize) -> Matrix { pub(crate) fn new(height: usize, width: usize) -> Matrix {
Matrix { Matrix {
data: vec![0.0; height * width].into_boxed_slice(), data: vec![0.0; height * width].into_boxed_slice(),
height, height,
@@ -148,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); let mut matrix = Matrix::new(self.width, self.height);
for c in 0..self.width { for c in 0..self.width {
@@ -166,7 +166,7 @@ impl Matrix {
/// # Panics /// # Panics
/// ///
/// Panics if the matrix is not square. /// Panics if the matrix is not square.
pub fn determinant(&self) -> f64 { pub(crate) fn determinant(&self) -> f64 {
assert_eq!( assert_eq!(
self.width, self.height, self.width, self.height,
"determinant requires a square matrix, got {}x{}", "determinant requires a square matrix, got {}x{}",
@@ -184,7 +184,7 @@ impl Matrix {
/// ///
/// See [`Lu::ln_abs_determinant`] for why a ratio of determinants must be /// See [`Lu::ln_abs_determinant`] for why a ratio of determinants must be
/// taken this way. /// taken this way.
pub fn ln_abs_determinant(&self) -> f64 { pub(crate) fn ln_abs_determinant(&self) -> f64 {
assert_eq!( assert_eq!(
self.width, self.height, self.width, self.height,
"determinant requires a square matrix, got {}x{}", "determinant requires a square matrix, got {}x{}",
@@ -203,7 +203,7 @@ impl Matrix {
/// # Panics /// # Panics
/// ///
/// Panics if the matrix is not square or is singular. /// Panics if the matrix is not square or is singular.
pub fn inverse(&self) -> Matrix { pub(crate) fn inverse(&self) -> Matrix {
assert_eq!( assert_eq!(
self.width, self.height, self.width, self.height,
"inverse requires a square matrix, got {}x{}", "inverse requires a square matrix, got {}x{}",
+48 -17
View File
@@ -1,6 +1,6 @@
//! Outcome of a match. //! 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). //! continuous per-team scores (engages `MarginFactor` in the engine).
use smallvec::SmallVec; use smallvec::SmallVec;
@@ -10,19 +10,41 @@ use smallvec::SmallVec;
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those /// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
/// teams. `ranks.len()` must equal the number of teams in the event. /// 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 /// feed observed margins to `MarginFactor`. `scores.len()` must equal the
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma` /// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
/// when `Some`; `None` inherits the history default. /// when `Some`; `None` inherits the history default.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
#[non_exhaustive] #[non_exhaustive]
#[must_use]
pub enum Outcome { 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]>), 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 { 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]>, scores: SmallVec<[f64; 4]>,
/// Per-event noise override. `None` means inherit /// Per-event noise override. `None` means inherit
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`. /// `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" /// `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 /// is exactly the class of quiet wrong answer this crate keeps removing, so
/// the check happens here where the mistake is. /// the check happens here where the mistake is.
#[must_use]
pub fn winner(winner: u32, n: u32) -> Self { pub fn winner(winner: u32, n: u32) -> Self {
Self::try_winner(winner, n) Self::try_winner(winner, n)
.unwrap_or_else(|_| panic!("winner index {winner} out of range 0..{n}")) .unwrap_or_else(|_| panic!("winner index {winner} out of range 0..{n}"))
@@ -63,7 +84,7 @@ impl Outcome {
pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> { pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> {
if winner >= n { if winner >= n {
return Err(crate::InferenceError::InvalidParameter { return Err(crate::InferenceError::InvalidParameter {
name: "winner", parameter: crate::Parameter::WinnerIndex,
value: f64::from(winner), value: f64::from(winner),
}); });
} }
@@ -72,7 +93,6 @@ impl Outcome {
} }
/// All `n` teams tied. /// All `n` teams tied.
#[must_use]
pub fn draw(n: u32) -> Self { pub fn draw(n: u32) -> Self {
Self::Ranked(SmallVec::from_vec(vec![0; n as usize])) Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
} }
@@ -87,23 +107,34 @@ impl Outcome {
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self { pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
Self::Scored { Self::Scored {
scores: scores.into_iter().collect(), scores: scores.into_iter().collect(),
sigma: None, score_sigma: None,
} }
} }
/// Explicit per-team continuous scores with a per-event noise override. /// Explicit per-team continuous scores with a per-event noise override.
/// ///
/// `sigma` must be `> 0.0`. Constructing an `Outcome` with a non-positive /// The noise is on the *observed score margin*, in the units of the scores
/// or NaN sigma is allowed; the value is rejected with /// themselves — it is not a skill sigma, which is what the old name
/// `scores_with_sigma` read as. It overrides `HistoryBuilder::score_sigma`
/// for this event only.
///
/// `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 /// `InferenceError::InvalidParameter` when the event is ingested, so
/// callers get an error rather than a panic. /// 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_noise<I: IntoIterator<Item = f64>>(scores: I, score_sigma: f64) -> Self {
Self::Scored { Self::Scored {
scores: scores.into_iter().collect(), 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] #[must_use]
pub fn team_count(&self) -> usize { pub fn team_count(&self) -> usize {
match self { match self {
@@ -185,7 +216,7 @@ mod tests {
#[test] #[test]
fn scores_with_sigma_round_trips() { fn scores_with_sigma_round_trips() {
let o = Outcome::scores_with_sigma([10.0, 4.0], 0.5); let o = Outcome::scores_with_noise([10.0, 4.0], 0.5);
assert_eq!(o.team_count(), 2); assert_eq!(o.team_count(), 2);
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..])); assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
} }
@@ -194,16 +225,16 @@ mod tests {
fn scores_constructor_leaves_sigma_unset() { fn scores_constructor_leaves_sigma_unset() {
let o = Outcome::scores([3.0, 1.0]); let o = Outcome::scores([3.0, 1.0]);
match o { 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"), Outcome::Ranked(_) => panic!("expected Scored variant"),
} }
} }
#[test] #[test]
fn scores_with_sigma_sets_sigma_some() { fn scores_with_sigma_sets_sigma_some() {
let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0); let o = Outcome::scores_with_noise([3.0, 1.0], 2.0);
match o { 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"), Outcome::Ranked(_) => panic!("expected Scored variant"),
} }
} }
@@ -213,9 +244,9 @@ mod tests {
/// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`. /// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`.
#[test] #[test]
fn scores_with_sigma_defers_validation_to_ingestion() { fn scores_with_sigma_defers_validation_to_ingestion() {
let o = Outcome::scores_with_sigma([3.0, 1.0], 0.0); let o = Outcome::scores_with_noise([3.0, 1.0], 0.0);
match o { 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"), Outcome::Ranked(_) => panic!("expected Scored variant"),
} }
} }
+2
View File
@@ -456,6 +456,7 @@ pub(crate) fn ranking_probability(
/// `Game::ranked` asks "what would we believe if *this* happened", which is /// `Game::ranked` asks "what would we believe if *this* happened", which is
/// what an expected-information-gain calculation needs alongside the weight. /// what an expected-information-gain calculation needs alongside the weight.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct Prediction { pub struct Prediction {
outcomes: Vec<(Vec<u32>, f64)>, outcomes: Vec<(Vec<u32>, f64)>,
} }
@@ -466,6 +467,7 @@ impl Prediction {
} }
/// Every possible finishing order and its probability, most likely first. /// Every possible finishing order and its probability, most likely first.
#[must_use]
pub fn outcomes(&self) -> impl ExactSizeIterator<Item = (&[u32], f64)> { pub fn outcomes(&self) -> impl ExactSizeIterator<Item = (&[u32], f64)> {
self.outcomes.iter().map(|(r, p)| (r.as_slice(), *p)) self.outcomes.iter().map(|(r, p)| (r.as_slice(), *p))
} }
+1 -2
View File
@@ -11,7 +11,7 @@ use crate::{
/// ///
/// A configuration rather than a person: the per-history temporal state /// A configuration rather than a person: the per-history temporal state
/// (messages, last appearance) lives on `Competitor`. /// (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 struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
pub(crate) prior: Gaussian, pub(crate) prior: Gaussian,
pub(crate) beta: f64, pub(crate) beta: f64,
@@ -61,7 +61,6 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
} }
/// The configured prior skill estimate. /// The configured prior skill estimate.
#[must_use]
pub fn prior(&self) -> Gaussian { pub fn prior(&self) -> Gaussian {
self.prior self.prior
} }
+140
View File
@@ -0,0 +1,140 @@
//! Declarative competitor configuration: a rule that supplies defaults for
//! competitors the history has not seen yet.
//!
//! [`History::register`](crate::History::register) states configuration for
//! *one* competitor, which covers a bot at a known strength or a handful of
//! reference points. It does not cover a *rule* — "every layout is static" —
//! because enumerating the keys means knowing the full key set up front, which
//! a consumer ingesting an event stream generally does not.
//!
//! ```
//! use trueskill_tt::{Gaussian, History, StartingPoint};
//!
//! let mut h = History::builder()
//! // Layouts do not improve; everybody else does.
//! .default_rating_for(|key: &&'static str| {
//! key.starts_with("layout_")
//! .then(|| StartingPoint::new().prior(Gaussian::from_ms(0.0, 1.0)).drift_scale(0.0))
//! })
//! .build();
//!
//! h.event(1).team(["layout_7"]).team(["alice"]).scores([3.0, 1.0]).commit()?;
//! h.converge()?;
//!
//! // The layout was pinned, so its uncertainty barely moved.
//! assert!(h.current_skill("layout_7").unwrap().sigma() < 1.0);
//! # Ok::<(), trueskill_tt::InferenceError>(())
//! ```
//!
//! # Why a trait, and why a fifth type parameter
//!
//! The rule is a type parameter on [`History`](crate::History), defaulted to
//! [`NoRule`], so it costs a caller who does not use one exactly nothing —
//! `History<String>` still spells out in full. A boxed `dyn Fn` would have
//! avoided the parameter at the price of `HistoryBuilder`'s derived `Clone`
//! and `Debug`.
//!
//! It is a trait rather than a bare `Fn` bound because a closure's type cannot
//! be written down, and the motivating consumer holds its `History` in
//! application state — so it has to name the type in a struct field. Implement
//! [`RatingRule`] on a named type of your own and that field is spellable.
//!
//! # What a rule may set, and what it may not
//!
//! A [`StartingPoint`], which is the same pair a
//! [`Member`](crate::Member) may carry: the prior and the drift scale. Not
//! `beta` and not the drift model — those describe the *history*, not one
//! competitor, and a rule that could vary them would be describing a different
//! model per competitor rather than a starting point within one.
//!
//! Keeping the rule to those two also keeps it independent of the history's
//! time and drift types, so [`HistoryBuilder::drift`](crate::HistoryBuilder::drift)
//! and [`HistoryBuilder::time_type`](crate::HistoryBuilder::time_type) still
//! work after a rule is set.
use crate::gaussian::Gaussian;
/// What a [`RatingRule`] may say about a competitor.
///
/// Both fields are optional and are applied independently, so a rule that sets
/// only `drift_scale` does not also assert a prior — the same reason
/// `Member`'s configuration is carried as "what was explicitly set" rather
/// than as a merged `Rating`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[must_use]
pub struct StartingPoint {
pub(crate) prior: Option<Gaussian>,
pub(crate) drift_scale: Option<f64>,
}
impl StartingPoint {
/// A starting point that says nothing yet.
pub fn new() -> Self {
Self::default()
}
/// Start this competitor from `prior` instead of the history's
/// `mu`/`sigma`.
pub fn prior(mut self, prior: Gaussian) -> Self {
self.prior = Some(prior);
self
}
/// Scale how fast this competitor drifts, relative to the history's drift
/// model. `0.0` pins them still.
pub fn drift_scale(mut self, drift_scale: f64) -> Self {
self.drift_scale = Some(drift_scale);
self
}
}
/// Supplies a [`StartingPoint`] for competitors the history has not seen.
///
/// Consulted once per competitor, when that competitor is created — not per
/// event and not per sweep. Returning `None` means "no opinion": the
/// competitor takes the history's own defaults.
///
/// # Precedence
///
/// Explicit configuration wins, field by field. A `prior` or `drift_scale`
/// from [`History::register`](crate::History::register) or from a
/// [`Member`](crate::Member) overrides whatever the rule returned for that
/// competitor. The specific beats the general, which is the only reading that
/// lets a rule have exceptions — treating the disagreement as
/// `ConflictingCompetitorConfig` would make one exceptional competitor
/// incompatible with having any rule at all.
///
/// Two *explicit* declarations that disagree remain an error. Neither of those
/// is more specific than the other, so there is nothing to prefer.
pub trait RatingRule<K> {
/// Where this competitor should start, or `None` for the history's
/// defaults.
fn starting_point(&self, key: &K) -> Option<StartingPoint>;
}
/// The default rule: no opinion about anybody.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct NoRule;
impl<K> RatingRule<K> for NoRule {
#[inline]
fn starting_point(&self, _key: &K) -> Option<StartingPoint> {
None
}
}
/// A [`RatingRule`] built from a closure by
/// [`HistoryBuilder::default_rating_for`](crate::HistoryBuilder::default_rating_for).
///
/// Public so it can be named where a closure's own type cannot be, though
/// implementing [`RatingRule`] on a named type of your own is the better way
/// to get a `History<..>` you can write down in a struct field.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FnRule<F>(pub F);
impl<K, F: Fn(&K) -> Option<StartingPoint>> RatingRule<K> for FnRule<F> {
#[inline]
fn starting_point(&self, key: &K) -> Option<StartingPoint> {
(self.0)(key)
}
}
+5 -12
View File
@@ -56,16 +56,16 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
self.get(idx).is_some() self.get(idx).is_some()
} }
/// Test-only: no code path in the crate needs a count.
#[cfg(test)]
#[must_use] #[must_use]
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.n_present self.n_present
} }
#[must_use] /// Test-only: iterating every competitor is an assertion helper, not part
pub fn is_empty(&self) -> bool { /// of inference, which walks slices rather than the store.
self.n_present == 0 #[cfg(test)]
}
pub fn iter(&self) -> impl Iterator<Item = (Index, &Competitor<T, D>)> { pub fn iter(&self) -> impl Iterator<Item = (Index, &Competitor<T, D>)> {
self.competitors self.competitors
.iter() .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))) .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>> { pub fn values_mut(&mut self) -> impl Iterator<Item = &mut Competitor<T, D>> {
self.competitors.iter_mut().filter_map(|s| s.as_mut()) self.competitors.iter_mut().filter_map(|s| s.as_mut())
} }
+169 -120
View File
@@ -9,7 +9,7 @@ use crate::{
arena::ScratchArena, arena::ScratchArena,
color_group::ColorGroups, color_group::ColorGroups,
drift::Drift, drift::Drift,
game::Game, game::GameRef,
gaussian::Gaussian, gaussian::Gaussian,
rating::Rating, rating::Rating,
storage::{CompetitorStore, SkillStore}, storage::{CompetitorStore, SkillStore},
@@ -26,7 +26,9 @@ pub(crate) struct Skill {
impl Skill { impl Skill {
pub(crate) fn posterior(&self) -> Gaussian { pub(crate) fn posterior(&self) -> Gaussian {
self.likelihood * self.backward * self.forward self.likelihood
.ep_product(self.backward)
.ep_product(self.forward)
} }
} }
@@ -50,12 +52,12 @@ pub enum EventKind {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct Item { struct Item {
agent: Index, competitor: Index,
/// This competitor's slot in the owning slice's `SkillStore`, resolved /// This competitor's slot in the owning slice's `SkillStore`, resolved
/// once at ingestion. /// once at ingestion.
/// ///
/// The convergence loop reaches skills through this rather than through /// 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`. /// that the store is compact rather than indexed by the global `Index`.
slot: u32, slot: u32,
likelihood: Gaussian, likelihood: Gaussian,
@@ -66,15 +68,15 @@ impl Item {
&self, &self,
forward: bool, forward: bool,
skills: &SkillStore, skills: &SkillStore,
agents: &CompetitorStore<T, D>, competitors: &CompetitorStore<T, D>,
) -> Rating<T, D> { ) -> Rating<T, D> {
let r = &agents[self.agent].rating; let r = &competitors[self.competitor].rating;
let skill = skills.at(self.slot); let skill = skills.at(self.slot);
if forward { if forward {
Rating::new(skill.forward, r.beta, r.drift).with_drift_scale(r.drift_scale) Rating::new(skill.forward, r.beta, r.drift).with_drift_scale(r.drift_scale)
} else { } else {
Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift) Rating::new(skill.posterior().cavity(self.likelihood), r.beta, r.drift)
.with_drift_scale(r.drift_scale) .with_drift_scale(r.drift_scale)
} }
} }
@@ -95,10 +97,10 @@ pub(crate) struct Event {
} }
impl 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 self.teams
.iter() .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> { fn outputs(&self) -> Vec<f64> {
@@ -112,14 +114,14 @@ impl Event {
&self, &self,
forward: bool, forward: bool,
skills: &SkillStore, skills: &SkillStore,
agents: &CompetitorStore<T, D>, competitors: &CompetitorStore<T, D>,
) -> Vec<Vec<Rating<T, D>>> { ) -> Vec<Vec<Rating<T, D>>> {
self.teams self.teams
.iter() .iter()
.map(|team| { .map(|team| {
team.items team.items
.iter() .iter()
.map(|item| item.within_prior(forward, skills, agents)) .map(|item| item.within_prior(forward, skills, competitors))
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -133,18 +135,23 @@ impl Event {
fn compute<T: Time, D: Drift<T>>( fn compute<T: Time, D: Drift<T>>(
&self, &self,
skills: &SkillStore, skills: &SkillStore,
agents: &CompetitorStore<T, D>, competitors: &CompetitorStore<T, D>,
p_draw: f64, p_draw: f64,
convergence: crate::ConvergenceOptions, convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena, arena: &mut ScratchArena,
) -> EventUpdate { ) -> EventUpdate {
let teams = self.within_priors(false, skills, agents); let teams = self.within_priors(false, skills, competitors);
let result = self.outputs(); let result = self.outputs();
let g = match self.kind { let g = match self.kind {
EventKind::Ranked => { EventKind::Ranked => GameRef::ranked_with_arena(
Game::ranked_with_arena(teams, &result, &self.weights, p_draw, convergence, arena) teams,
} &result,
EventKind::Scored { score_sigma } => Game::scored_with_arena( &self.weights,
p_draw,
convergence,
arena,
),
EventKind::Scored { score_sigma } => GameRef::scored_with_arena(
teams, teams,
&result, &result,
&self.weights, &self.weights,
@@ -166,7 +173,7 @@ impl Event {
for (i, item) in team.items.iter_mut().enumerate() { for (i, item) in team.items.iter_mut().enumerate() {
let fresh = update.likelihoods[t][i]; let fresh = update.likelihoods[t][i];
let old_likelihood = skills.at(item.slot).likelihood; let old_likelihood = skills.at(item.slot).likelihood;
let new_likelihood = (old_likelihood / item.likelihood) * fresh; let new_likelihood = old_likelihood.cavity(item.likelihood).ep_product(fresh);
skills.at_mut(item.slot).likelihood = new_likelihood; skills.at_mut(item.slot).likelihood = new_likelihood;
item.likelihood = fresh; item.likelihood = fresh;
} }
@@ -179,12 +186,12 @@ impl Event {
fn iteration_direct<T: Time, D: Drift<T>>( fn iteration_direct<T: Time, D: Drift<T>>(
&mut self, &mut self,
skills: &mut SkillStore, skills: &mut SkillStore,
agents: &CompetitorStore<T, D>, competitors: &CompetitorStore<T, D>,
p_draw: f64, p_draw: f64,
convergence: crate::ConvergenceOptions, convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena, 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); self.apply(skills, update);
} }
} }
@@ -228,7 +235,7 @@ pub struct TimeSlice<T: Time = i64> {
} }
impl<T: Time> TimeSlice<T> { 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 { Self {
events: Vec::new(), events: Vec::new(),
skills: SkillStore::new(), skills: SkillStore::new(),
@@ -255,7 +262,7 @@ impl<T: Time> TimeSlice<T> {
} }
let cg = color_greedy(n, |ev_idx| { 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); let mut reordered: Vec<Event> = Vec::with_capacity(n);
@@ -282,17 +289,17 @@ impl<T: Time> TimeSlice<T> {
); );
} }
pub fn add_events<D: Drift<T>>( pub(crate) fn add_events<D: Drift<T>>(
&mut self, &mut self,
composition: Vec<Vec<Vec<Index>>>, composition: Vec<Vec<Vec<Index>>>,
results: Option<Vec<Vec<f64>>>, results: Option<Vec<Vec<f64>>>,
weights: Option<Vec<Vec<Vec<f64>>>>, weights: Option<Vec<Vec<Vec<f64>>>>,
kinds: Vec<EventKind>, kinds: Vec<EventKind>,
agents: &CompetitorStore<T, D>, competitors: &CompetitorStore<T, D>,
) { ) {
let mut unique = Vec::with_capacity(10); 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) { if !unique.contains(idx) {
unique.push(*idx); unique.push(*idx);
@@ -302,10 +309,10 @@ impl<T: Time> TimeSlice<T> {
false false
}); });
for idx in this_agent { for idx in these_competitors {
let elapsed = compute_elapsed(agents[*idx].last_time.as_ref(), &self.time); 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) { if let Some(skill) = self.skills.get_mut(*idx) {
skill.elapsed = elapsed; skill.elapsed = elapsed;
@@ -332,12 +339,12 @@ impl<T: Time> TimeSlice<T> {
.map(|(t, team)| { .map(|(t, team)| {
let items = team let items = team
.iter() .iter()
.map(|&agent| Item { .map(|&competitor| Item {
agent, competitor,
// Every participant was inserted into `skills` // Every participant was inserted into `skills`
// just above, so the slot always resolves. // just above, so the slot always resolves.
slot: skills slot: skills
.slot_of(agent) .slot_of(competitor)
.expect("participant must be present in the slice store"), .expect("participant must be present in the slice store"),
likelihood: N_INF, likelihood: N_INF,
}) })
@@ -376,7 +383,7 @@ impl<T: Time> TimeSlice<T> {
self.color_groups_dirty = true; self.color_groups_dirty = true;
self.iteration(from, agents); self.iteration(from, competitors);
} }
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> { pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
@@ -393,7 +400,11 @@ impl<T: Time> TimeSlice<T> {
/// Panics if an event references a competitor with no entry in this /// Panics if an event references a competitor with no entry in this
/// slice's skill store. `add_events` inserts one for every participant, so /// slice's skill store. `add_events` inserts one for every participant, so
/// this cannot happen for slices built through the public API. /// 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 { if from == 0 && self.color_groups_dirty {
self.recompute_color_groups(); self.recompute_color_groups();
} }
@@ -401,11 +412,11 @@ impl<T: Time> TimeSlice<T> {
if from > 0 || self.color_groups.is_empty() { if from > 0 || self.color_groups.is_empty() {
// Initial pass (add_events) or no color groups yet: simple sequential sweep. // Initial pass (add_events) or no color groups yet: simple sequential sweep.
for event in self.events.iter_mut().skip(from) { 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 result = event.outputs();
let g = match event.kind { let g = match event.kind {
EventKind::Ranked => Game::ranked_with_arena( EventKind::Ranked => GameRef::ranked_with_arena(
teams, teams,
&result, &result,
&event.weights, &event.weights,
@@ -413,7 +424,7 @@ impl<T: Time> TimeSlice<T> {
self.convergence, self.convergence,
&mut self.arena, &mut self.arena,
), ),
EventKind::Scored { score_sigma } => Game::scored_with_arena( EventKind::Scored { score_sigma } => GameRef::scored_with_arena(
teams, teams,
&result, &result,
&event.weights, &event.weights,
@@ -426,8 +437,9 @@ impl<T: Time> TimeSlice<T> {
for (t, team) in event.teams.iter_mut().enumerate() { for (t, team) in event.teams.iter_mut().enumerate() {
for (i, item) in team.items.iter_mut().enumerate() { for (i, item) in team.items.iter_mut().enumerate() {
let old_likelihood = self.skills.at(item.slot).likelihood; let old_likelihood = self.skills.at(item.slot).likelihood;
let new_likelihood = let new_likelihood = old_likelihood
(old_likelihood / item.likelihood) * g.likelihoods[t][i]; .cavity(item.likelihood)
.ep_product(g.likelihoods[t][i]);
self.skills.at_mut(item.slot).likelihood = new_likelihood; self.skills.at_mut(item.slot).likelihood = new_likelihood;
item.likelihood = g.likelihoods[t][i]; item.likelihood = g.likelihoods[t][i];
} }
@@ -436,14 +448,14 @@ impl<T: Time> TimeSlice<T> {
event.log_evidence = g.log_evidence; event.log_evidence = g.log_evidence;
} }
} else { } else {
self.sweep_color_groups(agents); self.sweep_color_groups(competitors);
} }
} }
/// Full event sweep using the color-group partition. Colors are processed /// Full event sweep using the color-group partition. Colors are processed
/// sequentially; within each color the inner loop is parallel under rayon. /// 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 /// can observe another's writes. That makes the sweep separable: inference
/// runs concurrently over shared `&self.skills`, and the resulting updates /// runs concurrently over shared `&self.skills`, and the resulting updates
/// are folded in afterwards in index order. Splitting it this way needs no /// are folded in afterwards in index order. Splitting it this way needs no
@@ -451,7 +463,7 @@ impl<T: Time> TimeSlice<T> {
/// across thread counts because the apply order does not depend on which /// across thread counts because the apply order does not depend on which
/// worker finished first. /// worker finished first.
#[cfg(feature = "rayon")] #[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::*; use rayon::prelude::*;
thread_local! { thread_local! {
@@ -483,7 +495,7 @@ impl<T: Time> TimeSlice<T> {
let mut arena = cell.borrow_mut(); let mut arena = cell.borrow_mut();
arena.reset(); arena.reset();
ev.compute(skills, agents, p_draw, convergence, &mut arena) ev.compute(skills, competitors, p_draw, convergence, &mut arena)
}) })
}) })
.collect(); .collect();
@@ -495,7 +507,7 @@ impl<T: Time> TimeSlice<T> {
for ev in &mut self.events[range] { for ev in &mut self.events[range] {
ev.iteration_direct( ev.iteration_direct(
&mut self.skills, &mut self.skills,
agents, competitors,
p_draw, p_draw,
self.convergence, self.convergence,
&mut self.arena, &mut self.arena,
@@ -509,7 +521,7 @@ impl<T: Time> TimeSlice<T> {
/// Events within each color group are updated inline — no EventOutput allocation — /// Events within each color group are updated inline — no EventOutput allocation —
/// matching the T2 performance profile. /// matching the T2 performance profile.
#[cfg(not(feature = "rayon"))] #[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() { for color_idx in 0..self.color_groups.groups.len() {
if self.color_groups.groups[color_idx].is_empty() { if self.color_groups.groups[color_idx].is_empty() {
continue; continue;
@@ -523,7 +535,7 @@ impl<T: Time> TimeSlice<T> {
for ev in &mut self.events[range] { for ev in &mut self.events[range] {
ev.iteration_direct( ev.iteration_direct(
&mut self.skills, &mut self.skills,
agents, competitors,
p_draw, p_draw,
self.convergence, self.convergence,
&mut self.arena, &mut self.arena,
@@ -544,7 +556,7 @@ impl<T: Time> TimeSlice<T> {
/// schedule default. /// schedule default.
pub(crate) fn iterate_to_convergence<D: Drift<T>>( pub(crate) fn iterate_to_convergence<D: Drift<T>>(
&mut self, &mut self,
agents: &CompetitorStore<T, D>, competitors: &CompetitorStore<T, D>,
) -> usize { ) -> usize {
use crate::{tuple_gt, tuple_max}; use crate::{tuple_gt, tuple_max};
@@ -557,7 +569,7 @@ impl<T: Time> TimeSlice<T> {
while tuple_gt(step, epsilon) && i < max_iter { while tuple_gt(step, epsilon) && i < max_iter {
let old = self.posteriors(); let old = self.posteriors();
self.iteration(0, agents); self.iteration(0, competitors);
let new = self.posteriors(); let new = self.posteriors();
@@ -575,37 +587,37 @@ impl<T: Time> TimeSlice<T> {
i i
} }
pub(crate) fn forward_prior_out(&self, agent: &Index) -> Gaussian { pub(crate) fn forward_prior_out(&self, competitor: &Index) -> Gaussian {
let skill = self.skills.get(*agent).unwrap(); let skill = self.skills.get(*competitor).unwrap();
skill.forward * skill.likelihood skill.forward.ep_product(skill.likelihood)
} }
pub(crate) fn backward_prior_out<D: Drift<T>>( pub(crate) fn backward_prior_out<D: Drift<T>>(
&self, &self,
agent: &Index, competitor: &Index,
agents: &CompetitorStore<T, D>, competitors: &CompetitorStore<T, D>,
) -> Gaussian { ) -> Gaussian {
let skill = self.skills.get(*agent).unwrap(); let skill = self.skills.get(*competitor).unwrap();
let n = skill.likelihood * skill.backward; let n = skill.likelihood.ep_product(skill.backward);
n.forget( n.forget(
agents[*agent] competitors[*competitor]
.rating .rating
.drift_variance_for_elapsed(skill.elapsed), .drift_variance_for_elapsed(skill.elapsed),
) )
} }
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) { pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
for (agent, skill) in self.skills.iter_mut() { for (competitor, skill) in self.skills.iter_mut() {
skill.backward = agents[agent].message.unwrap_or(N_INF); 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>) { pub(crate) fn new_forward_info<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
for (agent, skill) in self.skills.iter_mut() { for (competitor, skill) in self.skills.iter_mut() {
skill.forward = agents[agent].receive_for_elapsed(skill.elapsed); 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. /// Run this slice's events on forward (filtering) information alone.
@@ -615,10 +627,18 @@ impl<T: Time> TimeSlice<T> {
/// configured prior. The sweep runs on a scratch copy, so the real slice /// configured prior. The sweep runs on a scratch copy, so the real slice
/// is untouched — which is what makes the filtered estimates independent /// is untouched — which is what makes the filtered estimates independent
/// of whether `History::converge` has run. /// 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>>( pub(crate) fn filtered_step<D: Drift<T>>(
&self, &self,
incoming: &HashMap<Index, Gaussian>, incoming: &HashMap<Index, Gaussian>,
agents: &CompetitorStore<T, D>, competitors: &CompetitorStore<T, D>,
targets: &std::collections::HashSet<Index>,
) -> FilteredStep { ) -> FilteredStep {
let mut scratch = TimeSlice { let mut scratch = TimeSlice {
events: self.events.clone(), events: self.events.clone(),
@@ -641,16 +661,16 @@ impl<T: Time> TimeSlice<T> {
event.log_evidence = 0.0; event.log_evidence = 0.0;
} }
for (agent, skill) in self.skills.iter() { for (competitor, skill) in self.skills.iter() {
let rating = &agents[agent].rating; 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)), Some(message) => message.forget(rating.drift_variance_for_elapsed(skill.elapsed)),
None => rating.prior, None => rating.prior,
}; };
let slot = scratch.skills.insert( let slot = scratch.skills.insert(
agent, competitor,
Skill { Skill {
forward, forward,
backward: N_INF, backward: N_INF,
@@ -666,19 +686,31 @@ impl<T: Time> TimeSlice<T> {
// than leave it to be rediscovered after it breaks. // than leave it to be rediscovered after it breaks.
debug_assert_eq!( debug_assert_eq!(
Some(slot), Some(slot),
self.skills.slot_of(agent), self.skills.slot_of(competitor),
"scratch slot must match the real slice's slot for {agent:?}" "scratch slot must match the real slice's slot for {competitor:?}"
); );
} }
scratch.iterate_to_convergence(agents); scratch.iterate_to_convergence(competitors);
FilteredStep { 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 posteriors: scratch
.skills .skills
.iter() .iter()
.map(|(agent, skill)| (agent, skill.posterior())) .map(|(competitor, skill)| (competitor, skill.posterior()))
.collect(), .collect(),
} }
} }
@@ -687,7 +719,7 @@ impl<T: Time> TimeSlice<T> {
&self, &self,
targets: &[Index], targets: &[Index],
forward: bool, forward: bool,
agents: &CompetitorStore<T, D>, competitors: &CompetitorStore<T, D>,
) -> f64 { ) -> f64 {
// Hashed once rather than scanned per player per event, so a // Hashed once rather than scanned per player per event, so a
// `log_evidence_for` with many keys is not quadratic. // `log_evidence_for` with many keys is not quadratic.
@@ -696,11 +728,11 @@ impl<T: Time> TimeSlice<T> {
let mut arena = ScratchArena::new(); let mut arena = ScratchArena::new();
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 { 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(); let result = event.outputs();
match event.kind { match event.kind {
EventKind::Ranked => { EventKind::Ranked => {
Game::ranked_with_arena( GameRef::ranked_with_arena(
teams, teams,
&result, &result,
&event.weights, &event.weights,
@@ -711,7 +743,7 @@ impl<T: Time> TimeSlice<T> {
.log_evidence .log_evidence
} }
EventKind::Scored { score_sigma } => { EventKind::Scored { score_sigma } => {
Game::scored_with_arena( GameRef::scored_with_arena(
teams, teams,
&result, &result,
&event.weights, &event.weights,
@@ -741,7 +773,7 @@ impl<T: Time> TimeSlice<T> {
.teams .teams
.iter() .iter()
.flat_map(|team| &team.items) .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)) .map(|event| run_event(event, &mut arena))
.sum() .sum()
@@ -753,27 +785,36 @@ impl<T: Time> TimeSlice<T> {
.teams .teams
.iter() .iter()
.flat_map(|team| &team.items) .flat_map(|team| &team.items)
.any(|item| target_set.contains(&item.agent)) .any(|item| target_set.contains(&item.competitor))
}) })
.map(|event| event.log_evidence) .map(|event| event.log_evidence)
.sum() .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 self.events
.iter() .iter()
.map(|event| { .map(|event| {
event event
.teams .teams
.iter() .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<_>>()
}) })
.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 self.events
.iter() .iter()
.map(|event| { .map(|event| {
@@ -827,7 +868,7 @@ impl<T: Time> TimeSlice<T> {
/// approximations that inference does not retain. /// approximations that inference does not retain.
pub(crate) fn scored_contrasts<D: Drift<T>>( pub(crate) fn scored_contrasts<D: Drift<T>>(
&self, &self,
agents: &CompetitorStore<T, D>, competitors: &CompetitorStore<T, D>,
) -> Vec<(Vec<(Index, f64)>, f64)> { ) -> Vec<(Vec<(Index, f64)>, f64)> {
let mut out = Vec::new(); let mut out = Vec::new();
@@ -853,8 +894,8 @@ impl<T: Time> TimeSlice<T> {
for (team, sign) in [(hi, 1.0), (lo, -1.0)] { for (team, sign) in [(hi, 1.0), (lo, -1.0)] {
for (m, item) in event.teams[team].items.iter().enumerate() { for (m, item) in event.teams[team].items.iter().enumerate() {
let w = event.weights[team][m]; let w = event.weights[team][m];
noise += w * w * agents[item.agent].rating.beta.powi(2); noise += w * w * competitors[item.competitor].rating.beta.powi(2);
contrast.push((item.agent, sign * w)); contrast.push((item.competitor, sign * w));
} }
} }
@@ -887,7 +928,7 @@ mod tests {
use super::*; use super::*;
use crate::{ use crate::{
KeyTable, competitor::Competitor, drift::ConstantDrift, rating::Rating, competitor::Competitor, drift::ConstantDrift, key_table::KeyTable, rating::Rating,
storage::CompetitorStore, storage::CompetitorStore,
}; };
@@ -902,11 +943,11 @@ mod tests {
let e = index_map.get_or_create("e"); let e = index_map.get_or_create("e");
let f = index_map.get_or_create("f"); 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] { for competitor in [a, b, c, d, e, f] {
agents.insert( competitors.insert(
agent, competitor,
Competitor { Competitor {
rating: Rating::new( rating: Rating::new(
Gaussian::from_ms(25.0, 25.0 / 3.0), Gaussian::from_ms(25.0, 25.0 / 3.0),
@@ -929,7 +970,7 @@ mod tests {
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]), Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
None, None,
vec![EventKind::Ranked; 3], vec![EventKind::Ranked; 3],
&agents, &competitors,
); );
let post = time_slice.posteriors(); let post = time_slice.posteriors();
@@ -965,7 +1006,7 @@ mod tests {
epsilon = 1e-6 epsilon = 1e-6
); );
assert_eq!(time_slice.iterate_to_convergence(&agents), 1); assert_eq!(time_slice.iterate_to_convergence(&competitors), 1);
} }
#[test] #[test]
@@ -979,11 +1020,11 @@ mod tests {
let e = index_map.get_or_create("e"); let e = index_map.get_or_create("e");
let f = index_map.get_or_create("f"); 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] { for competitor in [a, b, c, d, e, f] {
agents.insert( competitors.insert(
agent, competitor,
Competitor { Competitor {
rating: Rating::new( rating: Rating::new(
Gaussian::from_ms(25.0, 25.0 / 3.0), Gaussian::from_ms(25.0, 25.0 / 3.0),
@@ -1006,7 +1047,7 @@ mod tests {
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]), Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
None, None,
vec![EventKind::Ranked; 3], vec![EventKind::Ranked; 3],
&agents, &competitors,
); );
let post = time_slice.posteriors(); let post = time_slice.posteriors();
@@ -1027,7 +1068,7 @@ mod tests {
epsilon = 1e-6 epsilon = 1e-6
); );
assert!(time_slice.iterate_to_convergence(&agents) > 1); assert!(time_slice.iterate_to_convergence(&competitors) > 1);
let post = time_slice.posteriors(); let post = time_slice.posteriors();
@@ -1059,11 +1100,11 @@ mod tests {
let e = index_map.get_or_create("e"); let e = index_map.get_or_create("e");
let f = index_map.get_or_create("f"); 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] { for competitor in [a, b, c, d, e, f] {
agents.insert( competitors.insert(
agent, competitor,
Competitor { Competitor {
rating: Rating::new( rating: Rating::new(
Gaussian::from_ms(25.0, 25.0 / 3.0), Gaussian::from_ms(25.0, 25.0 / 3.0),
@@ -1086,10 +1127,10 @@ mod tests {
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]), Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
None, None,
vec![EventKind::Ranked; 3], vec![EventKind::Ranked; 3],
&agents, &competitors,
); );
time_slice.iterate_to_convergence(&agents); time_slice.iterate_to_convergence(&competitors);
let post = time_slice.posteriors(); let post = time_slice.posteriors();
@@ -1118,12 +1159,12 @@ mod tests {
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]), Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
None, None,
vec![EventKind::Ranked; 3], vec![EventKind::Ranked; 3],
&agents, &competitors,
); );
assert_eq!(time_slice.events.len(), 6); assert_eq!(time_slice.events.len(), 6);
time_slice.iterate_to_convergence(&agents); time_slice.iterate_to_convergence(&competitors);
let post = time_slice.posteriors(); let post = time_slice.posteriors();
@@ -1162,11 +1203,11 @@ mod tests {
let c = index_map.get_or_create("c"); let c = index_map.get_or_create("c");
let d = index_map.get_or_create("d"); 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] { for competitor in [a, b, c, d] {
agents.insert( competitors.insert(
agent, competitor,
Competitor { Competitor {
rating: Rating::new( rating: Rating::new(
Gaussian::from_ms(25.0, 25.0 / 3.0), Gaussian::from_ms(25.0, 25.0 / 3.0),
@@ -1189,7 +1230,7 @@ mod tests {
Some(vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]), Some(vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]),
None, None,
vec![EventKind::Ranked; 3], vec![EventKind::Ranked; 3],
&agents, &competitors,
); );
assert_eq!(ts.color_groups.n_colors(), 2); assert_eq!(ts.color_groups.n_colors(), 2);
@@ -1200,16 +1241,24 @@ mod tests {
assert_eq!(ts.color_groups.color_range(1), 2..3); assert_eq!(ts.color_groups.color_range(1), 2..3);
// Events at positions 0 and 1 (color 0) must be disjoint — verify by // 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 // checking that the competitor sets of self.events[0] and self.events[1] do
// not include the agent at self.events[2]. // not include the competitor at self.events[2].
let agents_in_ev2: Vec<Index> = ts.events[2].iter_agents().collect(); let competitors_in_ev2: Vec<Index> = ts.events[2].iter_competitors().collect();
let agents_in_ev0: Vec<Index> = ts.events[0].iter_agents().collect(); let competitors_in_ev0: Vec<Index> = ts.events[0].iter_competitors().collect();
let agents_in_ev1: Vec<Index> = ts.events[1].iter_agents().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). // ev0 and ev1 must be disjoint from each other (color-0 invariant).
assert!(agents_in_ev0.iter().all(|ag| !agents_in_ev1.contains(ag))); assert!(
// ev2 must share an agent with ev0 or ev1 (it needed its own color). competitors_in_ev0
let ev2_overlaps_ev0 = agents_in_ev2.iter().any(|ag| agents_in_ev0.contains(ag)); .iter()
let ev2_overlaps_ev1 = agents_in_ev2.iter().any(|ag| agents_in_ev1.contains(ag)); .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); assert!(ev2_overlaps_ev0 || ev2_overlaps_ev1);
} }
} }
+9 -4
View File
@@ -29,7 +29,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
let players = ["p0", "p1", "p2"]; let players = ["p0", "p1", "p2"];
let holes = ["h0", "h1"]; let holes = ["h0", "h1"];
let mut h: History<i64, _, _, &'static str> = History::builder() let mut h: History = History::builder()
.mu(0.0) .mu(0.0)
.sigma(6.0) .sigma(6.0)
.beta(1.0) .beta(1.0)
@@ -80,7 +80,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
println!("\n== the same nodes via posterior_of (exact marginal) =="); println!("\n== the same nodes via posterior_of (exact marginal) ==");
for k in players.iter().chain(holes.iter()) { for k in players.iter().chain(holes.iter()) {
let g = h.posterior_of(&[(k, 1.0)]).unwrap(); let g = h.joint().unwrap().posterior_of(&[(k, 1.0)]).unwrap();
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma()); println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
} }
@@ -97,7 +97,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
vec![(&"h0", 1.0), (&"h1", -1.0)], vec![(&"h0", 1.0), (&"h1", -1.0)],
), ),
] { ] {
let joint = h.posterior_of(&terms).unwrap(); let joint = h.joint().unwrap().posterior_of(&terms).unwrap();
// what a consumer gets today by adding marginals // what a consumer gets today by adding marginals
let naive: f64 = terms let naive: f64 = terms
.iter() .iter()
@@ -132,7 +132,12 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
// shares with its partners is pinned only by the prior. // shares with its partners is pinned only by the prior.
for k in players.iter().chain(holes.iter()) { for k in players.iter().chain(holes.iter()) {
let bp = h.current_skill(k).unwrap().sigma(); let bp = h.current_skill(k).unwrap().sigma();
let exact = h.posterior_of(&[(k, 1.0)]).unwrap().sigma(); let exact = h
.joint()
.unwrap()
.posterior_of(&[(k, 1.0)])
.unwrap()
.sigma();
assert!( assert!(
exact > 3.0 * bp, exact > 3.0 * bp,
"{k}: exact marginal {exact} should be much wider than the reported \ "{k}: exact marginal {exact} should be much wider than the reported \
+9 -9
View File
@@ -41,9 +41,9 @@ fn add_events_bulk_via_iter() {
h.add_events(events).unwrap(); h.add_events(events).unwrap();
let report = h.converge().unwrap(); let report = h.converge().unwrap();
assert!(report.converged); assert!(report.converged);
assert!(h.lookup(&"a").is_some()); assert!(h.current_skill("a").is_some());
assert!(h.lookup(&"b").is_some()); assert!(h.current_skill("b").is_some());
assert!(h.lookup(&"c").is_some()); assert!(h.current_skill("c").is_some());
} }
#[test] #[test]
@@ -103,9 +103,9 @@ fn fluent_event_builder_basic() {
let report = h.converge().unwrap(); let report = h.converge().unwrap();
assert!(report.converged); assert!(report.converged);
assert!(h.lookup(&"alice").is_some()); assert!(h.current_skill("alice").is_some());
assert!(h.lookup(&"bob").is_some()); assert!(h.current_skill("bob").is_some());
assert!(h.lookup(&"carol").is_some()); assert!(h.current_skill("carol").is_some());
} }
#[test] #[test]
@@ -162,7 +162,7 @@ fn current_skill_and_learning_curve() {
let b = h.current_skill(&"b").unwrap(); let b = h.current_skill(&"b").unwrap();
assert!(b.mu() < 25.0); 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.len(), 2);
assert_eq!(a_curve[0].0, 1); assert_eq!(a_curve[0].0, 1);
assert_eq!(a_curve[1].0, 2); assert_eq!(a_curve[1].0, 2);
@@ -186,7 +186,7 @@ fn log_evidence_total_vs_subset() {
h.record_winner(&"a", &"b", 1).unwrap(); h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"b", &"a", 2).unwrap(); h.record_winner(&"b", &"a", 2).unwrap();
let total = h.log_evidence(); 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!(total.is_finite());
assert!(a_only.is_finite()); assert!(a_only.is_finite());
} }
@@ -203,7 +203,7 @@ fn predict_quality_two_teams() {
h.record_winner(&"a", &"b", 1).unwrap(); h.record_winner(&"a", &"b", 1).unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
let q = h.predict_quality(&[&[&"a"], &[&"b"]]).unwrap(); let q = h.quality(&[&[&"a"], &[&"b"]]).unwrap();
assert!(q > 0.0 && q <= 1.0); assert!(q > 0.0 && q <= 1.0);
} }
+4 -1
View File
@@ -184,7 +184,10 @@ fn a_batch_declaring_two_different_priors_is_rejected() {
assert!( assert!(
matches!( matches!(
err, err,
InferenceError::ConflictingCompetitorConfig { field: "prior", .. } InferenceError::ConflictingCompetitorConfig {
field: trueskill_tt::CompetitorField::Prior,
..
}
), ),
"got {err:?}" "got {err:?}"
); );
+2 -2
View File
@@ -102,7 +102,7 @@ fn every_magnitude_parameter_rejects_a_negative_value() {
}), }),
), ),
( (
"Outcome::scores_with_sigma (at ingestion)", "Outcome::scores_with_noise (at ingestion)",
Box::new(|v| { Box::new(|v| {
let mut h = History::builder().build(); let mut h = History::builder().build();
h.add_events(vec![trueskill_tt::Event { h.add_events(vec![trueskill_tt::Event {
@@ -111,7 +111,7 @@ fn every_magnitude_parameter_rejects_a_negative_value() {
trueskill_tt::Team::with_members([Member::new("a")]), trueskill_tt::Team::with_members([Member::new("a")]),
trueskill_tt::Team::with_members([Member::new("b")]), trueskill_tt::Team::with_members([Member::new("b")]),
], ],
outcome: Outcome::scores_with_sigma([3.0, 1.0], v), outcome: Outcome::scores_with_noise([3.0, 1.0], v),
}]) }])
.is_err() .is_err()
}), }),
+3 -2
View File
@@ -11,7 +11,7 @@ use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team, ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
}; };
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>; type H = History;
fn duel(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> { fn duel(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> {
Event { Event {
@@ -54,6 +54,7 @@ fn hitting_the_cap_is_an_error() {
iterations, iterations,
final_step, final_step,
epsilon, epsilon,
..
} => { } => {
assert_eq!(iterations, 1); assert_eq!(iterations, 1);
assert!( assert!(
@@ -107,7 +108,7 @@ fn the_two_agree_on_a_converged_fit() {
/// At the old value of 30 this history stopped short and said nothing. /// At the old value of 30 this history stopped short and said nothing.
#[test] #[test]
fn the_default_cap_clears_an_ordinary_history() { fn the_default_cap_clears_an_ordinary_history() {
let mut h: History<i64, ConstantDrift, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.mu(0.0) .mu(0.0)
.sigma(6.0) .sigma(6.0)
+8 -5
View File
@@ -15,8 +15,7 @@ use std::{env, process::Command};
use smallvec::smallvec; use smallvec::smallvec;
use trueskill_tt::{ use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team, ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team, UnknownKeys,
UnknownKeys,
}; };
/// Set in the child so it reports instead of re-spawning. /// Set in the child so it reports instead of re-spawning.
@@ -24,7 +23,7 @@ const CHILD: &str = "TSTT_DETERMINISM_CHILD";
const RUNS: usize = 40; const RUNS: usize = 40;
type H = History<i64, ConstantDrift, NullObserver, String>; type H = History<String>;
fn fitted() -> H { fn fitted() -> H {
let mut h: H = History::builder() let mut h: H = History::builder()
@@ -84,13 +83,17 @@ fn fingerprint() -> String {
let known = "p0".to_string(); let known = "p0".to_string();
terms.push((&known, -1.0)); terms.push((&known, -1.0));
let posterior = h.posterior_of(&terms).unwrap(); let posterior = h.joint().unwrap().posterior_of(&terms).unwrap();
let a = "p0".to_string(); let a = "p0".to_string();
let b = "p1".to_string(); let b = "p1".to_string();
let target = [(&a, 1.0), (&b, -1.0)]; let target = [(&a, 1.0), (&b, -1.0)];
let teams: [&[&String]; 2] = [&[&a], &[&b]]; let teams: [&[&String]; 2] = [&[&a], &[&b]];
let evr = h.expected_variance_reduction(&teams, &target).unwrap(); let evr = h
.joint()
.unwrap()
.expected_variance_reduction(&teams, &target)
.unwrap();
let curves = h.learning_curves(); let curves = h.learning_curves();
let mut curve_bits: u64 = 0; let mut curve_bits: u64 = 0;
+13 -9
View File
@@ -8,7 +8,7 @@ mod common;
use common::assert_finite; use common::assert_finite;
use trueskill_tt::{ use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError, ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError,
NullObserver, Outcome, Rating, Outcome, Rating,
}; };
type R = Rating<i64, ConstantDrift>; type R = Rating<i64, ConstantDrift>;
@@ -126,7 +126,7 @@ fn empty_history_converges_trivially() {
/// indexed out of bounds in release, so this must run in both profiles. /// indexed out of bounds in release, so this must run in both profiles.
#[test] #[test]
fn converge_on_an_empty_history_with_owned_keys() { fn converge_on_an_empty_history_with_owned_keys() {
let mut history: History<i64, ConstantDrift, NullObserver, String> = History::builder() let mut history: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.score_sigma(5.0) .score_sigma(5.0)
.build(); .build();
@@ -157,9 +157,10 @@ fn event_builder_rejects_a_weights_length_mismatch() {
matches!( matches!(
err, err,
InferenceError::MismatchedShape { InferenceError::MismatchedShape {
kind: "weights", shape: trueskill_tt::Shape::Weights,
expected: 1, expected: 1,
got: 2, got: 2,
..
} }
), ),
"expected a weights MismatchedShape, got {err:?}" "expected a weights MismatchedShape, got {err:?}"
@@ -183,7 +184,10 @@ fn event_builder_weights_mismatch_leaves_the_history_untouched() {
.winner(0) .winner(0)
.commit(); .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] #[test]
@@ -198,7 +202,7 @@ fn empty_event_stream_then_converge() {
fn empty_history_queries_do_not_panic() { fn empty_history_queries_do_not_panic() {
let h = History::default(); let h = History::default();
assert!(h.learning_curves().is_empty()); 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()); assert!(h.current_skill("nobody").is_none());
} }
@@ -218,13 +222,13 @@ fn scored_event_rejects_non_positive_sigma() {
.event(1) .event(1)
.team(["a"]) .team(["a"])
.team(["b"]) .team(["b"])
.scores_with_sigma([3.0, 1.0], f64::NAN) .scores_with_noise([3.0, 1.0], f64::NAN)
.commit() .commit()
.unwrap_err(); .unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
InferenceError::InvalidParameter { InferenceError::InvalidParameter {
name: "score_sigma", parameter: trueskill_tt::Parameter::ScoreSigma,
.. ..
} }
)); ));
@@ -321,7 +325,7 @@ fn empty_history_has_no_filtered_estimates() {
assert!(history.filtered_learning_curves().is_empty()); 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) ---------------------------------------------- // --- Boundary inputs (#26) ----------------------------------------------
@@ -336,7 +340,7 @@ fn tight() -> ConvergenceOptions {
fn assert_curve_finite(h: &History, keys: &[&str], what: &str) { fn assert_curve_finite(h: &History, keys: &[&str], what: &str) {
for key in keys { for key in keys {
for (time, g) in h.learning_curve(*key) { for (time, g) in h.learning_curve(*key).unwrap() {
assert!( assert!(
g.mu().is_finite() && g.sigma().is_finite(), g.mu().is_finite() && g.sigma().is_finite(),
"{what}: non-finite posterior for {key} at t={time} (mu={} sigma={})", "{what}: non-finite posterior for {key} at t={time} (mu={} sigma={})",
+9 -11
View File
@@ -8,11 +8,11 @@
use smallvec::smallvec; use smallvec::smallvec;
use trueskill_tt::{ use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
NullObserver, Outcome, Team, Team,
}; };
type Fit = History<i64, ConstantDrift, NullObserver, &'static str>; type Fit = History;
const CONVERGENCE: ConvergenceOptions = ConvergenceOptions { const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
max_iter: 64, max_iter: 64,
@@ -277,13 +277,11 @@ fn reject(scale: f64) -> InferenceError {
#[test] #[test]
fn negative_scale_is_rejected() { fn negative_scale_is_rejected() {
assert_eq!( assert!(matches!(
reject(-1.0), reject(-1.0),
InferenceError::InvalidParameter { InferenceError::InvalidParameter { parameter: trueskill_tt::Parameter::DriftScale, value, .. }
name: "drift_scale", if value == -1.0
value: -1.0 ));
}
);
} }
#[test] #[test]
@@ -293,7 +291,7 @@ fn non_finite_scale_is_rejected() {
matches!( matches!(
reject(scale), reject(scale),
InferenceError::InvalidParameter { InferenceError::InvalidParameter {
name: "drift_scale", parameter: trueskill_tt::Parameter::DriftScale,
.. ..
} }
), ),
@@ -490,7 +488,7 @@ fn a_batch_that_contradicts_itself_is_rejected() {
matches!( matches!(
err, err,
InferenceError::ConflictingCompetitorConfig { InferenceError::ConflictingCompetitorConfig {
field: "drift_scale", field: trueskill_tt::CompetitorField::DriftScale,
.. ..
} }
), ),
+4 -2
View File
@@ -23,8 +23,10 @@ fn ts_rating(mu: f64, sigma: f64, beta: f64, gamma: f64) -> R {
fn game_1v1_golden_matches_historical() { fn game_1v1_golden_matches_historical() {
let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0); let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0); let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
let (a_post, b_post) = let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default())
Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default()).unwrap(); .unwrap()
.posteriors();
let (a_post, b_post) = (post[0][0], post[1][0]);
// Historical golden from pre-T2 test_1vs1 (team 0 wins): // Historical golden from pre-T2 test_1vs1 (team 0 wins):
assert_ulps_eq!( assert_ulps_eq!(
a_post, a_post,
+11 -7
View File
@@ -11,7 +11,7 @@ use trueskill_tt::{
Team, Team,
}; };
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>; type H = History;
fn history() -> H { fn history() -> H {
History::builder() History::builder()
@@ -64,8 +64,11 @@ fn members_matches_the_typed_path_exactly() {
for key in ["player", "layout_7"] { for key in ["player", "layout_7"] {
let a = typed.current_skill(&key).unwrap(); let a = typed.current_skill(&key).unwrap();
let b = fluent.current_skill(&key).unwrap(); let b = fluent.current_skill(&key).unwrap();
assert_eq!(a.pi(), b.pi(), "{key} pi"); // Exact equality, on the public moments rather than the natural
assert_eq!(a.tau(), b.tau(), "{key} tau"); // parameters: `mu` and `variance` are `tau/pi` and `1/pi`, so
// bit-equal natural parameters give bit-equal moments.
assert_eq!(a.mu(), b.mu(), "{key} mu");
assert_eq!(a.variance(), b.variance(), "{key} variance");
} }
} }
@@ -81,7 +84,7 @@ fn members_matches_the_typed_path_exactly() {
#[test] #[test]
fn a_drift_scale_set_through_members_is_applied() { fn a_drift_scale_set_through_members_is_applied() {
fn spread(h: &H, key: &'static str) -> f64 { 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"); assert!(curve.len() >= 2, "{key}: expected several appearances");
let (lo, hi) = curve.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (_, g)| { let (lo, hi) = curve.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (_, g)| {
(lo.min(g.sigma()), hi.max(g.sigma())) (lo.min(g.sigma()), hi.max(g.sigma()))
@@ -133,9 +136,10 @@ fn weights_still_guards_a_members_team() {
matches!( matches!(
err, err,
InferenceError::MismatchedShape { InferenceError::MismatchedShape {
kind: "weights", shape: trueskill_tt::Shape::Weights,
expected: 2, expected: 2,
got: 1 got: 1,
..
} }
), ),
"{err:?}" "{err:?}"
@@ -160,7 +164,7 @@ fn an_invalid_drift_scale_surfaces_from_commit() {
matches!( matches!(
err, err,
InferenceError::InvalidParameter { InferenceError::InvalidParameter {
name: "drift_scale", parameter: trueskill_tt::Parameter::DriftScale,
.. ..
} }
), ),
+149
View File
@@ -0,0 +1,149 @@
//! 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::{Event, History, InferenceError, Member, Outcome, Team};
type H = History;
/// 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 _ = history.converge().unwrap();
let smoothed = history.learning_curve("a"); let smoothed = history.learning_curve("a").unwrap();
let filtered = history.filtered_learning_curve("a"); let filtered = history.filtered_learning_curve("a").unwrap();
assert_eq!( assert_eq!(
smoothed.len(), smoothed.len(),
@@ -127,7 +127,7 @@ fn filtered_curves_plural_agrees_with_singular() {
assert_eq!( assert_eq!(
curves["b"], 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" "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 _ = history.converge().unwrap();
let smoothed = history.learning_curve("a"); let smoothed = history.learning_curve("a").unwrap();
let filtered = history.filtered_learning_curve("a"); let filtered = history.filtered_learning_curve("a").unwrap();
assert_eq!(smoothed.len(), 1); assert_eq!(smoothed.len(), 1);
assert_eq!(filtered.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 _ = incremental.converge().unwrap();
let from_batched = batched.filtered_learning_curve("a"); let from_batched = batched.filtered_learning_curve("a").unwrap();
let from_incremental = incremental.filtered_learning_curve("a"); let from_incremental = incremental.filtered_learning_curve("a").unwrap();
assert_eq!(from_batched.len(), from_incremental.len()); assert_eq!(from_batched.len(), from_incremental.len());
+27 -11
View File
@@ -32,10 +32,16 @@ fn game_ranked_1v1_golden() {
fn game_one_v_one_shortcut() { fn game_one_v_one_shortcut() {
let a = default_rating(); let a = default_rating();
let b = default_rating(); let b = default_rating();
let (a_post, b_post) = let game =
Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default()).unwrap(); Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
let post = game.posteriors();
let (a_post, b_post) = (post[0][0], post[1][0]);
assert!(a_post.mu() > 25.0); assert!(a_post.mu() > 25.0);
assert!(b_post.mu() < 25.0); assert!(b_post.mu() < 25.0);
// It returns a game like every other constructor, so evidence is askable.
// Two identical ratings make either result equally likely.
assert!((game.log_evidence() - 0.5_f64.ln()).abs() < 1e-12);
} }
#[test] #[test]
@@ -51,7 +57,7 @@ fn game_ranked_rejects_bad_p_draw() {
}, },
) )
.unwrap_err(); .unwrap_err();
assert!(matches!(err, InferenceError::InvalidProbability { .. })); assert!(matches!(err, InferenceError::InvalidParameter { .. }));
} }
#[test] #[test]
@@ -118,8 +124,10 @@ fn one_v_one_honours_the_draw_probability_it_is_given() {
p_draw: 0.25, p_draw: 0.25,
..GameOptions::default() ..GameOptions::default()
}; };
let (a_post, b_post) = Game::<i64, _>::one_v_one(&a, &b, Outcome::draw(2), &options) let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::draw(2), &options)
.expect("a draw is representable once p_draw is positive"); .expect("a draw is representable once p_draw is positive")
.posteriors();
let (a_post, b_post) = (post[0][0], post[1][0]);
// A symmetric draw leaves the means alone and sharpens both sides. // A symmetric draw leaves the means alone and sharpens both sides.
assert!((a_post.mu() - b_post.mu()).abs() < 1e-9); assert!((a_post.mu() - b_post.mu()).abs() < 1e-9);
@@ -135,8 +143,10 @@ fn one_v_one_honours_convergence_options() {
convergence: ConvergenceOptions::default(), convergence: ConvergenceOptions::default(),
..GameOptions::default() ..GameOptions::default()
}; };
let (a_post, _) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options).unwrap(); let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options)
assert!(a_post.mu() > 25.0); .unwrap()
.posteriors();
assert!(post[0][0].mu() > 25.0);
} }
/// `Game` is a public entry point that does not pass through `History`'s /// `Game` is a public entry point that does not pass through `History`'s
@@ -155,7 +165,7 @@ mod malformed_games {
let err = Game::<i64, _>::ranked(&[&[a]], Outcome::winner(0, 1), &GameOptions::default()) let err = Game::<i64, _>::ranked(&[&[a]], Outcome::winner(0, 1), &GameOptions::default())
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1 }), matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}" "{err:?}"
); );
} }
@@ -173,7 +183,7 @@ mod malformed_games {
) )
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1 }), matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}" "{err:?}"
); );
} }
@@ -184,7 +194,7 @@ mod malformed_games {
Game::<i64, ConstantDrift>::ranked(&[], Outcome::ranking([]), &GameOptions::default()) Game::<i64, ConstantDrift>::ranked(&[], Outcome::ranking([]), &GameOptions::default())
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 0 }), matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
"{err:?}" "{err:?}"
); );
} }
@@ -198,7 +208,7 @@ mod malformed_games {
Game::<i64, _>::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default()) Game::<i64, _>::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default())
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::EmptyTeam { team: 0 }), matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
"{err:?}" "{err:?}"
); );
} }
@@ -217,7 +227,13 @@ mod malformed_games {
) )
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "score", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Score,
..
}
),
"{bad}: {err:?}" "{bad}: {err:?}"
); );
} }
+110
View File
@@ -0,0 +1,110 @@
//! 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::{Event, History, InferenceError, Member, Outcome, Team};
type H = History;
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");
}
+1 -1
View File
@@ -47,7 +47,7 @@ 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)> { fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> {
let mut h: History<i64, _, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.convergence(tight()) .convergence(tight())
.build(); .build();
+20 -9
View File
@@ -14,8 +14,7 @@ use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
type Ev = Event<i64, &'static str>; type Ev = Event<i64, &'static str>;
fn history() -> History<i64, trueskill_tt::ConstantDrift, trueskill_tt::NullObserver, &'static str> fn history() -> History {
{
History::builder().score_sigma(1.0).build() History::builder().score_sigma(1.0).build()
} }
@@ -40,7 +39,7 @@ fn a_one_team_event_is_an_error_not_a_panic() {
}]) }])
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1 }), matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}" "{err:?}"
); );
} }
@@ -56,7 +55,7 @@ fn a_zero_team_event_is_an_error() {
}]) }])
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 0 }), matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
"{err:?}" "{err:?}"
); );
} }
@@ -75,7 +74,7 @@ fn an_empty_team_is_an_error_rather_than_a_free_win() {
}]) }])
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::EmptyTeam { team: 0 }), matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
"{err:?}" "{err:?}"
); );
// Nothing was recorded, so the history is still empty. // Nothing was recorded, so the history is still empty.
@@ -93,7 +92,7 @@ fn an_empty_team_is_reported_by_position() {
}]) }])
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::EmptyTeam { team: 1 }), matches!(err, InferenceError::EmptyTeam { team: 1, .. }),
"{err:?}" "{err:?}"
); );
} }
@@ -113,7 +112,13 @@ fn a_non_finite_score_is_rejected_at_ingestion() {
}]) }])
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "score", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Score,
..
}
),
"{bad}: {err:?}" "{bad}: {err:?}"
); );
assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway"); assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway");
@@ -137,7 +142,13 @@ fn a_non_finite_weight_is_rejected_at_ingestion() {
.commit() .commit()
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Weight,
..
}
),
"{bad}: {err:?}" "{bad}: {err:?}"
); );
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history"); assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
@@ -170,7 +181,7 @@ fn the_event_builder_inherits_the_shape_checks() {
let mut h = history(); let mut h = history();
let err = h.event(1).team(["a"]).winner(0).commit().unwrap_err(); let err = h.event(1).team(["a"]).winner(0).commit().unwrap_err();
assert!( assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1 }), matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}" "{err:?}"
); );
} }
+31 -18
View File
@@ -11,7 +11,7 @@ use trueskill_tt::{
UnknownKeys, UnknownKeys,
}; };
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>; type H = History;
fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> { fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> {
Event { Event {
@@ -78,17 +78,22 @@ const PAIRS: [(&str, &str); 6] = [
("c", "d"), ("c", "d"),
]; ];
/// A joint reused across questions answers exactly what a fresh one per
/// question does. That is the whole correctness claim behind caching the
/// factorisation (#51); it used to be checked against the `History` one-shot
/// wrappers, which were deleted in #78, so it is checked against a fresh
/// factorisation instead — the same comparison, without the wrapper.
#[test] #[test]
fn a_joint_answers_exactly_what_the_one_shot_call_does() { fn a_reused_joint_answers_exactly_what_a_fresh_one_does() {
let h = fitted(UnknownKeys::Reject); let h = fitted(UnknownKeys::Reject);
let joint = h.joint().unwrap(); let joint = h.joint().unwrap();
for (a, b) in PAIRS { for (a, b) in PAIRS {
let terms = [(&a, 1.0), (&b, -1.0)]; let terms = [(&a, 1.0), (&b, -1.0)];
let one_shot = h.posterior_of(&terms).unwrap(); let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
let cached = joint.posterior_of(&terms).unwrap(); let cached = joint.posterior_of(&terms).unwrap();
assert_eq!(one_shot.pi(), cached.pi(), "{a} - {b}"); assert_eq!(one_shot.mu(), cached.mu(), "{a} - {b}");
assert_eq!(one_shot.tau(), cached.tau(), "{a} - {b}"); assert_eq!(one_shot.variance(), cached.variance(), "{a} - {b}");
} }
} }
@@ -100,12 +105,12 @@ fn a_joint_agrees_at_a_pinned_time_too() {
for time in 1..=5 { for time in 1..=5 {
for (a, b) in PAIRS { for (a, b) in PAIRS {
let terms = [(&a, 1.0), (&b, -1.0)]; let terms = [(&a, 1.0), (&b, -1.0)];
let one_shot = h.posterior_of_at(time, &terms); let one_shot = h.joint().unwrap().posterior_of_at(time, &terms);
let cached = joint.posterior_of_at(time, &terms); let cached = joint.posterior_of_at(time, &terms);
match (one_shot, cached) { match (one_shot, cached) {
(Ok(x), Ok(y)) => { (Ok(x), Ok(y)) => {
assert_eq!(x.pi(), y.pi(), "t={time} {a} - {b}"); assert_eq!(x.mu(), y.mu(), "t={time} {a} - {b}");
assert_eq!(x.tau(), y.tau(), "t={time} {a} - {b}"); assert_eq!(x.variance(), y.variance(), "t={time} {a} - {b}");
} }
(Err(x), Err(y)) => assert_eq!(x, y, "t={time} {a} - {b}"), (Err(x), Err(y)) => assert_eq!(x, y, "t={time} {a} - {b}"),
(x, y) => panic!("t={time} {a} - {b}: disagreed on success: {x:?} vs {y:?}"), (x, y) => panic!("t={time} {a} - {b}: disagreed on success: {x:?} vs {y:?}"),
@@ -123,7 +128,11 @@ fn a_joint_scores_candidate_matchups_identically() {
for (x, y) in PAIRS { for (x, y) in PAIRS {
let teams: [&[&&str]; 2] = [&[&x], &[&y]]; let teams: [&[&&str]; 2] = [&[&x], &[&y]];
let one_shot = h.expected_variance_reduction(&teams, &target).unwrap(); let one_shot = h
.joint()
.unwrap()
.expected_variance_reduction(&teams, &target)
.unwrap();
let cached = joint.expected_variance_reduction(&teams, &target).unwrap(); let cached = joint.expected_variance_reduction(&teams, &target).unwrap();
assert_eq!(one_shot, cached, "{x} vs {y}"); assert_eq!(one_shot, cached, "{x} vs {y}");
} }
@@ -222,16 +231,20 @@ fn a_ranked_history_has_no_exact_joint() {
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
assert!(matches!( assert!(matches!(
h.joint().unwrap_err(), h.joint().unwrap_err(),
InferenceError::JointUnavailable { .. } InferenceError::JointRequiresScoredEvents
)); ));
} }
/// Distinguishable from the ranked case, which is the point of splitting
/// `JointUnavailable { reason: &str }` into three variants (#74): "add events"
/// and "use predict_win_probabilities" are different instructions, and telling
/// them apart used to mean matching on English prose.
#[test] #[test]
fn an_empty_history_has_no_joint() { fn an_empty_history_has_no_joint() {
let h = history(UnknownKeys::Reject); let h = history(UnknownKeys::Reject);
assert!(matches!( assert!(matches!(
h.joint().unwrap_err(), h.joint().unwrap_err(),
InferenceError::JointUnavailable { .. } InferenceError::EmptyHistory
)); ));
} }
@@ -252,18 +265,18 @@ fn unknown_keys_are_rejected_per_query() {
} }
/// Under `Prior`, an unseen competitor is independent of everything in the /// Under `Prior`, an unseen competitor is independent of everything in the
/// history, and the cached path must add the same prior variance the one-shot /// history, and a reused joint must add the same prior variance a fresh one
/// path does. /// does.
#[test] #[test]
fn unseen_competitors_match_the_one_shot_path() { fn unseen_competitors_match_a_fresh_factorisation() {
let h = fitted(UnknownKeys::Prior); let h = fitted(UnknownKeys::Prior);
let joint = h.joint().unwrap(); let joint = h.joint().unwrap();
let (a, z) = ("a", "nobody"); let (a, z) = ("a", "nobody");
let terms = [(&a, 1.0), (&z, -1.0)]; let terms = [(&a, 1.0), (&z, -1.0)];
let one_shot = h.posterior_of(&terms).unwrap(); let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
let cached = joint.posterior_of(&terms).unwrap(); let cached = joint.posterior_of(&terms).unwrap();
assert_eq!(one_shot.pi(), cached.pi()); assert_eq!(one_shot.mu(), cached.mu());
assert_eq!(one_shot.tau(), cached.tau()); assert_eq!(one_shot.variance(), cached.variance());
} }
/// A drift too small to represent must collapse, not corrupt the matrix. /// A drift too small to represent must collapse, not corrupt the matrix.
@@ -279,7 +292,7 @@ fn unseen_competitors_match_the_one_shot_path() {
#[test] #[test]
fn a_drift_too_small_to_represent_collapses_rather_than_corrupting() { fn a_drift_too_small_to_represent_collapses_rather_than_corrupting() {
fn variance(scale: f64) -> f64 { fn variance(scale: f64) -> f64 {
let mut h: History<i64, ConstantDrift, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.mu(0.0) .mu(0.0)
.sigma(6.0) .sigma(6.0)
+127
View File
@@ -0,0 +1,127 @@
//! The realistic program: keys arrive owned, queries are written with literals.
//!
//! Every prediction and joint query used to take `&[&[&K]]`, which at
//! `K = String` made a string literal *impossible* — the shape required three
//! levels of temporaries that all had to outlive the call. They are generic
//! over the borrowed key now, so one spelling works at both key types.
//!
//! Both key types are exercised in every test, because the point is that the
//! spelling is the same.
use trueskill_tt::{ConstantDrift, History};
type Owned = History<String>;
type Borrowed = History;
fn owned() -> Owned {
let mut h: Owned = History::builder().key_type::<String>().build();
for t in 1..=4 {
h.record_winner(&"alice".to_string(), &"bob".to_string(), t)
.expect("ingests");
}
h.converge().expect("converges");
h
}
fn borrowed() -> Borrowed {
let mut h = History::default();
for t in 1..=4 {
h.record_winner(&"alice", &"bob", t).expect("ingests");
}
h.converge().expect("converges");
h
}
#[test]
fn predictions_take_literals_at_either_key_type() {
let teams: &[&[&str]] = &[&["alice"], &["bob"]];
let a = owned()
.predict_win_probabilities(teams)
.expect("K = String");
let b = borrowed()
.predict_win_probabilities(teams)
.expect("K = &'static str");
assert_eq!(a, b, "the same fit through the same spelling");
assert!(a[0] > a[1], "alice won every game");
}
#[test]
fn every_team_shaped_query_accepts_the_same_slice() {
let h = owned();
let teams: &[&[&str]] = &[&["alice"], &["bob"]];
h.quality(teams).expect("quality");
let _ = h.predict_outcome(teams).expect("outcome");
h.predict_ranking(teams, &[0, 1]).expect("ranking");
h.expected_information_gain(teams)
.expect("information gain");
}
#[test]
fn linear_combinations_take_bare_keys() {
// `&[(&K, f64)]` at `K = String` meant `&[(&String, f64)]` — no literals.
// A scored history, because the joint needs one.
let mut h: Owned = History::builder().key_type::<String>().build();
for t in 1..=4 {
h.event(t)
.team([String::from("alice")])
.team([String::from("bob")])
.scores([21.0, 9.0])
.commit()
.expect("ingests");
}
h.converge().expect("converges");
let terms: &[(&str, f64)] = &[("alice", 1.0), ("bob", -1.0)];
let gap = h
.joint()
.expect("scored history has a joint")
.posterior_of(terms)
.expect("both keys are known");
assert!(gap.mu() > 0.0, "alice outscored bob every round");
}
/// `lookup` is gone with `Index` (#73); the accessors that answer the same
/// question all take a borrowed key.
#[test]
fn membership_queries_accept_a_borrowed_key() {
let h = owned();
assert!(h.current_skill("alice").is_some());
assert!(h.rating("alice").is_some());
assert!(h.learning_curve("alice").is_some());
assert!(h.current_skill("nobody").is_none());
assert!(h.rating("nobody").is_none());
assert!(h.learning_curve("nobody").is_none());
}
#[test]
fn gamma_sets_drift_without_naming_constant_drift() {
let mut a: Borrowed = History::builder().gamma(0.5).build();
let mut b: Borrowed = History::builder().drift(ConstantDrift::new(0.5)).build();
for h in [&mut a, &mut b] {
h.record_winner(&"x", &"y", 1).unwrap();
h.record_winner(&"y", &"x", 100).unwrap();
h.converge().unwrap();
}
let (ga, gb) = (a.current_skill("x").unwrap(), b.current_skill("x").unwrap());
assert_eq!((ga.mu(), ga.sigma()), (gb.mu(), gb.sigma()));
// Control: the shorthand is not a no-op — a different gamma differs.
let mut c: Borrowed = History::builder().gamma(0.0).build();
c.record_winner(&"x", &"y", 1).unwrap();
c.record_winner(&"y", &"x", 100).unwrap();
c.converge().unwrap();
assert_ne!(c.current_skill("x").unwrap().sigma(), ga.sigma());
}
#[test]
#[should_panic(expected = "gamma must be finite and non-negative")]
fn a_negative_gamma_is_rejected_rather_than_squared_away() {
let _: Borrowed = History::builder().gamma(-0.5).build();
}
+2 -2
View File
@@ -3,7 +3,7 @@
//! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the //! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the
//! moment-space `Sub` in the game chain propagated into every skill once the slice grew past //! moment-space `Sub` in the game chain propagated into every skill once the slice grew past
//! ~75 competitors (e.g. a real ranking dataset with hundreds of players). //! ~75 competitors (e.g. a real ranking dataset with hundreds of players).
use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS, NullObserver}; use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS};
/// Tiny deterministic LCG — avoids a dev-dependency on `rand`. /// Tiny deterministic LCG — avoids a dev-dependency on `rand`.
struct Lcg(u64); struct Lcg(u64);
@@ -24,7 +24,7 @@ impl Lcg {
} }
fn nan_after_fit(players: usize) -> usize { fn nan_after_fit(players: usize) -> usize {
let mut h: History<i64, ConstantDrift, NullObserver, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.beta(1.0) .beta(1.0)
.sigma(6.0) .sigma(6.0)
+8 -6
View File
@@ -132,10 +132,8 @@ fn key(i: usize) -> &'static str {
} }
/// Returns (worst mean error, worst sd ratio). /// Returns (worst mean error, worst sd ratio).
fn fitted( fn fitted(obs: &[(usize, usize, f64)]) -> History {
obs: &[(usize, usize, f64)], let mut h: History = History::builder()
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
let mut h: History<i64, _, _, &'static str> = History::builder()
.mu(MU0) .mu(MU0)
.sigma(SIGMA0) .sigma(SIGMA0)
.beta(BETA) .beta(BETA)
@@ -281,6 +279,8 @@ fn posterior_of_matches_the_exact_joint() {
for (i, j) in [(0usize, 1usize), (0, 2), (1, 3), (2, 4)] { for (i, j) in [(0usize, 1usize), (0, 2), (1, 3), (2, 4)] {
let got = h let got = h
.joint()
.unwrap()
.posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)]) .posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)])
.expect("scored slice should have a joint"); .expect("scored slice should have a joint");
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt(); let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
@@ -302,7 +302,7 @@ fn posterior_of_matches_the_exact_joint() {
// A single competitor: this is where the loopy marginal was 2x narrow. // A single competitor: this is where the loopy marginal was 2x narrow.
for (i, row) in cov.iter().enumerate() { for (i, row) in cov.iter().enumerate() {
let got = h.posterior_of(&[(&key(i), 1.0)]).unwrap(); let got = h.joint().unwrap().posterior_of(&[(&key(i), 1.0)]).unwrap();
let exact_sd = row[i].sqrt(); let exact_sd = row[i].sqrt();
assert!( assert!(
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9, (got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
@@ -322,7 +322,7 @@ fn cost_scaling() {
use std::time::Instant; use std::time::Instant;
for n in [50usize, 100, 200, 400, 800] { for n in [50usize, 100, 200, 400, 800] {
let names: Vec<String> = (0..n).map(|i| format!("c{i}")).collect(); let names: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
let mut h: History<i64, _, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.score_sigma(2.0) .score_sigma(2.0)
.drift(ConstantDrift::new(0.0)) .drift(ConstantDrift::new(0.0))
@@ -361,6 +361,8 @@ fn cost_scaling() {
let t = Instant::now(); let t = Instant::now();
let g = h let g = h
.joint()
.unwrap()
.posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)]) .posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)])
.unwrap(); .unwrap();
println!(" n={n:>4}: {:>10.2?} sigma {:.6}", t.elapsed(), g.sigma()); println!(" n={n:>4}: {:>10.2?} sigma {:.6}", t.elapsed(), g.sigma());
+4 -4
View File
@@ -56,7 +56,7 @@ fn overflow_during_inference_is_reported_not_hidden() {
for (name, sigma, beta, score_sigma, scores) in cases { for (name, sigma, beta, score_sigma, scores) in cases {
match scored_fit(sigma, beta, score_sigma, scores) { match scored_fit(sigma, beta, score_sigma, scores) {
Err(InferenceError::NonFiniteResult { context, step }) => { Err(InferenceError::NonFiniteStep { context, step, .. }) => {
assert_eq!(context, "History::converge", "{name}"); assert_eq!(context, "History::converge", "{name}");
assert!( assert!(
!step.0.is_finite() || !step.1.is_finite(), !step.0.is_finite() || !step.1.is_finite(),
@@ -86,7 +86,7 @@ fn a_broken_fit_is_never_reported_as_converged() {
let err = h.converge().unwrap_err(); let err = h.converge().unwrap_err();
assert!( assert!(
matches!(err, InferenceError::NonFiniteResult { .. }), matches!(err, InferenceError::NonFiniteStep { .. }),
"a breakdown must not be reported as convergence: {err:?}" "a breakdown must not be reported as convergence: {err:?}"
); );
@@ -104,7 +104,7 @@ fn a_broken_fit_is_never_reported_as_converged() {
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
h2.converge_partial().unwrap_err(), h2.converge_partial().unwrap_err(),
InferenceError::NonFiniteResult { .. } InferenceError::NonFiniteStep { .. }
)); ));
} }
@@ -161,7 +161,7 @@ fn a_nan_competitor_is_not_masked_by_a_healthy_one() {
.converge() .converge()
.expect_err("a NaN fit must never be reported as converged"); .expect_err("a NaN fit must never be reported as converged");
assert!( assert!(
matches!(err, InferenceError::NonFiniteResult { .. }), matches!(err, InferenceError::NonFiniteStep { .. }),
"{err:?}" "{err:?}"
); );
} }
+5 -7
View File
@@ -6,9 +6,7 @@ use trueskill_tt::{
UnknownKeys, UnknownKeys,
}; };
fn builder( fn builder(policy: UnknownKeys) -> History {
policy: UnknownKeys,
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
History::builder() History::builder()
.mu(0.0) .mu(0.0)
.sigma(6.0) .sigma(6.0)
@@ -37,9 +35,7 @@ fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'sta
/// A history where "veteran" and "regular" are well observed and "novice" /// A history where "veteran" and "regular" are well observed and "novice"
/// appears once. /// appears once.
fn fitted( fn fitted(policy: UnknownKeys) -> History {
policy: UnknownKeys,
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
let mut h = builder(policy); let mut h = builder(policy);
let mut events: Vec<_> = (0..40) let mut events: Vec<_> = (0..40)
.map(|t| round("veteran", "regular", 10.0 + f64::from(t % 3), 5.0)) .map(|t| round("veteran", "regular", 10.0 + f64::from(t % 3), 5.0))
@@ -120,6 +116,8 @@ fn swapping_the_teams_negates_the_margin() {
fn the_predictive_interval_exceeds_the_skill_uncertainty() { fn the_predictive_interval_exceeds_the_skill_uncertainty() {
let h = fitted(UnknownKeys::Prior); let h = fitted(UnknownKeys::Prior);
let skill_gap = h let skill_gap = h
.joint()
.unwrap()
.posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)]) .posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)])
.unwrap(); .unwrap();
let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap(); let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
@@ -148,6 +146,6 @@ fn shape_errors_are_reported() {
let empty: [&&str; 0] = []; let empty: [&&str; 0] = [];
assert!(matches!( assert!(matches!(
h.predict_margin(&[&[&"veteran"], &empty]), h.predict_margin(&[&[&"veteran"], &empty]),
Err(InferenceError::EmptyTeam { team: 1 }) Err(InferenceError::EmptyTeam { team: 1, .. })
)); ));
} }
+38 -40
View File
@@ -20,13 +20,13 @@ fn unknown_keys_are_reported_not_silently_dropped() {
let err = h let err = h
.predict_outcome(&[&[&"a"], &[&"ghost"]]) .predict_outcome(&[&[&"a"], &[&"ghost"]])
.expect_err("an unknown key must not yield a confident prediction"); .expect_err("an unknown key must not yield a confident prediction");
assert_eq!( assert!(
err, matches!(
InferenceError::UnknownKey { &err,
team: 1, InferenceError::UnknownKey { team: 1, member: 0, key, .. }
member: 0, if key == "\"ghost\""
key: "\"ghost\"".to_owned(), ),
} "{err:?}"
); );
// Every prediction entry point, not just one. // Every prediction entry point, not just one.
@@ -34,7 +34,7 @@ fn unknown_keys_are_reported_not_silently_dropped() {
h.predict_win_probabilities(&[&[&"a"], &[&"ghost"]]) h.predict_win_probabilities(&[&[&"a"], &[&"ghost"]])
.is_err() .is_err()
); );
assert!(h.predict_quality(&[&[&"a"], &[&"ghost"]]).is_err()); assert!(h.quality(&[&[&"a"], &[&"ghost"]]).is_err());
assert!(h.predict_ranking(&[&[&"a"], &[&"ghost"]], &[0, 1]).is_err()); assert!(h.predict_ranking(&[&[&"a"], &[&"ghost"]], &[0, 1]).is_err());
} }
@@ -42,13 +42,13 @@ fn unknown_keys_are_reported_not_silently_dropped() {
fn an_entirely_unknown_team_is_an_error() { fn an_entirely_unknown_team_is_an_error() {
let h = history_with(&["a", "b"], 0.0); let h = history_with(&["a", "b"], 0.0);
let err = h.predict_outcome(&[&[&"a"], &[&"x", &"y"]]).unwrap_err(); let err = h.predict_outcome(&[&[&"a"], &[&"x", &"y"]]).unwrap_err();
assert_eq!( assert!(
err, matches!(
InferenceError::UnknownKey { &err,
team: 1, InferenceError::UnknownKey { team: 1, member: 0, key, .. }
member: 0, if key == "\"x\""
key: "\"x\"".to_owned(), ),
} "{err:?}"
); );
} }
@@ -56,18 +56,22 @@ fn an_entirely_unknown_team_is_an_error() {
fn degenerate_team_shapes_are_errors_rather_than_panics() { fn degenerate_team_shapes_are_errors_rather_than_panics() {
let h = history_with(&["a", "b"], 0.0); let h = history_with(&["a", "b"], 0.0);
assert_eq!( assert!(matches!(
h.predict_outcome(&[&[&"a"]]).unwrap_err(), h.predict_outcome(&[&[&"a"]]).unwrap_err(),
InferenceError::NotEnoughTeams { got: 1 } InferenceError::NotEnoughTeams { got: 1, .. }
); ),);
assert_eq!( // An empty team list cannot infer the key type — nothing in `&[]` names it.
h.predict_outcome(&[]).unwrap_err(), // The annotation is the cost of `predict_*` being generic over the borrowed
InferenceError::NotEnoughTeams { got: 0 } // key, and it only bites on the degenerate call.
); let none: &[&[&str]] = &[];
assert_eq!( assert!(matches!(
h.predict_outcome(none).unwrap_err(),
InferenceError::NotEnoughTeams { got: 0, .. }
),);
assert!(matches!(
h.predict_outcome(&[&[&"a"], &[]]).unwrap_err(), h.predict_outcome(&[&[&"a"], &[]]).unwrap_err(),
InferenceError::EmptyTeam { team: 1 } InferenceError::EmptyTeam { team: 1, .. }
); ));
} }
#[test] #[test]
@@ -93,13 +97,10 @@ fn the_outcome_space_is_capped_rather_than_hanging() {
let refs: Vec<&[&&str]> = too_many.iter().map(Vec::as_slice).collect(); let refs: Vec<&[&&str]> = too_many.iter().map(Vec::as_slice).collect();
let err = h.predict_outcome(&refs).unwrap_err(); let err = h.predict_outcome(&refs).unwrap_err();
assert_eq!( assert!(matches!(
err, err,
InferenceError::TooManyTeams { InferenceError::TooManyTeams { got: 8, max, .. } if max == MAX_PREDICTED_TEAMS
got: 8, ));
max: MAX_PREDICTED_TEAMS
}
);
// The cheap paths stay available at any size. // The cheap paths stay available at any size.
let wins = h.predict_win_probabilities(&refs).unwrap(); let wins = h.predict_win_probabilities(&refs).unwrap();
@@ -282,15 +283,12 @@ fn information_gain_respects_the_entropy_ceiling() {
#[test] #[test]
fn information_gain_reports_unknown_keys() { fn information_gain_reports_unknown_keys() {
let h = history_with(&["a", "b"], 0.0); let h = history_with(&["a", "b"], 0.0);
assert_eq!( assert!(matches!(
h.expected_information_gain(&[&[&"a"], &[&"ghost"]]) &h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
.unwrap_err(), .unwrap_err(),
InferenceError::UnknownKey { InferenceError::UnknownKey { team: 1, member: 0, key, .. }
team: 1, if key == "\"ghost\""
member: 0, ));
key: "\"ghost\"".to_owned(),
}
);
} }
/// A draw-enabled history has three outcomes to weigh rather than two, so the /// A draw-enabled history has three outcomes to weigh rather than two, so the
@@ -409,7 +407,7 @@ fn prior_reaches_every_prediction_entry_point() {
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior); let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
let teams: &[&[&&str]] = &[&[&"a"], &[&"ghost"]]; let teams: &[&[&&str]] = &[&[&"a"], &[&"ghost"]];
assert!(h.predict_quality(teams).is_ok()); assert!(h.quality(teams).is_ok());
assert!(h.predict_win_probabilities(teams).is_ok()); assert!(h.predict_win_probabilities(teams).is_ok());
assert!(h.predict_outcome(teams).is_ok()); assert!(h.predict_outcome(teams).is_ok());
assert!(h.predict_ranking(teams, &[0, 1]).is_ok()); assert!(h.predict_ranking(teams, &[0, 1]).is_ok());
+1 -1
View File
@@ -154,7 +154,7 @@ fn the_known_ceiling_violation_no_longer_answers_wrongly() {
gain <= 2.0_f64.ln() + 1e-9, gain <= 2.0_f64.ln() + 1e-9,
"returned {gain}, over the ln 2 ceiling" "returned {gain}, over the ln 2 ceiling"
), ),
Err(InferenceError::GridTooCoarse { needed, max }) => { Err(InferenceError::GridTooCoarse { needed, max, .. }) => {
assert!(needed > max, "needed {needed} should exceed max {max}"); assert!(needed > max, "needed {needed} should exceed max {max}");
} }
Err(e) => panic!("unexpected error {e:?}"), 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, Outcome, Team,
};
type H = History;
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::NonFiniteStep { .. }),
"{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("quality", $h.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::NonFiniteSkill { .. }) => {}
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());
// `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::NoPerformanceVariance) => {}
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(); let _ = h.converge().unwrap();
for key in KEYS { 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}")); assert_finite(g, &format!("{key} at t={time}"));
} }
} }
+5 -8
View File
@@ -1,4 +1,4 @@
//! `quality()` beyond two rating groups. //! `quality()` beyond two teams.
//! //!
//! The historical golden (two equal singletons) is asserted in //! The historical golden (two equal singletons) is asserted in
//! `src/lib.rs::tests::test_quality`. These cover the N-group generalisation, //! `src/lib.rs::tests::test_quality`. These cover the N-group generalisation,
@@ -82,14 +82,14 @@ fn uneven_group_sizes_work() {
} }
#[test] #[test]
#[should_panic(expected = "at least 2 rating groups")] #[should_panic(expected = "at least 2 teams")]
fn single_group_panics_with_clear_message() { fn single_group_panics_with_clear_message() {
let r = rating(25.0, 3.0); let r = rating(25.0, 3.0);
let _ = quality(&[&[r]], BETA); let _ = quality(&[&[r]], BETA);
} }
#[test] #[test]
#[should_panic(expected = "at least 2 rating groups")] #[should_panic(expected = "at least 2 teams")]
fn zero_groups_panics_with_clear_message() { fn zero_groups_panics_with_clear_message() {
let _ = quality(&[], BETA); let _ = quality(&[], BETA);
} }
@@ -110,11 +110,8 @@ fn history_predict_quality_supports_three_teams() {
h.record_winner(&"b", &"c", 2).unwrap(); h.record_winner(&"b", &"c", 2).unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
let q = h.predict_quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap(); let q = h.quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap();
assert!( assert!(q.is_finite(), "3-team quality must be finite, got {q}");
q.is_finite(),
"3-team predict_quality must be finite, got {q}"
);
assert!((0.0..=1.0).contains(&q), "out of range: {q}"); assert!((0.0..=1.0).contains(&q), "out of range: {q}");
} }
+192
View File
@@ -0,0 +1,192 @@
//! `HistoryBuilder::default_rating_for`: configuring a *class* of competitors
//! rather than one at a time (#53).
//!
//! Every test carries a control — a key the rule does not match — so none can
//! pass by the rule firing for everybody, which would be indistinguishable
//! from changing the history defaults.
use trueskill_tt::{
ConstantDrift, Gaussian, History, HistoryBuilder, InferenceError, Member, NullObserver,
RatingRule, StartingPoint,
};
/// Pinned: no drift, and a tight prior at a known strength.
fn pinned() -> StartingPoint {
StartingPoint::new()
.prior(Gaussian::from_ms(5.0, 0.5))
.drift_scale(0.0)
}
fn play<R: RatingRule<&'static str>>(
h: &mut History<&'static str, i64, ConstantDrift, NullObserver, R>,
) {
for t in 1..=6 {
h.event(t)
.team(["layout_a"])
.team(["alice"])
.scores([3.0, 1.0])
.commit()
.expect("ingests");
}
h.converge().expect("converges");
}
#[test]
fn a_rule_configures_every_matching_key_without_naming_them() {
let mut ruled = History::builder()
.gamma(0.5)
.default_rating_for(|key: &&'static str| key.starts_with("layout_").then(pinned))
.build();
play(&mut ruled);
let mut plain = History::builder().gamma(0.5).build();
play(&mut plain);
let layout = ruled.current_skill("layout_a").expect("played");
// The rule pinned the layout: tight prior, no drift.
assert!(
layout.sigma() < 0.5,
"the layout should stay near its pinned prior, got sigma {}",
layout.sigma()
);
assert_ne!(
layout.sigma(),
plain.current_skill("layout_a").unwrap().sigma(),
"the rule must actually change the fit"
);
// The control is the *configuration*, not the posterior. Alice's posterior
// legitimately moves — she is playing a differently-configured opponent,
// and what she learns from beating it depends on how sure the model is
// about it. What must not move is what the rule was asked about.
let alice = ruled.rating("alice").expect("played");
assert_eq!(
alice.drift_scale(),
1.0,
"a non-matching key keeps the default drift"
);
assert_eq!(
(alice.prior().mu(), alice.prior().sigma()),
{
let p = plain.rating("alice").expect("played").prior();
(p.mu(), p.sigma())
},
"a non-matching key keeps the history's prior"
);
}
#[test]
fn a_rule_fires_for_a_competitor_first_seen_through_record_winner() {
// `record_winner` cannot carry configuration, which is the case a rule
// exists for.
let mut h = History::builder()
.default_rating_for(|key: &&'static str| key.starts_with("bot_").then(pinned))
.build();
h.record_winner(&"bot_1", &"human", 1).expect("ingests");
h.converge().expect("converges");
assert_eq!(h.rating("bot_1").expect("known").drift_scale(), 0.0);
assert_eq!(h.rating("human").expect("known").drift_scale(), 1.0);
}
#[test]
fn explicit_configuration_overrides_a_rule_field_by_field() {
let mut h = History::builder()
.default_rating_for(|_: &&'static str| Some(pinned()))
.build();
// Sets only the prior, so the rule's `drift_scale` must survive.
h.register(Member::new("a").with_prior(Gaussian::from_ms(-9.0, 2.0)))
.expect("new");
// Sets neither: the rule supplies both.
h.register(Member::new("b")).expect("new");
let a = h.rating("a").expect("registered");
assert_eq!(a.prior().mu(), -9.0, "explicit prior wins");
assert_eq!(a.drift_scale(), 0.0, "the rule's drift_scale survives");
let b = h.rating("b").expect("registered");
assert_eq!(b.prior().mu(), 5.0);
assert_eq!(b.drift_scale(), 0.0);
}
#[test]
fn two_explicit_declarations_that_disagree_are_still_an_error() {
// Precedence resolves rule-vs-explicit. It does not weaken the check
// between two explicit declarations, neither of which is more specific.
let mut h = History::builder()
.default_rating_for(|_: &&'static str| Some(pinned()))
.build();
let err = h
.add_events(vec![
event(1, "x", Gaussian::from_ms(1.0, 1.0)),
event(2, "x", Gaussian::from_ms(2.0, 1.0)),
])
.expect_err("two different priors for one competitor");
assert!(
matches!(err, InferenceError::ConflictingCompetitorConfig { .. }),
"{err:?}"
);
}
fn event(time: i64, key: &'static str, prior: Gaussian) -> trueskill_tt::Event<i64, &'static str> {
trueskill_tt::Event {
time,
teams: [
trueskill_tt::Team::with_members([Member::new(key).with_prior(prior)]),
trueskill_tt::Team::with_members([Member::new("opponent")]),
]
.into_iter()
.collect(),
outcome: trueskill_tt::Outcome::scores([2.0, 1.0]),
}
}
/// A named rule type, so the `History<..>` can be written down in a field.
struct StaticLayouts;
impl RatingRule<&'static str> for StaticLayouts {
fn starting_point(&self, key: &&'static str) -> Option<StartingPoint> {
key.starts_with("layout_").then(pinned)
}
}
/// The reason this is a trait rather than a bare `Fn` bound: a consumer holds
/// its history in application state and has to name the type.
struct Ladder {
history: History<&'static str, i64, ConstantDrift, NullObserver, StaticLayouts>,
}
#[test]
fn a_named_rule_type_can_be_stored_in_a_struct_field() {
let mut ladder = Ladder {
history: HistoryBuilder::default().rating_rule(StaticLayouts).build(),
};
play(&mut ladder.history);
assert!(
ladder
.history
.current_skill("layout_a")
.expect("played")
.sigma()
< 0.5
);
assert_eq!(
ladder
.history
.rating("alice")
.expect("played")
.drift_scale(),
1.0
);
}
#[test]
fn no_rule_is_the_default_and_costs_nothing_to_spell() {
// The whole point of defaulting the parameter: `History<K>` still works.
let h: History<String> = History::builder().key_type::<String>().build();
assert_eq!(h.competitor_count(), 0);
}
+2 -2
View File
@@ -35,7 +35,7 @@ fn ev(a: &str, b: &str, time: i64) -> Event<i64, String> {
/// Ingest each chunk in turn, converging fully after every one. /// Ingest each chunk in turn, converging fully after every one.
fn fit_in_chunks(chunks: Vec<Events>) -> Vec<(String, Gaussian)> { fn fit_in_chunks(chunks: Vec<Events>) -> Vec<(String, Gaussian)> {
let mut h: History<i64, _, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.convergence(tight()) .convergence(tight())
.build(); .build();
@@ -152,7 +152,7 @@ fn re_converging_an_unchanged_history_costs_one_iteration() {
let (early, late) = fixture(); let (early, late) = fixture();
let all: Vec<_> = early.into_iter().chain(late).collect(); let all: Vec<_> = early.into_iter().chain(late).collect();
let mut h: History<i64, _, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.convergence(tight()) .convergence(tight())
.build(); .build();
+20 -12
View File
@@ -17,24 +17,32 @@ fn record_winner_builds_history() {
h.record_winner(&"alice", &"bob", 1).unwrap(); h.record_winner(&"alice", &"bob", 1).unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
let a_idx = h.lookup(&"alice").unwrap(); // `lookup` returned an `Index` that nothing public accepted, so the
let b_idx = h.lookup(&"bob").unwrap(); // observable claim is the one worth making: two distinct competitors, each
// with their own posterior, and the winner ahead.
assert_ne!(a_idx, b_idx); assert_eq!(h.competitor_count(), 2);
let alice = h.current_skill("alice").expect("alice played");
let bob = h.current_skill("bob").expect("bob played");
assert!(alice.mu() > bob.mu());
} }
/// The same key names the same competitor across events, which is what
/// interning bought and the only part of it a caller can observe.
#[test] #[test]
fn intern_is_idempotent() { fn a_repeated_key_is_one_competitor() {
let mut h: History = History::builder().build(); let mut h: History = History::builder().build();
let a1 = h.intern(&"alice"); h.record_winner(&"alice", &"bob", 1).unwrap();
let a2 = h.intern(&"alice"); h.record_winner(&"alice", &"carol", 2).unwrap();
assert_eq!(a1, a2);
assert_eq!(h.competitor_count(), 3);
assert_eq!(h.learning_curve("alice").expect("known").len(), 2);
} }
#[test] #[test]
fn lookup_returns_none_for_missing() { fn an_unknown_key_is_unknown() {
let h: History = History::builder().build(); let h: History = History::builder().build();
assert!(h.lookup(&"nobody").is_none()); assert!(h.current_skill("nobody").is_none());
assert!(h.learning_curve("nobody").is_none());
} }
#[test] #[test]
@@ -50,6 +58,6 @@ fn record_draw_with_p_draw_set() {
h.record_draw(&"alice", &"bob", 1).unwrap(); h.record_draw(&"alice", &"bob", 1).unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
assert!(h.lookup(&"alice").is_some()); assert!(h.current_skill("alice").is_some());
assert!(h.lookup(&"bob").is_some()); assert!(h.current_skill("bob").is_some());
} }
+18 -12
View File
@@ -11,7 +11,7 @@ use trueskill_tt::{
Team, Team,
}; };
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>; type H = History;
const PINNED: Gaussian = Gaussian::from_ms(2.0, 0.5); const PINNED: Gaussian = Gaussian::from_ms(2.0, 0.5);
@@ -94,8 +94,8 @@ fn registering_matches_configuring_on_the_first_event() {
}; };
for ((k, a), (_, b)) in skills(&configured).into_iter().zip(skills(&registered)) { for ((k, a), (_, b)) in skills(&configured).into_iter().zip(skills(&registered)) {
assert_eq!(a.pi(), b.pi(), "{k} pi"); assert_eq!(a.mu(), b.mu(), "{k} mu");
assert_eq!(a.tau(), b.tau(), "{k} tau"); assert_eq!(a.variance(), b.variance(), "{k} variance");
} }
} }
@@ -119,7 +119,7 @@ fn registration_reaches_a_competitor_first_seen_through_record_winner() {
assert_eq!(rating.prior().mu(), PINNED.mu()); assert_eq!(rating.prior().mu(), PINNED.mu());
// Pinned means pinned: no drift across the two slices. // 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); assert!(curve.len() >= 2);
let widest = curve let widest = curve
.iter() .iter()
@@ -172,7 +172,13 @@ fn a_weight_on_a_registration_is_rejected() {
.register(Member::new("layout").with_weight(0.5)) .register(Member::new("layout").with_weight(0.5))
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Weight,
..
}
),
"{err:?}" "{err:?}"
); );
} }
@@ -188,7 +194,7 @@ fn an_invalid_drift_scale_on_a_registration_is_rejected() {
matches!( matches!(
err, err,
InferenceError::InvalidParameter { InferenceError::InvalidParameter {
name: "drift_scale", parameter: trueskill_tt::Parameter::DriftScale,
.. ..
} }
), ),
@@ -225,8 +231,8 @@ fn registration_makes_the_fit_order_independent() {
let forward = build(false); let forward = build(false);
let backward = build(true); let backward = build(true);
for ((k, a), (_, b)) in skills(&forward).into_iter().zip(skills(&backward)) { for ((k, a), (_, b)) in skills(&forward).into_iter().zip(skills(&backward)) {
assert_eq!(a.pi(), b.pi(), "{k} pi"); assert_eq!(a.mu(), b.mu(), "{k} mu");
assert_eq!(a.tau(), b.tau(), "{k} tau"); assert_eq!(a.variance(), b.variance(), "{k} variance");
} }
} }
@@ -246,8 +252,8 @@ fn rating_reads_back_what_was_stored() {
.unwrap(); .unwrap();
let r = h.rating(&"layout").unwrap(); let r = h.rating(&"layout").unwrap();
assert_eq!(r.drift_scale(), 0.25); assert_eq!(r.drift_scale(), 0.25);
assert_eq!(r.prior().pi(), PINNED.pi()); assert_eq!(r.prior().mu(), PINNED.mu());
assert_eq!(r.prior().tau(), PINNED.tau()); assert_eq!(r.prior().variance(), PINNED.variance());
// A competitor created by an event reports the history defaults. // A competitor created by an event reports the history defaults.
h.record_winner(&"player", &"layout", 1).unwrap(); h.record_winner(&"player", &"layout", 1).unwrap();
@@ -280,7 +286,7 @@ mod conflicting_configuration {
matches!( matches!(
err, err,
InferenceError::ConflictingCompetitorConfig { InferenceError::ConflictingCompetitorConfig {
field: "drift_scale", field: trueskill_tt::CompetitorField::DriftScale,
.. ..
} }
), ),
@@ -297,7 +303,7 @@ mod conflicting_configuration {
matches!( matches!(
err, err,
InferenceError::ConflictingCompetitorConfig { InferenceError::ConflictingCompetitorConfig {
field: "drift_scale", field: trueskill_tt::CompetitorField::DriftScale,
.. ..
} }
), ),
+178
View File
@@ -0,0 +1,178 @@
//! What a sparse factorisation of the joint would actually buy (#52).
//!
//! Run explicitly:
//!
//! ```text
//! cargo test --release --features approx,measure-sparsity \
//! --test sparsity_measurement -- --ignored --nocapture
//! ```
//!
//! The whole file is gated: it reaches for the joint's sparsity pattern, which
//! is exposed only under `measure-sparsity`.
#![cfg(feature = "measure-sparsity")]
use std::collections::HashSet;
use trueskill_tt::{ConvergenceOptions, History};
/// A history shaped like the issue's fixture: many slices, scored duels,
/// competitors reappearing across slices so the drift links are long.
fn fitted(slices: i64, duels: usize, competitors: usize) -> History<String> {
let mut h: History<String> = History::builder()
.key_type::<String>()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.gamma(0.05)
.convergence(ConvergenceOptions {
max_iter: trueskill_tt::ITERATIONS,
epsilon: 1e-8,
alpha: 1.0,
})
.build();
let mut k = 0usize;
for t in 0..slices {
for _ in 0..duels {
k += 1;
h.event(t)
.team([format!("p{}", k % competitors)])
.team([format!("p{}", (k + 37) % competitors)])
.scores([
(k as f64 * 0.3).sin().abs() * 20.0,
(k as f64 * 0.3).cos().abs() * 20.0,
])
.commit()
.expect("ingests");
}
}
h.converge().expect("converges");
h
}
/// Symbolic Cholesky by row-merge: returns (nnz(L), flops).
///
/// Fill-in is simulated directly — for each column, the set of rows below the
/// diagonal that are nonzero — which is exact and easily checked, at the cost
/// of being O(n * nnz(L)) rather than the linear elimination-tree method.
fn symbolic(n: usize, adj: &[HashSet<usize>], perm_of: &[usize]) -> (usize, f64) {
// `perm_of[old] = new`. Build the permuted lower-triangle pattern.
let mut cols: Vec<HashSet<usize>> = vec![HashSet::new(); n];
for (old, nbrs) in adj.iter().enumerate() {
let i = perm_of[old];
for &old_j in nbrs {
let j = perm_of[old_j];
if j < i {
cols[j].insert(i);
}
}
}
let mut nnz = 0usize;
let mut flops = 0.0f64;
for j in 0..n {
// Column j's pattern is final once every earlier column has merged in.
let rows: Vec<usize> = cols[j].iter().copied().collect();
let c = rows.len();
nnz += c + 1; // below-diagonal entries plus the diagonal
// Cholesky work for this column: one outer product over its pattern.
flops += (c as f64 + 1.0) * (c as f64 + 1.0);
// Fill-in: every pair in column j becomes an edge in the remaining graph.
for (a_idx, &a) in rows.iter().enumerate() {
for &b in &rows[a_idx + 1..] {
let (lo, hi) = if a < b { (a, b) } else { (b, a) };
cols[lo].insert(hi);
}
}
}
(nnz, flops)
}
#[test]
#[ignore = "measurement, run explicitly"]
fn what_sparsity_would_buy() {
for (slices, duels, competitors) in [(30, 8, 100), (76, 13, 200)] {
let h = fitted(slices, duels, competitors);
let (n, pattern) = h.joint_pattern_for_measurement();
let nnz_a: usize = pattern.iter().map(HashSet::len).sum::<usize>() + n;
let dense_flops = (n as f64).powi(3) / 3.0;
let natural: Vec<usize> = (0..n).collect();
let (nnz_nat, flops_nat) = symbolic(n, &pattern, &natural);
// AMD returns `perm[new] = old`; invert it.
let (col_ptr, row_idx) = csc(n, &pattern);
let p = feral_amd::amd_order(
&feral_amd::CscPattern::new(n, &col_ptr, &row_idx).expect("valid pattern"),
)
.expect("amd");
let mut perm_of = vec![0usize; n];
for (new, &old) in p.iter().enumerate() {
perm_of[old as usize] = new;
}
let (nnz_amd, flops_amd) = symbolic(n, &pattern, &perm_of);
println!(
"\n=== {slices} slices x {duels} duels, {competitors} competitors ===\n\
n = {n}\n\
nnz(A) = {nnz_a} ({:.4}% dense)\n\
dense flops = {:.3e}\n\
nnz(L) natural = {nnz_nat} flops = {:.3e} ({:.1}x vs dense)\n\
nnz(L) AMD = {nnz_amd} flops = {:.3e} ({:.1}x vs dense)",
100.0 * nnz_a as f64 / (n * n) as f64,
dense_flops,
flops_nat,
dense_flops / flops_nat,
flops_amd,
dense_flops / flops_amd,
);
}
}
/// Full symmetric pattern to CSC, as `feral-amd` wants it.
fn csc(n: usize, adj: &[HashSet<usize>]) -> (Vec<i32>, Vec<i32>) {
let mut col_ptr = Vec::with_capacity(n + 1);
let mut row_idx = Vec::new();
col_ptr.push(0i32);
for (j, nbrs) in adj.iter().enumerate() {
let mut rows: Vec<i32> = nbrs.iter().map(|&i| i as i32).collect();
rows.push(j as i32);
rows.sort_unstable();
rows.dedup();
row_idx.extend_from_slice(&rows);
col_ptr.push(row_idx.len() as i32);
}
(col_ptr, row_idx)
}
/// End-to-end factorisation time at the scale #52 was opened about.
#[test]
#[ignore = "measurement, run explicitly"]
fn factorisation_time_at_scale() {
use std::time::Instant;
for (slices, duels, competitors) in [(30, 8, 100), (76, 13, 200), (150, 26, 400)] {
let h = fitted(slices, duels, competitors);
let (n, _) = h.joint_pattern_for_measurement();
// Warm, then time.
let _ = h.joint().expect("scored history");
let t = Instant::now();
let joint = h.joint().expect("scored history");
let factor = t.elapsed();
let a = "p0".to_string();
let b = "p1".to_string();
let t = Instant::now();
let _ = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).expect("known");
let query = t.elapsed();
println!(
"n = {n:5} factorise = {factor:>12?} query = {query:>10?} \
(dense was O(n^3): {:.3e} flops)",
(n as f64).powi(3) / 3.0
);
}
}
+3 -3
View File
@@ -94,7 +94,7 @@ fn a_custom_time_type_and_a_custom_drift_work_together() {
} }
assert!(h.converge().unwrap().converged); assert!(h.converge().unwrap().converged);
let curve = h.learning_curve(&"veteran"); let curve = h.learning_curve(&"veteran").unwrap();
assert_eq!(curve.len(), 4, "one point per season: {curve:?}"); assert_eq!(curve.len(), 4, "one point per season: {curve:?}");
for (season, g) in &curve { for (season, g) in &curve {
assert!( assert!(
@@ -144,9 +144,9 @@ fn key_type_replaces_builder_with_key() {
/// Both axes at once, via the explicit constructor rather than the setters. /// Both axes at once, via the explicit constructor rather than the setters.
#[test] #[test]
fn new_constructs_on_any_axis_directly() { fn new_constructs_on_any_axis_directly() {
let mut h = HistoryBuilder::<Season, _, _, String>::new().build(); let mut h = HistoryBuilder::<String, Season>::new().build();
h.record_winner(&"a".to_string(), &"b".to_string(), Season(7)) h.record_winner(&"a".to_string(), &"b".to_string(), Season(7))
.unwrap(); .unwrap();
assert!(h.converge().unwrap().converged); assert!(h.converge().unwrap().converged);
assert_eq!(h.learning_curve("a")[0].0, Season(7)); assert_eq!(h.learning_curve("a").unwrap()[0].0, Season(7));
} }
+54 -13
View File
@@ -16,7 +16,7 @@ const BETA: f64 = 1.0;
const SCORE_SIGMA: f64 = 2.0; const SCORE_SIGMA: f64 = 2.0;
const GAMMA: f64 = 0.5; const GAMMA: f64 = 0.5;
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>; type H = History;
fn history(gamma: f64) -> H { fn history(gamma: f64) -> H {
History::builder() History::builder()
@@ -120,7 +120,11 @@ fn a_two_slice_joint_matches_the_exact_posterior() {
// The crate reads each competitor at their latest appearance: a1, b1. // The crate reads each competitor at their latest appearance: a1, b1.
let exact_gap = (cov[2][2] + cov[3][3] - 2.0 * cov[2][3]).sqrt(); let exact_gap = (cov[2][2] + cov[3][3] - 2.0 * cov[2][3]).sqrt();
let got = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap(); let got = h
.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
assert!( assert!(
(got.sigma() - exact_gap).abs() / exact_gap < 1e-9, (got.sigma() - exact_gap).abs() / exact_gap < 1e-9,
"difference: got {} exact {exact_gap}", "difference: got {} exact {exact_gap}",
@@ -128,7 +132,7 @@ fn a_two_slice_joint_matches_the_exact_posterior() {
); );
let exact_single = cov[2][2].sqrt(); let exact_single = cov[2][2].sqrt();
let got_single = h.posterior_of(&[(&"a", 1.0)]).unwrap(); let got_single = h.joint().unwrap().posterior_of(&[(&"a", 1.0)]).unwrap();
assert!( assert!(
(got_single.sigma() - exact_single).abs() / exact_single < 1e-9, (got_single.sigma() - exact_single).abs() / exact_single < 1e-9,
"single node: got {} exact {exact_single}", "single node: got {} exact {exact_single}",
@@ -154,6 +158,8 @@ fn competitors_last_seen_in_different_slices_are_comparable() {
// b last appeared at time 0; a and c at time 20. All three must resolve. // b last appeared at time 0; a and c at time 20. All three must resolve.
for (x, y) in [("a", "b"), ("b", "c"), ("a", "c")] { for (x, y) in [("a", "b"), ("b", "c"), ("a", "c")] {
let g = h let g = h
.joint()
.unwrap()
.posterior_of(&[(&x, 1.0), (&y, -1.0)]) .posterior_of(&[(&x, 1.0), (&y, -1.0)])
.unwrap_or_else(|e| panic!("{x} - {y} should resolve across slices: {e}")); .unwrap_or_else(|e| panic!("{x} - {y} should resolve across slices: {e}"));
assert!(g.sigma() > 0.0 && g.sigma().is_finite()); assert!(g.sigma() > 0.0 && g.sigma().is_finite());
@@ -175,7 +181,7 @@ fn means_agree_with_the_marginals() {
for k in ["a", "b", "c"] { for k in ["a", "b", "c"] {
let marginal = h.current_skill(&k).unwrap().mu(); let marginal = h.current_skill(&k).unwrap().mu();
let joint = h.posterior_of(&[(&k, 1.0)]).unwrap().mu(); let joint = h.joint().unwrap().posterior_of(&[(&k, 1.0)]).unwrap().mu();
assert!( assert!(
(marginal - joint).abs() < 1e-9, (marginal - joint).abs() < 1e-9,
"{k}: marginal {marginal}, joint {joint}" "{k}: marginal {marginal}, joint {joint}"
@@ -197,7 +203,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
]) ])
.unwrap(); .unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap() h.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap()
}; };
let together = { let together = {
let mut h = history(0.0); let mut h = history(0.0);
@@ -208,7 +217,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
]) ])
.unwrap(); .unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap() h.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap()
}; };
assert!( assert!(
@@ -234,7 +246,11 @@ fn drift_widens_a_comparison_across_time() {
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
// b was last seen at time 0; a at time 100. // b was last seen at time 0; a at time 100.
let g = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap(); let g = h
.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
assert!( assert!(
g.sigma() > previous, g.sigma() > previous,
"gamma={gamma}: sigma {} did not exceed {previous}", "gamma={gamma}: sigma {} did not exceed {previous}",
@@ -257,9 +273,21 @@ fn posterior_of_at_reads_as_of_a_time() {
.unwrap(); .unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
let early = h.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)]).unwrap(); let early = h
let late = h.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)]).unwrap(); .joint()
let latest = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap(); .unwrap()
.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
let late = h
.joint()
.unwrap()
.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
let latest = h
.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
// Asking as of the final slice is the same as asking for the latest. // Asking as of the final slice is the same as asking for the latest.
assert!((late.mu() - latest.mu()).abs() < 1e-9); assert!((late.mu() - latest.mu()).abs() < 1e-9);
@@ -275,7 +303,12 @@ fn posterior_of_at_reads_as_of_a_time() {
); );
// A time before any event has nothing to read. // A time before any event has nothing to read.
assert!(h.posterior_of_at(-1, &[(&"a", 1.0)]).is_err()); assert!(
h.joint()
.unwrap()
.posterior_of_at(-1, &[(&"a", 1.0)])
.is_err()
);
} }
/// Times between slices resolve to the latest appearance at or before them. /// Times between slices resolve to the latest appearance at or before them.
@@ -289,8 +322,16 @@ fn a_time_between_slices_reads_the_previous_appearance() {
.unwrap(); .unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
let at_zero = h.posterior_of_at(0, &[(&"a", 1.0)]).unwrap(); let at_zero = h
let between = h.posterior_of_at(50, &[(&"a", 1.0)]).unwrap(); .joint()
.unwrap()
.posterior_of_at(0, &[(&"a", 1.0)])
.unwrap();
let between = h
.joint()
.unwrap()
.posterior_of_at(50, &[(&"a", 1.0)])
.unwrap();
assert!((at_zero.mu() - between.mu()).abs() < 1e-12); assert!((at_zero.mu() - between.mu()).abs() < 1e-12);
assert!((at_zero.sigma() - between.sigma()).abs() < 1e-12); assert!((at_zero.sigma() - between.sigma()).abs() < 1e-12);
} }
+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 = 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());
}
+34 -9
View File
@@ -49,7 +49,13 @@ fn ranked_rejects_a_zero_damping_factor() {
) )
.expect_err("alpha = 0 must be rejected"); .expect_err("alpha = 0 must be rejected");
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"got {err:?}" "got {err:?}"
); );
} }
@@ -65,7 +71,13 @@ fn ranked_rejects_an_out_of_range_damping_factor() {
) )
.expect_err("alpha out of (0, 1] must be rejected"); .expect_err("alpha out of (0, 1] must be rejected");
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"alpha={alpha}: got {err:?}" "alpha={alpha}: got {err:?}"
); );
} }
@@ -81,7 +93,13 @@ fn scored_rejects_a_bad_damping_factor() {
) )
.expect_err("alpha = 0 must be rejected"); .expect_err("alpha = 0 must be rejected");
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"got {err:?}" "got {err:?}"
); );
} }
@@ -139,7 +157,7 @@ fn ingestion_rejects_a_tie_without_a_draw_probability() {
); );
} }
/// `Outcome::scores_with_sigma` documents that a non-positive sigma is /// `Outcome::scores_with_noise` documents that a non-positive sigma is
/// accepted at construction and rejected at ingestion. /// accepted at construction and rejected at ingestion.
#[test] #[test]
fn ingestion_rejects_a_non_positive_per_event_score_sigma() { fn ingestion_rejects_a_non_positive_per_event_score_sigma() {
@@ -152,7 +170,7 @@ fn ingestion_rejects_a_non_positive_per_event_score_sigma() {
Team::with_members([Member::new("a")]), Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]), Team::with_members([Member::new("b")]),
], ],
outcome: Outcome::scores_with_sigma([21.0, 9.0], sigma), outcome: Outcome::scores_with_noise([21.0, 9.0], sigma),
}]) }])
.expect_err("a non-positive per-event sigma must be rejected"); .expect_err("a non-positive per-event sigma must be rejected");
assert!( assert!(
@@ -248,9 +266,13 @@ mod builder_parameters {
}; };
let zero = fit(0.0); let zero = fit(0.0);
let positive = fit(25.0 / 6.0); let positive = fit(25.0 / 6.0);
assert!(zero.pi().is_finite() && zero.pi() > 0.0); // `variance` rather than `pi`: the natural parameters are the crate's
// internal representation and no longer public. It is the same
// quantity inverted, so a finite positive precision is a finite
// positive variance.
assert!(zero.variance().is_finite() && zero.variance() > 0.0);
assert!( assert!(
(zero.pi() - positive.pi()).abs() > 1e-6, (zero.variance() - positive.variance()).abs() > 1e-6,
"zero beta must not merely be ignored: {zero:?} vs {positive:?}" "zero beta must not merely be ignored: {zero:?} vs {positive:?}"
); );
} }
@@ -280,7 +302,10 @@ mod constructor_parameters {
#[test] #[test]
fn a_nan_sigma_passes_through_from_ms() { fn a_nan_sigma_passes_through_from_ms() {
let g = Gaussian::from_ms(25.0, f64::NAN); let g = Gaussian::from_ms(25.0, f64::NAN);
assert!(g.sigma().is_nan() || g.pi().is_nan()); // `sigma()` is NaN exactly when the precision is: it guards `pi <= 0`
// (reporting `inf`) and `pi == inf` (reporting `0.0`), so NaN survives
// only from a NaN precision.
assert!(g.sigma().is_nan());
} }
#[test] #[test]
@@ -353,7 +378,7 @@ mod constructor_parameters {
matches!( matches!(
err, err,
InferenceError::InvalidParameter { InferenceError::InvalidParameter {
name: "drift variance", parameter: trueskill_tt::Parameter::DriftVariance,
.. ..
} }
), ),
+48 -8
View File
@@ -6,7 +6,7 @@ use trueskill_tt::{
UnknownKeys, UnknownKeys,
}; };
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>; type H = History;
fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> { fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> {
Event { Event {
@@ -30,7 +30,7 @@ fn base() -> Vec<Event<i64, &'static str>> {
} }
fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H { fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
let mut h: History<i64, _, _, &'static str> = History::builder() let mut h: History = History::builder()
.mu(0.0) .mu(0.0)
.sigma(6.0) .sigma(6.0)
.beta(1.0) .beta(1.0)
@@ -60,15 +60,30 @@ fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
fn the_closed_form_matches_an_actual_refit() { fn the_closed_form_matches_an_actual_refit() {
let h = fit(None, UnknownKeys::Reject); let h = fit(None, UnknownKeys::Reject);
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let before = h.posterior_of(&target).unwrap().sigma().powi(2); let before = h
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2);
for (x, y) in [("a", "b"), ("c", "d"), ("a", "c"), ("b", "d")] { for (x, y) in [("a", "b"), ("c", "d"), ("a", "c"), ("b", "d")] {
let predicted = h let predicted = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&x], &[&y]], &target) .expected_variance_reduction(&[&[&x], &[&y]], &target)
.unwrap(); .unwrap();
let after = fit(Some(round(x, y, 3.0, 1.0)), UnknownKeys::Reject); let after = fit(Some(round(x, y, 3.0, 1.0)), UnknownKeys::Reject);
let actual = before - after.posterior_of(&target).unwrap().sigma().powi(2); let actual = before
- after
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2);
assert!( assert!(
(predicted - actual).abs() / actual.abs() < 1e-9, (predicted - actual).abs() / actual.abs() < 1e-9,
@@ -84,12 +99,27 @@ fn the_closed_form_matches_an_actual_refit() {
fn the_outcome_does_not_change_the_reduction() { fn the_outcome_does_not_change_the_reduction() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let h = fit(None, UnknownKeys::Reject); let h = fit(None, UnknownKeys::Reject);
let before = h.posterior_of(&target).unwrap().sigma().powi(2); let before = h
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2);
let mut seen = Vec::new(); let mut seen = Vec::new();
for (sa, sb) in [(3.0, 1.0), (100.0, -50.0), (0.0, 0.0)] { for (sa, sb) in [(3.0, 1.0), (100.0, -50.0), (0.0, 0.0)] {
let after = fit(Some(round("c", "d", sa, sb)), UnknownKeys::Reject); let after = fit(Some(round("c", "d", sa, sb)), UnknownKeys::Reject);
seen.push(before - after.posterior_of(&target).unwrap().sigma().powi(2)); seen.push(
before
- after
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2),
);
} }
for w in seen.windows(2) { for w in seen.windows(2) {
assert!( assert!(
@@ -107,9 +137,13 @@ fn it_ranks_candidates_by_how_much_they_answer_the_question() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let direct = h let direct = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"], &[&"b"]], &target) .expected_variance_reduction(&[&[&"a"], &[&"b"]], &target)
.unwrap(); .unwrap();
let unrelated = h let unrelated = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&"c"], &[&"d"]], &target) .expected_variance_reduction(&[&[&"c"], &[&"d"]], &target)
.unwrap(); .unwrap();
@@ -128,6 +162,8 @@ fn an_unrelated_unseen_matchup_teaches_nothing_about_the_target() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let reduction = h let reduction = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target) .expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target)
.unwrap(); .unwrap();
assert!( assert!(
@@ -142,7 +178,9 @@ fn shape_errors_are_reported() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
assert!(matches!( assert!(matches!(
h.expected_variance_reduction(&[&[&"a"]], &target), h.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"]], &target),
Err(InferenceError::MismatchedShape { Err(InferenceError::MismatchedShape {
expected: 2, expected: 2,
got: 1, got: 1,
@@ -150,7 +188,9 @@ fn shape_errors_are_reported() {
}) })
)); ));
assert!(matches!( assert!(matches!(
h.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target), h.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
Err(InferenceError::UnknownKey { .. }) Err(InferenceError::UnknownKey { .. })
)); ));
} }