31 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
63 changed files with 3547 additions and 975 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.
## 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
### 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
### Other (unconventional)
### Miscellaneous Tasks
- Merge branch 'fix/ingestion-shape'
- Merge branch 'feat/convergence-strictness'
- Merge branch 'fix/non-finite-weights'
- Merge branch 'test/close-coverage-gaps'
- Merge branch 'fix/game-boundary'
- chore: Release trueskill-tt version 0.8.0
### 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
### Other (unconventional)
- Merge branch 'feat/joint-handle'
## 0.6.0 - 2026-09-08
### Breaking Changes
+4 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "trueskill-tt"
version = "0.8.0"
version = "0.9.0"
edition = "2024"
rust-version = "1.85"
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
@@ -33,10 +33,6 @@ bench = false
name = "batch"
harness = false
[[bench]]
name = "gaussian"
harness = false
[[bench]]
name = "history_converge"
harness = false
@@ -51,12 +47,15 @@ harness = false
[dependencies]
approx = { version = "0.5.1", optional = true }
feral-amd = "0.2"
libm = "0.2.16"
rayon = { version = "1", optional = true }
smallvec = "1"
[features]
approx = ["dep:approx"]
# Exposes the joint sparsity pattern for the #52 measurement. Test-only.
measure-sparsity = []
rayon = ["dep:rayon"]
[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
Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
Bayesian skill rating over a time axis.
## Other implementations
Where plain TrueSkill gives each competitor one running estimate, TrueSkill
Through Time treats a whole history as a single model and infers skill *at every
point in time*. Evidence flows both directions: a result today sharpens the
estimate of who someone was last year, so early estimates stop being frozen
guesses and comparisons across eras become meaningful.
- [ttt-scala](https://github.com/ankurdave/ttt-scala)
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis)
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R)
- [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf)
- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/)
A Rust port of
[TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
## Install
```toml
[dependencies]
trueskill-tt = "0.8"
```
Optional features, both off by default:
- `approx``approx`'s equality traits for `Gaussian`. Useful in tests.
- `rayon` — parallelises the within-slice sweep and the per-slice passes of
`learning_curves` / `log_evidence`. Results stay bit-identical regardless of
worker count; `just determinism` asserts it at 1, 2, 4 and 8 threads.
## Quickstart
Record results, converge, then read off skills.
```rust
use trueskill_tt::History;
let mut history = History::default();
history.record_winner(&"alice", &"bob", 1)?;
history.record_winner(&"bob", &"carol", 2)?;
history.record_winner(&"alice", &"carol", 3)?;
history.converge()?;
let alice = history.current_skill("alice").unwrap();
assert!(alice.mu() > 0.0, "alice won every game she played");
# Ok::<(), trueskill_tt::InferenceError>(())
```
The third argument is the time. It is what makes this Through Time rather than
plain TrueSkill: skill is inferred at each of those moments, not once at the
end. `learning_curve` reads the whole trajectory back.
```rust
# use trueskill_tt::History;
# let mut history = History::default();
# history.record_winner(&"alice", &"bob", 1)?;
# history.record_winner(&"bob", &"carol", 2)?;
# history.record_winner(&"alice", &"carol", 3)?;
# history.converge()?;
// `None` means the key is unknown; `Some(vec![])` means known but unplayed.
let curve = history.learning_curve("alice").unwrap();
for (time, skill) in &curve {
println!("t={time}: {:.2} ± {:.2}", skill.mu(), skill.sigma());
}
// Everyone's latest posterior in one pass — the leaderboard query.
let latest = history.current_skills();
assert_eq!(latest.len(), 3);
# Ok::<(), trueskill_tt::InferenceError>(())
```
## Teams, rankings and draws
Anything beyond one-versus-one goes through the fluent event builder. An event
is only recorded by the terminal `.commit()`.
```rust
use trueskill_tt::History;
let mut history = History::builder().p_draw(0.1).build();
history
.event(1)
.team(["alice", "bob"])
.team(["carol", "dave"])
.ranking([0, 1]) // lower is better; equal values are a tie
.commit()?;
history.converge()?;
# Ok::<(), trueskill_tt::InferenceError>(())
```
**A tie needs a positive `p_draw`.** A `p_draw` of zero asserts draws cannot
happen, so a tied result has no representable likelihood and is rejected rather
than fitted to something else:
```rust
use trueskill_tt::{History, InferenceError};
let mut history = History::default(); // p_draw defaults to 0.0
let err = history.record_draw(&"alice", &"bob", 1).unwrap_err();
assert!(matches!(err, InferenceError::TieWithoutDrawProbability { .. }));
```
This also catches `Outcome::winner(w, n)` for three or more teams, which ties
every loser.
## Which entry point?
| You want to | Use |
|---|---|
| One match, two competitors | `record_winner` / `record_draw` |
| Teams, explicit ranks, scores, per-member weights | `history.event(t)…commit()` |
| A batch you already have as values | `add_events(iter)` |
| Score a hypothetical with no history at all | `Game` |
`Game` is the odd one out and worth being explicit about: it is a single match's
factor graph, it does not participate in a `History`, and nothing it computes is
remembered. Reach for it to evaluate a matchup in isolation; reach for `History`
for everything that accumulates.
## `converge` is strict
`converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the
step still above `epsilon`, and `Err(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
@@ -203,7 +330,7 @@ stay available at any size:
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
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.
If predicting for competitors you have never seen is the point rather than a
@@ -228,11 +355,11 @@ certain because it knows less.
```rust
use trueskill_tt::History;
let mut h = History::builder().build();
let mut h = History::default();
h.record_winner(&"alice", &"bob", 1).unwrap();
let _ = h.converge().unwrap();
h.converge().unwrap();
let skill = h.current_skill(&"alice").unwrap();
let skill = h.current_skill("alice").unwrap();
// "How sure am I that this is below the cutoff?" — a probability, not a
// `mu + z * sigma` band whose confidence drifts as sigma changes.
@@ -254,7 +381,7 @@ what you believe now and what you would believe afterwards.
```rust
use trueskill_tt::History;
let mut h = History::builder().build();
let mut h = History::default();
for t in 1..=10 {
h.record_winner(&"veteran", &"regular", t).unwrap();
h.record_winner(&"regular", &"veteran", t + 100).unwrap();
@@ -278,16 +405,22 @@ expensive than `quality()`. Scoring every pairing among `n` competitors is
`O(n² × outcomes)` passes — shortlist with `quality()` or
`predict_win_probabilities` first, then score only the shortlist.
## Todo
## Other implementations
- [x] Implement approx for Gaussian
- [x] Add more tests from `TrueSkillThroughTime.jl`
- [x] Generalise a time axis — `Time` is now a trait (`Untimed`, `i64`), not an enum
- [x] Add examples (`examples/atp.rs`, `examples/scored.rs`)
- [x] Add Observer (`Observer` / `NullObserver`)
- [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`)
- [x] N-team `predict_outcome` with draw mass, and `expected_information_gain`
- [x] Cross-check `quality()` against [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) — N identical teams follow the closed form `(1/5)^((n-1)/2)` for the conventional parameters, asserted for n = 2..10, and the n=3/n=5 values (0.200, 0.040) match the reference package
- [ttt-scala](https://github.com/ankurdave/ttt-scala)
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis)
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R)
- [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf)
- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/)
## Status
Every box on the old todo list is ticked, so it has been retired; open work
lives in the issue tracker instead. The crate is in use and the API is still
moving — breaking changes are batched into minor releases rather than dribbled
out. `CHANGELOG.md` lists them and [`MIGRATING.md`](MIGRATING.md) explains what
to do about them.
## License
-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 smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team,
};
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
fn build_history_1v1(
n_events: usize,
n_competitors: usize,
events_per_slice: usize,
seed: u64,
) -> History<i64, ConstantDrift, NullObserver, String> {
) -> History<String> {
let mut rng = seed;
let mut next = || {
rng = rng
+2 -4
View File
@@ -32,8 +32,7 @@ fn bench_ingest(c: &mut Criterion) {
b.iter_batched(
|| events(n, 0),
|evs| {
let mut h: History<i64, _, _, String> =
History::builder().key_type::<String>().build();
let mut h: History<String> = History::builder().key_type::<String>().build();
for ev in evs {
h.add_events(std::iter::once(ev)).unwrap();
}
@@ -47,8 +46,7 @@ fn bench_ingest(c: &mut Criterion) {
b.iter_batched(
|| events(n, 0),
|evs| {
let mut h: History<i64, _, _, String> =
History::builder().key_type::<String>().build();
let mut h: History<String> = History::builder().key_type::<String>().build();
h.add_events(evs).unwrap();
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};
/// 30 slices of 8 duels: 480 appearances over 100 competitors.
fn fitted() -> History<i64, ConstantDrift, trueskill_tt::NullObserver, String> {
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
fn fitted() -> 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)
.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 {
max_iter: 30,
max_iter: trueskill_tt::ITERATIONS,
epsilon: 1e-10,
alpha: 1.0,
})
@@ -58,8 +64,11 @@ fn bench_joint(c: &mut Criterion) {
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| {
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();
+1 -1
View File
@@ -5,7 +5,7 @@ use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
fn bench_scored_history(c: &mut Criterion) {
c.bench_function("scored_history_60_events_30_iter", |bencher| {
bencher.iter(|| {
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
let mut h: History<String> = History::builder()
.key_type::<String>()
.mu(25.0)
.sigma(25.0 / 3.0)
+5
View File
@@ -58,6 +58,11 @@ commit_parsers = [
{ message = "^test", group = "Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ 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 = ".*", group = "Other (unconventional)" },
]
+4 -3
View File
@@ -1,7 +1,8 @@
use plotters::prelude::*;
use smallvec::smallvec;
use time::{Date, Month};
use trueskill_tt::{Event, History, Member, Outcome, Team, drift::ConstantDrift};
use trueskill_tt::{
Event, History, Member, Outcome, Team, drift::ConstantDrift, smallvec::smallvec,
};
fn main() {
let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
@@ -42,7 +43,7 @@ fn main() {
}
}
let mut hist: History<i64, _, _, String> = History::builder()
let mut hist: History<String> = History::builder()
.key_type::<String>()
.sigma(1.6)
.drift(ConstantDrift::new(0.036))
+1 -2
View File
@@ -6,8 +6,7 @@
//!
//! Run with: `cargo run --example scored --release`
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team, smallvec::smallvec};
fn main() {
let mut h = History::builder()
+8 -4
View File
@@ -123,7 +123,7 @@ fn u_minus_ln1p(u: f64) -> f64 {
/// - `EmptyTeam` if any team has no members.
/// - `TooManyTeams` if the outcome space is too large to enumerate; see
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`.
/// - `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`
@@ -144,7 +144,8 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
});
}
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,
});
}
@@ -159,7 +160,7 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
.iter()
.map(|team| {
team.iter()
.fold(crate::N00, |acc, rating| acc + rating.performance())
.fold(crate::N00, |acc, rating| acc.convolve(rating.performance()))
})
.collect();
@@ -367,7 +368,10 @@ mod tests {
));
assert!(matches!(
expected_information_gain(&[&a, &a], &options(1.5)),
Err(InferenceError::InvalidProbability { .. })
Err(InferenceError::InvalidParameter {
parameter: crate::Parameter::PDraw,
..
})
));
}
+64 -2
View File
@@ -4,9 +4,38 @@ use std::time::Duration;
use smallvec::SmallVec;
/// The stopping rule for the fixed-point loops, plus how hard they are damped.
///
/// Set once per history through
/// [`HistoryBuilder::convergence`](crate::HistoryBuilder::convergence), and
/// carried by `GameOptions` for a single match scored without a history. The
/// defaults are the crate's globals: [`ITERATIONS`](crate::ITERATIONS),
/// [`EPSILON`](crate::EPSILON), and undamped EP.
///
/// Deliberately **not** `#[non_exhaustive]`, unlike [`ConvergenceReport`]. The
/// usual argument for marking an options struct is that `..Default::default()`
/// makes a future field additive — but `Default::default` is not a `const fn`,
/// so marking it would make
/// `const OPTS: ConvergenceOptions = ConvergenceOptions { .. }` impossible from
/// outside the crate, with no workaround. This type is `Copy` and a natural
/// const; that cost is permanent, and adding a field is a one-time major bump.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ConvergenceOptions {
/// Hard cap on full forward+backward sweeps.
///
/// A runaway guard, not a budget: the loop exits as soon as the step falls
/// to `epsilon`, so raising this costs nothing on a history that converges.
/// Reaching it is
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged).
pub max_iter: usize,
/// Convergence threshold, in skill units.
///
/// The sweep stops once *both* components of the step — the largest change
/// a whole iteration made to any competitor's posterior mean, and to any
/// posterior standard deviation — are at or below this. Larger values stop
/// sooner and further from the fixed point. Must be non-negative; NaN is
/// rejected, since every comparison against it is false and the loop would
/// read it as converged.
pub epsilon: f64,
/// EP damping factor in natural-parameter space: each per-factor
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
@@ -37,13 +66,13 @@ impl ConvergenceOptions {
pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> {
if !(self.alpha > 0.0 && self.alpha <= 1.0) {
return Err(crate::InferenceError::InvalidParameter {
name: "alpha",
parameter: crate::Parameter::Alpha,
value: self.alpha,
});
}
if self.epsilon.is_nan() || self.epsilon < 0.0 {
return Err(crate::InferenceError::InvalidParameter {
name: "epsilon",
parameter: crate::Parameter::Epsilon,
value: self.epsilon,
});
}
@@ -68,12 +97,45 @@ impl Default for ConvergenceOptions {
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged) there.
/// From [`History::converge_partial`](crate::History::converge_partial) it may
/// not be, and `converged` is what says so.
/// Constructed only by `converge` / `converge_partial`, never by a caller, so
/// `#[non_exhaustive]` costs nothing here and lets a future field be additive.
/// The two *options* structs deliberately do not carry it — see the note on
/// [`ConvergenceOptions`].
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct ConvergenceReport {
/// Full forward+backward sweeps actually run. `0` for a history with no
/// time slices, which is converged trivially.
pub iterations: usize,
/// How far the last sweep still moved the fit, as `(mean, standard
/// deviation)`.
///
/// Not natural parameters: each component is a componentwise maximum of
/// `|Δmu|` and `|Δsigma|` over every competitor posterior the sweep
/// touched, so both are in skill units and both are non-negative. Each is
/// compared against `epsilon` separately — `converged` means neither
/// exceeds it. `(0.0, 0.0)` for a history with no time slices.
pub final_step: (f64, f64),
/// Natural log of the model evidence for the whole history at this fit,
/// summed over every time slice.
///
/// The same quantity
/// [`History::log_evidence`](crate::History::log_evidence) returns, taken
/// once the sweep has stopped. Only comparable between fits of the same
/// events; higher means the model explains them better.
pub log_evidence: f64,
/// Whether the sweep reached `epsilon` rather than stopping at `max_iter`.
///
/// Always `true` from [`History::converge`](crate::History::converge),
/// which reports the other case as `NotConverged`. From
/// [`History::converge_partial`](crate::History::converge_partial) this is
/// the only thing that distinguishes a finished fit from a capped one.
pub converged: bool,
/// Wall-clock time each sweep took, in the order they ran.
///
/// One entry per iteration, so its length equals `iterations`; empty for a
/// history with no time slices. It times the sweeps only, so the final
/// log-evidence pass is not in any entry.
pub per_iteration_time: SmallVec<[Duration; 32]>,
}
+1 -1
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
/// construction-time mistake would panic mid-inference — and `Gaussian::from_ms`
/// is a worked example of why that is the wrong place for a guard, where
/// rejecting NaN turned the `NonFiniteResult` reporting path into a crash.
/// rejecting NaN turned the `NonFiniteStep` reporting path into a crash.
///
/// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value
/// back with [`ConstantDrift::gamma`].
+427 -39
View File
@@ -39,36 +39,236 @@ pub enum UnknownKeys {
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)]
#[non_exhaustive]
pub enum InferenceError {
/// Expected and actual lengths of some array-shaped input differ.
#[non_exhaustive]
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,
/// The length actually supplied.
got: usize,
},
/// An `Outcome` of the wrong variant was supplied for the requested inference.
#[non_exhaustive]
WrongOutcomeKind {
context: &'static str,
expected: &'static str,
got: &'static str,
/// The variant the call needs.
expected: OutcomeKind,
/// The variant actually supplied.
got: OutcomeKind,
},
/// A probability value is outside `[0, 1]`.
#[non_exhaustive]
InvalidProbability { value: f64 },
/// A scalar parameter is outside its valid range.
#[non_exhaustive]
InvalidParameter { name: &'static str, value: f64 },
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.
///
/// A zero draw probability asserts that draws cannot occur, so a tied
/// result has no representable likelihood. Configure a positive `p_draw`
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
#[non_exhaustive]
TieWithoutDrawProbability { teams: (usize, usize) },
TieWithoutDrawProbability {
/// Positions in the event's team list of the first tied pair, lowest
/// index first. Only one pair is reported — the event is rejected
/// whole, so enumerating the rest would add nothing.
teams: (usize, usize),
},
/// The convergence sweep hit `max_iter` with the step still above
/// `epsilon`.
///
@@ -84,19 +284,55 @@ pub enum InferenceError {
/// returns the short fit instead when that is genuinely what is wanted.
#[non_exhaustive]
NotConverged {
/// Full forward+backward sweeps run before the loop gave up.
iterations: usize,
/// How far the last sweep still moved the fit, as
/// `(largest change in a mean, largest change in a standard
/// deviation)` over every competitor posterior it touched — the same
/// quantity as
/// [`ConvergenceReport::final_step`](crate::ConvergenceReport).
final_step: (f64, f64),
/// The threshold both components of `final_step` had to reach.
epsilon: f64,
},
/// Inference produced a non-finite value (NaN or infinity).
/// A convergence sweep produced a non-finite step.
///
/// Indicates numerical breakdown; the resulting skills are meaningless
/// and must not be treated as a converged estimate.
/// EP has broken down; the resulting skills are meaningless and must not
/// be treated as a converged estimate. Further iterations cannot recover,
/// so the loop stops rather than reporting a NaN step as convergence.
#[non_exhaustive]
NonFiniteResult {
NonFiniteStep {
/// Where the breakdown was caught, e.g. `"History::converge"`.
context: &'static str,
/// The offending step as `(|d mu|, |d sigma|)`, at least one component
/// of which is NaN or infinite.
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
/// configuration.
///
@@ -108,8 +344,12 @@ pub enum InferenceError {
/// when a competitor's configuration is a property of the domain.
#[non_exhaustive]
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,
field: &'static str,
/// Which piece of configuration was declared twice.
field: CompetitorField,
},
/// A prediction referenced a key the history has no skill for.
///
@@ -123,8 +363,16 @@ pub enum InferenceError {
/// neutral value — turns the whole thing into a plausible constant.
#[non_exhaustive]
UnknownKey {
/// Position of the offending team in the supplied matchup. `0` on the
/// queries that take a flat list of keys rather than teams, where
/// there is only one list to index into.
team: usize,
/// Position of the offending key within that team, or within the flat
/// key list.
member: usize,
/// The key's `Debug` rendering, captured because `K` is only required
/// to be `Debug` — see the variant docs for why the indices alone are
/// not enough.
key: String,
},
/// `History::register` was called for a competitor that already exists.
@@ -138,10 +386,16 @@ pub enum InferenceError {
/// To change an existing competitor's configuration, supply it on an event
/// through `Member`; that refits the whole history.
#[non_exhaustive]
AlreadyRegistered { key: String },
AlreadyRegistered {
/// The already-known competitor's key, in its `Debug` rendering.
key: String,
},
/// A prediction was given a team with no members.
#[non_exhaustive]
EmptyTeam { team: usize },
EmptyTeam {
/// Position of the memberless team in the supplied list.
team: usize,
},
/// The prediction grid cannot resolve the narrowest feature in the matchup.
///
/// `predict_outcome` and `predict_ranking` integrate every team's density
@@ -165,12 +419,39 @@ pub enum InferenceError {
/// Nodes the grid may hold.
max: usize,
},
/// A joint posterior was requested where one cannot be formed exactly.
#[non_exhaustive]
JointUnavailable { reason: &'static str },
/// A joint posterior was requested from a history with no events.
///
/// 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.
#[non_exhaustive]
NotEnoughTeams { got: usize },
NotEnoughTeams {
/// How many teams the prediction was actually given. Two is the
/// minimum: there is nothing to compare against with fewer.
got: usize,
},
/// The full outcome distribution was requested for too many teams.
///
/// Each realisation sorts into exactly one (order, tie-pattern) event, so
@@ -180,28 +461,32 @@ pub enum InferenceError {
/// `predict_ranking`, or for `predict_win_probabilities`, both of which
/// stay cheap at any team count.
#[non_exhaustive]
TooManyTeams { got: usize, max: usize },
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 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MismatchedShape {
kind,
shape,
expected,
got,
} => {
write!(f, "{kind}: expected length {expected}, got {got}")
write!(f, "{shape}: expected {expected}, got {got}")
}
Self::WrongOutcomeKind {
context,
expected,
got,
} => {
write!(f, "{context}: expected {expected}, got {got}")
}
Self::InvalidProbability { value } => {
write!(f, "probability must be in [0, 1]; got {value}")
Self::WrongOutcomeKind { expected, got } => {
write!(
f,
"expected {expected}, got {got}; call {} for a {} outcome",
got.constructor(),
got.adjective()
)
}
Self::TieWithoutDrawProbability { teams } => {
write!(
@@ -222,14 +507,23 @@ impl fmt::Display for InferenceError {
alpha < 1.0 if it is oscillating"
)
}
Self::NonFiniteResult { context, step } => {
Self::NonFiniteStep { context, step } => {
write!(
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 } => {
write!(f, "{name} is invalid: {value}")
Self::NonFiniteSkill { mu, sigma } => {
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 } => {
write!(
@@ -242,7 +536,7 @@ impl fmt::Display for InferenceError {
f,
"team {team}, member {member}: no skill recorded for key {key} \
(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 } => {
@@ -265,9 +559,25 @@ impl fmt::Display for InferenceError {
one grid. Use predict_win_probabilities, which is accurate here"
)
}
Self::JointUnavailable { reason } => {
write!(f, "no exact joint posterior is available: {reason}")
Self::EmptyHistory => {
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 } => {
write!(f, "prediction needs at least 2 teams, got {got}")
}
@@ -283,3 +593,81 @@ impl fmt::Display 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")
);
}
}
+58
View File
@@ -13,8 +13,25 @@ use crate::{gaussian::Gaussian, outcome::Outcome, time::Time};
/// A single match at time `time` involving some number of teams.
#[derive(Clone, Debug, PartialEq)]
pub struct Event<T: Time, K> {
/// When the match happened, on the history's time axis.
///
/// Events sharing a `time` land in the same time slice and are fitted
/// together, so nothing distinguishes their order. Drift is driven by the
/// gap between a competitor's *consecutive appearances*, not by the gap
/// between slices, so a competitor idle across several slices accumulates
/// the whole span at once when it next plays.
pub time: T,
/// The teams that took part, positionally aligned with `outcome`: team `i`
/// here is the team `outcome` ranks or scores at index `i`.
///
/// Ingestion rejects fewer than two teams (`NotEnoughTeams`) and any team
/// with no members (`EmptyTeam`).
pub teams: SmallVec<[Team<K>; 4]>,
/// How the match ended: ranks (lower is better) or per-team scores (higher
/// is better), one entry per entry of `teams`.
///
/// A tie — two equal ranks — needs a positive `p_draw`, otherwise
/// ingestion fails with `TieWithoutDrawProbability`.
pub outcome: Outcome,
}
@@ -22,16 +39,31 @@ pub struct Event<T: Time, K> {
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct Team<K> {
/// The competitors playing together, in no significant order: the team's
/// performance is the weight-scaled sum over its members, which does not
/// depend on how they are listed.
///
/// Must be non-empty — an empty team contributes no performance at all, so
/// ingestion rejects it with `EmptyTeam` rather than returning a plausible
/// posterior for whoever it was matched against.
pub members: SmallVec<[Member<K>; 4]>,
}
impl<K> Team<K> {
/// A team with no members yet, to be filled through the public `members`
/// field.
///
/// Committing it while still empty is an `EmptyTeam` error.
pub fn new() -> Self {
Self {
members: SmallVec::new(),
}
}
/// A team of exactly these competitors.
///
/// Members must be built already — `Member::from(key)` covers the common
/// case of a plain key at default weight with no overrides.
pub fn with_members<I: IntoIterator<Item = Member<K>>>(members: I) -> Self {
Self {
members: members.into_iter().collect(),
@@ -64,8 +96,26 @@ impl<K> Default for Team<K> {
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct Member<K> {
/// The competitor's identity. Equal keys across events are the same
/// competitor: `History` interns each distinct key to an internal `Index`
/// the first time it sees it, and every later appearance resolves to that
/// same competitor's temporal state.
pub key: K,
/// This member's share of the team's performance, for this event only.
///
/// The team's performance is the sum of `weight × member performance`, so
/// `1.0` is a full share and `0.5` counts the member half; the message
/// coming back to the member is divided by the same weight. Defaults to
/// `1.0`.
///
/// Must be finite — a NaN or infinite weight is `InvalidParameter` at
/// ingestion. Zero and negative are accepted, both being expressible in
/// the same arithmetic.
pub weight: f64,
/// Starting skill for this competitor, replacing the history's `mu`/`sigma`
/// default. `None` keeps the history default.
///
/// Competitor configuration, not a per-event value; see the type docs.
pub prior: Option<Gaussian>,
/// Multiplier on the drift *variance* this competitor accumulates.
/// `None` means 1.0.
@@ -73,6 +123,8 @@ pub struct Member<K> {
}
impl<K> Member<K> {
/// A competitor taking a full share of its team's performance, with no
/// configuration overrides: the history's prior and drift apply.
pub fn new(key: K) -> Self {
Self {
key,
@@ -82,6 +134,12 @@ impl<K> Member<K> {
}
}
/// Change how much of the team's performance this member accounts for.
///
/// Unlike `prior` and `drift_scale`, this is genuinely per-event: the same
/// key can carry a different weight in every event it appears in, which is
/// what makes it usable for partial participation — a substitute who
/// played half the match, a doubles partner credited unequally.
pub fn with_weight(mut self, weight: f64) -> Self {
self.weight = weight;
self
+49 -12
View File
@@ -9,16 +9,44 @@ use crate::{
time::Time,
};
/// One match under construction, handed back by [`History::event`].
///
/// Describes a single event a piece at a time — teams, then per-member weights
/// if they differ, then how it ended — instead of assembling an
/// [`Event`] value and passing it to [`History::add_events`]. The two routes
/// ingest through the same chokepoint and accept the same things; this one just
/// reads better for a single match written by hand.
///
/// The builder borrows the history mutably and nothing reaches it until
/// [`EventBuilder::commit`]. A builder that is dropped instead ingests
/// nothing at all, silently — hence the `#[must_use]`, which is the only
/// warning you get. `commit` is also where validation surfaces: the setters
/// return `Self` to keep the chain fluent, so a mismatch such as a weight list
/// the wrong length is recorded while building and returned as an error from
/// `commit`.
///
/// ```
/// # use trueskill_tt::History;
/// let mut h = History::builder().build();
/// h.event(1)
/// .team(["alice", "bob"])
/// .team(["carol"])
/// .ranking([0, 1])
/// .commit()?;
/// assert_eq!(h.event_count(), 1);
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
#[must_use = "an event is only recorded by `.commit()`; a dropped builder \
silently ingests nothing"]
pub struct EventBuilder<'h, T, D, O, K>
pub struct EventBuilder<'h, T, D, O, K, R>
where
T: Time,
D: Drift<T>,
O: Observer<T>,
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>,
current_team_idx: Option<usize>,
/// First validation failure seen while building, surfaced by `commit`.
@@ -31,14 +59,15 @@ where
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
T: Time,
D: Drift<T>,
O: Observer<T>,
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 {
history,
event: Event {
@@ -115,7 +144,7 @@ where
if ws.len() != team.members.len() {
self.error.get_or_insert(InferenceError::MismatchedShape {
kind: "weights",
shape: crate::Shape::Weights,
expected: team.members.len(),
got: ws.len(),
});
@@ -144,13 +173,21 @@ where
/// Set explicit per-team continuous scores with a per-event noise override.
///
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only.
/// Must be `> 0.0`. Constructing the outcome with a non-positive or NaN
/// sigma is allowed; the value 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_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self {
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
/// `score_sigma` is the observation noise on the *score margin*, not a
/// skill sigma, and it overrides `HistoryBuilder::score_sigma` for this
/// event only. A small value takes the margin near-literally; a large one
/// barely moves the ratings.
///
/// Must be `> 0.0`. Building the outcome with a non-positive or NaN value
/// 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
}
+2 -2
View File
@@ -39,7 +39,7 @@ impl MarginFactor {
/// 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) {
let marginal = vars.get(self.diff);
let cavity = marginal / self.msg;
let cavity = marginal.cavity(self.msg);
if self.log_evidence_cached.is_none() {
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 old_msg = self.msg;
self.msg = damped;
vars.set(self.diff, cavity * damped);
vars.set(self.diff, cavity.ep_product(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`.
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
let marginal = vars.get(self.diff);
let cavity = marginal / self.msg;
let cavity = marginal.cavity(self.msg);
if self.log_evidence_cached.is_none() {
self.log_evidence_cached = Some(cavity_log_evidence(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 old_msg = self.msg;
@@ -57,7 +57,7 @@ impl TruncFactor {
// marginal_new = cavity * stored_msg. With alpha = 1.0 this equals
// `trunc` (since cavity * new_msg = trunc by construction); with
// 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)
}
+206 -91
View File
@@ -70,8 +70,25 @@ impl DiffFactor {
/// how much the engine trusts the observed score margin (smaller σ = more trust).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GameOptions {
/// Probability the model assigns to two teams drawing, which sets the width
/// of the truncation band around a tie. Must be in `[0.0, 1.0)`; defaults
/// to [`P_DRAW`](crate::P_DRAW).
///
/// At `0.0` the band has zero width, so a ranked outcome that ties two
/// teams has no representable likelihood and [`Game::ranked`] rejects it
/// with `TieWithoutDrawProbability`.
pub p_draw: f64,
/// Standard deviation of the observation noise on an observed score margin,
/// used only by [`Game::scored`], which rejects a non-positive or NaN value
/// with `InvalidParameter`. Defaults to `1.0`.
///
/// It is in the units of the scores themselves, and says how much of a
/// margin the model reads as skill rather than noise: a small sigma takes
/// the margin near-literally, a large one barely moves the ratings.
pub score_sigma: f64,
/// Stopping rule and damping for the within-game message-passing loop:
/// iterate until the largest message change falls below `epsilon`, or
/// `max_iter` passes, with each update damped by `alpha`.
pub convergence: crate::ConvergenceOptions,
}
@@ -85,21 +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
/// History's internal state), `OwnedGame<T, D>` owns the team ratings, so it
/// can be returned freely from public constructors. The inference inputs
/// themselves are not retained — nothing reads them back.
/// Rate a single match against ratings you already hold and read the updated
/// beliefs straight back. There is no history behind it: nothing is stored,
/// nothing propagates backward, and the priors you hand in are the only
/// evidence used. That makes it the wrong tool for the thing this crate exists
/// for — [`History`](crate::History) is what infers skill *through time*,
/// revising past estimates as later matches arrive, and a sequence of `Game`s
/// chained by hand is a forward-only filter, not the same answer.
///
/// Reach for 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)]
#[must_use]
pub struct OwnedGame<T: Time, D: Drift<T>> {
pub struct Game<T: Time, D: Drift<T>> {
teams: Vec<Vec<Rating<T, D>>>,
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
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(
teams: Vec<Vec<Rating<T, D>>>,
result: Vec<f64>,
@@ -111,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
// 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 {
teams: g.teams,
@@ -129,7 +177,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
) -> Self {
let mut arena = ScratchArena::new();
let g = Game::scored_with_arena(
let g = GameRef::scored_with_arena(
teams,
&scores,
&weights,
@@ -145,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]
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods
.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()
}
/// Natural log of how probable this outcome was under the priors, summed
/// over the diff chain's links.
///
/// Higher means the result was less surprising, so it doubles as a
/// closeness measure — two identically-rated competitors give exactly
/// `ln(0.5)`, either of them being equally likely to win:
///
/// ```
/// # use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
/// let r = Rating::new(Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift::new(0.0));
/// let g = Game::<i64, _>::ranked(&[&[r], &[r]], Outcome::winner(0, 2), &GameOptions::default())?;
/// assert!((g.log_evidence() - 0.5_f64.ln()).abs() < 1e-12);
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
///
/// Accumulated in log space because the linear product over a long chain
/// underflows to zero, and `ln(0.0)` is `-inf`.
#[must_use]
pub fn log_evidence(&self) -> f64 {
self.log_evidence
}
}
/// 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)]
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>>>,
result: &'a [f64],
weights: &'a [Vec<f64>],
@@ -171,7 +255,7 @@ pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
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(
teams: Vec<Vec<Rating<T, D>>>,
result: &'a [f64],
@@ -285,7 +369,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
.iter()
.zip(self.weights[t].iter())
.fold(N00, |p, (competitor, &w)| {
p + (competitor.performance() * w)
p.convolve(competitor.performance().scale(w))
})
}));
@@ -305,28 +389,28 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
step = (0.0_f64, 0.0_f64);
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg());
let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
let raw = pw.convolve_diff(pl);
arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
let d = lf.propagate(&mut arena.vars, alpha);
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));
arena.lhood_lose[e + 1] = new_ll;
}
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
let e = n_diffs - 1 - rev_i;
let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg());
let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
let raw = pw.convolve_diff(pl);
arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
let d = lf.propagate(&mut arena.vars, alpha);
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));
arena.lhood_win[e] = new_lw;
}
@@ -336,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.
if n_diffs == 1 {
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
- (arena.team_prior[1] * arena.lhood_win[1]);
arena.vars.set(links[0].diff(), raw * links[0].msg());
let raw = arena.team_prior[0]
.ep_product(arena.lhood_lose[0])
.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);
}
// Boundary updates: close the chain at both ends.
if n_diffs > 0 {
let pl1 = arena.team_prior[1] * arena.lhood_win[1];
arena.lhood_win[0] = pl1 + links[0].msg();
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2];
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
let pl1 = arena.team_prior[1].ep_product(arena.lhood_win[1]);
arena.lhood_win[0] = pl1.convolve(links[0].msg());
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.convolve_diff(links[n_diffs - 1].msg());
}
let log_evidence: f64 = links.iter().map(DiffFactor::log_evidence).sum();
@@ -365,7 +452,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
.enumerate()
.map(|(orig_i, (competitors, weights))| {
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,
// indexed by sorted position.
let performance = arena.team_prior[si];
@@ -373,7 +460,8 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
.iter()
.zip(weights.iter())
.map(|(competitor, &w)| {
((m - performance.exclude(competitor.performance() * w)) * (1.0 / w))
m.convolve_diff(performance.exclude(competitor.performance().scale(w)))
.scale(1.0 / w)
.forget(competitor.beta.powi(2))
})
.collect::<Vec<_>>()
@@ -413,27 +501,26 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
self.likelihoods = likelihoods;
}
#[must_use]
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
/// As [`Game::posteriors`].
///
/// 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
.iter()
.zip(self.teams.iter())
.map(|(l, t)| {
l.iter()
.zip(t.iter())
.map(|(&l, p)| l * p.prior)
.map(|(&l, p)| l.ep_product(p.prior))
.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.
///
/// `run_chain` builds one diff link per adjacent pair of teams, so fewer
@@ -457,12 +544,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
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
///
/// - `InvalidParameter` if `options.convergence` is out of range — an
/// `alpha` of zero would leave every EP update unapplied and silently
/// 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()`.
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
@@ -474,17 +567,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
teams: &[&[Rating<T, D>]],
outcome: crate::Outcome,
options: &GameOptions,
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
) -> Result<Self, crate::InferenceError> {
options.convergence.validate()?;
Self::validate_teams(teams)?;
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,
});
}
if outcome.team_count() != teams.len() {
return Err(crate::InferenceError::MismatchedShape {
kind: "outcome ranks vs teams",
shape: crate::Shape::OutcomeVsTeams,
expected: teams.len(),
got: outcome.team_count(),
});
@@ -493,9 +587,8 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
let ranks = outcome
.as_ranks()
.ok_or(crate::InferenceError::WrongOutcomeKind {
context: "Game::ranked",
expected: "Outcome::Ranked",
got: "Outcome::Scored",
expected: crate::OutcomeKind::Ranked,
got: crate::OutcomeKind::Scored,
})?;
let tied = if options.p_draw == 0.0 {
@@ -513,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 weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
Ok(OwnedGame::new(
Ok(Self::new(
teams_owned,
result,
weights,
@@ -522,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
///
/// - `InvalidParameter` if `options.score_sigma` is not strictly positive
@@ -534,18 +633,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
teams: &[&[Rating<T, D>]],
outcome: crate::Outcome,
options: &GameOptions,
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
) -> Result<Self, crate::InferenceError> {
options.convergence.validate()?;
Self::validate_teams(teams)?;
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
return Err(crate::InferenceError::InvalidParameter {
name: "score_sigma",
parameter: crate::Parameter::ScoreSigma,
value: options.score_sigma,
});
}
if outcome.team_count() != teams.len() {
return Err(crate::InferenceError::MismatchedShape {
kind: "outcome scores vs teams",
shape: crate::Shape::OutcomeVsTeams,
expected: teams.len(),
got: outcome.team_count(),
});
@@ -553,9 +652,8 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
let scores = outcome
.as_scores()
.ok_or(crate::InferenceError::WrongOutcomeKind {
context: "Game::scored",
expected: "Outcome::Scored",
got: "Outcome::Ranked",
expected: crate::OutcomeKind::Scored,
got: crate::OutcomeKind::Ranked,
})?
.to_vec();
// A non-finite score poisons the chain rather than failing it. Ranks
@@ -563,14 +661,14 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
for value in &scores {
if !value.is_finite() {
return Err(crate::InferenceError::InvalidParameter {
name: "score",
parameter: crate::Parameter::Score,
value: *value,
});
}
}
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();
Ok(OwnedGame::new_scored(
Ok(Self::new_scored(
teams_owned,
scores,
weights,
@@ -579,7 +677,24 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
))
}
/// Convenience wrapper over [`Game::ranked`] for two single-competitor 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
///
@@ -591,12 +706,12 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
b: &Rating<T, D>,
outcome: crate::Outcome,
options: &GameOptions,
) -> Result<(Gaussian, Gaussian), crate::InferenceError> {
let game = Self::ranked(&[&[*a], &[*b]], outcome, options)?;
let post = game.posteriors();
Ok((post[0][0], post[1][0]))
) -> Result<Self, crate::InferenceError> {
Self::ranked(&[&[*a], &[*b]], outcome, options)
}
/// A free-for-all: every competitor is their own one-member team.
///
/// # Errors
///
/// Wraps each competitor in a one-member team and delegates to
@@ -605,7 +720,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
competitors: &[&Rating<T, D>],
outcome: crate::Outcome,
options: &GameOptions,
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
) -> Result<Self, crate::InferenceError> {
let teams: Vec<Vec<Rating<T, D>>> = competitors.iter().map(|p| vec![**p]).collect();
let team_refs: Vec<&[Rating<T, D>]> = teams.iter().map(|t| t.as_slice()).collect();
Self::ranked(&team_refs, outcome, options)
@@ -635,7 +750,7 @@ mod tests {
);
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]],
&[0.0, 1.0],
&w,
@@ -663,7 +778,7 @@ mod tests {
);
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]],
&[0.0, 1.0],
&w,
@@ -691,7 +806,7 @@ mod tests {
);
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]],
&[0.0, 1.0],
&w,
@@ -725,7 +840,7 @@ mod tests {
];
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(),
&[1.0, 2.0, 0.0],
&w,
@@ -742,7 +857,7 @@ mod tests {
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 g = Game::ranked_with_arena(
let g = GameRef::ranked_with_arena(
teams.clone(),
&[2.0, 1.0, 0.0],
&w,
@@ -759,7 +874,7 @@ mod tests {
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 g = Game::ranked_with_arena(
let g = GameRef::ranked_with_arena(
teams,
&[1.0, 2.0, 0.0],
&w,
@@ -799,7 +914,7 @@ mod tests {
);
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]],
&[0.0, 0.0],
&w,
@@ -831,7 +946,7 @@ mod tests {
);
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]],
&[0.0, 0.0],
&w,
@@ -867,7 +982,7 @@ mod tests {
);
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]],
&[0.0, 0.0, 0.0],
&w,
@@ -904,7 +1019,7 @@ mod tests {
);
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]],
&[0.0, 0.0, 0.0],
&w,
@@ -956,7 +1071,7 @@ mod tests {
];
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],
&[1.0, 0.0, 0.0],
&w,
@@ -990,7 +1105,7 @@ mod tests {
)];
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()],
&[1.0, 0.0],
&w,
@@ -1015,7 +1130,7 @@ mod tests {
let w_b = vec![0.7];
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()],
&[1.0, 0.0],
&w,
@@ -1040,7 +1155,7 @@ mod tests {
let w_b = vec![0.7];
let w = [w_a, w_b];
let g = Game::ranked_with_arena(
let g = GameRef::ranked_with_arena(
vec![t_a, t_b],
&[1.0, 0.0],
&w,
@@ -1076,7 +1191,7 @@ mod tests {
)];
let w = [w_a, w_b];
let g = Game::ranked_with_arena(
let g = GameRef::ranked_with_arena(
vec![t_a, t_b],
&[1.0, 0.0],
&w,
@@ -1112,7 +1227,7 @@ mod tests {
)];
let w = [w_a, w_b];
let g = Game::ranked_with_arena(
let g = GameRef::ranked_with_arena(
vec![t_a, t_b],
&[1.0, 0.0],
&w,
@@ -1158,7 +1273,7 @@ mod tests {
let result = vec![10.0, 0.0]; // a beat b by 10
let weights = [vec![1.0], vec![1.0]];
let mut arena = ScratchArena::new();
let g = Game::scored_with_arena(
let g = GameRef::scored_with_arena(
teams,
&result,
&weights,
@@ -1178,7 +1293,7 @@ mod tests {
// Tighter score_sigma should produce a stronger update.
let mut arena2 = ScratchArena::new();
let g_tight = Game::scored_with_arena(
let g_tight = GameRef::scored_with_arena(
vec![vec![prior], vec![prior]],
&result,
&weights,
@@ -1252,7 +1367,7 @@ mod tests {
assert!(matches!(
err,
crate::InferenceError::InvalidParameter {
name: "score_sigma",
parameter: crate::Parameter::ScoreSigma,
..
}
));
@@ -1289,7 +1404,7 @@ mod tests {
let w_b = vec![0.9, 0.6];
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()],
&[1.0, 0.0],
&w,
@@ -1324,7 +1439,7 @@ mod tests {
let w_b = vec![0.7, 0.4];
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()],
&[1.0, 0.0],
&w,
@@ -1359,7 +1474,7 @@ mod tests {
let w_b = vec![0.7, 2.4];
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()],
&[1.0, 0.0],
&w,
@@ -1391,7 +1506,7 @@ mod tests {
);
let w = [vec![1.0, 1.0], vec![1.0]];
let g = Game::ranked_with_arena(
let g = GameRef::ranked_with_arena(
vec![
t_a.clone(),
vec![R::new(
@@ -1412,7 +1527,7 @@ mod tests {
let w_b = vec![1.0, 0.0];
let w = [w_a, w_b];
let g = Game::ranked_with_arena(
let g = GameRef::ranked_with_arena(
vec![t_a, t_b.clone()],
&[1.0, 0.0],
&w,
@@ -1437,7 +1552,7 @@ mod tests {
// Capped at 1 iteration: cannot fully propagate down a 4-team chain.
let mut arena = ScratchArena::new();
let g_capped = Game::ranked_with_arena(
let g_capped = GameRef::ranked_with_arena(
teams.clone(),
&result,
&weights,
@@ -1452,7 +1567,7 @@ mod tests {
// Same inputs, plenty of iterations: fully converged.
let mut arena = ScratchArena::new();
let g_full = Game::ranked_with_arena(
let g_full = GameRef::ranked_with_arena(
teams,
&result,
&weights,
@@ -1484,7 +1599,7 @@ mod tests {
let weights = vec![vec![1.0]; 4];
let mut arena = ScratchArena::new();
let g_undamped = Game::ranked_with_arena(
let g_undamped = GameRef::ranked_with_arena(
teams.clone(),
&result,
&weights,
@@ -1496,7 +1611,7 @@ mod tests {
// alpha=0.5 with extra iterations: should reach the same fixed point.
let mut arena = ScratchArena::new();
let g_damped = Game::ranked_with_arena(
let g_damped = GameRef::ranked_with_arena(
teams,
&result,
&weights,
+110 -66
View File
@@ -1,5 +1,3 @@
use std::ops;
use crate::{MU, N_INF, SIGMA};
/// A Gaussian distribution stored in natural parameters.
@@ -24,7 +22,7 @@ impl Gaussian {
///
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a
/// broken fit produces one, and `converge` reports that as
/// `NonFiniteResult` rather than panicking mid-inference.
/// `NonFiniteStep` rather than panicking mid-inference.
///
/// A negative sigma used to be accepted and returned results **bit
/// identical** to its absolute value, because sigma only ever enters as
@@ -48,7 +46,7 @@ impl Gaussian {
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
// NaN is admitted on purpose. A broken fit legitimately produces a NaN
// sigma — `sqrt` of a negative truncated variance — and the design is
// to propagate that to `converge`'s `NonFiniteResult` guard, not to
// to propagate that to `converge`'s `NonFiniteStep` guard, not to
// panic inside inference. Rejecting it here turned that reporting path
// into a crash, which two tests caught immediately.
assert!(
@@ -75,11 +73,12 @@ impl Gaussian {
/// Construct from mean and *variance*, skipping the square-root round trip.
///
/// `from_ms(mu, var.sqrt())` immediately squares the root away again to
/// recover `pi = 1/var`. Variance-combining operations (`Add`, `Sub`,
/// `exclude`, `forget`) work in variance space throughout, so they go
/// through here instead and never take a root.
/// recover `pi = 1/var`. Variance-combining operations work in variance
/// space throughout, so they go through here instead and never take a
/// root. Use it whenever you already hold a variance —
/// [`variance`](Gaussian::variance) is its inverse.
#[inline]
pub(crate) fn from_mv(mu: f64, var: f64) -> Self {
pub fn from_mv(mu: f64, var: f64) -> Self {
if var == f64::INFINITY {
Self { pi: 0.0, tau: 0.0 }
} else if var == 0.0 {
@@ -100,18 +99,32 @@ impl Gaussian {
Self { pi, tau }
}
/// Precision, `1 / sigma^2` — one of the two natural parameters.
///
/// This is the representation the type actually stores, which is why the EP
/// product and cavity (`Mul` / `Div`) are plain adds and subtracts. Larger
/// means more certain; `0.0` is an improper, uninformative message and
/// `inf` is a point mass.
#[inline]
#[must_use]
pub fn pi(&self) -> f64 {
pub(crate) fn pi(&self) -> f64 {
self.pi
}
/// Precision-adjusted mean, `mu / sigma^2` — the other natural parameter.
///
/// Stored rather than derived, for the same reason as [`Gaussian::pi`].
/// Meaningful only alongside `pi`: on its own it is not a location.
#[inline]
#[must_use]
pub fn tau(&self) -> f64 {
pub(crate) fn tau(&self) -> f64 {
self.tau
}
/// Mean skill: the point estimate.
///
/// Derived from the natural parameters as `tau / pi`. An improper message
/// (`pi <= 0`) has no defined mean and reports `0.0` — see
/// [`Gaussian::sigma`], which reports `inf` for the same state, and read
/// the two together before treating a mean as informative.
#[inline]
#[must_use]
pub fn mu(&self) -> f64 {
@@ -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
/// (`pi == inf`) cases.
/// Mirrors [`sigma`](Gaussian::sigma)'s treatment of the improper
/// (infinite) and point-mass (zero) cases, and is the inverse of
/// [`from_mv`](Gaussian::from_mv).
#[inline]
pub(crate) fn variance(&self) -> f64 {
#[must_use]
pub fn variance(&self) -> f64 {
if self.pi <= 0.0 {
f64::INFINITY
} 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]
#[must_use]
pub fn sigma(&self) -> f64 {
@@ -257,34 +278,65 @@ impl Default for Gaussian {
}
}
impl ops::Add<Gaussian> for Gaussian {
type Output = Gaussian;
/// Variance addition: (mu1 + mu2, sqrt(σ1² + σ2²)).
/// Used for combining performance and noise; rare relative to mul/div.
fn add(self, rhs: Gaussian) -> Self::Output {
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
}
}
impl ops::Sub<Gaussian> for Gaussian {
type Output = Gaussian;
/// (mu1 - mu2, sqrt(σ1² + σ2²)). Same sigma combination as Add.
fn sub(self, rhs: Gaussian) -> Self::Output {
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 {
impl Gaussian {
/// The EP factor **product**: multiply two messages about the same
/// variable.
///
/// Two natural-parameter additions and no square root, which is why the
/// 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)`,
/// nowhere near 40. It used to be spelled `a * b`, on a public `Mul` impl,
/// where that was a trap rather than a shorthand.
#[inline]
pub(crate) fn ep_product(self, rhs: Gaussian) -> Gaussian {
Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau)
}
}
impl ops::Mul<f64> for Gaussian {
type Output = Gaussian;
fn mul(self, scalar: f64) -> Self::Output {
/// The EP **cavity**: divide out a message this belief already absorbed.
///
/// 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() {
return N_INF;
}
@@ -299,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)]
mod tests {
/// A message that did not change must report no change, even when it is
@@ -364,64 +408,64 @@ mod tests {
// Subtracting such a message must not produce NaN (the original failure path).
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.tau().is_finite() && !diff.tau().is_nan());
}
#[test]
fn test_add() {
fn convolve_adds_variances() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.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.sigma() - 8.393118874676116).abs() < 1e-10);
}
#[test]
fn test_sub() {
fn convolve_diff_subtracts_means_and_adds_variances() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.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.sigma() - 8.393118874676116).abs() < 1e-10);
}
#[test]
fn test_mul() {
fn ep_product_is_not_arithmetic() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.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.sigma() - 0.992876838486922).abs() < 1e-10);
}
#[test]
fn test_div() {
fn cavity_undoes_a_product() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.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.sigma() - 1.0072787050317253).abs() < 1e-10);
}
#[test]
fn test_n00_is_add_identity() {
// N00 (sigma=0) is the additive identity for the variance-convolution Add op.
// N_INF (sigma=inf) is the identity for the EP-product Mul op.
// N00 (sigma=0) is the identity for `convolve`.
// N_INF (sigma=inf) is the identity for `ep_product`.
let g = Gaussian::from_ms(3.0, 2.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.sigma() - g.sigma()).abs() < 1e-12);
}
#[test]
fn test_mul_is_factor_product() {
// n * m in nat-params should be pi_n + pi_m, tau_n + tau_m
fn ep_product_adds_natural_parameters() {
// `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 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_tau = n.tau() + m.tau();
assert!((r.pi() - expected_pi).abs() < 1e-15);
@@ -429,10 +473,10 @@ mod tests {
}
#[test]
fn test_div_is_cavity() {
fn cavity_subtracts_natural_parameters() {
let n = Gaussian::from_ms(2.0, 1.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_tau = n.tau() - m.tau();
assert!((r.pi() - expected_pi).abs() < 1e-15);
+522 -384
View File
File diff suppressed because it is too large Load Diff
+405 -47
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
//! 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
//! squares and cannot.
//!
//! Factorising is `O(n^3)` and whitening is `O(n^2)`, so the split also
//! matters structurally: the expensive half depends only on the fit, and is
//! shared across every query a [`Joint`](crate::Joint) answers.
//! # Why this is sparse (#52)
//!
//! 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.
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,
/// `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 {
/// Factorise `a` (row-major, `n * n`, symmetric) into `L L^T`.
///
/// `a` is consumed as scratch.
/// Factorise the accumulated matrix into `L L^T`, under a fill-reducing
/// permutation.
///
/// Returns `None` if the matrix is not positive-definite, which for a
/// precision matrix means the model is improper — a competitor with
/// neither a proper prior nor any evidence.
pub(crate) fn factor(mut a: Vec<f64>, n: usize) -> Option<Self> {
debug_assert_eq!(a.len(), n * n);
/// neither a proper prior nor any evidence — or if the ordering fails.
pub(crate) fn factor(built: SymmetricBuilder, n: usize) -> Option<Self> {
if n == 0 {
return Some(Self {
n: 0,
inv: Vec::new(),
col_ptr: vec![0],
row_idx: Vec::new(),
val: Vec::new(),
});
}
for j in 0..n {
let mut d = a[j * n + j];
for k in 0..j {
d -= a[j * n + k] * a[j * n + k];
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
// too, and a negated comparison would let it through as "not
// positive".
if d.is_nan() || d <= 0.0 {
return None;
}
let d = d.sqrt();
a[j * n + j] = d;
for i in j + 1..n {
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;
}
let p = next[k];
next[k] += 1;
row_idx[p] = k;
val[p] = d.sqrt();
}
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
/// 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> {
debug_assert_eq!(b.len(), self.n);
let n = self.n;
let mut y = b.to_vec();
for i in 0..n {
// Folded from `y[i]` rather than summed and subtracted once, so the
// accumulation order matches a plain substitution loop exactly.
let row = &self.l[i * n..i * n + i];
let s = row
.iter()
.zip(&y[..i])
.fold(y[i], |acc, (l, v)| acc - l * v);
y[i] = s / self.l[i * n + i];
let mut y = vec![0.0f64; n];
for (old, &v) in b.iter().enumerate() {
y[self.inv[old]] = v;
}
for j in 0..n {
y[j] /= self.val[self.col_ptr[j]];
let yj = y[j];
for p in self.col_ptr[j] + 1..self.col_ptr[j + 1] {
y[self.row_idx[p]] -= self.val[p] * yj;
}
}
y
}
@@ -98,11 +350,24 @@ pub(crate) fn bilinear(y: &[f64], y_prime: &[f64]) -> f64 {
mod tests {
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
/// form `b^T A^-1 b` is `1 * 1/11 + 2 * 7/11 = 15/11`.
#[test]
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]);
assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12);
}
@@ -113,8 +378,8 @@ mod tests {
fn recovers_the_inverse_diagonal() {
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
// [0.75, 1.0, 0.75].
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
let c = Cholesky::factor(a, 3).unwrap();
let a = [2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
let c = dense(&a, 3).unwrap();
for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() {
let mut e = vec![0.0; 3];
e[i] = 1.0;
@@ -127,8 +392,8 @@ mod tests {
#[test]
fn recovers_an_off_diagonal_covariance() {
// Same A; (A^-1)_{0,1} = 0.5.
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
let c = Cholesky::factor(a, 3).unwrap();
let a = [2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
let c = dense(&a, 3).unwrap();
let y0 = c.whiten(&[1.0, 0.0, 0.0]);
let y1 = c.whiten(&[0.0, 1.0, 0.0]);
assert!((bilinear(&y0, &y1) - 0.5).abs() < 1e-12);
@@ -138,15 +403,108 @@ mod tests {
/// A variance can never come out negative, because it is a sum of squares.
#[test]
fn a_quadratic_form_is_never_negative() {
let a = vec![1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12];
let c = Cholesky::factor(a, 2).unwrap();
let a = [1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12];
let c = dense(&a, 2).unwrap();
let y = c.whiten(&[1.0, -1.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]
fn rejects_a_non_positive_definite_matrix() {
// 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());
}
}
+84 -8
View File
@@ -9,6 +9,10 @@
//! This is a Rust port of
//! [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
//!
//! Record results, converge, then read off skills:
@@ -85,6 +89,10 @@
//! regardless of worker count.
#![forbid(unsafe_code)]
// Turned on once the surface was fully documented (80 items at the time), so
// the next undocumented public item is a build failure rather than a warning
// nobody reads.
#![deny(missing_docs)]
/// Compiles every `rust` block in `README.md` as a doctest.
///
@@ -111,12 +119,25 @@ pub(crate) mod arena;
mod color_group;
mod competitor;
mod convergence;
/// Skill drift: how much a competitor's skill is allowed to move between
/// appearances.
///
/// Public because [`Drift`] is a trait a caller may implement — a per-sport
/// off-season, say, or a schedule where drift is a function of the calendar
/// rather than of elapsed ticks. [`ConstantDrift`] is what
/// [`HistoryBuilder`] uses by default.
pub mod drift;
mod error;
mod event;
mod event_builder;
pub(crate) mod factor;
mod game;
/// The Gaussian message type and its expectation-propagation algebra.
///
/// Public because [`Gaussian`] appears throughout the results: a posterior
/// skill, a learning-curve point, a predicted margin. The module carries the
/// operator documentation — `Mul`/`Div` are the EP product and cavity, not
/// arithmetic on random variables.
pub mod gaussian;
mod history;
mod joint;
@@ -127,6 +148,7 @@ mod outcome;
mod predict;
pub(crate) mod quadrature;
mod rating;
pub mod rating_rule;
pub(crate) mod storage;
mod time;
mod time_slice;
@@ -134,10 +156,10 @@ mod time_slice;
pub use acquisition::expected_information_gain;
pub use convergence::{ConvergenceOptions, ConvergenceReport};
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_builder::EventBuilder;
pub use game::{Game, GameOptions, OwnedGame};
pub use game::{Game, GameOptions};
pub use gaussian::Gaussian;
pub use history::{History, HistoryBuilder, Joint};
use matrix::Matrix;
@@ -145,13 +167,59 @@ pub use observer::{NullObserver, Observer};
pub use outcome::Outcome;
pub use predict::Prediction;
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};
/// Default performance noise: how much a single showing varies around skill.
///
/// Every other default is expressed as a multiple of this, so `BETA` sets the
/// scale of the whole rating system. Doubling it and doubling `SIGMA` and
/// `GAMMA` with it gives the same fit on a rescaled axis.
pub const BETA: f64 = 1.0;
/// Default prior mean skill.
///
/// Zero rather than a conventional 25: the scale is set by `BETA`, and a
/// centred axis makes a negative rating mean "below the prior" instead of
/// looking like an error.
pub const MU: f64 = 0.0;
/// Default prior standard deviation: how unsure the model starts out.
///
/// Six betas is deliberately wide — a new competitor's first result should
/// move them a long way, and the prior should not fight the evidence.
pub const SIGMA: f64 = BETA * 6.0;
/// Default drift: the standard deviation of skill movement per unit of time.
///
/// Enters inference as a *variance* (`gamma^2` per elapsed tick), which is why
/// [`ConstantDrift`] squares it and why a negative gamma would be
/// indistinguishable from its absolute value — see [`ConstantDrift::new`].
pub const GAMMA: f64 = BETA * 0.03;
/// Default draw probability: zero, meaning ties are not modelled.
///
/// A history that ingests a tie needs a positive value. With `p_draw == 0.0`
/// the truncation margin is zero and the two-sided tie update evaluates
/// `0/0`, so ingestion rejects such events with
/// [`InferenceError::TieWithoutDrawProbability`].
pub const P_DRAW: f64 = 0.0;
/// Default convergence threshold, in the same units as
/// [`ConvergenceReport::final_step`](crate::ConvergenceReport).
///
/// The sweep stops once the largest change a full iteration makes to any
/// message falls below this.
pub const EPSILON: f64 = 1e-6;
/// Default cap on convergence sweeps.
///
@@ -226,16 +294,24 @@ const ASYMPTOTIC_MILLS_ALPHA: f64 = 100.0;
pub(crate) const N00: Gaussian = Gaussian::from_ms(0.0, 0.0);
pub(crate) const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
/// An interned competitor handle: a dense slot number, not a user key.
///
/// `History` stores skills and messages by `Index` rather than by `K`, so the
/// hot path never hashes a key. 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)]
pub struct Index(usize);
pub(crate) struct Index(usize);
impl Index {
/// The underlying slot number.
///
/// 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 {
pub(crate) fn get(self) -> usize {
self.0
}
}
+46 -15
View File
@@ -1,6 +1,6 @@
//! Outcome of a match.
//!
//! `Ranked(ranks)` for ordinal results; `Scored { scores, sigma }` for
//! `Ranked(ranks)` for ordinal results; `Scored { scores, score_sigma }` for
//! continuous per-team scores (engages `MarginFactor` in the engine).
use smallvec::SmallVec;
@@ -10,7 +10,7 @@ use smallvec::SmallVec;
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
/// teams. `ranks.len()` must equal the number of teams in the event.
///
/// `Scored { scores, sigma }`: higher score = better. Adjacent (sorted) pairs
/// `Scored { scores, score_sigma }`: higher score = better. Adjacent (sorted) pairs
/// feed observed margins to `MarginFactor`. `scores.len()` must equal the
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
/// when `Some`; `None` inherits the history default.
@@ -18,13 +18,33 @@ use smallvec::SmallVec;
#[non_exhaustive]
#[must_use]
pub enum Outcome {
/// An ordinal finish: one rank per team, in the order the teams were given.
///
/// Lower is better, `0` is first, and equal values are a tie between those
/// teams — which needs `p_draw > 0`, or ingestion rejects the event with
/// [`InferenceError::TieWithoutDrawProbability`](crate::InferenceError::TieWithoutDrawProbability).
///
/// Only the ordering and the equalities are used. Ranks need not be dense
/// or start at zero: inference sorts the teams and compares rank-adjacent
/// pairs against a margin set by `p_draw`, so `[0, 1, 2]` and `[0, 5, 90]`
/// are the same observation. A gap does not mean a bigger win — use
/// `Scored` when the size of the difference is evidence.
Ranked(SmallVec<[u32; 4]>),
/// A continuous finish: one score per team, higher is better.
///
/// Unlike `Ranked`, the *sizes* of the differences are evidence. Teams are
/// sorted by score and each adjacent pair's observed gap is fed to a
/// `MarginFactor` as a measurement with standard deviation `score_sigma`,
/// so
/// beating a team by ten says more than beating them by one.
#[non_exhaustive]
Scored {
/// Per-team scores, in the order the teams were given; higher is
/// better. Must have one entry per team, and every entry finite.
scores: SmallVec<[f64; 4]>,
/// Per-event noise override. `None` means inherit
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
sigma: Option<f64>,
score_sigma: Option<f64>,
},
}
@@ -64,7 +84,7 @@ impl Outcome {
pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> {
if winner >= n {
return Err(crate::InferenceError::InvalidParameter {
name: "winner",
parameter: crate::Parameter::WinnerIndex,
value: f64::from(winner),
});
}
@@ -87,23 +107,34 @@ impl Outcome {
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
Self::Scored {
scores: scores.into_iter().collect(),
sigma: None,
score_sigma: None,
}
}
/// Explicit per-team continuous scores with a per-event noise override.
///
/// `sigma` must be `> 0.0`. Constructing an `Outcome` with a non-positive
/// or NaN sigma is allowed; the value is rejected with
/// The noise is on the *observed score margin*, in the units of the scores
/// 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
/// 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 {
scores: scores.into_iter().collect(),
sigma: Some(sigma),
score_sigma: Some(score_sigma),
}
}
/// How many teams this outcome describes — the number of ranks, or of
/// scores.
///
/// Ingestion checks it against the event's own team list and rejects a
/// disagreement with `MismatchedShape`, so this is the cheap way to check
/// an outcome built elsewhere before committing the event.
#[must_use]
pub fn team_count(&self) -> usize {
match self {
@@ -185,7 +216,7 @@ mod tests {
#[test]
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.as_scores(), Some(&[10.0, 4.0][..]));
}
@@ -194,16 +225,16 @@ mod tests {
fn scores_constructor_leaves_sigma_unset() {
let o = Outcome::scores([3.0, 1.0]);
match o {
Outcome::Scored { scores: _, sigma } => assert!(sigma.is_none()),
Outcome::Scored { score_sigma, .. } => assert!(score_sigma.is_none()),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
}
#[test]
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 {
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"),
}
}
@@ -213,9 +244,9 @@ mod tests {
/// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`.
#[test]
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 {
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"),
}
}
+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)
}
}
+42 -26
View File
@@ -9,7 +9,7 @@ use crate::{
arena::ScratchArena,
color_group::ColorGroups,
drift::Drift,
game::Game,
game::GameRef,
gaussian::Gaussian,
rating::Rating,
storage::{CompetitorStore, SkillStore},
@@ -26,7 +26,9 @@ pub(crate) struct Skill {
impl Skill {
pub(crate) fn posterior(&self) -> Gaussian {
self.likelihood * self.backward * self.forward
self.likelihood
.ep_product(self.backward)
.ep_product(self.forward)
}
}
@@ -74,7 +76,7 @@ impl Item {
if forward {
Rating::new(skill.forward, r.beta, r.drift).with_drift_scale(r.drift_scale)
} 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)
}
}
@@ -95,7 +97,7 @@ pub(crate) struct Event {
}
impl Event {
pub(crate) fn iter_agents(&self) -> impl Iterator<Item = Index> + '_ {
pub(crate) fn iter_competitors(&self) -> impl Iterator<Item = Index> + '_ {
self.teams
.iter()
.flat_map(|t| t.items.iter().map(|it| it.competitor))
@@ -141,10 +143,15 @@ impl Event {
let teams = self.within_priors(false, skills, competitors);
let result = self.outputs();
let g = match self.kind {
EventKind::Ranked => {
Game::ranked_with_arena(teams, &result, &self.weights, p_draw, convergence, arena)
}
EventKind::Scored { score_sigma } => Game::scored_with_arena(
EventKind::Ranked => GameRef::ranked_with_arena(
teams,
&result,
&self.weights,
p_draw,
convergence,
arena,
),
EventKind::Scored { score_sigma } => GameRef::scored_with_arena(
teams,
&result,
&self.weights,
@@ -166,7 +173,7 @@ impl Event {
for (i, item) in team.items.iter_mut().enumerate() {
let fresh = update.likelihoods[t][i];
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;
item.likelihood = fresh;
}
@@ -255,7 +262,7 @@ impl<T: Time> TimeSlice<T> {
}
let cg = color_greedy(n, |ev_idx| {
self.events[ev_idx].iter_agents().collect::<Vec<_>>()
self.events[ev_idx].iter_competitors().collect::<Vec<_>>()
});
let mut reordered: Vec<Event> = Vec::with_capacity(n);
@@ -292,7 +299,7 @@ impl<T: Time> TimeSlice<T> {
) {
let mut unique = Vec::with_capacity(10);
let this_agent = composition.iter().flatten().flatten().filter(|idx| {
let these_competitors = composition.iter().flatten().flatten().filter(|idx| {
if !unique.contains(idx) {
unique.push(*idx);
@@ -302,7 +309,7 @@ impl<T: Time> TimeSlice<T> {
false
});
for idx in this_agent {
for idx in these_competitors {
let elapsed = compute_elapsed(competitors[*idx].last_time.as_ref(), &self.time);
let forward = competitors[*idx].receive(&self.time);
@@ -409,7 +416,7 @@ impl<T: Time> TimeSlice<T> {
let result = event.outputs();
let g = match event.kind {
EventKind::Ranked => Game::ranked_with_arena(
EventKind::Ranked => GameRef::ranked_with_arena(
teams,
&result,
&event.weights,
@@ -417,7 +424,7 @@ impl<T: Time> TimeSlice<T> {
self.convergence,
&mut self.arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
EventKind::Scored { score_sigma } => GameRef::scored_with_arena(
teams,
&result,
&event.weights,
@@ -430,8 +437,9 @@ impl<T: Time> TimeSlice<T> {
for (t, team) in event.teams.iter_mut().enumerate() {
for (i, item) in team.items.iter_mut().enumerate() {
let old_likelihood = self.skills.at(item.slot).likelihood;
let new_likelihood =
(old_likelihood / item.likelihood) * g.likelihoods[t][i];
let new_likelihood = old_likelihood
.cavity(item.likelihood)
.ep_product(g.likelihoods[t][i]);
self.skills.at_mut(item.slot).likelihood = new_likelihood;
item.likelihood = g.likelihoods[t][i];
}
@@ -581,7 +589,7 @@ impl<T: Time> TimeSlice<T> {
pub(crate) fn forward_prior_out(&self, competitor: &Index) -> Gaussian {
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>>(
@@ -590,7 +598,7 @@ impl<T: Time> TimeSlice<T> {
competitors: &CompetitorStore<T, D>,
) -> Gaussian {
let skill = self.skills.get(*competitor).unwrap();
let n = skill.likelihood * skill.backward;
let n = skill.likelihood.ep_product(skill.backward);
n.forget(
competitors[*competitor]
.rating
@@ -724,7 +732,7 @@ impl<T: Time> TimeSlice<T> {
let result = event.outputs();
match event.kind {
EventKind::Ranked => {
Game::ranked_with_arena(
GameRef::ranked_with_arena(
teams,
&result,
&event.weights,
@@ -735,7 +743,7 @@ impl<T: Time> TimeSlice<T> {
.log_evidence
}
EventKind::Scored { score_sigma } => {
Game::scored_with_arena(
GameRef::scored_with_arena(
teams,
&result,
&event.weights,
@@ -1235,14 +1243,22 @@ mod tests {
// Events at positions 0 and 1 (color 0) must be disjoint — verify by
// checking that the competitor sets of self.events[0] and self.events[1] do
// not include the competitor at self.events[2].
let agents_in_ev2: Vec<Index> = ts.events[2].iter_agents().collect();
let agents_in_ev0: Vec<Index> = ts.events[0].iter_agents().collect();
let agents_in_ev1: Vec<Index> = ts.events[1].iter_agents().collect();
let competitors_in_ev2: Vec<Index> = ts.events[2].iter_competitors().collect();
let competitors_in_ev0: Vec<Index> = ts.events[0].iter_competitors().collect();
let competitors_in_ev1: Vec<Index> = ts.events[1].iter_competitors().collect();
// ev0 and ev1 must be disjoint from each other (color-0 invariant).
assert!(agents_in_ev0.iter().all(|ag| !agents_in_ev1.contains(ag)));
assert!(
competitors_in_ev0
.iter()
.all(|ag| !competitors_in_ev1.contains(ag))
);
// ev2 must share an competitor with ev0 or ev1 (it needed its own color).
let ev2_overlaps_ev0 = agents_in_ev2.iter().any(|ag| agents_in_ev0.contains(ag));
let ev2_overlaps_ev1 = agents_in_ev2.iter().any(|ag| agents_in_ev1.contains(ag));
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);
}
}
+9 -4
View File
@@ -29,7 +29,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
let players = ["p0", "p1", "p2"];
let holes = ["h0", "h1"];
let mut h: History<i64, _, _, &'static str> = History::builder()
let mut h: History = History::builder()
.mu(0.0)
.sigma(6.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) ==");
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());
}
@@ -97,7 +97,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
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
let naive: f64 = terms
.iter()
@@ -132,7 +132,12 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
// shares with its partners is pinned only by the prior.
for k in players.iter().chain(holes.iter()) {
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!(
exact > 3.0 * bp,
"{k}: exact marginal {exact} should be much wider than the reported \
+7 -7
View File
@@ -41,9 +41,9 @@ fn add_events_bulk_via_iter() {
h.add_events(events).unwrap();
let report = h.converge().unwrap();
assert!(report.converged);
assert!(h.lookup(&"a").is_some());
assert!(h.lookup(&"b").is_some());
assert!(h.lookup(&"c").is_some());
assert!(h.current_skill("a").is_some());
assert!(h.current_skill("b").is_some());
assert!(h.current_skill("c").is_some());
}
#[test]
@@ -103,9 +103,9 @@ fn fluent_event_builder_basic() {
let report = h.converge().unwrap();
assert!(report.converged);
assert!(h.lookup(&"alice").is_some());
assert!(h.lookup(&"bob").is_some());
assert!(h.lookup(&"carol").is_some());
assert!(h.current_skill("alice").is_some());
assert!(h.current_skill("bob").is_some());
assert!(h.current_skill("carol").is_some());
}
#[test]
@@ -203,7 +203,7 @@ fn predict_quality_two_teams() {
h.record_winner(&"a", &"b", 1).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);
}
+4 -1
View File
@@ -184,7 +184,10 @@ fn a_batch_declaring_two_different_priors_is_rejected() {
assert!(
matches!(
err,
InferenceError::ConflictingCompetitorConfig { field: "prior", .. }
InferenceError::ConflictingCompetitorConfig {
field: trueskill_tt::CompetitorField::Prior,
..
}
),
"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| {
let mut h = History::builder().build();
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("b")]),
],
outcome: Outcome::scores_with_sigma([3.0, 1.0], v),
outcome: Outcome::scores_with_noise([3.0, 1.0], v),
}])
.is_err()
}),
+2 -2
View File
@@ -11,7 +11,7 @@ use trueskill_tt::{
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> {
Event {
@@ -108,7 +108,7 @@ fn the_two_agree_on_a_converged_fit() {
/// At the old value of 30 this history stopped short and said nothing.
#[test]
fn the_default_cap_clears_an_ordinary_history() {
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
let mut h: History<String> = History::builder()
.key_type::<String>()
.mu(0.0)
.sigma(6.0)
+8 -5
View File
@@ -15,8 +15,7 @@ use std::{env, process::Command};
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team,
UnknownKeys,
ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team, UnknownKeys,
};
/// 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;
type H = History<i64, ConstantDrift, NullObserver, String>;
type H = History<String>;
fn fitted() -> H {
let mut h: H = History::builder()
@@ -84,13 +83,17 @@ fn fingerprint() -> String {
let known = "p0".to_string();
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 b = "p1".to_string();
let target = [(&a, 1.0), (&b, -1.0)];
let teams: [&[&String]; 2] = [&[&a], &[&b]];
let evr = h.expected_variance_reduction(&teams, &target).unwrap();
let evr = h
.joint()
.unwrap()
.expected_variance_reduction(&teams, &target)
.unwrap();
let curves = h.learning_curves();
let mut curve_bits: u64 = 0;
+5 -5
View File
@@ -8,7 +8,7 @@ mod common;
use common::assert_finite;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError,
NullObserver, Outcome, Rating,
Outcome, Rating,
};
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.
#[test]
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>()
.score_sigma(5.0)
.build();
@@ -157,7 +157,7 @@ fn event_builder_rejects_a_weights_length_mismatch() {
matches!(
err,
InferenceError::MismatchedShape {
kind: "weights",
shape: trueskill_tt::Shape::Weights,
expected: 1,
got: 2,
..
@@ -222,13 +222,13 @@ fn scored_event_rejects_non_positive_sigma() {
.event(1)
.team(["a"])
.team(["b"])
.scores_with_sigma([3.0, 1.0], f64::NAN)
.scores_with_noise([3.0, 1.0], f64::NAN)
.commit()
.unwrap_err();
assert!(matches!(
err,
InferenceError::InvalidParameter {
name: "score_sigma",
parameter: trueskill_tt::Parameter::ScoreSigma,
..
}
));
+6 -6
View File
@@ -8,11 +8,11 @@
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member,
NullObserver, Outcome, Team,
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
Team,
};
type Fit = History<i64, ConstantDrift, NullObserver, &'static str>;
type Fit = History;
const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
max_iter: 64,
@@ -279,7 +279,7 @@ fn reject(scale: f64) -> InferenceError {
fn negative_scale_is_rejected() {
assert!(matches!(
reject(-1.0),
InferenceError::InvalidParameter { name: "drift_scale", value, .. }
InferenceError::InvalidParameter { parameter: trueskill_tt::Parameter::DriftScale, value, .. }
if value == -1.0
));
}
@@ -291,7 +291,7 @@ fn non_finite_scale_is_rejected() {
matches!(
reject(scale),
InferenceError::InvalidParameter {
name: "drift_scale",
parameter: trueskill_tt::Parameter::DriftScale,
..
}
),
@@ -488,7 +488,7 @@ fn a_batch_that_contradicts_itself_is_rejected() {
matches!(
err,
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() {
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 (a_post, b_post) =
Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default())
.unwrap()
.posteriors();
let (a_post, b_post) = (post[0][0], post[1][0]);
// Historical golden from pre-T2 test_1vs1 (team 0 wins):
assert_ulps_eq!(
a_post,
+8 -5
View File
@@ -11,7 +11,7 @@ use trueskill_tt::{
Team,
};
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
type H = History;
fn history() -> H {
History::builder()
@@ -64,8 +64,11 @@ fn members_matches_the_typed_path_exactly() {
for key in ["player", "layout_7"] {
let a = typed.current_skill(&key).unwrap();
let b = fluent.current_skill(&key).unwrap();
assert_eq!(a.pi(), b.pi(), "{key} pi");
assert_eq!(a.tau(), b.tau(), "{key} tau");
// Exact equality, on the public moments rather than the natural
// 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");
}
}
@@ -133,7 +136,7 @@ fn weights_still_guards_a_members_team() {
matches!(
err,
InferenceError::MismatchedShape {
kind: "weights",
shape: trueskill_tt::Shape::Weights,
expected: 2,
got: 1,
..
@@ -161,7 +164,7 @@ fn an_invalid_drift_scale_surfaces_from_commit() {
matches!(
err,
InferenceError::InvalidParameter {
name: "drift_scale",
parameter: trueskill_tt::Parameter::DriftScale,
..
}
),
+2 -4
View File
@@ -4,11 +4,9 @@
//! `filtered_log_evidence_for` was the missing corner: the one a per-competitor
//! prequential score needs.
use trueskill_tt::{
ConstantDrift, Event, History, InferenceError, Member, NullObserver, Outcome, Team,
};
use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
type H = History<i64, ConstantDrift, NullObserver, &'static str>;
type H = History;
/// Two disjoint cohorts, so a key restriction is guaranteed to leave events out.
fn two_cohorts() -> H {
+23 -7
View File
@@ -32,10 +32,16 @@ fn game_ranked_1v1_golden() {
fn game_one_v_one_shortcut() {
let a = 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();
let post = game.posteriors();
let (a_post, b_post) = (post[0][0], post[1][0]);
assert!(a_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]
@@ -51,7 +57,7 @@ fn game_ranked_rejects_bad_p_draw() {
},
)
.unwrap_err();
assert!(matches!(err, InferenceError::InvalidProbability { .. }));
assert!(matches!(err, InferenceError::InvalidParameter { .. }));
}
#[test]
@@ -118,8 +124,10 @@ fn one_v_one_honours_the_draw_probability_it_is_given() {
p_draw: 0.25,
..GameOptions::default()
};
let (a_post, b_post) = Game::<i64, _>::one_v_one(&a, &b, Outcome::draw(2), &options)
.expect("a draw is representable once p_draw is positive");
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::draw(2), &options)
.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.
assert!((a_post.mu() - b_post.mu()).abs() < 1e-9);
@@ -135,8 +143,10 @@ fn one_v_one_honours_convergence_options() {
convergence: ConvergenceOptions::default(),
..GameOptions::default()
};
let (a_post, _) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options).unwrap();
assert!(a_post.mu() > 25.0);
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options)
.unwrap()
.posteriors();
assert!(post[0][0].mu() > 25.0);
}
/// `Game` is a public entry point that does not pass through `History`'s
@@ -217,7 +227,13 @@ mod malformed_games {
)
.unwrap_err();
assert!(
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Score,
..
}
),
"{bad}: {err:?}"
);
}
+2 -4
View File
@@ -4,11 +4,9 @@
//! Each test carries a control: the same call on a key the history *does* know,
//! so it cannot pass merely because everything returns the same thing.
use trueskill_tt::{
ConstantDrift, Event, History, InferenceError, Member, NullObserver, Outcome, Team,
};
use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
type H = History<i64, ConstantDrift, NullObserver, &'static str>;
type H = History;
fn history() -> H {
let mut h = H::default();
+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)> {
let mut h: History<i64, _, _, String> = History::builder()
let mut h: History<String> = History::builder()
.key_type::<String>()
.convergence(tight())
.build();
+15 -4
View File
@@ -14,8 +14,7 @@ use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
type Ev = Event<i64, &'static str>;
fn history() -> History<i64, trueskill_tt::ConstantDrift, trueskill_tt::NullObserver, &'static str>
{
fn history() -> History {
History::builder().score_sigma(1.0).build()
}
@@ -113,7 +112,13 @@ fn a_non_finite_score_is_rejected_at_ingestion() {
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Score,
..
}
),
"{bad}: {err:?}"
);
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()
.unwrap_err();
assert!(
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Weight,
..
}
),
"{bad}: {err:?}"
);
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
+31 -18
View File
@@ -11,7 +11,7 @@ use trueskill_tt::{
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> {
Event {
@@ -78,17 +78,22 @@ const PAIRS: [(&str, &str); 6] = [
("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]
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 joint = h.joint().unwrap();
for (a, b) in PAIRS {
let terms = [(&a, 1.0), (&b, -1.0)];
let one_shot = h.posterior_of(&terms).unwrap();
let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
let cached = joint.posterior_of(&terms).unwrap();
assert_eq!(one_shot.pi(), cached.pi(), "{a} - {b}");
assert_eq!(one_shot.tau(), cached.tau(), "{a} - {b}");
assert_eq!(one_shot.mu(), cached.mu(), "{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 (a, b) in PAIRS {
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);
match (one_shot, cached) {
(Ok(x), Ok(y)) => {
assert_eq!(x.pi(), y.pi(), "t={time} {a} - {b}");
assert_eq!(x.tau(), y.tau(), "t={time} {a} - {b}");
assert_eq!(x.mu(), y.mu(), "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}"),
(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 {
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();
assert_eq!(one_shot, cached, "{x} vs {y}");
}
@@ -222,16 +231,20 @@ fn a_ranked_history_has_no_exact_joint() {
let _ = h.converge().unwrap();
assert!(matches!(
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]
fn an_empty_history_has_no_joint() {
let h = history(UnknownKeys::Reject);
assert!(matches!(
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
/// history, and the cached path must add the same prior variance the one-shot
/// path does.
/// history, and a reused joint must add the same prior variance a fresh one
/// does.
#[test]
fn unseen_competitors_match_the_one_shot_path() {
fn unseen_competitors_match_a_fresh_factorisation() {
let h = fitted(UnknownKeys::Prior);
let joint = h.joint().unwrap();
let (a, z) = ("a", "nobody");
let terms = [(&a, 1.0), (&z, -1.0)];
let one_shot = h.posterior_of(&terms).unwrap();
let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
let cached = joint.posterior_of(&terms).unwrap();
assert_eq!(one_shot.pi(), cached.pi());
assert_eq!(one_shot.tau(), cached.tau());
assert_eq!(one_shot.mu(), cached.mu());
assert_eq!(one_shot.variance(), cached.variance());
}
/// 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]
fn a_drift_too_small_to_represent_collapses_rather_than_corrupting() {
fn variance(scale: f64) -> f64 {
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
let mut h: History<String> = History::builder()
.key_type::<String>()
.mu(0.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
//! 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).
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`.
struct Lcg(u64);
@@ -24,7 +24,7 @@ impl Lcg {
}
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>()
.beta(1.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).
fn fitted(
obs: &[(usize, usize, f64)],
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
let mut h: History<i64, _, _, &'static str> = History::builder()
fn fitted(obs: &[(usize, usize, f64)]) -> History {
let mut h: History = History::builder()
.mu(MU0)
.sigma(SIGMA0)
.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)] {
let got = h
.joint()
.unwrap()
.posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)])
.expect("scored slice should have a joint");
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.
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();
assert!(
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
@@ -322,7 +322,7 @@ fn cost_scaling() {
use std::time::Instant;
for n in [50usize, 100, 200, 400, 800] {
let names: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
let mut h: History<i64, _, _, String> = History::builder()
let mut h: History<String> = History::builder()
.key_type::<String>()
.score_sigma(2.0)
.drift(ConstantDrift::new(0.0))
@@ -361,6 +361,8 @@ fn cost_scaling() {
let t = Instant::now();
let g = h
.joint()
.unwrap()
.posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)])
.unwrap();
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 {
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!(
!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();
assert!(
matches!(err, InferenceError::NonFiniteResult { .. }),
matches!(err, InferenceError::NonFiniteStep { .. }),
"a breakdown must not be reported as convergence: {err:?}"
);
@@ -104,7 +104,7 @@ fn a_broken_fit_is_never_reported_as_converged() {
.unwrap();
assert!(matches!(
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()
.expect_err("a NaN fit must never be reported as converged");
assert!(
matches!(err, InferenceError::NonFiniteResult { .. }),
matches!(err, InferenceError::NonFiniteStep { .. }),
"{err:?}"
);
}
+4 -6
View File
@@ -6,9 +6,7 @@ use trueskill_tt::{
UnknownKeys,
};
fn builder(
policy: UnknownKeys,
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
fn builder(policy: UnknownKeys) -> History {
History::builder()
.mu(0.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"
/// appears once.
fn fitted(
policy: UnknownKeys,
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
fn fitted(policy: UnknownKeys) -> History {
let mut h = builder(policy);
let mut events: Vec<_> = (0..40)
.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() {
let h = fitted(UnknownKeys::Prior);
let skill_gap = h
.joint()
.unwrap()
.posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)])
.unwrap();
let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
+7 -3
View File
@@ -34,7 +34,7 @@ fn unknown_keys_are_reported_not_silently_dropped() {
h.predict_win_probabilities(&[&[&"a"], &[&"ghost"]])
.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());
}
@@ -60,8 +60,12 @@ fn degenerate_team_shapes_are_errors_rather_than_panics() {
h.predict_outcome(&[&[&"a"]]).unwrap_err(),
InferenceError::NotEnoughTeams { got: 1, .. }
),);
// An empty team list cannot infer the key type — nothing in `&[]` names it.
// The annotation is the cost of `predict_*` being generic over the borrowed
// key, and it only bites on the degenerate call.
let none: &[&[&str]] = &[];
assert!(matches!(
h.predict_outcome(&[]).unwrap_err(),
h.predict_outcome(none).unwrap_err(),
InferenceError::NotEnoughTeams { got: 0, .. }
),);
assert!(matches!(
@@ -403,7 +407,7 @@ fn prior_reaches_every_prediction_entry_point() {
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
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_outcome(teams).is_ok());
assert!(h.predict_ranking(teams, &[0, 1]).is_ok());
+7 -7
View File
@@ -10,10 +10,10 @@
//! returning `Err`.
use trueskill_tt::{
ConstantDrift, Event, Gaussian, History, InferenceError, Member, NullObserver, Outcome, Team,
ConstantDrift, Event, Gaussian, History, InferenceError, Member, Outcome, Team,
};
type H = History<i64, ConstantDrift, NullObserver, &'static str>;
type H = History;
fn build(beta: f64, prior: Option<Gaussian>, outcome: Outcome) -> H {
let mut h: H = History::builder()
@@ -49,7 +49,7 @@ fn nan_poisoned() -> H {
);
let err = h.converge().expect_err("this fixture must not converge");
assert!(
matches!(err, InferenceError::NonFiniteResult { .. }),
matches!(err, InferenceError::NonFiniteStep { .. }),
"{err:?}"
);
h
@@ -78,7 +78,7 @@ macro_rules! all_predictions {
($h:ident, $f:expr) => {{
let teams: &[&[&&'static str]] = &[&[&"a"], &[&"b"]];
let f = $f;
f("predict_quality", $h.predict_quality(teams).map(|_| ()));
f("quality", $h.quality(teams).map(|_| ()));
f(
"predict_win_probabilities",
$h.predict_win_probabilities(teams).map(|_| ()),
@@ -101,7 +101,7 @@ fn a_nan_poisoned_fit_is_refused_by_every_prediction_path() {
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
match r {
Err(InferenceError::NonFiniteResult { .. }) => {}
Err(InferenceError::NonFiniteSkill { .. }) => {}
other => panic!("{name} answered from a NaN fit: {other:?}"),
}
});
@@ -116,12 +116,12 @@ fn degenerate_performances_are_refused_rather_than_answered_wrongly() {
assert_eq!(skill.sigma(), 0.0);
assert!(skill.mu().is_finite());
// `predict_quality` previously PANICKED here, out of a method that returns
// `quality` previously PANICKED here, out of a method that returns
// `Result`: the contrast covariance is exactly singular when beta is zero
// and every skill is a point mass.
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
match r {
Err(InferenceError::InvalidParameter { .. }) => {}
Err(InferenceError::NoPerformanceVariance) => {}
other => panic!("{name} predicted from a degenerate fit: {other:?}"),
}
});
+2 -5
View File
@@ -110,11 +110,8 @@ fn history_predict_quality_supports_three_teams() {
h.record_winner(&"b", &"c", 2).unwrap();
let _ = h.converge().unwrap();
let q = h.predict_quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap();
assert!(
q.is_finite(),
"3-team predict_quality must be finite, got {q}"
);
let q = h.quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap();
assert!(q.is_finite(), "3-team quality must be finite, got {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.
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>()
.convergence(tight())
.build();
@@ -152,7 +152,7 @@ fn re_converging_an_unchanged_history_costs_one_iteration() {
let (early, late) = fixture();
let all: Vec<_> = early.into_iter().chain(late).collect();
let mut h: History<i64, _, _, String> = History::builder()
let mut h: History<String> = History::builder()
.key_type::<String>()
.convergence(tight())
.build();
+20 -12
View File
@@ -17,24 +17,32 @@ fn record_winner_builds_history() {
h.record_winner(&"alice", &"bob", 1).unwrap();
let _ = h.converge().unwrap();
let a_idx = h.lookup(&"alice").unwrap();
let b_idx = h.lookup(&"bob").unwrap();
assert_ne!(a_idx, b_idx);
// `lookup` returned an `Index` that nothing public accepted, so the
// observable claim is the one worth making: two distinct competitors, each
// with their own posterior, and the winner ahead.
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]
fn intern_is_idempotent() {
fn a_repeated_key_is_one_competitor() {
let mut h: History = History::builder().build();
let a1 = h.intern(&"alice");
let a2 = h.intern(&"alice");
assert_eq!(a1, a2);
h.record_winner(&"alice", &"bob", 1).unwrap();
h.record_winner(&"alice", &"carol", 2).unwrap();
assert_eq!(h.competitor_count(), 3);
assert_eq!(h.learning_curve("alice").expect("known").len(), 2);
}
#[test]
fn lookup_returns_none_for_missing() {
fn an_unknown_key_is_unknown() {
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]
@@ -50,6 +58,6 @@ fn record_draw_with_p_draw_set() {
h.record_draw(&"alice", &"bob", 1).unwrap();
let _ = h.converge().unwrap();
assert!(h.lookup(&"alice").is_some());
assert!(h.lookup(&"bob").is_some());
assert!(h.current_skill("alice").is_some());
assert!(h.current_skill("bob").is_some());
}
+17 -11
View File
@@ -11,7 +11,7 @@ use trueskill_tt::{
Team,
};
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
type H = History;
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)) {
assert_eq!(a.pi(), b.pi(), "{k} pi");
assert_eq!(a.tau(), b.tau(), "{k} tau");
assert_eq!(a.mu(), b.mu(), "{k} mu");
assert_eq!(a.variance(), b.variance(), "{k} variance");
}
}
@@ -172,7 +172,13 @@ fn a_weight_on_a_registration_is_rejected() {
.register(Member::new("layout").with_weight(0.5))
.unwrap_err();
assert!(
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Weight,
..
}
),
"{err:?}"
);
}
@@ -188,7 +194,7 @@ fn an_invalid_drift_scale_on_a_registration_is_rejected() {
matches!(
err,
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 backward = build(true);
for ((k, a), (_, b)) in skills(&forward).into_iter().zip(skills(&backward)) {
assert_eq!(a.pi(), b.pi(), "{k} pi");
assert_eq!(a.tau(), b.tau(), "{k} tau");
assert_eq!(a.mu(), b.mu(), "{k} mu");
assert_eq!(a.variance(), b.variance(), "{k} variance");
}
}
@@ -246,8 +252,8 @@ fn rating_reads_back_what_was_stored() {
.unwrap();
let r = h.rating(&"layout").unwrap();
assert_eq!(r.drift_scale(), 0.25);
assert_eq!(r.prior().pi(), PINNED.pi());
assert_eq!(r.prior().tau(), PINNED.tau());
assert_eq!(r.prior().mu(), PINNED.mu());
assert_eq!(r.prior().variance(), PINNED.variance());
// A competitor created by an event reports the history defaults.
h.record_winner(&"player", &"layout", 1).unwrap();
@@ -280,7 +286,7 @@ mod conflicting_configuration {
matches!(
err,
InferenceError::ConflictingCompetitorConfig {
field: "drift_scale",
field: trueskill_tt::CompetitorField::DriftScale,
..
}
),
@@ -297,7 +303,7 @@ mod conflicting_configuration {
matches!(
err,
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
);
}
}
+1 -1
View File
@@ -144,7 +144,7 @@ fn key_type_replaces_builder_with_key() {
/// Both axes at once, via the explicit constructor rather than the setters.
#[test]
fn new_constructs_on_any_axis_directly() {
let mut h = HistoryBuilder::<Season, _, _, String>::new().build();
let mut h = HistoryBuilder::<String, Season>::new().build();
h.record_winner(&"a".to_string(), &"b".to_string(), Season(7))
.unwrap();
assert!(h.converge().unwrap().converged);
+54 -13
View File
@@ -16,7 +16,7 @@ const BETA: f64 = 1.0;
const SCORE_SIGMA: f64 = 2.0;
const GAMMA: f64 = 0.5;
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
type H = History;
fn history(gamma: f64) -> H {
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.
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!(
(got.sigma() - exact_gap).abs() / exact_gap < 1e-9,
"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 got_single = h.posterior_of(&[(&"a", 1.0)]).unwrap();
let got_single = h.joint().unwrap().posterior_of(&[(&"a", 1.0)]).unwrap();
assert!(
(got_single.sigma() - exact_single).abs() / exact_single < 1e-9,
"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.
for (x, y) in [("a", "b"), ("b", "c"), ("a", "c")] {
let g = h
.joint()
.unwrap()
.posterior_of(&[(&x, 1.0), (&y, -1.0)])
.unwrap_or_else(|e| panic!("{x} - {y} should resolve across slices: {e}"));
assert!(g.sigma() > 0.0 && g.sigma().is_finite());
@@ -175,7 +181,7 @@ fn means_agree_with_the_marginals() {
for k in ["a", "b", "c"] {
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!(
(marginal - joint).abs() < 1e-9,
"{k}: marginal {marginal}, joint {joint}"
@@ -197,7 +203,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
])
.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 mut h = history(0.0);
@@ -208,7 +217,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
])
.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!(
@@ -234,7 +246,11 @@ fn drift_widens_a_comparison_across_time() {
let _ = h.converge().unwrap();
// b was last seen at time 0; a at time 100.
let g = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
let g = h
.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
assert!(
g.sigma() > previous,
"gamma={gamma}: sigma {} did not exceed {previous}",
@@ -257,9 +273,21 @@ fn posterior_of_at_reads_as_of_a_time() {
.unwrap();
let _ = h.converge().unwrap();
let early = h.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
let late = h.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
let latest = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
let early = h
.joint()
.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.
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.
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.
@@ -289,8 +322,16 @@ fn a_time_between_slices_reads_the_previous_appearance() {
.unwrap();
let _ = h.converge().unwrap();
let at_zero = h.posterior_of_at(0, &[(&"a", 1.0)]).unwrap();
let between = h.posterior_of_at(50, &[(&"a", 1.0)]).unwrap();
let at_zero = h
.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.sigma() - between.sigma()).abs() < 1e-12);
}
+1 -1
View File
@@ -42,7 +42,7 @@ fn a_struct_holding_a_history_can_derive_debug() {
#[test]
fn history_builder_is_debug_and_clone() {
let b: HistoryBuilder<i64, ConstantDrift, _, &'static str> = History::builder();
let b: HistoryBuilder = History::builder();
let cloned = b.clone();
assert!(!format!("{cloned:?}").is_empty());
}
+34 -9
View File
@@ -49,7 +49,13 @@ fn ranked_rejects_a_zero_damping_factor() {
)
.expect_err("alpha = 0 must be rejected");
assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"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");
assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"alpha={alpha}: got {err:?}"
);
}
@@ -81,7 +93,13 @@ fn scored_rejects_a_bad_damping_factor() {
)
.expect_err("alpha = 0 must be rejected");
assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"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.
#[test]
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("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");
assert!(
@@ -248,9 +266,13 @@ mod builder_parameters {
};
let zero = fit(0.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!(
(zero.pi() - positive.pi()).abs() > 1e-6,
(zero.variance() - positive.variance()).abs() > 1e-6,
"zero beta must not merely be ignored: {zero:?} vs {positive:?}"
);
}
@@ -280,7 +302,10 @@ mod constructor_parameters {
#[test]
fn a_nan_sigma_passes_through_from_ms() {
let g = Gaussian::from_ms(25.0, f64::NAN);
assert!(g.sigma().is_nan() || g.pi().is_nan());
// `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]
@@ -353,7 +378,7 @@ mod constructor_parameters {
matches!(
err,
InferenceError::InvalidParameter {
name: "drift variance",
parameter: trueskill_tt::Parameter::DriftVariance,
..
}
),
+48 -8
View File
@@ -6,7 +6,7 @@ use trueskill_tt::{
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> {
Event {
@@ -30,7 +30,7 @@ fn base() -> Vec<Event<i64, &'static str>> {
}
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)
.sigma(6.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() {
let h = fit(None, UnknownKeys::Reject);
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")] {
let predicted = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&x], &[&y]], &target)
.unwrap();
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!(
(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() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
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();
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);
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) {
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 direct = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"], &[&"b"]], &target)
.unwrap();
let unrelated = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&"c"], &[&"d"]], &target)
.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 reduction = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target)
.unwrap();
assert!(
@@ -142,7 +178,9 @@ fn shape_errors_are_reported() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
assert!(matches!(
h.expected_variance_reduction(&[&[&"a"]], &target),
h.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"]], &target),
Err(InferenceError::MismatchedShape {
expected: 2,
got: 1,
@@ -150,7 +188,9 @@ fn shape_errors_are_reported() {
})
));
assert!(matches!(
h.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
h.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
Err(InferenceError::UnknownKey { .. })
));
}