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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #74.

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

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

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

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

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

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

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

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

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

Closes #53.

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

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

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

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

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

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

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

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

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

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

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

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

Closes #52.

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

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

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

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

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

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

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

Refs #54.

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

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

Now:

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

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

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

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

Refs #72.

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

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

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

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

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

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

Closes #78.

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

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

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

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

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

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

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

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

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

Closes #73.

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

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

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

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

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

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

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

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

Closes #71.

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

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

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

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

Closes #69.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 22:11:25 +02:00
logaritmisk da55d2a7d1 Merge api/non-exhaustive-options (#74) 2026-09-09 22:05:11 +02:00
logaritmiskandClaude Opus 5 6a893ffe57 fix!: non_exhaustive on ConvergenceReport, and not on the options structs
`ConvergenceReport` is only ever constructed by `converge` /
`converge_partial`, so marking it costs a caller nothing and makes a
future field additive.

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

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

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

Refs #74.

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

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

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

Refs #75.

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

Several docs are measurements rather than readings of the code:

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

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

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

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

Closes #77. Refs #75.

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

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

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

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

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

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

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

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

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

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

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

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

Closes #76.

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

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

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

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

Closes #67. Refs #73.

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

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

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

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

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

Refs #70.

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

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

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

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

Closes #66, closes #70.

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

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

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

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

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

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

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

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

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

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

Refs #75

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

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

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

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

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

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

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

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

Refs #78, #76, #74

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

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

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

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

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

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

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

Refs #76, #67

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

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

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

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

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

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

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

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

Refs #73, #70

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

Closes #68

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

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

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

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

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

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

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

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

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

Closes #68

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

Closes #65

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

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

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

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

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

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

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

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

Closes #65

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

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

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

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

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

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

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

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

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

Closes #64

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

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

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

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

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

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

Closes #62

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

Three production sites did not:

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

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

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

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

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

Closes #63

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

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

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

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

Two things the tests caught that I had wrong:

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

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

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

Closes #61

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

Two separate cancellations, fixed separately.

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

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

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

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

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

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

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

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

Closes #60

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

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

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

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

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

Closes #59

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #55, closes #56

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

Measured, 8 competitors over 15 slices:

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

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

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

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

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

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

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

Closes #57

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

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

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

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

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

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

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

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

Closes #58

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 16:59:56 +02:00
logaritmisk 7da2328692 chore: Release trueskill-tt version 0.8.0 2026-09-08 21:18:53 +02:00
logaritmiskandClaude Opus 5 a73afa5f24 Merge branch 'fix/game-boundary'
Reject malformed games at the Game entry point, which does not pass
through History's ingestion chokepoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:13:02 +02:00
logaritmiskandClaude Opus 5 eebf8aacd3 fix!: reject malformed games at the Game boundary too
I fixed this at `History`'s ingestion chokepoint and said the boundary
was complete. It was not. `Game` is a separate public entry point that
does not pass through that chokepoint, and every one of the same four
defects was still live there:

  Game::ranked(&[&[a]], ..)  -> PANIC at src/game.rs:317
  Game::scored(&[&[a]], ..)  -> PANIC at src/game.rs:317
  Game::ranked(&[&[], &[a]]) -> Ok, finite posterior for the opponent
  Game::scored(.., [NaN, 1]) -> Ok

The same panic, from safe API, in release. Fixing one path and
generalising from it is exactly the mistake that produced the
latest-slice joint bug: validating on the shape that cannot expose the
problem, then reporting the property as held.

`Game::validate_teams` is shared by `ranked` and `scored`, with the
non-finite score check in `scored` alongside it. Ranks need no equivalent
— they are `u32`.

`one_v_one` and `free_for_all` build their teams internally and are
unaffected; a test asserts all three well-formed constructors still
succeed, so the check cannot quietly widen.

BREAKING CHANGE: `Game::ranked` and `Game::scored` return
`NotEnoughTeams`, `EmptyTeam` or `InvalidParameter` for inputs they
previously panicked on or silently accepted.

Refs #18, #26

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:13:02 +02:00
logaritmiskandClaude Opus 5 4e9aa6bdc1 Merge branch 'test/close-coverage-gaps'
Cover non-finite results and color-group disjointness, closing the two
test gaps #26 named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:09:25 +02:00
logaritmiskandClaude Opus 5 a18df521eb test: cover non-finite results and color-group disjointness
The two gaps #26 named that were never filled.

NonFiniteResult had no test at all — the name appeared in `tests/` only
inside a doc comment, and it is the sub-claim in that issue's title. It
turns out to be very much reachable, and from *finite* inputs: sigma at
1e300, beta at 1e300, sigma at 1e-300, score_sigma at 1e-300, and scores
at 1e308 all overflow inside inference, where the boundary checks cannot
see them. That matters because the failure is silent by default — NaN
fails every comparison, so a naive `step < epsilon` reads a NaN step as
converged, which is why the crate has `step_converged`/`step_is_finite`.
Pinned from outside, including that `converge_partial` does not launder a
breakdown into an `Ok`, and with a control asserting merely extreme
parameters still converge so the suite cannot pass by always failing.

Color-group disjointness was #26's fourth acceptance criterion and had
only five hand-written cases. Now a proptest over three shapes: a dense
pool where collisions force colors to multiply, a sparse one where most
events are independent, and repeated members within a single event.

Two of my first assertions were wrong about the code rather than the
reverse. A competitor named twice *within* one event is not a collision —
`color_greedy` collects each event's members into a set for that reason.
And contiguity is not a property of `color_greedy`: it holds only after
`recompute_color_groups` reorders events so each color occupies one
range. The test now asserts what is actually promised — that the reorder
is always *possible*, since the parallel sweep slices `&mut` sub-ranges
from those groups and overlapping ranges would be unsound.

Refs #26

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:09:25 +02:00
logaritmiskandClaude Opus 5 1e4b589a9c Merge branch 'fix/non-finite-weights'
Reject non-finite weights at ingestion, completing the malformed-input
boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:03:17 +02:00
logaritmiskandClaude Opus 5 8b20e0c560 fix!: reject non-finite weights at ingestion
Measured, a NaN weight behaved exactly as `0.0`:

  weight NaN -> Ok, converged: true, 1 iteration, step (0.0, 0.0)
                skill pi 0.027777777777777776, tau 0.0
  weight 0.0 -> Ok, same skill, bit for bit

So a NaN arriving from a division or a parse was indistinguishable from
a deliberate zero, and the fit reported itself as cleanly converged.

Worth correcting an earlier description of this: the event does not
vanish. The member contributes nothing, which is precisely what weight
zero means, and that equivalence is what makes it undetectable rather
than merely wrong.

Zero and negative weights stay accepted. Both are expressible choices
about how much a member contributes, and tests/degenerate_inputs.rs pins
their behaviour deliberately; only values that are not quantities at all
are rejected. A test asserts they still ingest, so the new check cannot
quietly widen.

This completes the boundary: every malformed input that previously
produced a plausible answer — a one-team event, an empty team, a
non-finite score, a non-finite weight — now fails where it enters.

BREAKING CHANGE: an event carrying a non-finite weight returns
`InvalidParameter` instead of silently treating that member as weightless.

Refs #18

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:03:16 +02:00
logaritmiskandClaude Opus 5 862779ae34 Merge branch 'feat/convergence-strictness'
Make a short fit an error, raise the default iteration cap, validate the
remaining HistoryBuilder parameters, add History::register and
History::rating, reject competitor config conflicts across batches, and
document what the joint's cost scales in.

Closes #50

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:00:39 +02:00
logaritmiskandClaude Opus 5 f692906ce4 docs: state what the joint's cost actually scales in
A consumer measured an 8x difference in solve time between two fits over
the same events, the same slices and the same ~2,000 nodes:

  career fit   (gamma = 0)      787 ms
  drifting fit (gamma = 0.15)  6214 ms

Entirely the collapse rule. A competitor with zero drift contributes one
variable however long the history, so a drift-free fit's joint is smaller
than a drifting one's by roughly the slice count — and to factorise, by
its cube. Choosing a drift configuration is therefore also choosing a
query cost, and nothing said so.

Documented on `Joint`, on `Joint::variables` and on `posterior_of`, with
the measurement. `variables()` is named as the number that decides
affordability, since it can be read before committing to a batch.

Also states the thing the consumer proposed as a future optimisation,
because it is already true: an absence is not an appearance, so a
competitor seen in the first and last of a hundred slices contributes two
variables rather than a hundred. The matrix is already as small as the
model allows on that axis.

tests/joint_handle.rs pins the mechanism — ten slices, two competitors,
twenty variables drifting against two at `gamma = 0` — so a change to the
collapse rule cannot quietly remove the property the docs now promise.

Refs #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 19:45:51 +02:00
logaritmiskandClaude Opus 5 e493f47e99 feat!: add History::register and History::rating, and reject config conflicts across batches
Three things #38 asked for, on a premise that had half dissolved. The
issue argued from "captured at first appearance", "missing it is silent"
and "missing it is permanent"; 8c087ad made configuration apply whenever
supplied and refit the whole history, so two of those are already gone.
What survived is the literal title — no way to say it before the first
event — plus the absence of any way to check.

`register(Member)` states configuration before anything is observed. It
takes the same `Member` ingestion takes, so there is one vocabulary
rather than two, and it creates the competitor immediately, which is what
makes it observable. It reaches a competitor first seen through
`record_winner`, the route #37 deliberately did not extend.

`rating(&key)` reads back what was stored. Every other accessor reports
what inference inferred; this reports what it was told, which is what
makes a configuration mistake detectable from outside the crate at all.

Conflicting configuration is now an error across batches, not only within
one. The `priors` map is rebuilt per `add_events` call, so a second batch
silently overwrote what a first declared, last-write-wins. That cut
directly against the invariant tests/ingestion_equivalence.rs exists to
protect: the same contradictory events errored when batched and
succeeded, order-dependently, when fed one at a time. Detection lives on
a new `declared` map on `History`, because a `Rating` cannot say whether
a value was chosen or inherited from the defaults — which is exactly the
distinction the check needs. Checked before anything mutates, so a
rejected batch leaves the history untouched.

`register` rejects a non-default `weight` rather than ignoring it. Weight
is per-event and has no meaning on a registration, and silently dropping
a field the caller set is the defect this whole area keeps producing.

The declarative `default_rating_for` closure is not here. It is the
better answer for ustat's actual case — thousands of keys matching a
rule, rather than enumerated — but it adds a `Fn` parameter to `History`,
which the issue itself flags as in tension with the crate's posture. That
wants its own decision rather than riding along.

BREAKING CHANGE: two different values for one competitor's `prior` or
`drift_scale` supplied across separate `add_events` calls now return
`ConflictingCompetitorConfig` instead of silently taking the later one.

Refs #38

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 16:11:09 +02:00
logaritmiskandClaude Opus 5 4f6360128d feat!: validate mu, sigma and beta on HistoryBuilder
The last three unvalidated setters, beside `p_draw`, `score_sigma` and
`convergence`, which all assert eagerly. Measured before choosing bounds:

  beta = 0        -> works, pi 0.0211 (vs 0.0193 at the default)
  beta = -4.17    -> bit-identical to +4.17
  sigma = -8.33   -> bit-identical to +8.33
  sigma = 0       -> NonFiniteResult, current_skill returns tau: NaN
  sigma = inf     -> same
  mu = NaN        -> same

So the bounds are not the obvious ones. `beta = 0` is legitimate and
meaningful — performance is then exactly skill, and the fit moves
measurably rather than degenerating — so zero is allowed and a test pins
that it reaches a different answer, since "allowed" would otherwise be
indistinguishable from "unchecked".

The negative cases are the quiet ones. `sigma` and `beta` enter inference
only as squares, so a negative value behaves as its absolute value and
the sign is dropped without comment. That is the same defect
`Member::with_drift_scale` already rejects, for the reason already
written there.

The non-finite cases are detected today — `converge` reports
NonFiniteResult — but a caller who reads `current_skill` first is handed
`tau: NaN`, so rejecting at the boundary is what actually closes it.

BREAKING CHANGE: `HistoryBuilder::mu`, `sigma` and `beta` now panic on
values they previously accepted, matching the existing behaviour of
`p_draw`, `score_sigma` and `convergence`.

Refs #18

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 16:06:37 +02:00
logaritmiskandClaude Opus 5 eff63dfa2a feat!: make a short fit an error and raise the default iteration cap
`ITERATIONS` was 30, and overrunning it returned `Ok` with
`converged: false`. Both halves were wrong.

The cap is a runaway guard, not a budget: the sweep exits as soon as the
step falls below `epsilon`, so a high cap costs nothing on a history that
converges. Measured on one needing four sweeps, `max_iter` 30 and
100_000 both finish in 4 iterations and ~130us. So 30 could never make
anything faster — it could only stop a healthy history early, and it did:
160 events over 100 competitors already needs 42.

Not scaled to the history, because iteration count tracks how loopy the
graph is rather than how big it is. At a fixed 320 events over 40 slices,
varying only the competitors sharing them: 3 competitors needs 2_789
sweeps, 10 needs 1_068, 100 needs 90, 400 needs 2. Three orders of
magnitude on identical event and slice counts, so any formula in those
two numbers would be badly wrong on some real shape. A single value set
high enough that reaching it means oscillation is the honest version.

With the cap raised, stopping at it means something is genuinely wrong,
so `converge` now returns `InferenceError::NotConverged` rather than a
flag on a success. A short fit is wrong by a little — every rating
finite, the ordering sensible, nothing saying the numbers were still
moving — and a flag has to be checked while `let _ = h.converge()` is the
natural way not to. That is not hypothetical: it is how a real defect hid
in this crate's own test suite.

`converge_partial` returns the short fit for callers who want one. Only a
single existing test needed it, which is the evidence that a capped fit
is a deliberate choice rather than the common case.

Also corrects the `ITERATIONS` docs, which claimed convergence cost is
"roughly linear in the cap". It is linear in the iterations actually run.

BREAKING CHANGE: `History::converge` returns `Err(NotConverged)` where it
previously returned `Ok` with `converged: false`. Callers that want the
old behaviour should use `History::converge_partial`. The default
`max_iter` changes from 30 to 10_000, so a history that was silently
truncated will now converge properly and its numbers will move.

Closes #50

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 16:04:22 +02:00
logaritmiskandClaude Opus 5 7c6965c6a9 Merge branch 'fix/ingestion-shape'
Reject malformed events at the ingestion boundary, add
EventBuilder::members, and record the rayon opt-in deviation.

Closes #5
Closes #37

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 15:53:13 +02:00
logaritmiskandClaude Opus 5 911b48faba feat: add EventBuilder::members for per-member configuration
`EventBuilder` could set weights and nothing else, so `prior` and
`drift_scale` were reachable only through the typed
`Event`/`Team`/`Member` shape plus `add_events`. Which ingestion route a
competitor arrived through decided whether it could be configured.

`members(...)` takes `Member` values directly, so `Member`'s own builder
expresses everything. `team(...)` stays the common case.

One escape hatch rather than `priors` and `drift_scales` setters beside
`weights`, as the issue suggested and then argued against itself: a
parallel array per field means a parallel length check per field, and
each one is a new way to get the lengths wrong. `Member` already has a
builder; this just lets the fluent path reach it.

`record_winner`/`record_draw` are deliberately left alone. They are the
two-argument convenience path, and extending them would be a breaking
signature change. The issue's reason for wanting them extended has also
weakened: it said a competitor arriving through them was "permanently
stuck on the history defaults", and since 8c087ad that is no longer true
— a later `add_events` carrying the `Member` refits the whole history.
Measured, late configuration through that route reaches mu 40.000000000,
identical to configuring from the start.

Refs #37

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 12:32:00 +02:00
logaritmiskandClaude Opus 5 f57784c141 docs: record the rayon opt-in deviation in spec section 6
Issue #5 asked for a decision, not an implementation: either flip rayon
to default-on, or record why the spec was deviated from and close.

Opt-in stands. The measured speedups are 1.0x realistic / 1.3x
pathological (#4), so default-on would cost every downstream user a
thread pool and a dependency for approximately nothing.

The condition the decision was waiting on cannot be met: #5 was blocked
on re-measuring after cross-slice dirty-bit skipping landed, and #4 was
closed by removing the inert slices_skipped field rather than by
implementing it. There is no forthcoming measurement to wait for.

Also corrects the spec's own reasoning. It cited an unsafe concurrent
write through SkillStore as a cost of going default-on; the crate is
forbid(unsafe_code) and the compute/apply split avoids that entirely.
The case for opt-in is the measurements, not a safety argument.

Closes #5

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 12:29:40 +02:00
logaritmiskandClaude Opus 5 8e4d6a637d fix: reject malformed events at the ingestion boundary
A one-team event reached `run_chain`, which builds one diff link per
adjacent pair of teams, leaving it to index `links[1..]` on an empty
vector. That panicked with "range start index 1 out of range for slice
of length 0" — from `History::add_events`, in a release build, through
entirely safe API.

An empty team was the quieter half of the same gap. It contributes no
performance, so a malformed event converged and handed back a finite,
plausible-looking posterior for whoever it was matched against. That is
this crate's characteristic defect: a public surface reporting a
constant that looks like an answer.

A non-finite score was the third. `converge` did report NonFiniteResult,
so it was detected — but a caller reading `current_skill` before
converging was handed `tau: NaN` with nothing to say so.

`NotEnoughTeams` and `EmptyTeam` already existed. They were checked on
the prediction paths and nowhere else, which is exactly why ingestion
could still manufacture the states they describe. The checks go in
`add_events_with_prior` alongside the tie check, for the same reason
that one is there: every ingestion route lands on it, so `record_winner`,
`record_draw` and `EventBuilder` inherit them rather than each needing
their own.

Also corrects documentation that had been stating the opposite of the
code since 8c087ad in 0.4.0. README.md and the `with_prior` /
`with_drift_scale` doc comments all still said competitor configuration
was "captured at first appearance" and had "no effect" on a known key.
It now applies whenever supplied and refits the whole history. A reader
would have concluded late configuration was impossible and built a
workaround for a limitation that does not exist. CI compiles README code
blocks but not prose, which is why it survived three releases.

The comment in tests/degenerate_inputs.rs claiming a one-team event was
"rejected for an unrelated reason" was wrong when written — it panicked.

Refs #18, #26

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 12:29:14 +02:00
logaritmisk 82eff740b6 chore: Release trueskill-tt version 0.7.0 2026-09-08 10:27:21 +02:00
logaritmiskandClaude Opus 5 c1b1c6c7d7 Merge branch 'feat/joint-handle'
Factorise the joint once with History::joint, so a batch of queries pays
the O(n^3) Cholesky once rather than once per question.

Closes #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 10:24:09 +02:00
logaritmiskandClaude Opus 5 1bb6bb31d8 feat: factorise the joint once with History::joint
`posterior_of`, `posterior_of_at` and `expected_variance_reduction` each
built the joint precision matrix, factorised it, asked one question and
threw it away. The factorisation is O(n^3) in the history's appearances
and depends only on the fit, so a caller asking about every pair in a
standings table, every cell in a grid, or every candidate in an
active-learning sweep paid for the same factorisation once per question.

`History::joint()` returns a `Joint` handle that pays it once. Measured
on 1976 appearances, 90 queries: 68.4s one-shot against 745ms factorise
plus 93ms of queries — 81.6x, with bit-identical answers. Per query,
Criterion at 480 appearances: 9.0ms one-shot against 48us cached, 187x.

The handle borrows the history, which is what makes it correct with no
invalidation logic: the borrow checker forbids adding events or refitting
while it is alive, so there is no window in which the factorisation could
describe a fit that no longer exists. It also makes the lifetime of the
n^2 factor explicit rather than parking it in the history forever — at
4000 appearances that is 128MB, which is not something to cache silently.

Every question the joint answers turns out to be a bilinear form,

    c^T A^-1 a = (L^-1 c) . (L^-1 a)

so no caller ever needs L^-1 c itself. Replacing the general solve with a
forward substitution drops the back substitution as wasted work, halving
a query, and removes a failure mode: a variance as `c . (A^-1 c)` is a
difference of products that can round negative, where `|L^-1 c|^2` is a
sum of squares and cannot.

The one-shot calls are unchanged in cost and now delegate to the handle,
so the two paths cannot drift apart. tests/joint_handle.rs asserts they
agree bit for bit, including at pinned times, under UnknownKeys::Prior,
and across candidate matchups.

Refs #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 07:53:51 +02:00
logaritmisk b113385c6f chore: Release trueskill-tt version 0.6.0 2026-09-08 06:46:36 +02:00
logaritmiskandClaude Opus 5 f345e7690e fix!: make the joint span slices, not just the latest one
`posterior_of` shipped in 0.5.0 reading a single slice. Measured against
a real Through-Time history that answers almost nothing: ustat's round
fit is 76 per-day slices whose last one holds a solo round, so 0 of 55
pair differences resolved and the single node that did was degenerate —
a one-competitor slice has no correlation to account for and returns the
marginal unchanged.

That was my mistake, and the fixture chose it. I validated against
single-slice histories, which is exactly the shape that cannot reveal
the problem. In a library whose premise is skill over time, competitors
are read at *their own* last appearance and those are different slices
by construction.

The joint is now time-expanded: one variable per appearance, linked by
the prior on a first appearance, the drift between consecutive ones, and
the within-slice event contrasts. Consecutive appearances with no drift
between them are the same variable rather than two joined by an infinite
precision, which keeps the matrix positive-definite when a competitor is
pinned with `drift_scale = 0`.

`posterior_of` now reads each competitor at their own latest appearance,
which is where `current_skill` reads them, so the two agree about which
posterior they describe. Adds `posterior_of_at(time, terms)` for a
comparison anchored to a moment, matching `learning_curve`'s reading.

Validated against a hand-written exact posterior for a two-competitor,
two-slice history — the precision matrix is spelled out in the test
rather than obtained from the crate, so it is an independent check
rather than a restatement. Also pinned: competitors last seen in
different slices now compare at all, means still agree with the
marginals, zero drift makes slice layout irrelevant, and more drift
widens a comparison across time.

BREAKING CHANGE: `posterior_of` and `expected_variance_reduction` now
consider the whole history rather than its latest slice, so results
change for any multi-slice history. `JointUnavailable` is now returned
when *any* slice holds ranked events, not just the last.

Refs #46, #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 06:04:41 +02:00
logaritmisk d9e85cda1d chore: Release trueskill-tt version 0.5.0 2026-09-08 02:16:55 +02:00
logaritmiskandClaude Opus 5 633a503900 refactor!: remove the factor-graph surface nothing used, add try_winner
Two decisions taken before cutting 0.5.0.

#42 — the `Schedule` trait was public API the engine never called. Its
only call site was `Game::custom`, itself `#[doc(hidden)]`, and
`EpsilonOrMax` was never constructed anywhere. Removing that surface
showed the problem was larger than the issue described: with `custom`
gone, the compiler found `Factor`, `BuiltinFactor`, `RankDiffFactor` and
`TeamSumFactor` all dead too.

`Game::run_chain` drives a local `DiffFactor` enum and bypasses the
whole T1 abstraction — it has done since it was written. So this is not
just an unused extension point but the machinery it was built on, and
`CLAUDE.md` was documenting it as live architecture.

Removed: `graph` module, `Schedule`, `EpsilonOrMax`, `ScheduleReport`,
`Game::custom`, `Factor`, `BuiltinFactor`, `RankDiffFactor`,
`TeamSumFactor`. `TruncFactor`, `MarginFactor`, `VarStore` and `VarId`
stay — inference uses those. The measurement behind choosing removal
over wiring is in #42: the within-game loop converges in 1 to 8
iterations against a cap of 30, so a `Residual` schedule has no headroom
to reclaim, and `Damped` already shipped as `ConvergenceOptions::alpha`.

#20 — `Outcome::winner` panicking on an out-of-range index. Kept, and
the reasoning is now on the method. It is the only constructor here that
validates, which looks inconsistent until you try deferring like its
siblings: `winner(5, 2)` produces ranks `[1, 1]`, an all-tied draw that
ingestion accepts without complaint when `p_draw > 0`. Asking "team 5
won" and silently getting "everyone drew" is the exact failure this
crate keeps removing, so the check belongs where the mistake is.

Adds `Outcome::try_winner` for indices that are computed or parsed
rather than written literally, following the `new`/`try_new` convention.
That is additive; the panicking form stays because every call site in
this repo, its tests and its README passes literals, where a `?` would
be noise.

BREAKING CHANGE: the `graph` module and everything it exported are
removed, as are `Game::custom` and `ScheduleReport`.

Closes #42. Closes #20.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 02:14:43 +02:00
logaritmiskandClaude Opus 5 7cf45db5cf feat: add expected_variance_reduction for scored active learning
#49: `expected_information_gain` enumerates discrete outcomes, so a
consumer recording continuous scores cannot ask which matchup to run
next. The issue flagged this as possibly a research question, since
"expected variance reduction under EP may not have a clean closed form
even for Gaussian likelihoods".

It does. Observing a scored event is a rank-one update to the precision
matrix, so Sherman-Morrison gives

    reduction = (c^T L^-1 a)^2 / (v + a^T L^-1 a)

for target functional c and matchup contrast a. Verified against an
actual refit on four candidate matchups: agreement to 1e-9 relative.

Two consequences worth stating.

There is no expectation to take. The expression depends on which matchup
is played but not on how it turns out, because for a Gaussian likelihood
the posterior variance update is data-independent. Pinned by
`the_outcome_does_not_change_the_reduction`, which refits with scores of
(3, 1), (100, -50) and (0, 0) and gets the same answer. The name keeps
the term the active-learning literature uses; no averaging happens.

It is also far cheaper than its ranked counterpart — one linear solve
rather than a full inference pass per possible outcome — because
`c^T L^-1 a` and `a^T L^-1 a` share the same solve.

`target` is deliberately the same linear-functional shape as
`posterior_of`, as the issue proposed, so the two share a concept rather
than inventing two.

The load-bearing test is the refit comparison. An acquisition function
is the archetype of a surface that returns finite, plausible, monotone
numbers while being wrong, and then quietly selects worse matchups
forever; ranking behaviour alone would not catch that.

Closes #49

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 02:08:32 +02:00
logaritmiskandClaude Opus 5 c866210c65 feat: add History::predict_margin for scored matchups
#48: every predict_* answers "who wins", and a consumer recording scores
never asks that. It wants the interval on the result, and having none it
hand-fitted a noise law whose fitted node weight came out at 0.0 — so
the quoted sigma was 5.83 whether the competitor had forty rounds or
none, against real residual spreads of 5.8 and 12.44.

`predict_margin` composes the three things that make a scored result
uncertain: the joint posterior over the competitors, their per-event
performance noise, and the observation noise on the score. It widens as
the model knows less — measured, sigma 2.48 against an opponent with
forty rounds, 3.38 against one seen once, wider still against one never
seen — which is the property the hand-fitted law lost.

It is a margin, not a score, and that is not a shortcut. Scored
ingestion reduces every event to `score_a - score_b` before inference,
so the absolute level is discarded: shifting every score in a history by
+100 or -1000 produces a bit-identical fit, verified. There is no
information from which to predict what a competitor will *score*.
Returning one would be a number derived entirely from the prior, which
is exactly the plausible constant this crate keeps finding and removing.

`posterior_of` now honours `UnknownKeys::Prior`, which gives #48 its
second requirement — "I have never seen this competitor, here is the
prior-informed answer". An unseen competitor shares no event with the
slice, so it is independent by construction and its variance is additive
rather than part of the solve.

Worth recording for expectations: for a *margin* the joint buys little
over adding marginals (2.4798 against 2.5112 here), because a margin is
a difference and differences are where the loopy underestimate and the
ignored correlation cancel. The gain here is having a predictive
distribution at all. `posterior_of`'s correlation handling earns its
keep on sums and single nodes instead — see tests/additive_model.rs.

Closes #48

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 02:02:15 +02:00
logaritmiskandClaude Opus 5 1f791bcddd test: pin what an additive model does to combined uncertainty
Investigating #47 needed a reproduction of the reporter's structure: a
joint player/layout model where every observation measures a sum of
nodes against a reference, so the data pins differences and leaves the
overall level to the prior.

The result corrects #46's framing as well as answering #47. Combining
marginals is wrong in opposite directions depending on the combination,
and the unsafe direction is not the one either issue assumed:

    combination            exact   adding marginals
    p0 + h0 (a round)     4.0850   0.8041   0.20x   OVERconfident
    p0 - p1 (rank two)    0.8741   0.8592   0.98x   about right
    h0 - h1               0.7119   0.7057   0.99x   about right

#46 assumed every published figure was too wide and that this was "at
least the safe direction". That holds for differences. For sums it
reverses: adding marginals is five times too narrow, which publishes a
claim the data does not support.

For a single node the exact marginal is ~5x wider than message passing
reports, because the level it shares with its partners is pinned only by
the prior. That is the honest posterior for a weakly identified
parameter, not a defect — and it is why #47's node "should be
publishable" intuition and its posterior sigma disagree.

Refs #46, #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 01:52:39 +02:00
logaritmiskandClaude Opus 5 c52e2550af feat: add History::posterior_of for a linear combination of competitors
#46: every accessor returns a per-competitor marginal, and almost
nothing a consumer publishes is one competitor. Combining marginals
assumes independence, and competitors are correlated through every event
they share.

`posterior_of(&[(a, 1.0), (b, -1.0)])` returns the posterior of that
combination with the correlation intact. Validated against the exact
linear-Gaussian posterior on both a tree and a loopy fixture, for
differences and for single competitors: agreement to 1e-9 relative in
every case.

The investigation that preceded this is why it is not a covariance
accessor. Marginals from loopy message passing are about half the true
width, and ignoring correlation overstates a difference — the two errors
partially cancel, leaving 1.327x rather than 2.646x. Bolting true
correlations onto the existing marginals would have given 0.765 against
a true 1.524, which is overconfident: the direction the reporter
specifically called unsafe. Rebuilding the joint from the factor
structure fixes both at once, and a single-competitor query now returns
the exact marginal rather than the narrow one.

The precision matrix depends only on structure — who played whom, with
what weights and what noise — not on the observed outcomes, and the
means were already exact. So only the second moment is reconstructed.

Known limits, all deliberate and documented on the method:

- Latest slice only. A functional spanning times, such as "current
  versus career", needs the time-expanded joint and is not covered.
- Scored events only. A ranked outcome's truncation is EP-approximated
  and its converged factors are not retained after inference, so ranked
  slices return `JointUnavailable` rather than a plausible wrong number.
- Dense Cholesky, O(n^3) per query in the slice's competitor count:
  38.8us at 50, 5.66ms at 400, 49.1ms at 800. Fine for the sizes this
  serves today; caching the factorization per slice would make repeat
  queries O(n^2), and sparsity is the next step after that.

Refs #46, #47, #48

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 01:46:35 +02:00
logaritmisk 4924bc8b57 style: use arrays rather than vec! in the calibration fixture
clippy::useless_vec. Pushed broken because the verification chain used
`if ... then echo` blocks that report status without gating the `&&`
that follows — the same shape of mistake as e1bddf2, where a grep
succeeded on the error text. Both times the check printed FAIL and the
push went ahead.

The reliable form is a single fail-fast chain:

    just lint && just test && just determinism && cargo test --doc

so nothing runs after the first failure.
2026-09-08 01:39:21 +02:00
logaritmiskandClaude Opus 5 36eacf5f67 test: calibrate the marginals against the exact posterior
Investigation for #46 and #47, before touching either.

A scored history is linear-Gaussian, so its true joint posterior has a
closed form and the crate can be checked against ground truth. Measured
on five competitors:

                    means      marginal sd (crate / exact)
    tree (star)     exact      1.000
    loopy (robin)   exact      0.502

On a tree the crate is exact in both. With cycles the means stay exact —
the standard Gaussian-BP result, and the property ratings rely on —
while marginal variances come out about half the true width.

That is the opposite direction from what #47 reports, so whatever is
happening in that consumer's model, the crate being conservative is not
it.

It also means #46 cannot be implemented as an added covariance accessor.
The exact correlation between two nodes here is +0.857, so ignoring it
overstates the width of a difference — but the too-narrow marginals
partially cancel that, leaving 1.327x rather than 2.646x. Adding true
correlations to these marginals without correcting them would give 0.765
against a true 1.524: overconfident, which is the direction the reporter
specifically called unsafe.

Pins the two real invariants (exactness on a tree, exact means with
cycles) and deliberately only records the variance gap, since closing it
is what #46 proposes.

Also records the working rules this project has converged on: investigate
before implementing, fix the root issue, and scout crates.io on measured
accuracy rather than adoption.

Refs #46, #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 01:38:46 +02:00
logaritmiskandClaude Opus 5 71554fd944 feat: add UnknownKeys::Prior, and explain why there is no Skip
#44's third ask was an opt-in mode so a caller with partially-known
teams need not pre-filter. The requested shape was `Skip` — drop unknown
members. Measured, that is the wrong mode to build.

A team's performance is the *sum* of its members, so dropping one drops
its variance too. On a two-member team with one unknown:

    SKIP  (drop the member)  : performance sigma 2.37
    PRIOR (member at prior)  : performance sigma 6.53   (2.76x wider)

Skipping makes the model *more* certain because it knows *less*, which
is backwards. `Prior` is also the answer the model already gives for a
competitor it knows about but has no evidence for — measured, such a
competitor sits at sigma 4.99 against the prior's 6.0 — so it
corresponds to a state the model can actually be in. Skipping does not.

So the enum is `Reject` (default, unchanged) and `Prior`, and it is
`#[non_exhaustive]` in case a real use for skipping turns up later.

Placed on `HistoryBuilder` rather than per-call. Neither consumer wants
it to vary between queries: one scores thousands of candidate matchups
in a loop, the other's headline feature is predicting a competitor
nobody has faced. That makes it a property of how the model is being
used, and keeps five prediction signatures unchanged.

This also gives #48 the semantics it asked for — "I have never seen this
competitor, here is the prior-informed answer" — which it needs for
predicting a course nobody has played.

`an_unknown_member_widens_its_team_rather_than_narrowing_it` pins the
property that ruled `Skip` out, so a future convenience cannot quietly
reintroduce it.

Closes #44. Refs #48

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 01:28:49 +02:00
logaritmiskandClaude Opus 5 2cf21a753d docs: record that the event log is the source of truth, and why
#45 asked whether a fitted `History` can be persisted, and noted that
the absence of `serde` "reads as an omission rather than a decision,
which invites exactly this issue from every consumer in turn". Fair.

Documents the decision on `History` along with the reasoning that makes
it one: `converge` reaches a fixed point determined by the events,
ratings and configuration alone, so a snapshot would carry no
information the event log does not — it would cache the computation,
never the answer.

It also bounds what a snapshot could buy, since that is the question a
consumer actually has. Re-converging an unchanged history costs one
iteration, measured at 0.91 ms against 365 ms cold on 2 000 events, so
it would make a cold restart cheap and do nothing for appends. Appending
one event moves its participants more than a sigma across their whole
history, so that re-convergence is real work rather than repeated work.

Closes #45

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 01:22:28 +02:00
logaritmiskandClaude Opus 5 35d7512557 fix(test): the ingestion-order property was comparing two truncated fits
`ingestion_order_does_not_change_the_answer` failed with a 1.2e-6 gap in
mu and read as a violation of the invariant. It was not. Both sides ran
`max_iter: 200` against `epsilon: 1e-10`, and the batched side stopped
at the cap with a step of 3.4e-9 — so the test compared two fits that
had not converged and attributed the difference to ingestion order.

Raised to 20_000, at which both converge and the property holds. Runtime
is unchanged at 0.05s, because converging is what the iterations were
for.

The test now asserts `report.converged` on both sides before comparing.
That is the part worth keeping: any test that compares two fits for
equality is measuring truncation unless it first establishes that both
reached a fixed point. `ingestion_equivalence.rs` already did this;
`properties.rs` did not.

Found because c12bc83 made `ConvergenceReport` `#[must_use]`, which is
the same failure #50 describes — a short fit is wrong by a little and
looks entirely plausible. The blanket `let _ =` that commit applied to
78 call sites was too blunt here: binding the report to `_` silenced the
one signal that would have caught this, in a test whose whole purpose is
to compare two fits.

Refs #50

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 01:20:48 +02:00
logaritmisk e1bddf2474 style: factor the event-pair type out of the reconvergence fixture
clippy's `type_complexity` fires on the tuple-of-vectors return. Caught
after pushing, because the verification chain used `&&` between `just
lint` and a `grep` that succeeded on the error text — so a failing lint
reported success. The gate has to be on the command, not on whether the
output matched.
2026-09-08 01:18:59 +02:00
logaritmiskandClaude Opus 5 5d36fa1008 test: pin that re-convergence is path-independent
Answering #45 — can a fitted `History` be persisted — needed to know
whether `converge` reaches a fixed point determined by the events alone,
or one that depends on the message state it started from. It is the
former, and that is worth a test rather than a comment.

`tests/ingestion_equivalence.rs` varies how events are batched but
converges only at the end. These converge *between* batches, which is
the path a caller takes when it fits, serves, then ingests more.

Measured divergence from a single fit over the same events: 6.2e-13 for
an append strictly later than every existing slice, 8.9e-11 for one
interleaved with them. Both at the convergence tolerance. The design
question guessed the interleaved case might be weaker; it is not, and
the reason is that Through Time revises the past on every converge
anyway, so doing it in two steps is not a special case.

Also pins that re-converging an unchanged history costs one iteration.
Measured on a 2000-event fixture that is 0.91ms against 365ms cold — the
fact that makes a restored snapshot worth having at all.

Refs #45

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 01:18:28 +02:00
logaritmiskandClaude Opus 5 c12bc830a5 feat!: name the unknown key, expose tail probabilities, flag short fits
Three issues from two downstream consumers, all small, all sharing a
theme: the crate had the information and would not hand it over.

#44 — `UnknownKey { team: 0, member: 0 }` did not say which key. A
consumer upgrading 0.1.2 -> 0.4.1 had every one of 5591 predictions
return this error, fell back to a neutral 0.5, and lost its entire
metadata model for a day. Nothing crashed and nothing logged; it was
found by sweeping an unrelated parameter and noticing the output did not
move. The 0.4.0 change that made unknown keys an error was right — the
error was just too anonymous to act on. It now carries the key's `Debug`
rendering, and its `Display` says what to do about it. The precondition
is documented on every prediction entry point, which the reporter said
would alone have saved the day.

#43 — `cdf` was `pub(crate)`, so a consumer asking "is this competitor
below the cutoff" approximated it with a `mu + z * sigma` band and had no
way to say what confidence any `z` bought. Adds
`Gaussian::probability_below` / `probability_above`. The second is
separate on purpose: `1 - cdf` collapses to exactly zero past ~8.3
sigma, and a stopping rule is evaluated precisely there. Both route
through the survival function added in 0.4.1, so this is visibility
rather than new numerics.

#50 — `ConvergenceReport` was not `#[must_use]`, so the one signal that
a fit stopped short was trivially discarded. It now is, and that
immediately found 78 sites doing exactly that — including this crate's
own ATP example, which was capped at 10 sweeps when the history needs
30. The example now reads the report and says so.

`ITERATIONS = 30` is documented as the floor it is, with the three
measurements to hand: 400 events over 100 competitors already stops
there at ~7e-3 against a 1e-6 tolerance, the ATP example needs 30 at a
much looser one, and a consumer's 2000-node model needs 76 to 161.

BREAKING CHANGE: `InferenceError::UnknownKey` gains a `key` field, and
the prediction methods now require `K: Debug` in order to fill it.

Closes #43, #50. Refs #44 — its third ask, an opt-in `UnknownKeys::Skip`
mode, is a live API question and deliberately not answered here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 23:41:03 +02:00
logaritmisk 901f60972e chore: Release trueskill-tt version 0.4.2 2026-09-07 22:46:54 +02:00
logaritmiskandClaude Opus 5 8116fd081f test: localise the erfc_inv tail residual to the caller's argument
Scouting crates.io for a more accurate `erfc` than `libm` turned up a
1.16e-12 relative error in `erfc_inv` at `p_draw = 0.999999`, measured
against a 70-digit `decimal` reference. It looked like too few Newton
steps. It is not: adding a fourth changed nothing.

The error is in forming the argument. `1.0 - 0.999999` is
`1.0000000000287557e-06` — 0.999999 is not representable, and
subtracting from one cancels, leaving 2.9e-11 of relative error before
`erfc_inv` is entered. Given an exactly-representable argument it
returns 1.8e-16. So the routine was never the problem, and the extra
iteration has been reverted rather than shipped as a fix for a defect
that was not there.

`puruspe::inverfc` returns the identical wrong value for the identical
reason, which is what makes the shared upstream cause obvious.

Adds a test that separates the two, and corrects a quantile constant in
`erfc_inv_matches_known_quantiles` that was recalled rather than
computed: `Phi^-1(0.9999995)` is 4.89163847569859, not
4.891638475699099. The others were checked against the same reference
and were right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 22:43:50 +02:00
logaritmiskandClaude Opus 5 17d072b2ae fix: route every transcendental through libm, and combine sigmas with hypot
Follow-on from #41, which added `libm` for `erfc`. Surveying what else
the dependency offers: its unique surface over `std` is `erf`/`erfc`,
`lgamma`/`tgamma` and Bessel functions, and only the first was ever
needed. But the survey found something better than another special
function.

`std`'s `exp` and `ln` delegate to the *system* math library. IEEE 754
specifies the basic operations and `sqrt` exactly and says nothing about
transcendentals, so those differ per platform. Measured here over 200k
inputs:

    exp: 19425/200000 differ from libm (worst 1 ulp)
    log:  9932/200000 differ

Inference is an iterative fixed point, so a one-ULP difference can change
an iteration count and move the answer by more than one ULP. Routing
every transcendental through `libm` makes a fit reproducible across
platforms — a stronger guarantee than `tests/determinism.rs`, which only
covers thread counts.

It costs nothing. `Batch::iteration` measured -2.7% [-5.7%, -0.3%] with
the whole set swapped, and not one golden moved.

Also switches the two places that combined sigmas as
`sqrt(a^2 + b^2)` to `hypot`. Squaring overflows to infinity above
~1.3e154 and flushes to zero below ~1.5e-154 — measured, the naive form
returns `inf` where `hypot` returns 1.41e160 — and `Gaussian`'s
constructors are public, so a caller can reach both ends.

Deliberately not done: rewriting the KL divergence's `ln` of a ratio via
`ln_1p`. The cancellation is real as the ratio approaches one, but
measured absolute error is at most ~1e-11 in a quantity of order 0.4
nats, so it changes nothing.

The invariant is recorded in `CLAUDE.md` and on `erfc`'s own docs, since
nothing enforces it mechanically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 22:33:30 +02:00
logaritmiskandClaude Opus 5 3dd659307a fix: replace the erfc approximation with libm, for free
#41 asked whether the Numerical Recipes `erfcc` approximation — 1.2e-7
relative, and the binding accuracy constraint on the whole crate — was
worth replacing, given it sits in the inference hot loop. It is, and it
costs nothing.

Measured, against an independent incomplete-gamma reference:

    range        previous (NR)        libm
    [-3, 0]            7.95e-8     2.15e-14
    [0, 0.5]           8.69e-8     4.70e-14
    [0.5, 2]           9.38e-8     1.24e-12
    [2, 6]             1.04e-7     5.20e-14
    [6, 26]            1.07e-7     1.75e-13

    erfc(0)          1.00000003          1.0  (exactly)
    |erfc(z)+erfc(-z)-2|  6.00e-8     2.22e-16

Performance, on `benches/batch.rs`: change [-3.34% +2.26%], p = 0.89 —
no change detected.

That result is counterintuitive, because libm's erfc is 1.65x slower
when swept uniformly over [-2.5, 2.5]. The sweep was the wrong input
distribution. Capturing the arguments inference actually passes:

    |x|<0.5    96.16%
    0.5-0.84    2.05%
    0.84-1.25   1.24%
    1.25-2      0.54%
    2-6         0.00%

98% fall below 0.84375, which is exactly where FDLIBM skips the
exponential entirely — while the NR form always pays for one. On the
real trace libm is the faster of the two (3.23 vs 3.78 ns/call).

An ad-hoc `Instant` harness reported a 16% end-to-end speedup; that was
an artifact of its own setup allocating and leaking per run, and
criterion's verdict of "no change" is the one to believe.

What it bought:

- `compute_margin` against exact quantiles: 8.4e-8 -> 1.7e-16.
- `cdf(mu, mu, sigma)` is now exactly 0.5; it was 1.5e-8 out.
- `sf + cdf` sums to one within a ULP, from 3e-8.
- `erfcx`'s two branches now agree to round-off across the crossover
  rather than to 1e-7, so the log-space evidence path and the linear one
  are consistent.
- Ten test tolerances tightened from 1e-6 to 1e-13..1e-15, and the
  prediction floor is now the integrator's rather than `cdf`'s.

Five goldens moved, by 2.4e-9 to 6e-7 — the magnitude of the removed
error, and `test_env_ttt`'s mu still rounds to the same six decimals.
Re-recorded with more digits so future drift stays visible. Verified as
movement toward truth per the goldens policy: every value now derives
from a primitive checked against an independent reference and satisfying
the exact identities, which the previous one did not.

Adds `libm` — zero transitive dependencies, rust-lang maintained.

Closes #41

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 22:25:00 +02:00
logaritmisk 7e289ee834 chore: Release trueskill-tt version 0.4.1 2026-09-07 21:49:05 +02:00
logaritmiskandClaude Opus 5 564969ee5d test: pin quality()'s N-group closed form, closing the README cross-check
The scan for precision defects found none in `quality()` — but it did
find that N identical teams have an exact closed form, which is a much
stronger regression net than the single two-team golden that was there.

For two identical single-player teams quality is
`sqrt(2b^2 / (2b^2 + s1^2 + s2^2))`. With the conventional parameters
that ratio is exactly 1/5, and the N-group generalisation is
`(1/5)^((n-1)/2)` — one factor per adjacent pair. Measured across
n = 2..10 the implementation matches to 1e-9, so the determinant path
that #9 rebuilt is correct over the whole range, not just at n = 2.

The n=3 and n=5 values (0.200 and 0.040) are also what the `trueskill`
Python package produces for the same configuration, which is the
cross-implementation check the README Todo has been asking for since
the redesign. Asserted separately as literals so a change to the
closed-form reasoning cannot silently carry them along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 21:46:46 +02:00
logaritmiskandClaude Opus 5 683813ec10 fix: correct erfc_inv's sign error and keep evidence in log space
A systematic scan for precision defects, following the tail-precision
work in 7341669. Three findings; the first is a correctness bug in a
released version.

1. `erfc_inv`'s initial guess had the wrong sign. Numerical Recipes'
   `inverfc` uses -0.70711 as the leading coefficient; this used
   +FRAC_1_SQRT_2. Since `rational - t` is negative, that put Newton on
   the mirror image of the root, and three fixed iterations could not
   cross back. Measured against exact standard-normal quantiles:

       p_draw   old rel err   new rel err
       0.50        1.46e-7       8.40e-8
       0.90        1.02e-1       8.63e-9
       0.95        3.06e-1       1.91e-8
       0.99        8.05e-1       5.89e-9

   `compute_margin` inherited it, so the draw margin was wrong for any
   `p_draw` above about 0.6 and *non-monotone* above 0.9 — it ran
   0.674, 1.476, 0.503, 0.982 as p_draw went 0.5, 0.9, 0.99, 0.999. A
   history configured for a 0.99 draw rate was being fitted at 0.385.
   Note it was slightly wrong everywhere, not only in the tail.

2. `MarginFactor` computed a density and clamped it. `pdf` underflows
   past ~38 sigma, so `ln` of the clamped zero reported -708 nats
   however far out the score actually was: 4292 nats adrift at 100
   sigma, and unbounded beyond. This is the same defect as the one
   fixed in `TruncFactor`, one file over, on the scored-outcome path.

3. `TruncFactor` still bottomed out past ~38 sigma even after 7341669
   removed the cancellation, because the linear probability itself
   underflows there.

2 and 3 are fixed the same way: factors cache a *log* evidence, built
from new `ln_pdf`, `ln_sf` and `ln_interval` helpers that factor the
shared exponential out analytically via the `erfcx` added earlier.
Nothing underflows, at any separation.

One golden moved. `test_1vs1vs1` runs at `p_draw = 0.5`, so it goes
through `compute_margin`; its 1e-6-place values shifted. Verified as
movement *toward* analytic truth by comparing both the old and new
inverse against exact quantiles, per the goldens policy in CLAUDE.md —
not re-baselined on faith.

Two test tolerances are asserted at 1e-6 rather than tighter because
above x = 2 `erfcx` uses a continued fraction accurate to ~1e-15 while
`erfc` carries ~1e-7, so the log path is the more accurate of the two
and they part company at `erfc`'s error. That floor is tracked in #41.

Refs #41

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 21:45:03 +02:00
logaritmisk 2a48d10aa9 chore: Release trueskill-tt version 0.4.0 2026-09-07 15:49:12 +02:00
logaritmiskandClaude Opus 5 d4f91fd221 fix: reject convergence options that silently disable inference
`Game::ranked` and `Game::scored` validated `p_draw` and `score_sigma`
but never `convergence`. `ConvergenceOptions` has public fields and
`GameOptions` carries one, so a caller could hand the engine a set that
`HistoryBuilder`'s eager asserts never saw. Past that, the only guard
was a `debug_assert!`, which is gone in the profile users ship.

An `alpha` of zero is the bad case, and it fails silently rather than
loudly. Measured in release before the fix:

    likelihoods: [[Gaussian { pi: 0.0, tau: 0.0 }],
                  [Gaussian { pi: 0.0, tau: 0.0 }]]

Every EP update unapplied, every likelihood uninformative, inference
returning the priors it was given — and an `OwnedGame` that looks
entirely ordinary to the caller. `HistoryBuilder::convergence` already
documents exactly this hazard; the `Game` constructors just did not
share the check.

Adds `ConvergenceOptions::validate`, called by both constructors.
Rejects `alpha` outside `(0.0, 1.0]` and negative `epsilon`; NaN fails
both comparisons and is rejected too.

`tests/validation.rs` states the release-mode guarantee for the whole
public surface, not just this hole, and CI already runs the suite in
release. Probing the other conditions #18 lists found five of eight
already enforced — ties without a draw probability, per-event score
sigma, weight/team dimensions, draw-probability range, score-sigma
range — so this closes the remaining gap rather than the whole issue.
The engine keeps its `debug_assert!`s as invariant documentation.

Refs #18

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 15:48:31 +02:00
logaritmiskandClaude Opus 5 8c087ad015 fix!: apply competitor configuration whenever it is supplied
`Member::with_prior` and `with_drift_scale` were consumed only on the
branch that *creates* a competitor — `priors.remove` sat inside
`if !self.agents.contains(..)`. Supplying either for a key the history
already knew did nothing at all: no error, no warning, and output
computed from the default prior. A prior applied on a competitor's very
first event and was silently discarded ever after.

Configuration now applies whenever supplied. Two details this forced:

Configuration is tracked per *field* rather than as a merged `Rating`.
A member setting only `drift_scale` must not also assert the default
prior, or it would silently undo a prior seeded on an earlier event.

Slice state has to be refreshed. `drift_scale` is re-derived on every
forward pass, but a prior is written into the competitor's earliest
slice once, at ingestion, and `iteration` refreshes only slices after
the first. Without the refresh a late prior would reach the drift terms
and nothing else — a subtler version of the drop being fixed. This was
caught by a test, not by reading the code.

Conflicting values for one competitor within a single batch are now
`ConflictingCompetitorConfig` rather than resolved by iteration order.
Events in a batch are unordered, so "last one wins" would make the
result depend on traversal — and `tests/ingestion_equivalence.rs` exists
to rule exactly that out. Repeating the same value stays inert, which is
the shape callers get when configuration is a property of the domain.

That invariant turned out to be tested only for *unconfigured*
competitors: every helper in that file built members with `Member::new`.
Extended to cover configured ones, including a check that configuration
changes the fit at all, so the order tests cannot pass vacuously.

`with_prior` had no coverage under `tests/` whatsoever, which is how
this survived. Adds `tests/competitor_config.rs`.

`drift_scale_is_ignored_after_first_appearance` asserted the old
behaviour and now asserts the new one. It was written as a deliberate
change-detector — "moving the capture would be a visible break, not a
silent one" — so it inverted rather than being deleted.

Also removes `InferenceError::ConvergenceFailed` and `NegativePrecision`,
which no code path ever constructed: public variants advertising failure
modes no caller could observe. Partial #20 — its other items were
already resolved, except `Outcome::winner` still panicking.

BREAKING CHANGE: `prior` and `drift_scale` now take effect for
competitors the history already knows, where they were previously
ignored; a batch supplying conflicting values for one competitor is now
an error. `InferenceError::ConvergenceFailed` and
`InferenceError::NegativePrecision` are removed.

Closes #10. Refs #20.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 15:42:31 +02:00
logaritmiskandClaude Opus 5 7341669d1a fix: stop destroying tail precision in evidence and truncation
`erfc` is sound — it holds ~1e-7 *relative* accuracy down to 1e-296 with
no tail degradation. Three expressions built on it threw that away by
subtracting quantities that both approach the same value.

1. `cavity_evidence` computed `1.0 - cdf(margin, ..)`, which is
   algebraically `sf(margin, ..)` and numerically a catastrophe: 7%
   error by eight sigma, and exactly zero past ~8.3, where the true
   probability is 1e-19 and perfectly representable. Clamped, that
   reached `log_evidence` as ln(f64::MIN_POSITIVE) = -708 whatever the
   truth was — off by 665 nats at nine sigma.

   `1 - cdf` is smallest precisely when the result contradicts the
   prior, so the model-comparison number was worst for upsets: the
   observation it exists to notice. Adds `sf`, the survival function,
   computed without the subtraction. The tie branch picks whichever tail
   keeps both of its terms small, for the same reason.

2. `v_w` computed the inverse Mills ratio as `pdf(-a) / cdf(-a)`. Both
   underflow together past about 39 sigma, giving `0 / 0` and putting
   NaN straight into the posterior. Adds `erfcx`, so the shared
   `exp(-alpha^2 / 2)` cancels analytically instead of being evaluated
   twice and divided.

3. With that fixed, `w = v * (v - alpha)` became the next casualty: `v`
   tends to `alpha`, so the gap lost every digit and drove `w` above 1,
   making `sqrt(1 - w)` NaN at alpha = 1e6. The gap now comes from its
   asymptotic series, which forms no difference at all. The tie branch
   had the same defect one expression over — `v * v - u` with both terms
   at 1e18 returned w = -128 — and a far-tail window is
   indistinguishable from a half-line, so it shares the asymptotic.

No public signature changes, and no existing golden moved: every one of
these only alters regions the old code got wrong. The two identity tests
are asserted at 1e-6 rather than tighter because `erfc` is not exactly
antisymmetric — `erfc(z) + erfc(-z)` differs from 2 by ~3e-8, and
`erfc(0)` returns 1.00000003. That floor is tracked separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 15:28:35 +02:00
logaritmiskandClaude Opus 5 2fff745c3b feat: let observers be shared, boxed, or borrowed
`History` takes its observer by value and never hands it back, so a
caller who wanted to read what an observer recorded had no way to keep a
handle to it. The natural spelling did not compile:

    let recorder = Arc::new(Recorder::default());
    History::builder().observer(Arc::clone(&recorder))
    // error[E0277]: `Arc<Recorder>: Observer<i64>` is not satisfied

The workaround was for every observer to wrap each of its own fields in
an `Arc` and derive `Clone` — one allocation and one lock per field, a
pattern each implementor had to rediscover, and nothing documenting it.

Adds blanket `Observer` impls for `Arc<O>`, `Box<O>` and `&O`. All are
`?Sized`, so `Arc<dyn Observer<T>>` and `Box<dyn Observer<T>>` work too
and an observer can be chosen at runtime. Also adds
`History::observer()` and `into_observer()`, so a non-shared observer's
state can be inspected in place or reclaimed after `converge` without
needing interior mutability at all.

`tests/observer.rs` is simplified to the shared spelling, so the
recommended pattern is the one demonstrated rather than the workaround.

Closes #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 15:13:28 +02:00
logaritmiskandClaude Opus 5 3c2f9ac64c feat: add expected information gain for active matchup selection
`quality()` answers "is this matchup fair". Callers picking which
comparison to run next need "is this matchup informative", and the two
coincide only for two evenly matched competitors. Without a principled
alternative, downstream code was reaching for hand-rolled heuristics
like `quality * sigma_a^2 * sigma_b^2`, which double-counts uncertainty:
the two factors are not independent.

Adds `expected_information_gain`, the outcome-weighted divergence
between current beliefs and the beliefs each result would produce:

    EIG = SUM P(outcome) * KL(posterior_after(outcome) || prior)

Available standalone over `Rating`s, and as
`History::expected_information_gain` using current skills and the
history's own beta, drift and p_draw — so the outcomes it weighs are the
ones that would actually be fitted.

This is the mutual information between the outcome and the skills, which
gives an analytic ceiling: gain cannot exceed the entropy of the thing
being observed, so at most `ln k` nats for k outcomes. That bound is the
sharpest test available, because an acquisition function is unusually
exposed to returning finite, plausible, monotone numbers while being
wrong — it would simply select slightly worse matchups forever. A
prototype of this returned 4.77 nats from a sign error while passing
every monotonicity check; `never_exceeds_the_entropy_of_the_outcome`
catches that class unconditionally.

Measured against the ceiling the values are meaningful rather than
vacuous: 0.382 nats for an even matchup between diffuse priors against
an 0.693 ceiling, falling to 0.013 for a lopsided one and 0.000 for a
hopeless one.

`disagrees_with_the_quality_times_variance_heuristic` pins down that
this is not a monotone transform of the heuristic it replaces — the two
rank a lopsided matchup and a confident even one in opposite orders — so
a later "simplification" cannot quietly revert to it.

Cost is one inference pass per possible outcome, documented on the
public API alongside the shortlist-then-score pattern, so callers do not
discover it in production.

Also folds the duplicated key-gathering in `predict_quality` and
`performances` into one validated `member_skills`.

Refs #39

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 15:08:12 +02:00
logaritmiskandClaude Opus 5 507894dae7 refactor!: close the remaining API gaps from #21
Three unrelated small defects, all requiring signature changes:

- `Game::one_v_one` hardcoded `GameOptions::default()`, so a 1v1 could
  never set `p_draw` or convergence options — and a drawn 1v1 was
  therefore unreachable through it, since the default `p_draw` is zero.
  It now takes `&GameOptions` like every other constructor.

- `Observer::on_batch_processed` was declared on the trait and never
  called from anywhere: implementors wired up a callback that could not
  fire. It is now called after each slice sweep, and renamed
  `on_slice_processed` to match the vocabulary the codebase adopted in
  T2 — the unit of work is a `TimeSlice`, not a batch. A slice is swept
  once travelling backward and once forward, so a multi-slice history
  fires it twice per slice per iteration; the doc comment says so.

- `pub mod factors` sat beside `pub(crate) mod factor`, two module paths
  differing by one character with only one of them importable. The
  public facade is now `graph`.

Tests cover each as a behaviour rather than a compile check: a drawn 1v1
succeeds only when p_draw is supplied, and the observer tests fail if
any callback stops firing.

BREAKING CHANGE: `Game::one_v_one` takes a fourth `&GameOptions`
argument; `Observer::on_batch_processed` is renamed
`on_slice_processed`; the `factors` module is renamed `graph`.

Closes #21

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 14:57:39 +02:00
logaritmiskandClaude Opus 5 bb2a845882 feat!: N-team outcome prediction with draw mass, replacing the 2-team panic
`predict_outcome` asserted `teams.len() == 2` and returned `[p, 1 - p]`,
allocating no probability to a draw even with `p_draw > 0`. For a
draw-enabled model the numbers were simply wrong, at any team count.

It now returns `Result<Prediction, InferenceError>` and supports N teams.

Two algorithms, both deterministic:

- Who finishes first. Performances are independent Gaussians, so this
  separates into a one-dimensional integral per team rather than a
  multivariate orthant probability. Adaptive Gauss-Kronrod evaluates it
  to ~1e-15, matching the exact two-team closed form.
- A specific finishing order. The factor graph only constrains
  rank-adjacent teams, so a full order is a chain of local constraints,
  not a general orthant integral. That chain collapses into a sequential
  recursion over cumulative integrals: O(teams * grid) per order.

Fixed-node Gauss-Hermite is the obvious tool for the first and is a trap:
when a rival's sigma is small the CDF product becomes a step narrower
than the node spacing, and the nodes step over it. Measured 4.4e-4 off
the closed form on a mildly skewed matchup and 1.7e-2 on a small-sigma
one, while still returning something that looks like a probability.
Adaptive refinement is what makes that case safe, and
`win_probabilities_survive_a_rival_with_a_tiny_sigma` pins it down.

The acceptance test is an identity rather than a golden: the outcome
space is exhaustive and disjoint, so the probabilities sum to one. Any
drift is integration error and nothing else. Gauss-Hermite failed it at
4.4e-4; this holds to ~1e-9.

Also from #21: unknown keys are now reported rather than dropped, so a
team of strangers can no longer produce a confident-looking prediction.
`predict_quality` returns `Result` for the same reason.

BREAKING CHANGE: `predict_outcome` returns `Result<Prediction, _>`
instead of `Vec<f64>`; `predict_quality` returns `Result<f64, _>`.

Refs #21, #39

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 14:55:11 +02:00
logaritmiskandClaude Opus 5 87fca8dcca docs: correct drifted documentation and compile the README in CI
Four README code blocks no longer compiled: `Player` was renamed `Rating`
in T2, the `Drift` trait gained a `T: Time` parameter and a second method,
and two blocks were missing imports outright. The `Rating` example needed
more than a rename — with the binding unused, `T` is ambiguous because
`ConstantDrift` implements `Drift<T>` for every `T`, so it now carries an
explicit annotation.

Nothing compiled those blocks. `src/lib.rs` gains a `cfg(doctest)` struct
carrying `#[doc = include_str!("../README.md")]`, which turns every `rust`
block into a doctest without displacing the curated crate docs as the
front page. Verified it bites: reintroducing `Player` fails the build with
E0432 rather than shipping. Illustrative blocks are fenced `text` — note
that a bare fence defaults to `rust` under rustdoc, which is how the
`variance_delta = elapsed * γ²` formula became a compile error.

Prose fixes: README claimed `Gaussian::forget` takes a square root (it
works in variance space) and pointed at a `.gamma()` builder method that
does not exist. CLAUDE.md's data-flow diagram spliced the public ingestion
shape into the internal one — `Team` is not in that chain — listed
`cdf()`/`erfc()` as public when they are `pub(crate)` and private, and
called `SkillStore` public when only `CompetitorStore` escapes the crate.

Rustdoc fixes: `EventBuilder::scores_with_sigma` claimed a debug-assert
that `Outcome::scores_with_sigma` never had and whose own docs contradict;
rejection happens at ingestion as `InvalidParameter`. `event.rs` described
`add_events_with_prior` as replaced when it is still the ingestion
chokepoint. `factors.rs` advertised `Game::custom` without noting it is
`#[doc(hidden)]`. Internal T2/T4 milestone labels are dropped from public
items; the ones in the private `time_slice` module are left alone.

Closes #35

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014b6wy2q8rnFK8U8GPJVQNU
2026-09-01 19:30:32 +02:00
logaritmiskandClaude Opus 5 ef62b57a08 fix(release): skip the changelog hook during a dry run
`just release-plan` is documented as a preview that writes nothing, but
cargo-release runs pre-release hooks during a dry run too. The hook wrote
CHANGELOG.md and `git add`ed it, so the clean-tree check in `just release`
then refused to run — the repo's own two-step release workflow could not
be followed as written.

Guard the hook on DRY_RUN, which cargo-release 1.1.5 exports to the hook
environment (verified by dumping `env` from a throwaway hook; it also sets
CRATE_NAME, PREV_VERSION and NEW_VERSION). The clean-tree check itself is
left alone: it is load-bearing, because publishing is irreversible.

Closes #36

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014b6wy2q8rnFK8U8GPJVQNU
2026-09-01 19:26:22 +02:00
logaritmisk b2a7ade10c chore: Release trueskill-tt version 0.3.0 2026-09-01 06:34:31 +02:00
logaritmiskandClaude Opus 5 617bc07f6f feat: allow drift to vary per competitor via Member::with_drift_scale
Drift was a property of the History, so every competitor drifted at the
same rate and a fixed reference point could not share a graph with moving
competitors. A bot at a known strength, a rating floor, a course
difficulty — all of them drifted along with the players.

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

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

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

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

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

Closes #34

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Benchmarks, against the pre-change code:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Breaking: `TimeSlice::add_events` is public.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:43:31 +02:00
logaritmisk 07285283b6 chore: Release trueskill-tt version 0.2.0 2026-08-27 17:37:31 +02:00
logaritmiskandClaude Opus 5 b73cf0145a chore: dual-license MIT OR Apache-2.0
The Rust ecosystem convention, and what kickscore, xy and saphyr already use —
kickscore being the closest sibling to this crate.

LICENSE-APACHE is the canonical 201-line Apache-2.0 text with the appendix
left as the unfilled template, which is the form Rust crates ship. LICENSE-MIT
carries the copyright line. Both are picked up by cargo automatically and
appear in the packaged crate.

This also unblocks the release workflow. `cargo publish` does not check the
license field when the target is an alternative registry, but cargo-release
does, and refuses outright:

    error: trueskill-tt is missing the following fields:
             license || license-file

--no-verify does not bypass it. So `just release` was inert without this,
whatever cargo publish alone would have accepted.

README gains the conventional dual-license section and the contribution note
that dedicates inbound contributions under the same terms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:35:42 +02:00
logaritmiskandClaude Opus 5 56ff01074f docs(cargo): correct the licence note — kellnr does not require one
The note claimed `cargo publish` rejects a crate without `license`. That is
true only for crates.io; publishing to an alternative registry does not check
it, verified by a dry run against kellnr that packages and verifies cleanly.

Staying unlicensed is a deliberate choice, so the note now says that and states
the actual consequence — all-rights-reserved by default — rather than a
mechanical blocker that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:30:59 +02:00
logaritmiskandClaude Opus 5 8d47e54a8a chore: keep the 48 MB ATP dataset out of the published crate
`cargo publish --dry-run` packaged 48.1 MiB (8.6 MiB compressed) for a library
whose source is 312 KB. All of it was examples/atp.csv, a tennis dataset the
atp example reads.

examples/atp.rs opens it by relative path at runtime rather than include_str!,
so excluding the data still compiles and `cargo package --verify` still builds
every target — the example just needs the file fetched from the repo to run.

Packaged size is now 360.8 KiB / 83.1 KiB compressed, a 133x reduction. Every
consumer would otherwise have paid that download.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:29:44 +02:00
logaritmiskandClaude Opus 5 7de092ba12 chore: target releases at the private kellnr registry
Mirrors the textus setup, adapted for a single crate rather than a workspace.

Cargo.toml gains publish = ["kellnr"], which does double duty: it points
cargo-release at the private registry and makes an accidental `cargo publish`
to crates.io a hard error rather than an irreversible mistake.

.cargo/config.toml is committed rather than left to a per-user
~/.cargo/config.toml. Without it a fresh clone, a new machine, or CI fails
with "registry index was not found in any configuration: kellnr" before
compiling anything. The index URL is not a secret; the token stays in
~/.cargo/credentials.toml, or CARGO_REGISTRIES_KELLNR_TOKEN in CI.

release.toml flips publish from false to true and pins push = false, so the
Justfile recipe pushes last — after tags and publish have both succeeded.
The git-cliff pre-release hook is unchanged.

cliff.toml gained a Breaking Changes group. Its commit_parsers matched on type
alone with conventional_commits = false, so `refactor!: remove the inert online
flag` rendered as an ordinary Refactor bullet and the break was invisible in
the generated changelog. The new parsers match a `!` subject and a
BREAKING CHANGE body, and must precede the type parsers because the first match
wins. The unreleased section now opens with the break, which matters because
the next release is the one that removes HistoryBuilder::online.

The release recipe runs `just ci` before cutting: cargo-release only
verify-compiles the packaged crate and publishing cannot be undone, and the
release profile is where this crate's defects have historically hidden.

Still unpublishable: Cargo.toml has no `license`. That is a deliberate TODO,
not an oversight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:28:51 +02:00
logaritmiskandClaude Opus 5 eeb43e3be1 fix: close out four small issues and pin #27's repro
#29 — log_evidence and log_evidence_for took &mut self while mutating
nothing. Loosening them to &self is not source-breaking for ordinary callers
(a &mut reborrows as & transparently) and brings them in line with the
filtered_* accessors added last week.

Not the mechanical change it looked like: under the rayon feature the closure
in log_evidence_internal captured all of &self rather than just the competitor
store, which drags KeyTable<K> in and demands K: Sync from every caller. That
compiled while the method took &mut self and stopped compiling the moment it
did not. Binding `let agents = &self.agents;` before the closure narrows the
capture; the comment there says why, because the next person to inline it will
reintroduce the bound.

#31 — TimeSlice::add_events constructed Skill with ..Default::default() while
filtered_step spells every field out. The design relies on a new Skill field
being a compile error at construction sites rather than a silent default, and
that tripwire only fired at one of the two. Now both.

#28 — log_evidence_internal's `forward` flag is a genuine forward-only
quantity only on a history that has never been converged, because iteration
alternates sweeps and the likelihood feeding the forward message absorbs
backward information from the second iteration onward. Documented, with a
pointer to filtered_log_evidence for the quantity that survives convergence.
That trap is one function away from the one #19 was about.

#23 — color_greedy carried #[allow(dead_code)] despite being called by
recompute_color_groups: a mute button on a live function, which is the
specific complaint in that issue.

#27 was already fixed — the guard landed in f4e2922 and the issue was filed
against 7742b2b, which merge-base confirms predates it — but nothing pinned
it. Added the issue's own reproduction, which matters because the two profiles
fail differently and a debug-only test would miss the release path. Removing
both guards reproduces the issue verbatim: "attempt to subtract with overflow"
in debug, "index out of bounds: the len is 0 but the index is
18446744073709551615" in release.

Also amended the filtered-estimates spec (#30): the tolerance-not-bit-identity
caveat is conservative. Forcing the scratch onto the sequential sweep instead
of the grouped one — a far larger perturbation than a permuted event order —
still agrees within 1e-8 under tight convergence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:21:05 +02:00
logaritmiskandClaude Opus 5 69ddebe21d docs: state filtered accessor cost and evidence semantics precisely
filtered_learning_curve's signature mirrors learning_curve, which is cheap
per key — so the mirroring trained callers to assume this one is too. It is
a full forward pass per call, making the natural loop over competitors
O(competitors * events). The doc now says so in complexity terms and points
multi-key callers at the plural form.

filtered_log_evidence claimed each event is scored "using only what was
known before it". That is exact for a slice holding one event, but events
sharing a timestamp inform each other through the within-slice sweep, so
the honest claim is "before that time". The behaviour is deliberate and
matches log_evidence's own convention; only the promise was too strong.

This branch exists because a feature's documentation was quietly false.
Shipping it with two more overstated doc comments would be a poor joke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 16:59:42 +02:00
logaritmisk 9c39d1e681 test: pin the invariants that make filtered estimates trustworthy
The bracket test proves the feature works on one fixture. These pin the bug
class:

- Invariance to converge(). This is the one that matters. Reading
  skill.forward instead of the carried message makes it fail immediately,
  because converge() alternates sweeps and contaminates skill.forward with
  backward information from the second iteration onward. That is the
  property a stored field cannot have, and the reason issue #19's proposed
  fix would not have worked.
- Invariance to ingestion order, the crate's standing invariant.
- One slice has no future to propagate back, so filtered equals smoothed.
- Empty history yields zero and empty maps.

Agreement is to 1e-8 under tight convergence rather than bit-identity:
iteration recomputes the colour partition only when from == 0, so an
incrementally built slice keeps insertion order until the first converge()
reorders it, and the scratch clone inherits whichever order it finds. Same
fixed point, different path to it.
2026-08-27 16:37:21 +02:00
logaritmisk 50e11cfbfa feat: add filtered learning curves
learning_curve returns post-convergence posteriors, so every point is
smoothed: the estimate at a given date incorporates rounds played years
later. On ustat's data that starts six players' curves already spread apart
at sigma 0.9-1.6 against a prior of 6.0, barely moving thereafter.

filtered_learning_curve plots the same competitor on forward-only
information, so everyone starts at the prior and fans out. It could not be
reconstructed from the public API before: a caller could only refit over
events[0..k] for every k, which is O(n^2) fits for something one forward
pass already computes.
2026-08-27 16:30:19 +02:00
logaritmisk d4af048914 feat: add filtered_log_evidence
Scores every event on what was known before it, rather than on priors that
carry information from events which had not happened yet. This is the
quantity HistoryBuilder::online promised and never delivered.

The pass walks slices in time order carrying its own forward messages, and
per slice runs the unmodified production sweep on a scratch copy whose
backward message is left improper. Reusing iterate_to_convergence rather
than reimplementing inference means a competitor playing twice at one time
is handled by the same within-slice EP that converge() uses, instead of
being approximated the way the old evidence paths approximated it.

Nothing is stored on Skill and nothing on self is mutated, so the result is
independent of whether converge() has run — the property a stored field
cannot have.
2026-08-27 16:21:01 +02:00
logaritmisk bf9d964cae refactor!: remove the inert online flag
Skill.online was initialised to N_INF and assigned nowhere, so
HistoryBuilder::online(true) made every rating improper and log_evidence()
reported n * ln(0.5) — every game scored as a coin flip. The value is finite
and plausible, which is why it went unnoticed.

The default was false, so no existing result changes. A working replacement
lands next; a stored field cannot hold the quantity, because converge()
alternates sweeps and contaminates skill.forward with backward information
from the second iteration onward.

Also renames a test binding from ..._online to ..._forward: it passes the
forward flag, and the two senses being conflated is how this survived.
2026-08-27 16:11:37 +02:00
logaritmiskandClaude Opus 5 187aede924 docs: implementation plan for filtered estimates
Five tasks: delete the inert online machinery, add filtered_log_evidence,
add the two learning-curve methods, pin the invariants, record the API break.

Two spec corrections fell out of writing it. The spec claimed filtered results
would be bit-identical before and after converge(); they cannot be. iteration
recomputes the colour partition only when from == 0, so a slice built by
repeated appends keeps insertion order until the first converge() reorders it,
and the scratch clone inherits whichever order it finds — same fixed point,
different path. Corrected to agreement within 1e-8 under tight convergence,
matching the house pattern in tests/ingestion_equivalence.rs. The spec also
declared filtered_pass as Vec<(T, Vec<(Index, Gaussian)>)>, which cannot carry
the evidence its own step 3 harvests; it returns Vec<(T, FilteredStep)>.

CHANGELOG.md is generated by git-cliff, so the spec's "CHANGELOG records the
API break" cannot be satisfied by editing the file — it regenerates. Task 5
records the break through the commit subject and verifies the generated output
instead. cliff.toml has no breaking-change parser at all, which the task is
told to report rather than work around.

An adversarial reviewer checked the plan against the source before this commit
and found four real defects, all in plan text, none in the design:

- Two prescribed mutations provably could not fail their named tests. The
  learning-curve mutation altered only what filtered_pass writes after a slice,
  while the test inspected filtered[0], which is computed from an empty message
  map. Fixed by asserting monotonic mu across the whole curve.
- The ingestion-order fixture used four distinct timestamps, giving one event
  per slice — the exact degenerate shape ingestion_equivalence.rs documents as
  the weak case, making the assertion true by construction. Fixed to several
  events per timestamp with shared competitors.
- filtered_learning_curves was never asserted for content, only for emptiness
  on an empty history.
- A doc comment restated learning_curves' claim that key(idx) is O(n) and the
  method O(n^2). KeyTable::key is self.reverse.get(idx.0) — O(1) — and the
  type's own doc says so. The claim predates reverse becoming a Vec. The plan
  now corrects the original at history.rs:323 rather than copying it.

The reviewer confirmed the central claim by tracing the call graph: N_INF is
{pi: 0, tau: 0} and Mul is a natural-parameter add, so it is an exact
multiplicative identity, and the only write to skill.backward in the crate is
in new_backward_info, reachable only from History::iteration and never from
iterate_to_convergence under either rayon cfg.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 16:04:16 +02:00
logaritmiskandClaude Opus 5 4fde482e48 docs: spec for filtered (forward-only) estimates
`HistoryBuilder::online(true)` is inert: it flips a flag that reaches
`Item::within_prior`, which reads `Skill.online` — a field initialised to
`N_INF` and assigned nowhere. So `log_evidence()` under that setting reports
`n * ln(0.5)`, every game scored as a coin flip. The number is finite and
plausible, which is why nothing caught it.

Issue #19 proposed populating the field during the forward pass. That does
not work, and the reason shapes the whole design. `new_forward_info` sets
`skill.forward` from the previous slice's `forward_prior_out`, which is
`skill.forward * skill.likelihood`; `History::iteration` alternates backward
and forward sweeps, so from the second iteration onward that likelihood has
already absorbed backward information. After `converge()`, `skill.forward`
is a smoothed quantity — and so is anything written from it.

The same reasoning condemns the neighbouring `forward: bool` flag, which is
a filtering quantity only on a history that was never converged. That is why
the test at history.rs:1183 can assert the two evidences are equal. Left
alone here; recorded as a follow-up.

The design is a read-only forward-only pass instead: walk slices in time
order carrying their own forward messages, and per slice build a scratch
clone whose `backward` is `N_INF`, then run the unmodified production sweep
on it. Reusing `iterate_to_convergence` rather than reimplementing inference
means a competitor playing twice at one time is handled by the same
within-slice EP that `converge()` uses, instead of being approximated the
way today's evidence paths approximate it. Nothing is stored on `Skill`,
which drops 16 bytes and helps #17 regardless.

Three methods ship — `filtered_log_evidence`, `filtered_learning_curves`,
`filtered_learning_curve` — all taking `&self`. The second consumer is
ustat, whose learning curves start already collapsed to sigma 0.9-1.6
against a prior of 6.0 because every point is smoothed; the filtered view
cannot be reconstructed from the public API today except by O(n^2) refits.

The red test brackets the issue's own fixture strictly between 5*ln(0.5) and
the batch evidence, so neither "still inert" nor "accidentally smoothed"
passes. The invariant that would have caught this bug class is that filtered
results are identical before and after `converge()` — exactly what a stored
field cannot give.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 15:42:54 +02:00
logaritmiskandClaude Opus 5 9e8515b7cd docs: refresh README and CLAUDE.md; add ingest benchmark
The CLAUDE.md architecture section still described the pre-redesign engine:
its data flow named `Batch`, `Agent`, `Player` and `message.rs`, none of
which have existed since T2, and the public API it listed did not match
`lib.rs`. It is the first thing a fresh session reads, so it was actively
misleading. Rewritten against the current module layout, with the invariants
that are easy to violate — ties needing a positive `p_draw`, NaN never being
convergence, log-space evidence, color contiguity, `forbid(unsafe_code)`,
and ingestion-order equivalence — written down.

The README Todo list had five entries that were already done, including
"Time needs to be an enum": `Time` has been a trait since T2, and the
`batch::compute_elapsed()` it pointed at no longer exists. The genuinely
open item — cross-checking `quality()` against sublee/trueskill — stays.

`benches/ingest.rs` measures one-event-per-call against a single batched
call. The rest of the suite only measured batched construction, which is why
the quadratic fixed earlier on this branch went unnoticed for so long.

`TimeSlice::log_evidence` also hashes its target set once instead of
scanning the slice per player per event, so `log_evidence_for` with many
keys is no longer quadratic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 22:05:49 +02:00
logaritmiskandClaude Opus 5 9506fed4b3 chore: add CI, crate metadata, and crate-level documentation
There was no CI of any kind — no workflows directory at all — despite a full
release pipeline (release.toml, cliff.toml, a maintained CHANGELOG, three
tagged releases). The workflow covers the feature combinations that actually
have distinct behaviour, including a release-profile job: `debug_assert!` is
compiled out there, which is exactly where the validation this branch added
has to hold, and a debug-only suite would never have seen it. Determinism is
checked at RAYON_NUM_THREADS of 1, 2, 4 and 8.

The Justfile gains test/lint/fmt/determinism recipes so the same checks run
locally with one command, and `just ci` runs the lot.

`Cargo.toml` had only name, version and edition, so `cargo publish` would
have been rejected. Added description, repository, readme, keywords,
categories, exclude, and `rust-version = "1.85"` — the edition-2024 floor,
now verified by a CI job. Two let-chains introduced earlier on this branch
would have pushed that to 1.88; they are rewritten to keep the floor where
it was.

`src/lib.rs` had no `//!` header at all, so the docs.rs landing page would
have been a bare symbol list — conspicuous given every other module has one.
It now explains what Through Time does differently, and carries three
runnable examples (which `cargo test --doc` checks, where previously there
was nothing to check), including the draw/p_draw interaction that is the
easiest way to get an error out of this crate.

`cargo publish --dry-run` now packages and verifies cleanly. The only
remaining blocker is `license`, which is yours to choose — noted as a TODO
in the manifest rather than picked unilaterally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 22:03:39 +02:00
logaritmiskandClaude Opus 5 6030dc78de refactor: unify convergence defaults, validate builders, clear dead code
Convergence configuration had two disagreeing sources of truth and one
misleading report:

- `EpsilonOrMax::default()` capped at 10 iterations while
  `ConvergenceOptions::default()` allowed 30, and which applied depended on
  whether inference went through `run_chain` or a `Schedule`. The schedule
  default now derives from `ConvergenceOptions`.
- A graph with no iterating factors reported `converged: false` with an
  infinite step, despite being at its fixed point after the setup pass. It
  now reports converged with a zero step.
- `TimeSlice::iterate_to_convergence` hard-coded an epsilon and a
  20-iteration cap matching neither. It reads `self.convergence` and is
  scoped to `#[cfg(test)]`, which is all it was ever used by.

`HistoryBuilder::p_draw` and `::convergence` now validate their arguments
like `score_sigma` already did, instead of accepting a negative `p_draw` or
an `alpha` of zero — the latter leaves every EP update unapplied, so
inference silently returns the priors.

Removing the `#[allow(dead_code)]` masks let the compiler report what they
were hiding: four `OwnedGame` fields that were stored and never read, two
`ColorGroups` helpers and three `SkillStore` helpers used only by tests, and
`iterate_to_convergence` above. Test-only items are now `#[cfg(test)]` and
the unread fields are gone.

Also exported `HistoryBuilder`, which was public but unreachable — callers
could chain `History::builder()` but could not name the type — and added
`Rating::{prior, beta, drift}` and `Index::get`, so handles the API hands
out can be read back.

Two goldens moved, both convergence residuals rather than exact values:
`iterate_to_convergence` now runs to 30 iterations instead of 20, landing
nearer the symmetric truth of 25.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 22:01:49 +02:00
logaritmiskandClaude Opus 5 355cdb7e05 perf(gaussian): drop the sqrt round-trip from variance-space operations
`Add`, `Sub`, `exclude` and `forget` combined variances by way of standard
deviations: `sigma()` takes a square root, `.powi(2)` squares it away,
`var.sqrt()` takes another, and `from_ms` squares that one back. Three roots
to compute a value that is `1/pi` all along.

They now go through `variance()` and a new `from_mv(mu, var)`, which skip
both conversions. `Sub` is the hot one — `RankDiffFactor::propagate` is
`a - b`, run for every adjacent team pair on every forward and backward
sweep of every EP iteration.

`run_chain` also stopped recomputing each team's weighted performance in the
likelihood loop; the fold is already in `arena.team_prior`, indexed by the
sorted position the loop has in hand. Each `performance()` is itself a
`forget`, so the duplicate cost scaled with players per team.

Measured on this machine, before and after, same fixtures:

    Batch::iteration          23.57us -> 19.31us   (-18%)
    scored_history_60_events   1.071ms -> 983us    (-8%)

The `Gaussian::add`/`sub` microbenchmarks cannot resolve the change: they
sit at ~234ps against a ~218ps floor that `mul`/`div` also hit, so the
harness overhead dominates a single operation.

One golden moved. Two identical competitors drawing must land on their
shared prior mean exactly, by symmetry; the root-free path now returns
25.0 where the reference transcription recorded 24.999999 — that value
rounded to six decimals. Asserting a six-decimal transcription at
epsilon 1e-6 left no headroom, so the expectation is now the exact value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:58:30 +02:00
logaritmiskandClaude Opus 5 06b6a68499 fix(rayon): remove the aliasing unsafe from the parallel sweep
The parallel color-group sweep passed a `*mut SkillStore` through a `usize`
and cast it back inside the rayon closure, so every worker materialised its
own `&mut SkillStore` to the same store. Two live `&mut` to one object is an
aliasing violation whatever the workers subsequently touch — `&mut` carries
`noalias` down to LLVM — and laundering the pointer through `usize` also
discarded provenance. The existing SAFETY comment argued element
disjointness, which is true and is why nothing miscompiled in practice, but
it is not the property the aliasing rules ask about.

Events in a color group touch disjoint agents, so none can observe another's
writes. That makes the sweep separable rather than merely safe-in-practice:
`Event::compute` runs inference over shared `&self.skills` with no mutation,
and `Event::apply` folds the results in afterwards in index order. No
`unsafe`, no aliasing argument, and the apply order does not depend on which
worker finished first, so results stay bit-identical across thread counts.

The crate now contains no `unsafe` at all, locked in with
`#![forbid(unsafe_code)]`.

Splitting compute from apply also removes the duplicated sweep body: the
`from > 0` branch of `TimeSlice::iteration` was a verbatim copy of
`iteration_direct`, and both now share one implementation.

Cost, measured on the three `history_converge` workloads (sequential vs
parallel, this machine):

    500x100@10perslice     4.02ms -> 4.21ms
    2000x200@20perslice   19.70ms -> 19.76ms
    1v1-5000x50000        11.75ms -> 10.46ms

The deferred apply gives back part of the parallel win on the only workload
where rayon ever helped (1.12x here, against the 1.3x T3 reported), and the
sequential path is unchanged. Trading a fraction of a 1.3x speedup on one
pathological shape for the removal of undefined behaviour is the right side
of that bargain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:54:34 +02:00
logaritmiskandClaude Opus 5 c088214fed fix(history): stop reprocessing the slice that was just appended to
`add_events_with_prior` advanced `k` past the slice it had written when it
created a new one, but not when it appended to an existing one. The trailing
forward-refresh loop therefore started *on* the slice just modified and ran
`new_forward_info` over it again.

That is not merely redundant work. The loop immediately above it sets each
agent's message to `forward * likelihood` for that slice, and
`new_forward_info` then assigns `skill.forward = message.forget(drift)` —
folding the slice's own likelihood back into its own forward prior. The
skills it produced depended on how events had been batched.

Ingesting one event at a time now converges to the same fixed point as
ingesting the same events in a single call, which it previously did not:
for five events sharing a timestamp, competitor `a` converged to
mu=7.44 sigma=3.90 batched versus mu=7.99 sigma=3.10 incrementally. Both
runs had converged; the gap was not a convergence residual.

The numerical goldens never caught this because they all ingest in one call
with a distinct timestamp per event, so the append-to-existing-slice branch
is never taken. `tests/ingestion_equivalence.rs` covers it directly, and
asserts convergence before comparing so that a residual cannot be mistaken
for agreement.

Removing the redundant re-inference also removes the dominant cost of
incremental ingestion, which was quadratic in the number of events already
in the slice:

    events   before     after    speedup
       500   45.8ms     1.1ms       42x
      1000  179.5ms     2.8ms       64x
      2000  721.8ms     9.9ms       73x
      4000    2.9s     35.4ms       82x

Ingesting one at a time is now 1.8x a single batched call, down from 148x.

Two supporting changes are included:

- Color groups are rebuilt lazily rather than on every append. Nothing
  reads the partition between an append and the next full sweep, so the
  per-append rebuild was pure waste.
- `ColorGroups::groups_are_contiguous` is asserted after each rebuild and
  in `color_range`. The parallel sweep derives one `&mut` sub-slice per
  color from those ranges and relies on them being disjoint; that invariant
  was established by construction but never checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:51:02 +02:00
logaritmiskandClaude Opus 5 0f1a1b8911 fix(evidence): accumulate in log space and floor the per-link value
Per-link evidence was multiplied in linear space and logged only at the
end. Each link contributes a probability in (0, 1], so the product over an
n-team game decays geometrically: around a thousand links it flushes to
exactly 0.0 and `ln(0.0)` is `-inf`, which then propagates through the sum
in `History::log_evidence_internal` and takes the whole history with it.
`Game::free_for_all` builds one team per player, so this is reachable at
the competitor counts the T3 benchmarks target.

`Game`, `OwnedGame`, and `time_slice::Event` now carry `log_evidence`
directly, summed over links rather than multiplied then logged.

The cached per-link evidence is also floored at `f64::MIN_POSITIVE`. It
could legitimately reach zero or go negative: `1.0 - cdf(..)` rounds to
zero for a near-certain outcome, and the `erfc` approximation carries
~1e-7 error so `cdf` can exceed 1.0 and make the difference negative —
`ln` of which is NaN.

Existing log-evidence goldens are unchanged, confirming the accumulation
is numerically equivalent in the range where the old form worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:43:57 +02:00
logaritmiskandClaude Opus 5 0d32690fcc fix(quality): support any number of rating groups
`quality()` was two-group-only in three separate ways, and
`History::predict_quality` inherited all of them.

- The contrast-matrix column counter tracked two positions with two
  variables that only agree on the first row, so three or more groups wrote
  past the end of the row and panicked with an out-of-bounds index. The
  negative block always begins immediately after the positive one, so the
  second counter is unnecessary.
- `Matrix::inverse` was implemented only for the 1x1 case and otherwise
  `panic!("eh, okey")`. It now uses LU decomposition with partial pivoting,
  which also replaces the recursive cofactor `determinant` — that was O(n!)
  and allocated a `Vec` per minor, so a 10-team match needed 362,880 terms.
- Degenerate inputs (zero groups, one group, empty groups) underflowed or
  produced NaN. They now assert with a message naming the requirement.

`Matrix` also gains dimension checks on multiply/add and bounds checks on
indexing, and loses the now-unused `adjugate`/`minor` cofactor path.

The two-group golden is unchanged. N-group behaviour is covered by
invariants — permutation invariance, and quality falling as a skill gap
widens — since no reference values were available to compare against; the
sublee/trueskill cross-check remains open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:42:04 +02:00
logaritmiskandClaude Opus 5 6b8bd786d7 style: make NaN rejection explicit in score_sigma validation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:38:48 +02:00
logaritmiskandClaude Opus 5 f4e2922d59 fix: reject ties without draw probability; never report NaN as converged
A tie with `p_draw == 0.0` produced NaN posteriors in release builds and
`converge()` reported `converged: true`, because every comparison against
NaN is false and `tuple_gt` therefore read NaN as "below epsilon".

Two independent defects, fixed together:

- Ingestion now rejects tied outcomes when the draw probability is zero,
  promoting the existing `debug_assert!` in `Game::ranked_with_arena` to a
  real `InferenceError::TieWithoutDrawProbability`. Validation sits in
  `add_events_with_prior`, the chokepoint every route reaches — including
  `record_draw`, which bypasses `Outcome` entirely.
- `converge()` treats a non-finite step as failure and returns
  `InferenceError::NonFiniteResult` rather than claiming convergence.

Also in this change:

- `History::converge()` on an empty history returned a `usize` underflow
  panic from `0..len()-1`; it now short-circuits to a zero-iteration report.
- `Outcome::scores_with_sigma` no longer panics on a non-positive sigma;
  the value is validated at ingestion so callers get an error instead.
- `InferenceError` gains `WrongOutcomeKind`, replacing the misuse of
  `MismatchedShape` for variant mismatches (which rendered as the nonsense
  "expected length 0, got 0"), and is now `#[non_exhaustive]`.

Note `Outcome::winner(w, n)` for n >= 3 ties every loser, so those events
now require a positive `p_draw`. They previously returned NaN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:38:29 +02:00
100 changed files with 21000 additions and 1934 deletions
+15
View File
@@ -0,0 +1,15 @@
# `Cargo.toml` sets `publish = ["kellnr"]`, so `cargo publish` targets the
# private registry and refuses crates.io. Cargo needs that registry's index
# declared to resolve the name.
#
# Committed rather than left to a per-user `~/.cargo/config.toml` so the repo
# is self-contained: a fresh clone, a new machine, or CI would otherwise fail
# with
#
# error: registry index was not found in any configuration: `kellnr`
#
# Index URL only — it is not a secret. Publish tokens live in
# `~/.cargo/credentials.toml` (per-user, never committed) or, in CI, in
# `CARGO_REGISTRIES_KELLNR_TOKEN`.
[registries.kellnr]
index = "sparse+https://crates.aceofba.se/api/v1/crates/"
+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
+87
View File
@@ -0,0 +1,87 @@
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# The build most consumers get.
- name: default
features: ""
profile: ""
# Most numerical goldens need `approx` for assert_ulps_eq.
- name: approx
features: "--features approx"
profile: ""
# The parallel path, including tests/determinism.rs.
- name: rayon
features: "--features approx,rayon"
profile: ""
# Critical: debug_assert! is compiled out here, which is where the
# tie/p_draw and score_sigma validation actually has to hold.
- name: release
features: "--features approx"
profile: "--release"
name: test (${{ matrix.name }})
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test ${{ matrix.profile }} ${{ matrix.features }}
- run: cargo test ${{ matrix.profile }} ${{ matrix.features }} --doc
determinism:
name: determinism across thread counts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# Posteriors must be bit-identical regardless of how many rayon workers
# run the color-group sweep.
- run: |
for threads in 1 2 4 8; do
echo "== RAYON_NUM_THREADS=$threads =="
RAYON_NUM_THREADS=$threads cargo test --release \
--features approx,rayon --test determinism
done
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --all-targets --all-features -- -D warnings
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# rustfmt.toml uses nightly-only options (imports_granularity).
- uses: dtolnay/rust-toolchain@nightly
with:
components: rustfmt
- run: cargo +nightly fmt --check
msrv:
name: minimum supported Rust version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.85.0
- uses: Swatinem/rust-cache@v2
- run: cargo check --all-targets --features approx,rayon
+1
View File
@@ -7,3 +7,4 @@
NOTEPAD.md NOTEPAD.md
/.claude /.claude
proptest-regressions/
+242
View File
@@ -2,6 +2,244 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 0.8.0 - 2026-09-08
### Breaking Changes
- feat!: make a short fit an error and raise the default iteration cap
- feat!: validate mu, sigma and beta on HistoryBuilder
- feat!: add History::register and History::rating, and reject config conflicts across batches
- fix!: reject non-finite weights at ingestion
- fix!: reject malformed games at the Game boundary too
### Bug Fixes
- fix: reject malformed events at the ingestion boundary
### Documentation
- docs: record the rayon opt-in deviation in spec section 6
- docs: state what the joint's cost actually scales in
### Features
- feat: add EventBuilder::members for per-member configuration
### Other (unconventional)
- 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'
### Testing
- test: cover non-finite results and color-group disjointness
## 0.7.0 - 2026-09-08
### Features
- feat: factorise the joint once with History::joint
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.7.0
### Other (unconventional)
- Merge branch 'feat/joint-handle'
## 0.6.0 - 2026-09-08
### Breaking Changes
- fix!: make the joint span slices, not just the latest one
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.6.0
## 0.5.0 - 2026-09-08
### Breaking Changes
- feat!: name the unknown key, expose tail probabilities, flag short fits
- refactor!: remove the factor-graph surface nothing used, add try_winner
### Bug Fixes
- fix(test): the ingestion-order property was comparing two truncated fits
### Documentation
- docs: record that the event log is the source of truth, and why
### Features
- feat: add UnknownKeys::Prior, and explain why there is no Skip
- feat: add History::posterior_of for a linear combination of competitors
- feat: add History::predict_margin for scored matchups
- feat: add expected_variance_reduction for scored active learning
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.5.0
### Styling
- style: factor the event-pair type out of the reconvergence fixture
- style: use arrays rather than vec! in the calibration fixture
### Testing
- test: pin that re-convergence is path-independent
- test: calibrate the marginals against the exact posterior
- test: pin what an additive model does to combined uncertainty
## 0.4.2 - 2026-09-07
### Bug Fixes
- fix: replace the erfc approximation with libm, for free
- fix: route every transcendental through libm, and combine sigmas with hypot
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.4.2
### Testing
- test: localise the erfc_inv tail residual to the caller's argument
## 0.4.1 - 2026-09-07
### Bug Fixes
- fix: correct erfc_inv's sign error and keep evidence in log space
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.4.1
### Testing
- test: pin quality()'s N-group closed form, closing the README cross-check
## 0.4.0 - 2026-09-07
### Breaking Changes
- feat!: N-team outcome prediction with draw mass, replacing the 2-team panic
- refactor!: close the remaining API gaps from #21
- fix!: apply competitor configuration whenever it is supplied
### Bug Fixes
- fix(release): skip the changelog hook during a dry run
- fix: stop destroying tail precision in evidence and truncation
- fix: reject convergence options that silently disable inference
### Documentation
- docs: correct drifted documentation and compile the README in CI
### Features
- feat: add expected information gain for active matchup selection
- feat: let observers be shared, boxed, or borrowed
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.4.0
## 0.3.0 - 2026-09-01
### Breaking Changes
- refactor!: make Competitor::message an Option, and compute_elapsed loud
- refactor!: replace emptiness-as-sentinel with Option for results and weights
- refactor!: remove ConvergenceReport::slices_skipped
### Bug Fixes
- fix: enforce EventBuilder weight/team length in release
### Documentation
- docs: complete the public API documentation contract
### Features
- feat: allow drift to vary per competitor via Member::with_drift_scale
### Miscellaneous Tasks
- chore: ignore proptest regression seed files
- chore: Release trueskill-tt version 0.3.0
### Performance
- perf: stop cloning inference inputs in OwnedGame and ingestion
- perf: make the per-slice SkillStore compact instead of dense
### Testing
- test: add property-based tests, a shared finiteness helper, and boundary inputs
## 0.2.0 - 2026-08-27
### Breaking Changes
- refactor!: remove the inert online flag
### Bug Fixes
- fix: reject ties without draw probability; never report NaN as converged
- fix(quality): support any number of rating groups
- fix(evidence): accumulate in log space and floor the per-link value
- fix(history): stop reprocessing the slice that was just appended to
- fix(rayon): remove the aliasing unsafe from the parallel sweep
- fix: close out four small issues and pin #27's repro
### Documentation
- docs: refresh README and CLAUDE.md; add ingest benchmark
- docs: spec for filtered (forward-only) estimates
- docs: implementation plan for filtered estimates
- docs: state filtered accessor cost and evidence semantics precisely
- docs(cargo): correct the licence note — kellnr does not require one
### Features
- feat: add filtered_log_evidence
- feat: add filtered learning curves
### Miscellaneous Tasks
- chore: add CI, crate metadata, and crate-level documentation
- chore: target releases at the private kellnr registry
- chore: keep the 48 MB ATP dataset out of the published crate
- chore: dual-license MIT OR Apache-2.0
- chore: Release trueskill-tt version 0.2.0
### Performance
- perf(gaussian): drop the sqrt round-trip from variance-space operations
### Refactor
- refactor: unify convergence defaults, validate builders, clear dead code
### Styling
- style: make NaN rejection explicit in score_sigma validation
### Testing
- test: pin the invariants that make filtered estimates trustworthy
## 0.1.2 - 2026-06-12 ## 0.1.2 - 2026-06-12
### Bug Fixes ### Bug Fixes
@@ -32,6 +270,10 @@ All notable changes to this project will be documented in this file.
- feat(outcome): per-event score_sigma override on Outcome::Scored - feat(outcome): per-event score_sigma override on Outcome::Scored
- feat(event_builder): expose scores_with_sigma fluent method - feat(event_builder): expose scores_with_sigma fluent method
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.1.2
### Refactor ### Refactor
- refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain - refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
+114 -26
View File
@@ -5,42 +5,130 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands ## Commands
```bash ```bash
cargo build # Build the library just test # Full suite across every feature combination CI checks
cargo test --lib # Run all library tests just check # Fast inner loop: cargo test --features approx
cargo test --lib <test_name> # Run a single test by name just lint # clippy, warnings denied
cargo test --lib -- --nocapture # Run tests with stdout output just fmt # ALWAYS nightly — rustfmt.toml uses nightly-only options
cargo clippy # Lint just determinism # Bit-identical posteriors at RAYON_NUM_THREADS 1/2/4/8
cargo bench # Run benchmarks (criterion) just ci # Everything CI runs
cargo test --lib <test_name> # A single test by name
cargo bench # Criterion benchmarks
``` ```
The `approx` feature enables `approx::AbsDiffEq` for `Gaussian`: **Run tests in release too.** `debug_assert!` is compiled out there, and that
```bash is where several defects have hidden — a debug-only run is not evidence.
cargo test --features approx `just test` includes a release job.
```
### Feature flags
- `approx``approx::AbsDiffEq` etc. for `Gaussian`. Most numerical goldens need it.
- `rayon` — opt-in parallel within-slice sweep and per-slice query passes.
## Working rules
- **Investigate before implementing.** Measure the actual behaviour first —
against an analytic reference where one exists. Several "obvious" fixes in
this repo turned out to be wrong in sign or unnecessary, and the measurement
is what caught them.
- **Fix the root issue, not the symptom.** A clamp that hides an underflow, or
a tolerance loosened to make a test pass, is a defect deferred.
- **Scout crates.io before hand-rolling numerics.** Check accuracy against an
independent reference rather than trusting downloads: `puruspe` has 1.4M
downloads and is 346 ULP off in the tail, where `libm` is 1. Fewer
dependencies is preferable, not mandatory — take the dependency when it is
measurably better.
## Architecture ## Architecture
This is a Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py) — a Bayesian skill rating system that tracks skill evolution over time using Gaussian message passing. A Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py):
Bayesian skill rating that infers skill at every point in time, propagating
evidence both forward and backward across a history.
### Data flow ### Data flow
Ingestion (public types, `event.rs`):
``` ```
History → Batch[] → Game[] → teams/players Event<T, K> → Team<K>[] → Member<K>[]
``` ```
- **`History`** (`history.rs`) — top-level container. Organizes games by time into `Batch`es, runs forward/backward message passing across batches, and exposes `learning_curves()` and `log_evidence()`. `History::add_events` flattens that into indices; teams survive only as
- **`Batch`** (`batch.rs`) — all games at a single time step. Runs `iteration()` to update skill estimates via `Game::posteriors()`, collecting `Skill` distributions per player. grouping, not as a value. Inference then runs on the internal shapes:
- **`Game`** (`game.rs`) — a single match. Given teams (slices of `Gaussian`), computes posterior skill distributions using Gaussian factor graphs and `message.rs` helpers.
- **`Agent`** (`agent.rs`) — wraps a `Player` with temporal state (`last_time`, `message`). `receive()` applies time-decay (`gamma`) when the player reappears after a gap.
- **`Player`** (`player.rs`) — static configuration: prior `Gaussian`, `beta` (performance noise), `gamma` (skill drift per time unit).
- **`Gaussian`** (`gaussian.rs`) — core probability type. Stored as natural parameters (`pi = 1/sigma²`, `tau = mu/sigma²`). Arithmetic ops implement message multiplication/division in the factor graph.
- **`message.rs`** — `TeamMessage` and `DiffMessage`: intermediate factor graph messages used inside `Game`.
- **`MarginFactor`** (`factor/margin.rs`) — Gaussian observation factor on a diff variable; engaged by `Outcome::Scored`.
- **`lib.rs`** — exports the public API (`Game`, `Gaussian`, `History`, `Player`) and standalone functions (`quality()`, `pdf()`, `cdf()`, `erfc()`). Also defines global defaults: `MU=0.0`, `SIGMA=6.0`, `BETA=1.0`, `GAMMA=0.03`, `P_DRAW=0.0`, `EPSILON=1e-6`, `ITERATIONS=30`.
### Key design points ```
History → TimeSlice[] → Event[] → Item[]
Game (factor graph) → Schedule → BuiltinFactor[]
```
- `History` uses `IndexMap<K>` (defined in `lib.rs`) to map arbitrary player keys to `Agent` state. - **`History`** (`history.rs`) top level. Interns keys, groups events into
- Convergence is measured by the maximum `delta()` across all skill distributions; iteration stops when below `EPSILON` or after `ITERATIONS` rounds. `TimeSlice`s by time, runs the forward/backward sweep in `converge()`, and
- The `approx` feature gates `AbsDiffEq` on `Gaussian` for use in tests — the feature is optional and only needed for approximate equality assertions. answers `learning_curves()`, `current_skill()`, `log_evidence()`,
- `time` in `History`/`Batch` is currently an `f64`; the README notes it needs to become an enum to support richer temporal states. `predict_quality()`, `predict_outcome()`. Built via `HistoryBuilder`.
- **`TimeSlice`** (`time_slice.rs`) — all events at one time. Owns a
`SkillStore` and a `ScratchArena`; `iteration()` sweeps its events, using
`ColorGroups` to partition independent ones.
- **`Event`** — two distinct types, do not confuse them. The *public* ingestion
`Event<T, K>` is in `event.rs` (with `Team`/`Member`); the *internal*
`pub(crate) Event` in `time_slice.rs` is one match during inference, where
`compute()` runs inference reading skills immutably and `apply()` folds the
result back. That split is what lets a color group run in parallel with no
`unsafe`.
- **`Game`** (`game.rs`) — a single match's factor graph. `run_chain` builds the
diff chain between rank-adjacent teams and drives it to convergence.
- **`Gaussian`** (`gaussian.rs`) — natural parameters (`pi = 1/sigma²`,
`tau = mu/sigma²`). `Mul`/`Div` are the EP product/cavity: pure adds and
subtracts. Variance-space ops (`Add`, `Sub`, `exclude`, `forget`) go through
`from_mv`/`variance()` and take no square root.
- **`factor/`** — `TruncFactor` (ranked) and `MarginFactor` (scored) over a
flat `VarStore`. `Game::run_chain` drives them directly through a local
`DiffFactor` enum; there is no `Schedule` indirection and no generic `Factor`
trait. Both were removed once measurement showed nothing had ever used them
— see #42.
- **`Competitor`** (`competitor.rs`) — per-history temporal state (`message`,
`last_time`). **`Rating`** (`rating.rs`) — static config (prior, `beta`, drift).
- **`storage/`** — `SkillStore` (per slice, `pub(crate)`) and `CompetitorStore`
(per history, public), both indexed by `Index`. The module is `pub`, but only
`CompetitorStore` is reachable from outside the crate.
- **`KeyTable`** (`key_table.rs`) — user key ↔ `Index`, both directions O(1).
- **`Drift`** (`drift.rs`) / **`Time`** (`time.rs`) — traits. `Time` is a *trait*
(`i64`, `Untimed`), not an enum.
- **`lib.rs`** — public exports, global defaults (`MU`, `SIGMA`, `BETA`,
`GAMMA`, `P_DRAW`, `EPSILON`, `ITERATIONS`), and the standalone `quality()`.
The `cdf()` / `erfc()` helpers live here too but are `pub(crate)` and private
respectively — not public API.
### Invariants worth knowing
- **A tie needs `p_draw > 0`.** With `p_draw == 0.0` the truncation margin is
zero and the two-sided tie update evaluates `0/0`. Ingestion rejects such
events with `InferenceError::TieWithoutDrawProbability`. This includes
`Outcome::winner(w, n)` for `n >= 3`, which ties every loser.
- **NaN is never convergence.** Comparisons against NaN are all false, so
`tuple_gt` reads NaN as "below epsilon". Use `step_converged` /
`step_is_finite`, never `!tuple_gt(..)` alone.
- **Evidence accumulates in log space.** A linear product over a long diff
chain underflows to zero, and `ln(0)` is `-inf`.
- **Colors are contiguous.** `recompute_color_groups` reorders events so each
color occupies one range; `ColorGroups::groups_are_contiguous` asserts it.
- **Transcendentals go through `libm`, not `std`.** IEEE 754 pins the basic
operations and `sqrt` but says nothing about `exp`/`log`/`erf`, and `std`
delegates to the *system* math library — measured, `f64::exp` and `libm::exp`
disagree on 9.7% of inputs by one ULP. Since inference is an iterative fixed
point, one ULP can change an iteration count. Use `libm::exp` / `libm::log` in
inference code; `f64::sqrt` is fine (IEEE specifies it). Tests may use either.
- **The crate is `#![forbid(unsafe_code)]`.** Keep it that way.
- **Ingestion order must not change the answer.** Events added one at a time
must converge to the same fixed point as the same events batched — see
`tests/ingestion_equivalence.rs`.
### Testing notes
- Numerical goldens are cross-validated against the Python/Julia reference.
Some are *convergence residuals*, not exact values; treat a small movement
as suspicious but check whether the new value is closer to the analytic
truth (symmetric fixtures converge to their prior mean exactly) before
assuming a regression.
- `tests/degenerate_inputs.rs` covers empty/boundary/error paths,
`tests/ingestion_equivalence.rs` covers batching order, `tests/quality.rs`
covers N-group quality, `tests/determinism.rs` covers thread counts.
+41 -5
View File
@@ -1,7 +1,30 @@
[package] [package]
name = "trueskill-tt" name = "trueskill-tt"
version = "0.1.2" version = "0.8.0"
edition = "2024" edition = "2024"
rust-version = "1.85"
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
repository = "https://git.aceofba.se/logaritmisk/trueskill-tt"
authors = ["Anders Olsson"]
# Publishing is restricted to the private kellnr registry; this also makes
# an accidental `cargo publish` to crates.io a hard error rather than a
# irreversible mistake. Index is declared in `.cargo/config.toml`.
publish = ["kellnr"]
readme = "README.md"
keywords = ["trueskill", "rating", "bayesian", "elo", "skill"]
categories = ["algorithms", "science", "game-development"]
license = "MIT OR Apache-2.0"
# `examples/atp.csv` is a 48 MB tennis dataset — 99% of the packaged crate,
# for a library whose source is 312 KB. `examples/atp.rs` opens it by
# relative path at runtime, so excluding the data still compiles; the
# example just needs the file fetched from the repo to run.
exclude = [
"/docs",
"/benches/*.txt",
"/temp",
"/.gitea",
"/examples/atp.csv",
]
[lib] [lib]
bench = false bench = false
@@ -10,10 +33,6 @@ bench = false
name = "batch" name = "batch"
harness = false harness = false
[[bench]]
name = "gaussian"
harness = false
[[bench]] [[bench]]
name = "history_converge" name = "history_converge"
harness = false harness = false
@@ -22,22 +41,35 @@ harness = false
name = "scored" name = "scored"
harness = false harness = false
[[bench]]
name = "ingest"
harness = false
[dependencies] [dependencies]
approx = { version = "0.5.1", optional = true } approx = { version = "0.5.1", optional = true }
feral-amd = "0.2"
libm = "0.2.16"
rayon = { version = "1", optional = true } rayon = { version = "1", optional = true }
smallvec = "1" smallvec = "1"
[features] [features]
approx = ["dep:approx"] approx = ["dep:approx"]
# Exposes the joint sparsity pattern for the #52 measurement. Test-only.
measure-sparsity = []
rayon = ["dep:rayon"] rayon = ["dep:rayon"]
[dev-dependencies] [dev-dependencies]
criterion = "0.5" criterion = "0.5"
plotters = { version = "0.3", default-features = false, features = ["svg_backend", "all_elements", "all_series"] } plotters = { version = "0.3", default-features = false, features = ["svg_backend", "all_elements", "all_series"] }
plotters-backend = "0.3" plotters-backend = "0.3"
proptest = "1.11.0"
time = { version = "0.3", features = ["parsing"] } time = { version = "0.3", features = ["parsing"] }
trueskill-tt = { path = ".", features = ["approx"] } trueskill-tt = { path = ".", features = ["approx"] }
# Debug symbols in release are for `just flame` (cargo-flamegraph), which needs
# them to symbolicate. Profile settings in a library are ignored by downstream
# consumers, so these only affect local builds — this is deliberate, not an
# oversight.
[profile.release] [profile.release]
debug = true debug = true
@@ -46,3 +78,7 @@ debug = true
[profile.dev] [profile.dev]
debug = true debug = true
[[bench]]
name = "joint"
harness = false
+81
View File
@@ -1,4 +1,39 @@
alias b := bench alias b := bench
alias t := test
# Run the full test suite across the feature combinations CI checks.
test:
cargo test
cargo test --features approx
cargo test --features approx,rayon
cargo test --release --features approx
# Fast inner-loop tests.
check:
cargo test --features approx
# Posteriors must be bit-identical across rayon worker counts.
determinism:
#!/usr/bin/env bash
set -euo pipefail
for threads in 1 2 4 8; do
echo "== RAYON_NUM_THREADS=$threads =="
RAYON_NUM_THREADS=$threads cargo test --release \
--features approx,rayon --test determinism
done
lint:
cargo clippy --all-targets --all-features -- -D warnings
# Always nightly: rustfmt.toml uses nightly-only options.
fmt:
cargo +nightly fmt
fmt-check:
cargo +nightly fmt --check
# Everything CI runs.
ci: fmt-check lint test determinism
store: store:
cargo bench -- --save-baseline base cargo bench -- --save-baseline base
@@ -8,3 +43,49 @@ bench:
flame: flame:
cargo flamegraph --root --example atp cargo flamegraph --root --example atp
# ---------------------------------------------------------------------------
# Release workflow
#
# Publishing goes to the private kellnr registry only: `Cargo.toml` sets
# `publish = ["kellnr"]`, so an accidental `cargo publish` to crates.io is a
# hard error rather than an irreversible mistake. The index is declared in the
# committed `.cargo/config.toml`; the token is per-user and lives in
# `~/.cargo/credentials.toml` (`cargo login --registry kellnr`).
#
# Step 1: just release-plan [level] — dry run, no writes
# Step 2: just release [level] — bump, changelog, tag, publish, push
#
# LEVEL is the cargo-release bump level (default `minor`). On 0.x:
# minor -> breaking bump (0.1.2 -> 0.2.0) <- any public-API change
# patch -> additive only (0.1.2 -> 0.1.3)
# major -> reserved for the 1.0.0 jump
#
# `release.toml` regenerates CHANGELOG.md with git-cliff in a pre-release hook
# and keeps push = false; this recipe pushes last, after publish has succeeded.
# ---------------------------------------------------------------------------
# Dry-run preview of the next release. Inspect the version bump and the
# "Publishing ..." line before running `just release`.
release-plan level="minor":
cargo release {{level}}
# Cut a release from a clean main: gate -> bump -> tag -> publish -> push.
release level="minor":
#!/usr/bin/env bash
set -euo pipefail
if [[ "$(git branch --show-current)" != "main" ]]; then
echo "error: run 'just release' from the 'main' branch" >&2; exit 1
fi
if [[ -n "$(git status --porcelain)" ]]; then
echo "error: working tree is dirty — commit or stash first" >&2; exit 1
fi
# cargo-release only verify-compiles the packaged crate; it does not run the
# suite, and publishing is irreversible. Run the same gate CI does, which
# includes the release profile where debug_assert! is compiled out.
just ci
cargo release {{level}} --execute --no-confirm
git push --follow-tags
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2026 Anders Olsson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+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.
+376 -39
View File
@@ -1,76 +1,281 @@
# TrueSkill - Through Time # TrueSkill - Through Time
Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py). Bayesian skill rating over a time axis.
## Other implementations Where plain TrueSkill gives each competitor one running estimate, TrueSkill
Through Time treats a whole history as a single model and infers skill *at every
point in time*. Evidence flows both directions: a result today sharpens the
estimate of who someone was last year, so early estimates stop being frozen
guesses and comparisons across eras become meaningful.
- [ttt-scala](https://github.com/ankurdave/ttt-scala) A Rust port of
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis) [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R) ## Install
- [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf)
- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/) ```toml
[dependencies]
trueskill-tt = "0.8"
```
Optional features, both off by default:
- `approx``approx`'s equality traits for `Gaussian`. Useful in tests.
- `rayon` — parallelises the within-slice sweep and the per-slice passes of
`learning_curves` / `log_evidence`. Results stay bit-identical regardless of
worker count; `just determinism` asserts it at 1, 2, 4 and 8 threads.
## Quickstart
Record results, converge, then read off skills.
```rust
use trueskill_tt::History;
let mut history = History::default();
history.record_winner(&"alice", &"bob", 1)?;
history.record_winner(&"bob", &"carol", 2)?;
history.record_winner(&"alice", &"carol", 3)?;
history.converge()?;
let alice = history.current_skill("alice").unwrap();
assert!(alice.mu() > 0.0, "alice won every game she played");
# Ok::<(), trueskill_tt::InferenceError>(())
```
The third argument is the time. It is what makes this Through Time rather than
plain TrueSkill: skill is inferred at each of those moments, not once at the
end. `learning_curve` reads the whole trajectory back.
```rust
# use trueskill_tt::History;
# let mut history = History::default();
# history.record_winner(&"alice", &"bob", 1)?;
# history.record_winner(&"bob", &"carol", 2)?;
# history.record_winner(&"alice", &"carol", 3)?;
# history.converge()?;
// `None` means the key is unknown; `Some(vec![])` means known but unplayed.
let curve = history.learning_curve("alice").unwrap();
for (time, skill) in &curve {
println!("t={time}: {:.2} ± {:.2}", skill.mu(), skill.sigma());
}
// Everyone's latest posterior in one pass — the leaderboard query.
let latest = history.current_skills();
assert_eq!(latest.len(), 3);
# Ok::<(), trueskill_tt::InferenceError>(())
```
## Teams, rankings and draws
Anything beyond one-versus-one goes through the fluent event builder. An event
is only recorded by the terminal `.commit()`.
```rust
use trueskill_tt::History;
let mut history = History::builder().p_draw(0.1).build();
history
.event(1)
.team(["alice", "bob"])
.team(["carol", "dave"])
.ranking([0, 1]) // lower is better; equal values are a tie
.commit()?;
history.converge()?;
# Ok::<(), trueskill_tt::InferenceError>(())
```
**A tie needs a positive `p_draw`.** A `p_draw` of zero asserts draws cannot
happen, so a tied result has no representable likelihood and is rejected rather
than fitted to something else:
```rust
use trueskill_tt::{History, InferenceError};
let mut history = History::default(); // p_draw defaults to 0.0
let err = history.record_draw(&"alice", &"bob", 1).unwrap_err();
assert!(matches!(err, InferenceError::TieWithoutDrawProbability { .. }));
```
This also catches `Outcome::winner(w, n)` for three or more teams, which ties
every loser.
## Which entry point?
| You want to | Use |
|---|---|
| One match, two competitors | `record_winner` / `record_draw` |
| Teams, explicit ranks, scores, per-member weights | `history.event(t)…commit()` |
| A batch you already have as values | `add_events(iter)` |
| Score a hypothetical with no history at all | `Game` |
`Game` is the odd one out and worth being explicit about: it is a single match's
factor graph, it does not participate in a `History`, and nothing it computes is
remembered. Reach for it to evaluate a matchup in isolation; reach for `History`
for everything that accumulates.
## `converge` is strict
`converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the
step still above `epsilon`, and `Err(NonFiniteStep)` if a sweep produces NaN.
It used to return `Ok` with `converged: false`, which was the worst available
shape. A fit that stops short is *wrong by a little*: every posterior is finite,
the ordering looks sensible, and nothing about the output says the numbers were
still moving. Detection was opt-in, and `let _ = h.converge()` silently opted
out — which is how a real defect hid in this crate's own test suite.
The default `max_iter` is high enough that reaching it means something is
genuinely wrong rather than that the history is large; the loop exits at
`epsilon` long before, so raising the cap costs nothing when it is not needed.
Use `converge_partial` when a deliberately capped, unconverged fit is the point.
Predictions are strict for the same reason: every `predict_*` method reads
skills through one gate that refuses a NaN-poisoned fit, rather than returning a
plausible number computed from it.
## Drift ## Drift
Skill drift models how a player's true skill can change between appearances. Each time a player reappears after a gap, their skill uncertainty is widened by the drift model before the new evidence is incorporated. Skill drift models how a competitor's true skill can change between appearances.
Each time they reappear after a gap, their skill uncertainty is widened by the
drift model before the new evidence is incorporated.
Drift is represented by the `Drift` trait: Drift is represented by the `Drift` trait (`src/drift.rs`), generic over the
history's time type:
```rust ```text
pub trait Drift: Copy + Debug { pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
fn variance_delta(&self, elapsed: i64) -> f64; fn variance_delta(&self, from: &T, to: &T) -> f64;
fn variance_for_elapsed(&self, elapsed: i64) -> f64;
} }
``` ```
`variance_delta` returns the amount to add to `σ²` given the elapsed time since the player last played. Internally, `Gaussian::forget` uses this to compute the new sigma: `σ_new = sqrt(σ² + variance_delta)`. Both methods return the amount to add to `σ²`, not to `σ`. `variance_delta`
works from two timestamps; `variance_for_elapsed` takes an already-computed
elapsed count, and is used on the paths that cache it. `Gaussian::forget`
applies the result entirely in variance space — `from_mv(mu, variance() +
variance_delta)` — taking no square root.
That block is a quotation rather than a doctest. The custom-drift example below
is compiled by CI, so it is what actually pins the signature.
### ConstantDrift ### ConstantDrift
The built-in `ConstantDrift` implements a linear random walk — skill uncertainty grows proportionally to time: The built-in `ConstantDrift` implements a linear random walk — skill uncertainty
grows proportionally to time:
``` ```text
variance_delta = elapsed * γ² variance_delta = elapsed * γ²
``` ```
This is the standard TrueSkill Through Time model. Use it by passing a `ConstantDrift(gamma)` when constructing a `Player`: This is the standard TrueSkill Through Time model. Pass a `ConstantDrift::new(gamma)`
when constructing a `Rating`:
```rust ```rust
use trueskill_tt::{Player, Gaussian, drift::ConstantDrift}; use trueskill_tt::{ConstantDrift, Gaussian, Rating};
// gamma = 0.1 means skill can shift ~0.1 per time unit // gamma = 0.1 means skill can shift ~0.1 per time unit.
let player = Player::new(Gaussian::from_ms(0.0, 6.0), 1.0, ConstantDrift(0.1)); let rating: Rating<i64, ConstantDrift> =
Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, ConstantDrift::new(0.1));
assert_eq!(rating.drift().gamma(), 0.1);
``` ```
The type annotation is load-bearing: `ConstantDrift` implements `Drift<T>` for
every `T: Time`, so without it `T` is ambiguous.
### Custom drift ### Custom drift
Implement `Drift` to express any other model. For example, a drift that saturates after a long absence (uncertainty grows with the square root of elapsed time instead of linearly): Implement `Drift<T>` to express any other model. For example, a drift that
saturates after a long absence, with uncertainty growing as the square root of
elapsed time instead of linearly:
```rust ```rust
use trueskill_tt::drift::Drift; use trueskill_tt::{Drift, Gaussian, History, Rating, Time};
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
struct SqrtDrift { struct SqrtDrift {
gamma: f64, gamma: f64,
} }
impl Drift for SqrtDrift { impl<T: Time> Drift<T> for SqrtDrift {
fn variance_delta(&self, elapsed: i64) -> f64 { fn variance_delta(&self, from: &T, to: &T) -> f64 {
(elapsed as f64).sqrt() * self.gamma * self.gamma let elapsed = from.elapsed_to(to).max(0) as f64;
elapsed.sqrt() * self.gamma * self.gamma
}
fn variance_for_elapsed(&self, elapsed: i64) -> f64 {
(elapsed.max(0) as f64).sqrt() * self.gamma * self.gamma
} }
} }
let player = Player::new(Gaussian::from_ms(0.0, 6.0), 1.0, SqrtDrift { gamma: 0.5 }); // On a single Rating:
let rating: Rating<i64, SqrtDrift> =
Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, SqrtDrift { gamma: 0.5 });
// Or for a whole History, via the builder:
let history = History::builder().drift(SqrtDrift { gamma: 0.5 }).build();
assert_eq!(rating.beta(), 1.0);
assert_eq!(history.log_evidence(), 0.0);
``` ```
To use a custom drift type with `History`, use the `.drift()` builder method instead of `.gamma()`: `HistoryBuilder::drift` is the only way to set a history's drift model; there is
no `gamma()` shorthand. The default is `ConstantDrift::new(GAMMA)`.
### Per-competitor drift
A `History` has one drift model, but individual competitors can scale it.
`Member::with_drift_scale(s)` multiplies the drift *variance* that competitor
accumulates, so `s` is in the same units as `gamma`: `ConstantDrift::new(g)` at
scale `s` behaves exactly as `ConstantDrift::new(g * s)` would, for that competitor
alone.
`0.0` pins a competitor still. That is what makes a **fixed reference point**
expressible in the same graph as moving competitors — a bot at a known
strength, a rating floor, a course difficulty:
```rust ```rust
let h = History::builder() use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
.drift(SqrtDrift { gamma: 0.5 })
.build(); let mut h = History::builder().drift(ConstantDrift::new(0.1)).build();
h.add_events(vec![Event {
time: 0,
teams: [
Team::with_members([Member::new("player")]),
// A course does not improve. Pin it, and the round's evidence
// lands on the player instead of being split between the two.
Team::with_members([Member::new("layout_7").with_drift_scale(0.0)]),
]
.into_iter()
.collect(),
outcome: Outcome::winner(0, 2),
}])
.unwrap();
h.converge().unwrap();
``` ```
Like `with_prior`, the scale is **competitor configuration, not a per-event
value**: it applies to the competitor for the whole history, and it applies
whenever it is supplied — including on a key the history already knows.
Configuring one late still refits the whole history rather than taking effect
only from that event onward, because `converge` refits from competitor state.
Repeating the same value is inert; supplying two *different* values for one
competitor within a single batch is `InferenceError::ConflictingCompetitorConfig`,
since events in a batch have no order. The scale must be finite and
non-negative; ingestion otherwise fails with `InferenceError::InvalidParameter`.
The fluent `EventBuilder` reaches this too: `.team([...])` is the common case
and leaves both unset, while `.members([...])` takes `Member` values directly,
so `h.event(t).members([Member::new("layout_7").with_drift_scale(0.0)])` is
equivalent to the typed shape above.
## Scored outcomes ## Scored outcomes
Use `Outcome::scores([...])` when you have continuous per-team scores rather Use `Outcome::scores([...])` when you have continuous per-team scores rather
@@ -80,7 +285,7 @@ soft Gaussian evidence about the latent performance diff. Configure
(smaller σ = more trust). (smaller σ = more trust).
```rust ```rust
use trueskill_tt::{History, Outcome}; use trueskill_tt::History;
let mut h = History::builder().score_sigma(2.0).build(); let mut h = History::builder().score_sigma(2.0).build();
h.event(1) h.event(1)
@@ -92,12 +297,144 @@ h.event(1)
h.converge().unwrap(); h.converge().unwrap();
``` ```
## Todo ## Prediction
- [x] Implement approx for Gaussian `predict_outcome` gives the full distribution over finishing orders. Each entry
- [x] Add more tests from `TrueSkillThroughTime.jl` is a rank vector in the same shape `Outcome::ranking` takes — equal ranks mean a
- [ ] Add tests for `quality()` (Use [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) as reference) tie — so an outcome feeds straight back into inference.
- [ ] Benchmark Batch::iteration()
- [ ] Time needs to be an enum so we can have multiple states (see `batch::compute_elapsed()`) ```rust
- [ ] Add examples (use same TrueSkillThroughTime.(py|jl)) use trueskill_tt::History;
- [ ] Add Observer (see [argmin](https://docs.rs/argmin/latest/argmin/core/trait.Observe.html) for inspiration)
let mut h = History::builder().p_draw(0.1).build();
h.record_winner(&"alice", &"bob", 1).unwrap();
h.converge().unwrap();
let p = h.predict_outcome(&[&[&"alice"], &[&"bob"]]).unwrap();
// Probabilities are exhaustive and disjoint, so they sum to one.
assert!((p.total() - 1.0).abs() < 1e-6);
let (best, likelihood) = p.most_likely().unwrap();
println!("most likely: {best:?} at {likelihood:.3}");
println!("draw: {:.3}", p.probability_of(&[0, 0]));
```
Supports any number of teams. Because the outcome space grows factorially, the
full distribution is capped at `MAX_PREDICTED_TEAMS`; two cheaper entry points
stay available at any size:
- `predict_win_probabilities(teams)``P(team i finishes strictly first)`,
quadratic in team count.
- `predict_ranking(teams, ranks)` — one specific finishing order.
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
`current_skill` if your caller cannot guarantee that.
If predicting for competitors you have never seen is the point rather than a
mistake, say so once:
```rust
use trueskill_tt::{History, UnknownKeys};
let h = History::builder().unknown_keys(UnknownKeys::Prior).build();
```
An unknown competitor is then answered from the configured prior, which is the
honest reading — you have no evidence about them — and correctly *widens* a team
that contains one. There is deliberately no "skip the member" mode: a team's
performance is the sum of its members, so dropping one would make the model more
certain because it knows less.
### Asking about one competitor
`Gaussian` answers tail questions directly, which is what a stopping rule needs:
```rust
use trueskill_tt::History;
let mut h = History::default();
h.record_winner(&"alice", &"bob", 1).unwrap();
h.converge().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.
let _ = skill.probability_below(20.0);
// Use this rather than `1.0 - probability_below(x)`: the complement cancels
// away every digit in the upper tail, which is where a stopping rule lives.
let _ = skill.probability_above(30.0);
```
## Which match to play next
`quality()` measures whether a matchup is *fair*. That is not the same as
whether it is *informative*, and the two only coincide for two evenly matched
competitors. When each observation costs something, ask
`expected_information_gain` instead — the outcome-weighted divergence between
what you believe now and what you would believe afterwards.
```rust
use trueskill_tt::History;
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();
}
h.record_winner(&"veteran", &"newcomer", 500).unwrap();
h.converge().unwrap();
let settled = h.expected_information_gain(&[&[&"veteran"], &[&"regular"]]).unwrap();
let unknown = h.expected_information_gain(&[&[&"veteran"], &[&"newcomer"]]).unwrap();
// Playing the newcomer teaches you more than replaying a settled rivalry.
assert!(unknown > settled);
```
The result is in nats, and is bounded by the entropy of the outcome: at most
`ln 2 ≈ 0.693` for a two-way result, `ln 3` once draws are possible, `ln k` for
`k` outcomes. A value near zero means you already know how it ends.
This costs one full inference pass **per possible outcome**, so it is far more
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.
## Other implementations
- [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
Licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
<http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or
<http://opensource.org/licenses/MIT>)
at your option.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
dual licensed as above, without any additional terms or conditions.
+45 -35
View File
@@ -1,45 +1,55 @@
//! One slice's event sweep.
//!
//! Written against the public API rather than against `TimeSlice` directly.
//! It used to reach for `TimeSlice`, `KeyTable`, `CompetitorStore`,
//! `Competitor` and `EventKind`, and was the *only* thing outside `src/`
//! that did — so a benchmark was dictating five public types that no test,
//! example or consumer could otherwise obtain.
//!
//! A single-slice history's `converge` calls exactly the same per-slice sweep,
//! so capping at one iteration measures the same code path.
use criterion::{Criterion, criterion_group, criterion_main}; use criterion::{Criterion, criterion_group, criterion_main};
use trueskill_tt::{ use smallvec::smallvec;
BETA, Competitor, ConvergenceOptions, EventKind, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA, use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
TimeSlice, drift::ConstantDrift, gaussian::Gaussian, storage::CompetitorStore,
};
fn criterion_benchmark(criterion: &mut Criterion) { fn criterion_benchmark(criterion: &mut Criterion) {
let mut index_map = KeyTable::new(); let build = || {
let mut h = History::builder()
.convergence(ConvergenceOptions {
max_iter: 1,
epsilon: 0.0,
alpha: 1.0,
})
.drift(ConstantDrift::new(0.0))
.build();
let a = index_map.get_or_create("a"); // 100 events, all at one time, so the history has a single slice.
let b = index_map.get_or_create("b"); let events: Vec<Event<i64, &'static str>> = (0..100)
let c = index_map.get_or_create("c"); .map(|_| Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
})
.collect();
h.add_events(events).expect("fixture ingests");
h
};
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new(); criterion.bench_function("slice_sweep_100_events", |b| {
b.iter_batched(
for agent in [a, b, c] { build,
agents.insert( |mut h| {
agent, // `converge_partial`, not `converge`: one iteration is
Competitor { // deliberately short of convergence and `converge` reports that
rating: Rating::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)), // as an error.
..Default::default() let _ = h.converge_partial();
}, },
criterion::BatchSize::SmallInput,
); );
}
let mut composition = Vec::new();
let mut results = Vec::new();
let mut weights = Vec::new();
for _ in 0..100 {
composition.push(vec![vec![a], vec![b]]);
results.push(vec![1.0, 0.0]);
weights.push(vec![vec![1.0], vec![1.0]]);
}
let kinds = vec![EventKind::Ranked; composition.len()];
let mut time_slice = TimeSlice::new(1, P_DRAW, ConvergenceOptions::default());
time_slice.add_events(composition, results, weights, kinds, &agents);
criterion.bench_function("Batch::iteration", |b| {
b.iter(|| time_slice.iteration(0, &agents))
}); });
} }
-53
View File
@@ -1,53 +0,0 @@
use criterion::{Criterion, criterion_group, criterion_main};
use trueskill_tt::gaussian::Gaussian;
fn benchmark_gaussian_arithmetic(criterion: &mut Criterion) {
// Define test Gaussians
let g1 = Gaussian::from_ms(25.0, 25.0 / 3.0);
let g2 = Gaussian::from_ms(0.0, 1.0);
let g3 = Gaussian::from_ms(1.0, 1.0);
// Benchmark addition
criterion.bench_function("Gaussian::add", |bencher| {
bencher.iter(|| g1 + g2);
});
// Benchmark subtraction
criterion.bench_function("Gaussian::sub", |bencher| {
bencher.iter(|| g1 - g3);
});
// Benchmark multiplication
criterion.bench_function("Gaussian::mul", |bencher| {
bencher.iter(|| g1 * g2);
});
// Benchmark division
// NOTE: numerator must have higher precision (smaller sigma) than the
// denominator in this representation; g2 (sigma=1) / g1 (sigma=8.33) is
// well-defined, whereas g1 / g2 underflows and panics in mu_sigma.
criterion.bench_function("Gaussian::div", |bencher| {
bencher.iter(|| g2 / g1);
});
// Benchmark natural parameter conversions
criterion.bench_function("Gaussian::pi", |bencher| {
bencher.iter(|| g1.pi());
});
criterion.bench_function("Gaussian::tau", |bencher| {
bencher.iter(|| g1.tau());
});
// Benchmark combined pi/tau operations (used in mul/div)
criterion.bench_function("Gaussian::pi_tau_combined", |bencher| {
bencher.iter(|| {
let pi = g1.pi();
let tau = g1.tau();
(pi, tau)
});
});
}
criterion_group!(benches, benchmark_gaussian_arithmetic);
criterion_main!(benches);
+8 -9
View File
@@ -25,16 +25,14 @@
use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
use smallvec::smallvec; use smallvec::smallvec;
use trueskill_tt::{ use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team,
};
fn build_history_1v1( fn build_history_1v1(
n_events: usize, n_events: usize,
n_competitors: usize, n_competitors: usize,
events_per_slice: usize, events_per_slice: usize,
seed: u64, seed: u64,
) -> History<i64, ConstantDrift, NullObserver, String> { ) -> History<String> {
let mut rng = seed; let mut rng = seed;
let mut next = || { let mut next = || {
rng = rng rng = rng
@@ -43,11 +41,12 @@ fn build_history_1v1(
rng rng
}; };
let mut h = History::<i64, _, _, String>::builder_with_key() let mut h = History::builder()
.key_type::<String>()
.mu(25.0) .mu(25.0)
.sigma(25.0 / 3.0) .sigma(25.0 / 3.0)
.beta(25.0 / 6.0) .beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0)) .drift(ConstantDrift::new(25.0 / 300.0))
.convergence(ConvergenceOptions { .convergence(ConvergenceOptions {
max_iter: 30, max_iter: 30,
epsilon: 1e-6, epsilon: 1e-6,
@@ -82,7 +81,7 @@ fn bench_converge(c: &mut Criterion) {
b.iter_batched( b.iter_batched(
|| build_history_1v1(500, 100, 10, 42), || build_history_1v1(500, 100, 10, 42),
|mut h| { |mut h| {
h.converge().unwrap(); let _ = h.converge().unwrap();
}, },
BatchSize::SmallInput, BatchSize::SmallInput,
); );
@@ -92,7 +91,7 @@ fn bench_converge(c: &mut Criterion) {
b.iter_batched( b.iter_batched(
|| build_history_1v1(2000, 200, 20, 42), || build_history_1v1(2000, 200, 20, 42),
|mut h| { |mut h| {
h.converge().unwrap(); let _ = h.converge().unwrap();
}, },
BatchSize::SmallInput, BatchSize::SmallInput,
); );
@@ -106,7 +105,7 @@ fn bench_converge(c: &mut Criterion) {
b.iter_batched( b.iter_batched(
|| build_history_1v1(5000, 50000, 5000, 42), || build_history_1v1(5000, 50000, 5000, 42),
|mut h| { |mut h| {
h.converge().unwrap(); let _ = h.converge().unwrap();
}, },
BatchSize::SmallInput, BatchSize::SmallInput,
); );
+62
View File
@@ -0,0 +1,62 @@
//! Ingestion cost: one event per call versus one batched call.
//!
//! The rest of the suite only measures batched construction, which is why a
//! quadratic in the incremental path went unnoticed — `record_winner` and
//! `event(..).commit()` each ingest a single event, so a caller looping over a
//! match feed takes that path.
use std::hint::black_box;
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use smallvec::smallvec;
use trueskill_tt::{Event, History, Member, Outcome, Team};
fn events(n: usize, time: i64) -> Vec<Event<i64, String>> {
(0..n)
.map(|i| Event {
time,
teams: smallvec![
Team::with_members([Member::new(format!("p{}", 2 * i))]),
Team::with_members([Member::new(format!("p{}", 2 * i + 1))]),
],
outcome: Outcome::winner(0, 2),
})
.collect()
}
fn bench_ingest(c: &mut Criterion) {
let mut group = c.benchmark_group("ingest");
for n in [250usize, 500, 1000] {
group.bench_with_input(BenchmarkId::new("one-at-a-time", n), &n, |b, &n| {
b.iter_batched(
|| events(n, 0),
|evs| {
let mut h: History<String> = History::builder().key_type::<String>().build();
for ev in evs {
h.add_events(std::iter::once(ev)).unwrap();
}
black_box(h.time_slices_len())
},
criterion::BatchSize::SmallInput,
);
});
group.bench_with_input(BenchmarkId::new("single-batch", n), &n, |b, &n| {
b.iter_batched(
|| events(n, 0),
|evs| {
let mut h: History<String> = History::builder().key_type::<String>().build();
h.add_events(evs).unwrap();
black_box(h.time_slices_len())
},
criterion::BatchSize::SmallInput,
);
});
}
group.finish();
}
criterion_group!(benches, bench_ingest);
criterion_main!(benches);
+81
View File
@@ -0,0 +1,81 @@
//! Cost of the joint posterior: factorising versus querying.
//!
//! The split is the whole point of `History::joint`. Factorising is `O(n^3)` in
//! the history's appearances and depends only on the fit; a query is `O(n^2)`
//! and depends only on the question. `posterior_of_one_shot` pays both every
//! time, `joint_query` pays only the second.
use criterion::{Criterion, criterion_group, criterion_main};
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<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: trueskill_tt::ITERATIONS,
epsilon: 1e-10,
alpha: 1.0,
})
.build();
let mut events: Vec<Event<i64, String>> = Vec::new();
let mut k = 0usize;
for t in 0..30i64 {
for _ in 0..8 {
k += 1;
events.push(Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(format!("p{}", k % 100))]),
Team::with_members([Member::new(format!("p{}", (k + 37) % 100))]),
],
outcome: Outcome::scores([
(k as f64 * 0.3).sin().abs() * 20.0,
(k as f64 * 0.3).cos().abs() * 20.0,
]),
});
}
}
h.add_events(events).unwrap();
let _ = h.converge().unwrap();
h
}
fn bench_joint(c: &mut Criterion) {
let h = fitted();
let a = "p0".to_string();
let b = "p1".to_string();
let terms = [(&a, 1.0), (&b, -1.0)];
c.bench_function("joint_factorise_480_appearances", |bencher| {
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.joint().unwrap().posterior_of(&terms).unwrap()));
});
let joint = h.joint().unwrap();
c.bench_function("joint_query_480_appearances", |bencher| {
bencher.iter(|| std::hint::black_box(joint.posterior_of(&terms).unwrap()));
});
}
criterion_group!(benches, bench_joint);
criterion_main!(benches);
+4 -3
View File
@@ -5,11 +5,12 @@ use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
fn bench_scored_history(c: &mut Criterion) { fn bench_scored_history(c: &mut Criterion) {
c.bench_function("scored_history_60_events_30_iter", |bencher| { c.bench_function("scored_history_60_events_30_iter", |bencher| {
bencher.iter(|| { bencher.iter(|| {
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key() let mut h: History<String> = History::builder()
.key_type::<String>()
.mu(25.0) .mu(25.0)
.sigma(25.0 / 3.0) .sigma(25.0 / 3.0)
.beta(25.0 / 6.0) .beta(25.0 / 6.0)
.drift(ConstantDrift(0.03)) .drift(ConstantDrift::new(0.03))
.score_sigma(2.0) .score_sigma(2.0)
.build(); .build();
@@ -29,7 +30,7 @@ fn bench_scored_history(c: &mut Criterion) {
}); });
} }
h.add_events(events).unwrap(); h.add_events(events).unwrap();
h.converge().unwrap(); let _ = h.converge().unwrap();
}); });
}); });
} }
+10
View File
@@ -44,6 +44,11 @@ split_commits = false
# Assigns commits to groups. # Assigns commits to groups.
# Optionally sets the commit's scope and can decide to exclude commits from further processing. # Optionally sets the commit's scope and can decide to exclude commits from further processing.
commit_parsers = [ commit_parsers = [
# Must precede the type parsers below: a `feat!`/`fix!`/`refactor!` subject
# matches those too, and the first match wins. Without this a breaking
# change renders as an ordinary line of its own type.
{ message = "^[a-z]+(\\(.+\\))?!:", group = "Breaking Changes" },
{ body = "BREAKING CHANGE", group = "Breaking Changes" },
{ message = "^feat", group = "Features" }, { message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" }, { message = "^fix", group = "Bug Fixes" },
{ message = "^doc", group = "Documentation" }, { message = "^doc", group = "Documentation" },
@@ -53,6 +58,11 @@ commit_parsers = [
{ message = "^test", group = "Testing" }, { message = "^test", group = "Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true }, { message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore", group = "Miscellaneous Tasks" }, { message = "^chore", group = "Miscellaneous Tasks" },
{ message = "^ci", group = "CI" },
# Every branch lands with `--no-ff`, so a release's merge commits outnumber
# its real ones and say nothing the merged commits do not. They were
# filling an "Other (unconventional)" section with 14 lines of noise.
{ message = "^Merge ", skip = true },
{ body = ".*security", group = "Security" }, { body = ".*security", group = "Security" },
{ body = ".*", group = "Other (unconventional)" }, { body = ".*", group = "Other (unconventional)" },
] ]
File diff suppressed because it is too large Load Diff
@@ -500,6 +500,26 @@ All public traits (`Time`, `Drift`, `Observer`, `Factor`, `Schedule`) require `S
`rayon` as default-on feature; with `default-features = false`, parallel paths fall back to sequential iterators behind `cfg(feature = "rayon")`. `rayon` as default-on feature; with `default-features = false`, parallel paths fall back to sequential iterators behind `cfg(feature = "rayon")`.
> **Not implemented. Deliberate deviation, decided 2026-09-08 (issue #5).**
>
> `rayon` ships **opt-in**: `Cargo.toml` has no `default = [...]` key. The
> measured speedups are 1.0x on realistic workloads and 1.3x on a pathological
> one (issue #4), because typical slices hold too few events to amortize
> rayon's task-spawn overhead. Default-on would hand every downstream user a
> thread pool and a dependency for approximately no gain.
>
> This section made the trade conditional on cross-slice dirty-bit skipping
> landing and changing the parallel story. It did not land: #4 was closed on
> 2026-08-27 by removing the inert `ConvergenceReport::slices_skipped` field
> rather than by implementing the mechanism, so the re-measurement this was
> waiting on will not arrive.
>
> The "Trade-offs" note below also cited an `unsafe` concurrent-write path
> through `SkillStore` as a cost of default-on. That cost does not exist: the
> crate is `#![forbid(unsafe_code)]`, and the compute/apply split on the
> internal `Event` is what lets a color group run in parallel without it. The
> case for opt-in rests on the measurements alone.
### Expected speedup ballpark ### Expected speedup ballpark
For 1000 players, 60 events/slice × 1000 slices, 30 convergence iterations: For 1000 players, 60 events/slice × 1000 slices, 30 convergence iterations:
@@ -521,7 +541,7 @@ These are pre-implementation estimates. Each tier validates with criterion.
- Color-group parallelism requires up-front graph coloring at ingestion. Cost: linear in events, run once per `add_events`. Cheap. - Color-group parallelism requires up-front graph coloring at ingestion. Cost: linear in events, run once per `add_events`. Cheap.
- Default = asynchronous EP (preserves current semantics). Synchronous opt-in only. - Default = asynchronous EP (preserves current semantics). Synchronous opt-in only.
- Cross-slice sweep stays sequential; no speculative parallel sweeps. - Cross-slice sweep stays sequential; no speculative parallel sweeps.
- Rayon default-on but feature-gated. - Rayon default-on but feature-gated. **Superseded — shipped opt-in; see the deviation note in Section 6.**
### Open question ### Open question
@@ -0,0 +1,342 @@
# Filtered (Forward-Only) Estimates
Closes [#19](https://git.aceofba.se/logaritmisk/trueskill-tt/issues/19).
## Summary
`HistoryBuilder::online(true)` is inert. It flips a flag that reaches
`Item::within_prior` (`src/time_slice.rs:70-71`), which reads
`Skill.online` (`src/time_slice.rs:25`) — a field initialised to `N_INF`
(`src/time_slice.rs:41`) and never assigned anywhere. The online path
therefore builds every rating from the improper Gaussian, and
`log_evidence()` silently reports `n × ln(0.5)`: every game scored as a
coin flip, finite and plausible-looking.
This spec replaces the field and the flag with a **read-only forward-only
pass** over the converged history, exposed as three new public methods.
The pass reuses the production within-slice sweep verbatim rather than
reimplementing inference, and stores nothing on `Skill`.
## Background
### Why a stored field cannot hold this quantity
The issue proposes populating `skill.online` during the forward pass,
alongside `new_forward_info` (`src/time_slice.rs:576`). That would not
work, and understanding why determines the whole design.
`new_forward_info` sets `skill.forward` from
`agents[a].receive_for_elapsed(...)`, whose `message` was written by the
previous slice's `forward_prior_out` (`src/time_slice.rs:549`):
```rust
skill.forward * skill.likelihood
```
`History::iteration` (`src/history.rs:255`) alternates a backward sweep
over slices and a forward sweep. From the second iteration onward, the
`skill.likelihood` feeding that message has already absorbed backward
information from the preceding backward sweep. So after `converge()`,
**`skill.forward` is a smoothed quantity, not a filtering one** — and any
field written from it inherits the same contamination on every sweep
after the first.
### The neighbouring trap
The same reasoning applies to the existing `forward: bool` parameter on
`log_evidence_internal` (`src/history.rs:395`). It is a genuine filtering
quantity only on a history that has never been converged. That is why the
test at `src/history.rs:1183` can assert
```rust
assert_ulps_eq!(trueskill_log_evidence, trueskill_log_evidence_online, epsilon = 1e-6);
```
— the fixture is never converged, so the forward message still equals the
cavity prior. (Note also that the local binding is named `..._online`
while the flag it passes is `forward`; the two senses were already
muddled.)
Fixing `forward: bool` is **out of scope** here; see *Out-of-scope
follow-ups*.
### Why this is worth implementing rather than deleting
The forward-only estimate has a second consumer beyond prequential model
comparison. `learning_curve()` returns post-convergence posteriors, so
every point is smoothed — the estimate at a given date incorporates
rounds played years later. On [ustat](https://git.aceofba.se/logaritmisk/ustat)'s
real data (prior μ=0, σ=6) that produces curves which start already
spread apart and barely move:
```
player first point final point
Eskil mu +3.72 sigma 1.17 mu +4.61 sigma 1.21
Anders Olsson mu +1.61 sigma 0.90 mu +1.16 sigma 0.82
LUDVIGSSON mu -2.09 sigma 1.08 mu -2.61 sigma 1.13
Anners mu -2.85 sigma 1.27 mu -2.86 sigma 1.26
```
σ at the *first* plotted point is 0.901.60 against a prior of 6.00. A
caller cannot reconstruct the filtered view from the public API today
except by refitting over `events[0..k]` for every k — O(n²) fits for
something one forward pass already computes.
## Scope
### What ships
1. A read-only forward-only pass on `History`, walking slices in time
order and carrying its own forward messages.
2. Three public methods: `filtered_log_evidence`,
`filtered_learning_curves`, `filtered_learning_curve`.
3. Removal of `Skill.online`, `History.online`, `HistoryBuilder.online`,
`HistoryBuilder::online()`, and the `online: bool` parameter threaded
through `Item::within_prior`, `Event::within_priors`, and
`TimeSlice::log_evidence`.
4. `#[derive(Clone)]` on `Event`, `Team`, `Item`; `iterate_to_convergence`
loses its `#[cfg(test)]` gate.
5. A CHANGELOG entry recording the API break.
### What does not ship
- No change to `log_evidence()`, `log_evidence_for()`, `learning_curve()`,
`learning_curves()`, or `current_skill()`. Their values are unchanged
by this work.
- No fix to the `forward: bool` flag described above.
- No caching of pass results. Each call runs a full pass; the doc
comments say so.
- No `rayon` parallelism across slices — the pass is sequentially
dependent by construction.
- No prior-predictive accessor. The pass computes the pre-event forward
message internally, but only the filtered posterior is exposed until a
second caller needs otherwise.
## Design
### Naming
`filtered_*`, not `online_*`. "Filtered" is the standard term for the
forward-only estimate, and the crate already uses "online" for a second,
unrelated thing — incremental ingestion, which `benches/baseline.txt:128`
calls the "online-add" path. Two senses of one word in one crate is how
the present bug reads as plausible.
### The pass
```rust
pub(crate) struct FilteredStep {
log_evidence: f64,
posteriors: Vec<(Index, Gaussian)>,
}
fn filtered_pass(&self) -> Vec<(T, FilteredStep)>
```
`posteriors` doubles as the outgoing forward message: the scratch sweep never
writes `backward`, so it stays `N_INF`, and `Skill::posterior()` and
`forward_prior_out` are then the same product.
Walk `self.time_slices` in order, carrying
`messages: HashMap<Index, Gaussian>` — the forward message out of each
competitor's most recent appearance. For each slice:
1. **Build a scratch clone.** Same `time`, `p_draw`, `convergence`, and
cloned `events` with every `item.likelihood` reset to `N_INF`. Fresh
`SkillStore` in which, for each agent present in the real slice:
```rust
forward = match messages.get(&agent) {
Some(msg) => msg.forget(rating.drift.variance_for_elapsed(skill.elapsed)),
None => rating.prior,
}
backward = N_INF
likelihood = N_INF
elapsed = skill.elapsed // copied from the real slice
```
This mirrors `Competitor::receive_for_elapsed` (`src/competitor.rs:39`)
exactly, including its `message != N_INF` fallback to the prior.
`skill.elapsed` is reused rather than recomputed: it is maintained by
`add_events_with_prior` across out-of-order ingestion, and production
convergence already trusts it.
2. **Run the real sweep.** `scratch.iterate_to_convergence(agents)`
(`src/time_slice.rs:516`), unmodified. Fidelity comes from reusing the
production path rather than a parallel reimplementation — in
particular, a competitor appearing in two events at the same time is
handled by the same within-slice EP that `converge()` uses, not
approximated the way the current `online`/`forward` evidence paths are
(they run each event independently and sum).
3. **Harvest.** With `backward == N_INF` acting as the multiplicative
identity, `Skill::posterior()` is exactly forward × likelihood — the
filtered posterior. Slice evidence is
`scratch.events.iter().map(|e| e.log_evidence).sum()`; `apply`
(`src/time_slice.rs:162`) writes that field on every event during the
sweep.
4. **Carry forward.** `messages.insert(a, scratch.forward_prior_out(&a))`
for each agent in the slice.
Steps 14 are the forward half of `History::iteration`
(`src/history.rs:283-297`) with the backward half never run. The pass
touches no field of `self`.
### Public API
```rust
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
pub fn filtered_log_evidence(&self) -> f64;
pub fn filtered_learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>>;
pub fn filtered_learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized;
}
```
All take `&self` — the pass mutates nothing. Shapes deliberately mirror
`learning_curve` / `learning_curves` (`src/history.rs:325`, `:381`) so a
caller can plot smoothed and filtered curves on one chart with the same
handling code.
`filtered_learning_curve` runs the same full pass as the plural form and
collects one key; the cost is identical, only the collection differs.
Callers wanting several keys should use the plural form. Documented on
both methods.
Because the pass carries its own messages and re-runs inference, its
results **do not depend on whether `converge()` has been called**. That
is the property a stored field cannot have, and it is asserted as a test.
### Removal inventory
| Location | Change |
|---|---|
| `src/time_slice.rs:25` | delete `pub(crate) online: Gaussian` |
| `src/time_slice.rs:41` | delete `online: N_INF` from `Default` |
| `src/time_slice.rs:62,70-73` | drop `online` param and its branch from `Item::within_prior` |
| `src/time_slice.rs:110,120` | drop `online` param from `Event::within_priors` |
| `src/time_slice.rs:585,597,626,634` | drop `online` param from `TimeSlice::log_evidence`; `online \|\| forward` becomes `forward` |
| `src/history.rs:32,63,138,158,174,199,226` | delete the two `online` field declarations (`:32`, `:199`) and the five struct-literal copies |
| `src/history.rs:90-93` | delete `HistoryBuilder::online()` |
| `src/history.rs:402,410` | drop the `self.online` argument |
| `src/history.rs:1183-1189` | the `..._online` assertion becomes a `forward`-flag assertion; rename the binding to match what it tests |
`Skill` loses 16 bytes, which is a small independent win for #17.
## Testing strategy
Every new test is mutation-proved before it counts: break the production
line it names, watch it fail for the *right* assertion, restore. A test
never observed failing is not evidence.
### The red test
On the issue's own fixture — five 1v1 games, same winner each time —
`filtered_log_evidence()` must land strictly between the two known
endpoints:
```
5 × ln(0.5) = -3.4657... (today's inert value)
< filtered
< -0.4012... (batch / smoothed evidence)
```
Two-sided, so neither "still inert" nor "accidentally smoothed" can pass.
The lower bound is right for a real reason: game one genuinely *is* a
coin flip under filtering, games two through five are not.
### Invariants
1. **Invariant to `converge()`** — `filtered_log_evidence()` and
`filtered_learning_curves()` agree before and after `converge()`. This
is exactly what `skill.forward` fails, and what makes a stored field
the wrong mechanism.
Agreement is to tolerance, not bit-identity, and the reason is worth
recording. `iteration` calls `recompute_color_groups`
(`src/time_slice.rs:369`) only when `from == 0`, so a slice built by
repeated appends keeps insertion order until the first `converge()`
reorders it. The scratch clone inherits whichever order it finds, and
greedy coloring over a permuted input can group differently, giving a
different within-slice sweep order — same EP fixed point, different
path to it. Follow the house pattern in
`tests/ingestion_equivalence.rs`: converge tightly (`max_iter: 2_000`,
`epsilon: 1e-12`) and compare within `1e-8`.
2. **Invariant to ingestion order** — events added one at a time produce
the same filtered results as the same events batched. Extends the
existing invariant in `tests/ingestion_equivalence.rs`.
3. **Single-slice exactness** — for a history with one time slice there
is no future to propagate back, so filtered results equal smoothed
results exactly.
4. **Uncertainty ordering** — for a competitor with many later games, σ
at the first filtered point is greater than σ at the first smoothed
point, and less than the prior σ. This is the ustat complaint restated
as an assertion.
5. **Degenerate inputs** — empty history yields `0.0` and empty maps;
unknown key yields an empty curve. Added to
`tests/degenerate_inputs.rs`.
### Regression net
The existing suite must be unchanged by the removals: `log_evidence()`,
`log_evidence_for()`, and every numerical golden keep their current
values, since the default `online` was already `false` and the flag was
inert.
## Verification gates
- `just test` — full matrix, including the release job. `debug_assert!`
is compiled out in release, and that is where defects in this crate
have hidden before.
- `just lint` — clippy, warnings denied.
- `just fmt` — nightly.
- `just determinism` — the new pass must not perturb bit-identical
posteriors across `RAYON_NUM_THREADS` 1/2/4/8.
- `#![forbid(unsafe_code)]` stays.
## Risks
- **Clone cost.** One slice's events are cloned per slice visited. At
ustat scale this is negligible, but the pass is O(events) allocation on
top of O(events) inference. Accepted: fidelity to the production sweep
is worth more than avoiding the clone, and no caller is on a hot path.
- **`iterate_to_convergence` leaving test-only status.** Its doc comment
claims "only used by tests"; that comment must be updated, or it
becomes the next piece of load-bearing prose that is quietly false.
- **Event order is inherited, not normalised.** The scratch clone takes
the real slice's current event order, which differs pre- and
post-`converge()` for incrementally-ingested slices (see *Invariants*).
Results agree to within convergence tolerance rather than exactly.
Normalising the order in the scratch builder would buy bit-identity at
the cost of diverging from what the real sweep does; not worth it.
**Measured after implementation, this risk is smaller than stated.**
Flipping the scratch's `color_groups_dirty` from `true` to `false`
switches it between the grouped sweep (`sweep_color_groups`) and the
sequential fallback across its entire convergence loop — a far larger
perturbation than a permuted event order — and the ingestion-order
invariance test stays green at `1e-8` under `max_iter: 2_000`,
`epsilon: 1e-12`. EP reaches the same fixed point regardless of sweep
order once driven far enough. The tolerance caveat is correct but
conservative. Note the flag itself is load-bearing: with it `false` the
scratch would take the sequential path always, diverging from the
production sweep it exists to mirror.
- **Divergence risk.** If `TimeSlice`'s sweep gains state that the
scratch construction does not initialise, the pass silently reads a
default. The scratch builder must construct `Skill` field-by-field
rather than via `..Default::default()`, so adding a field to `Skill`
is a compile error here rather than a silent wrong answer.
## Out-of-scope follow-ups
File as separate issues:
1. **`forward: bool` is only a filtering quantity pre-convergence**
(`src/history.rs:395`). Either document the constraint or fold the
flag into the new pass and delete it.
2. **`log_evidence` takes `&mut self`** (`src/history.rs:416`) but
mutates nothing. The new `filtered_*` methods take `&self`; the
asymmetry is worth removing.
+29 -8
View File
@@ -1,7 +1,8 @@
use plotters::prelude::*; use plotters::prelude::*;
use smallvec::smallvec;
use time::{Date, Month}; use time::{Date, Month};
use trueskill_tt::{Event, History, Member, Outcome, Team, drift::ConstantDrift}; use trueskill_tt::{
Event, History, Member, Outcome, Team, drift::ConstantDrift, smallvec::smallvec,
};
fn main() { fn main() {
let mut csv = csv::Reader::open("examples/atp.csv").unwrap(); let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
@@ -42,18 +43,38 @@ fn main() {
} }
} }
let mut hist: History<i64, _, _, String> = History::builder_with_key() let mut hist: History<String> = History::builder()
.key_type::<String>()
.sigma(1.6) .sigma(1.6)
.drift(ConstantDrift(0.036)) .drift(ConstantDrift::new(0.036))
.convergence(trueskill_tt::ConvergenceOptions { .convergence(trueskill_tt::ConvergenceOptions {
max_iter: 10, // This history needs 30 sweeps to reach the epsilon below. It was
// capped at 10 until the `#[must_use]` on `ConvergenceReport`
// surfaced that the example had been shipping a short fit.
max_iter: 100,
epsilon: 0.01, epsilon: 0.01,
alpha: 1.0, alpha: 1.0,
}) })
.build(); .build();
hist.add_events(events).unwrap(); hist.add_events(events).unwrap();
hist.converge().unwrap();
// Read the report rather than discarding it. A fit that hits `max_iter`
// without reaching `epsilon` is not an error and does not look wrong — every
// rating comes back finite and sensibly ordered — so this flag is the only
// thing that says the numbers were still moving when the sweep stopped.
let report = hist.converge().unwrap();
eprintln!(
"converged={} after {} sweeps, final step {:?}",
report.converged, report.iterations, report.final_step
);
if !report.converged {
eprintln!(
"warning: stopped after {} sweeps with a final step of {:?}, \
short of epsilon — raise ConvergenceOptions::max_iter",
report.iterations, report.final_step
);
}
let players = [ let players = [
("aggasi", "a092", 38800i64), ("aggasi", "a092", 38800i64),
@@ -77,7 +98,7 @@ fn main() {
let mut y_spec = (f64::MAX, f64::MIN); let mut y_spec = (f64::MAX, f64::MIN);
for &(_, id, cutoff) in &players { for &(_, id, cutoff) in &players {
for (ts, gs) in hist.learning_curve(id) { for (ts, gs) in hist.learning_curve(id).unwrap() {
if ts >= cutoff { if ts >= cutoff {
continue; continue;
} }
@@ -123,7 +144,7 @@ fn main() {
let mut upper = Vec::new(); let mut upper = Vec::new();
let mut lower = Vec::new(); let mut lower = Vec::new();
for (ts, gs) in hist.learning_curve(id) { for (ts, gs) in hist.learning_curve(id).unwrap() {
if ts >= cutoff { if ts >= cutoff {
continue; continue;
} }
+2 -3
View File
@@ -6,15 +6,14 @@
//! //!
//! Run with: `cargo run --example scored --release` //! Run with: `cargo run --example scored --release`
use smallvec::smallvec; use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team, smallvec::smallvec};
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
fn main() { fn main() {
let mut h = History::builder() let mut h = History::builder()
.mu(25.0) .mu(25.0)
.sigma(25.0 / 3.0) .sigma(25.0 / 3.0)
.beta(25.0 / 6.0) .beta(25.0 / 6.0)
.drift(ConstantDrift(0.03)) .drift(ConstantDrift::new(0.03))
.score_sigma(2.0) // tune to data; smaller = trust margins more .score_sigma(2.0) // tune to data; smaller = trust margins more
.build(); .build();
+14 -2
View File
@@ -1,2 +1,14 @@
publish = false # Publish to the registry named in Cargo.toml's `publish` list (kellnr).
pre-release-hook = ["sh", "-c", "git cliff -o CHANGELOG.md --tag {{version}} && git add CHANGELOG.md"] publish = true
# Hold off pushing until tags and publish have both succeeded; `just release`
# pushes last.
push = false
# Regenerate the changelog and stage it so it lands in the release commit.
#
# Guarded on DRY_RUN because cargo-release runs pre-release hooks during a dry
# run too (verified against cargo-release 1.1.5, which exports DRY_RUN=true,
# CRATE_NAME, PREV_VERSION and NEW_VERSION to the hook). Without the guard,
# `just release-plan` — documented as a preview that writes nothing — writes and
# `git add`s CHANGELOG.md, and the clean-tree check in `just release` then
# refuses to run. That check is load-bearing: publishing is irreversible.
pre-release-hook = ["sh", "-c", '[ "$DRY_RUN" = "true" ] || (git cliff -o CHANGELOG.md --tag {{version}} && git add CHANGELOG.md)']
+395
View File
@@ -0,0 +1,395 @@
//! Active learning: which comparison teaches you the most.
//!
//! [`quality`](crate::quality) answers "is this matchup *fair*". That is a
//! different question from "is this matchup *informative*", and the two
//! coincide only for two evenly matched competitors. When each observation
//! costs something — a human click, a scheduled fixture — the question worth
//! asking is the second one.
//!
//! The quantity here is expected information gain: the outcome-weighted
//! divergence between what you believe now and what you would believe after
//! seeing the result.
//!
//! ```text
//! EIG(matchup) = SUM P(outcome) * KL( posterior_after(outcome) || prior )
//! outcome
//! ```
//!
//! It is the mutual information between the observed outcome and the skills,
//! which is worth remembering because it pins the scale: information gain
//! cannot exceed the entropy of the thing you are about to observe. A contest
//! with `k` distinguishable outcomes can teach you at most `ln k` nats,
//! whatever the ratings. That ceiling is the sharpest available test of an
//! implementation — see [`expected_information_gain`].
use crate::{
GameOptions, Gaussian, InferenceError, Outcome, Rating, drift::Drift, predict, time::Time,
};
/// Outcomes below this probability contribute nothing measurable and are not
/// worth an inference pass.
///
/// The contribution of an outcome is `P * KL`, and `KL` is bounded in practice
/// by tens of nats, so a probability this small moves the total by less than
/// the quadrature error already present in `P` itself.
const NEGLIGIBLE: f64 = 1e-12;
/// `KL(q || p)` for two univariate Gaussians, in nats.
///
/// Both arguments are proper posteriors from inference, so the degenerate
/// cases guarded here (zero or infinite variance) indicate that inference has
/// broken down rather than anything a caller did.
fn kl_divergence(q: Gaussian, p: Gaussian) -> f64 {
let (var_q, var_p) = (q.sigma().powi(2), p.sigma().powi(2));
if !(var_q.is_finite() && var_p.is_finite()) || var_q <= 0.0 || var_p <= 0.0 {
return 0.0;
}
let mean_gap = q.mu() - p.mu();
// Algebraically `0.5 * (ln(var_p/var_q) + (var_q + gap^2)/var_p - 1)`, but
// written so that neither term can go negative.
//
// The direct form cancels against its `- 1.0` for two near-identical
// distributions and returns a *negative* divergence — measured, 762 082 of
// 3 000 000 near-identical pairs, worst `-5.55e-17`, which is exactly one
// ULP of the 1.0. It also loses the answer entirely where it is small:
// at `var_q/var_p - 1 = 1e-9` the direct form gives `0.0` where the true
// value is `2.5e-19`.
//
// With `u = var_q/var_p - 1` the variance part is `0.5 * (u - ln(1+u))`,
// which is non-negative for every `u > -1`, and the mean part is a square
// over a positive variance. Non-negativity is then structural rather than
// incidental.
let u = var_q / var_p - 1.0;
0.5 * u_minus_ln1p(u) + mean_gap * mean_gap / (2.0 * var_p)
}
/// `u - ln(1 + u)`, without the cancellation that spelling invites.
///
/// Both terms are approximately `u` for small `u`, so the subtraction loses
/// everything just where the result matters. The Taylor series
/// `u^2/2 - u^3/3 + u^4/4 - ...` is exact in that regime and manifestly
/// non-negative, since `u^2/2` dominates.
fn u_minus_ln1p(u: f64) -> f64 {
if u.abs() < 1e-4 {
let u2 = u * u;
u2 * (0.5 - u / 3.0 + u2 / 4.0)
} else {
u - libm::log1p(u)
}
}
/// Expected information gain of a hypothetical matchup, in nats.
///
/// Enumerates the outcomes this matchup could have, runs inference for each to
/// get the belief it would produce, and weights the resulting divergence by
/// that outcome's probability. A higher value means the result would teach you
/// more.
///
/// # Interpreting the value
///
/// Nats. The upper bound is the entropy of the outcome variable: at most
/// `ln 2 ≈ 0.693` for a two-way result, `ln 3 ≈ 1.099` once draws are
/// possible, `ln k` for `k` outcomes. A value near the ceiling means the
/// result is close to a coin flip *and* would move the posteriors a long way;
/// a value near zero means you already know what will happen, or that the
/// result would barely change your beliefs if you saw it.
///
/// This is not a monotone transform of [`quality`](crate::quality). A lopsided
/// matchup between two uncertain competitors scores well on quality-times-
/// variance heuristics and poorly here, because the near-certain outcome
/// carries almost no information.
///
/// # Cost
///
/// One full inference pass per possible outcome, so this is far more expensive
/// than `quality()` — which is one closed-form evaluation. The outcome count
/// grows quickly with team count (3 outcomes for two teams that can draw, 13
/// for three, 75 for four), and scoring every candidate pairing among `n`
/// competitors is `O(n² × outcomes)` inference passes.
///
/// For a selector over many candidates, shortlist with the cheap
/// [`quality`](crate::quality) or
/// [`predict_win_probabilities`](crate::History::predict_win_probabilities)
/// first and score only the shortlist here. The expected-variance-reduction
/// proxy sometimes suggested as a cheaper alternative is *not* cheaper: it
/// needs the same hypothetical posteriors, so it shares the dominant cost.
///
/// # Errors
///
/// - `NotEnoughTeams` if fewer than two teams are supplied.
/// - `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).
/// - `InvalidParameter` for a `p_draw` outside `[0.0, 1.0)`.
/// - `GridTooCoarse` when the performance sigmas are too far apart to
/// integrate on one grid. This comes from `outcome_distribution`, which runs
/// before any inference — so it is not covered by "anything `Game::ranked`
/// returns" below.
/// - Anything [`Game::ranked`](crate::Game::ranked) returns for a hypothetical
/// outcome.
pub fn expected_information_gain<T: Time, D: Drift<T>>(
teams: &[&[Rating<T, D>]],
options: &GameOptions,
) -> Result<f64, InferenceError> {
if teams.len() < 2 {
return Err(InferenceError::NotEnoughTeams { got: teams.len() });
}
if teams.len() > crate::MAX_PREDICTED_TEAMS {
return Err(InferenceError::TooManyTeams {
got: teams.len(),
max: crate::MAX_PREDICTED_TEAMS,
});
}
if !(0.0..1.0).contains(&options.p_draw) {
return Err(InferenceError::InvalidParameter {
parameter: crate::Parameter::PDraw,
value: options.p_draw,
});
}
for (idx, team) in teams.iter().enumerate() {
if team.is_empty() {
return Err(InferenceError::EmptyTeam { team: idx });
}
}
// Prediction runs on performances: skill inflated by each member's beta.
let performances: Vec<Gaussian> = teams
.iter()
.map(|team| {
team.iter()
.fold(crate::N00, |acc, rating| acc.convolve(rating.performance()))
})
.collect();
// Draw margins per pair, derived from the teams' betas exactly as
// inference derives them, so the outcomes weighted here are the outcomes
// that would actually be fitted.
let beta_sq: Vec<f64> = teams
.iter()
.map(|team| team.iter().map(|r| r.beta().powi(2)).sum())
.collect();
let p_draw = options.p_draw;
let margins = predict::Margins::new(teams.len(), |i, j| {
if p_draw == 0.0 {
0.0
} else {
crate::compute_margin(p_draw, (beta_sq[i] + beta_sq[j]).sqrt())
}
});
let mut gain = 0.0;
for (ranks, probability) in predict::outcome_distribution(&performances, &margins)? {
if probability <= NEGLIGIBLE {
continue;
}
let game = crate::Game::ranked(teams, Outcome::ranking(ranks), options)?;
let posteriors = game.posteriors();
// Beliefs factorise across competitors, so the joint divergence is the
// sum of the per-competitor ones.
let divergence: f64 = teams
.iter()
.zip(&posteriors)
.flat_map(|(team, posterior)| team.iter().zip(posterior))
.map(|(rating, &after)| kl_divergence(after, rating.prior()))
.sum();
gain += probability * divergence;
}
Ok(gain)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{BETA, ConstantDrift, GAMMA};
type R = Rating<i64, ConstantDrift>;
fn rating(mu: f64, sigma: f64) -> R {
R::new(
Gaussian::from_ms(mu, sigma),
BETA,
ConstantDrift::new(GAMMA),
)
}
fn options(p_draw: f64) -> GameOptions {
GameOptions {
p_draw,
..GameOptions::default()
}
}
fn eig(teams: &[&[R]], p_draw: f64) -> f64 {
expected_information_gain(teams, &options(p_draw)).unwrap()
}
/// The analytic ceiling. Information gain is the mutual information between
/// the outcome and the skills, so it cannot exceed the entropy of the
/// outcome variable — whatever the ratings. This is the check a subtly
/// wrong implementation fails while still returning plausible numbers: an
/// early prototype of this returned 4.77 nats from a sign error and passed
/// every monotonicity test.
#[test]
fn never_exceeds_the_entropy_of_the_outcome() {
let ceiling_two = std::f64::consts::LN_2;
for (a, b) in [
(rating(0.0, 6.0), rating(0.0, 6.0)),
(rating(0.0, 0.5), rating(0.0, 0.5)),
(rating(12.0, 6.0), rating(-12.0, 6.0)),
(rating(40.0, 1.0), rating(-40.0, 1.0)),
(rating(3.0, 6.0), rating(-2.0, 0.1)),
(rating(0.0, 25.0), rating(0.0, 25.0)),
] {
let g = eig(&[&[a], &[b]], 0.0);
assert!(
g >= 0.0 && g <= ceiling_two,
"EIG {g} outside [0, ln 2] for mu=({}, {}) sigma=({}, {})",
a.prior().mu(),
b.prior().mu(),
a.prior().sigma(),
b.prior().sigma()
);
}
}
/// With draws enabled there are three outcomes, so the ceiling rises to
/// `ln 3` — and the two-outcome bound no longer applies.
#[test]
fn the_ceiling_follows_the_outcome_count() {
let ceiling_three = 3.0f64.ln();
for sigma in [0.5, 3.0, 6.0, 25.0] {
let g = eig(&[&[rating(0.0, sigma)], &[rating(0.0, sigma)]], 0.25);
assert!(
g >= 0.0 && g <= ceiling_three,
"EIG {g} outside [0, ln 3] at sigma {sigma}"
);
}
}
/// An even matchup between uncertain competitors is the informative one.
/// A hopelessly lopsided matchup teaches you almost nothing, because you
/// already know how it ends.
#[test]
fn an_even_matchup_beats_a_lopsided_one() {
let even = eig(&[&[rating(0.0, 6.0)], &[rating(0.0, 6.0)]], 0.0);
let lopsided = eig(&[&[rating(12.0, 6.0)], &[rating(-12.0, 6.0)]], 0.0);
assert!(
even > lopsided,
"even {even} should beat lopsided {lopsided}"
);
}
/// Certainty is the thing information gain is measuring the absence of:
/// the less you know, the more there is to learn.
#[test]
fn gain_falls_as_certainty_rises() {
let mut previous = f64::INFINITY;
for sigma in [12.0, 6.0, 3.0, 1.0, 0.5, 0.1] {
let g = eig(&[&[rating(0.0, sigma)], &[rating(0.0, sigma)]], 0.0);
assert!(
g < previous,
"sigma {sigma}: {g} did not fall below {previous}"
);
previous = g;
}
assert!(previous >= 0.0);
}
/// The heuristic this replaces is `quality * sigma_a^2 * sigma_b^2`. It is
/// not a monotone transform of information gain — it ranks a lopsided
/// matchup above a confident even one, and EIG ranks them the other way.
/// Pinning the disagreement down is what stops a future "simplification"
/// from quietly reverting to the heuristic.
#[test]
fn disagrees_with_the_quality_times_variance_heuristic() {
let heuristic = |a: &R, b: &R| {
crate::quality(&[&[a.prior()], &[b.prior()]], BETA)
* a.prior().sigma().powi(2)
* b.prior().sigma().powi(2)
};
let (confident_a, confident_b) = (rating(0.0, 0.5), rating(0.0, 0.5));
let (lopsided_a, lopsided_b) = (rating(12.0, 6.0), rating(-12.0, 6.0));
assert!(
heuristic(&lopsided_a, &lopsided_b) > heuristic(&confident_a, &confident_b),
"the heuristic should prefer the lopsided matchup"
);
assert!(
eig(&[&[confident_a], &[confident_b]], 0.0) > eig(&[&[lopsided_a], &[lopsided_b]], 0.0),
"information gain should prefer the even matchup"
);
}
#[test]
fn supports_more_than_two_teams() {
let teams: Vec<Vec<R>> = vec![
vec![rating(0.0, 6.0)],
vec![rating(0.0, 6.0)],
vec![rating(0.0, 6.0)],
];
let refs: Vec<&[R]> = teams.iter().map(Vec::as_slice).collect();
let g = expected_information_gain(&refs, &options(0.0)).unwrap();
// Six distinguishable orderings with no draws.
assert!(
g > 0.0 && g <= 6.0f64.ln(),
"three-team EIG {g} out of range"
);
}
#[test]
fn multi_member_teams_are_supported() {
let a = [rating(0.0, 6.0), rating(1.0, 4.0)];
let b = [rating(0.0, 6.0)];
let g = expected_information_gain(&[&a, &b], &options(0.0)).unwrap();
assert!(g > 0.0 && g <= std::f64::consts::LN_2, "{g}");
}
#[test]
fn degenerate_shapes_are_errors() {
let a = [rating(0.0, 6.0)];
assert!(matches!(
expected_information_gain(&[&a], &options(0.0)),
Err(InferenceError::NotEnoughTeams { got: 1 })
));
let empty: [R; 0] = [];
assert!(matches!(
expected_information_gain(&[&a, &empty], &options(0.0)),
Err(InferenceError::EmptyTeam { team: 1 })
));
assert!(matches!(
expected_information_gain(&[&a, &a], &options(1.5)),
Err(InferenceError::InvalidParameter {
parameter: crate::Parameter::PDraw,
..
})
));
}
#[test]
fn kl_divergence_is_zero_for_identical_beliefs() {
let g = Gaussian::from_ms(3.0, 2.0);
assert!(kl_divergence(g, g).abs() < 1e-15);
}
#[test]
fn kl_divergence_is_non_negative_and_grows_with_separation() {
let prior = Gaussian::from_ms(0.0, 3.0);
let mut previous = 0.0;
for mu in [0.0, 0.5, 1.0, 2.0, 4.0] {
let d = kl_divergence(Gaussian::from_ms(mu, 3.0), prior);
assert!(d >= 0.0, "negative divergence at mu {mu}: {d}");
assert!(d >= previous, "not increasing at mu {mu}");
previous = d;
}
}
}
+164 -11
View File
@@ -26,39 +26,75 @@ pub(crate) struct ColorGroups {
} }
impl ColorGroups { impl ColorGroups {
#[allow(dead_code)]
pub(crate) fn new() -> Self { pub(crate) fn new() -> Self {
Self::default() Self::default()
} }
#[allow(dead_code)]
pub(crate) fn n_colors(&self) -> usize {
self.groups.len()
}
#[allow(dead_code)]
pub(crate) fn is_empty(&self) -> bool { pub(crate) fn is_empty(&self) -> bool {
self.groups.is_empty() self.groups.is_empty()
} }
/// Total event count across all colors. /// Number of distinct colors in the partition. Test-only.
#[allow(dead_code)] #[cfg(test)]
pub(crate) fn n_colors(&self) -> usize {
self.groups.len()
}
/// Total event count across all colors. Test-only.
#[cfg(test)]
pub(crate) fn total_events(&self) -> usize { pub(crate) fn total_events(&self) -> usize {
self.groups.iter().map(|g| g.len()).sum() self.groups.iter().map(|g| g.len()).sum()
} }
/// Contiguous index range for one color after events have been reordered /// Contiguous index range for one color after events have been reordered
/// into color-contiguous positions by `TimeSlice::recompute_color_groups`. /// into color-contiguous positions by `TimeSlice::recompute_color_groups`.
#[allow(dead_code)]
pub(crate) fn color_range(&self, color_idx: usize) -> std::ops::Range<usize> { pub(crate) fn color_range(&self, color_idx: usize) -> std::ops::Range<usize> {
let group = &self.groups[color_idx]; let group = &self.groups[color_idx];
if group.is_empty() { if group.is_empty() {
return 0..0; return 0..0;
} }
let start = *group.first().unwrap(); let start = *group.first().unwrap();
let end = *group.last().unwrap() + 1; let end = *group.last().unwrap() + 1;
debug_assert_eq!(
end - start,
group.len(),
"color {color_idx} is not contiguous; its range would overlap other colors"
);
start..end start..end
} }
/// Whether every color occupies a contiguous, ascending range of event
/// indices, and no two colors overlap.
///
/// The parallel sweep derives one `&mut` sub-slice per color from these
/// ranges and relies on them being disjoint. That disjointness is what
/// makes concurrent writes to distinct skills sound, so it is checked
/// rather than assumed.
pub(crate) fn groups_are_contiguous(&self) -> bool {
let mut expected_start = 0;
for group in &self.groups {
if group.is_empty() {
continue;
}
let ascending_run = group
.iter()
.enumerate()
.all(|(offset, &idx)| idx == group[0] + offset);
if !ascending_run || group[0] != expected_start {
return false;
}
expected_start += group.len();
}
true
}
} }
/// Compute color groups greedily. /// Compute color groups greedily.
@@ -67,7 +103,6 @@ impl ColorGroups {
/// `Index` values that event touches. The returned `ColorGroups` has one /// `Index` values that event touches. The returned `ColorGroups` has one
/// inner `Vec<usize>` per color, containing event indices in the order /// inner `Vec<usize>` per color, containing event indices in the order
/// they were assigned. /// they were assigned.
#[allow(dead_code)]
pub(crate) fn color_greedy<I, F>(n_events: usize, index_set: F) -> ColorGroups pub(crate) fn color_greedy<I, F>(n_events: usize, index_set: F) -> ColorGroups
where where
F: Fn(usize) -> I, F: Fn(usize) -> I,
@@ -156,3 +191,121 @@ mod tests {
assert_eq!(cg.total_events(), 4); assert_eq!(cg.total_events(), 4);
} }
} }
#[cfg(test)]
mod properties {
use std::collections::HashSet;
use proptest::prelude::*;
use super::*;
/// The property the whole parallel sweep rests on: two events sharing a
/// competitor must never land in the same color, because a color group is
/// run concurrently and two events touching one competitor would race.
///
/// Hand-written cases cover the shapes someone thought of. This covers the
/// ones nobody did — the correctness of `sweep_color_groups` depends on it
/// holding for every input, not for five.
fn check(events: &[Vec<usize>]) {
let groups = color_greedy(events.len(), |ev| {
events[ev]
.iter()
.copied()
.map(Index::from)
.collect::<Vec<_>>()
});
// Disjointness *between events* within a color. Deduplicated per
// event, because one event legitimately naming a competitor twice is
// not a collision — `color_greedy` collects each event's members into
// a set for exactly that reason.
for color in 0..groups.n_colors() {
let mut seen: HashSet<usize> = HashSet::new();
for &ev in &groups.groups[color] {
let members: HashSet<usize> = events[ev].iter().copied().collect();
for competitor in members {
assert!(
seen.insert(competitor),
"competitor {competitor} shared by two events in color {color}"
);
}
}
}
// Every event is assigned exactly once. Without this, a partition that
// dropped events would satisfy disjointness trivially.
let mut assigned: Vec<usize> = groups.groups.iter().flatten().copied().collect();
assigned.sort_unstable();
assert_eq!(assigned, (0..events.len()).collect::<Vec<_>>());
assert_eq!(groups.total_events(), events.len());
// No empty colors: one would waste a sweep and make `n_colors`
// misleading.
for (color, group) in groups.groups.iter().enumerate() {
assert!(!group.is_empty(), "color {color} is empty");
}
// Contiguity is not a property of `color_greedy` — it holds only after
// `recompute_color_groups` reorders the events so each color occupies
// one range. What must always hold is that the reorder is *possible*:
// relabelling events in group order yields contiguous groups. The
// parallel sweep slices `&mut` sub-ranges from those, so if this ever
// failed the reorder would produce overlapping ranges.
let mut next = 0usize;
let relabelled: Vec<Vec<usize>> = groups
.groups
.iter()
.map(|group| {
group
.iter()
.map(|_| {
let i = next;
next += 1;
i
})
.collect()
})
.collect();
assert!(ColorGroups { groups: relabelled }.groups_are_contiguous());
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
/// Small competitor pool, so collisions are common and colors are
/// forced to multiply.
#[test]
fn colors_are_disjoint_on_a_dense_pool(
events in prop::collection::vec(
prop::collection::vec(0usize..6, 1..4),
0..20,
)
) {
check(&events);
}
/// Wide pool, so most events are independent and land in one color.
#[test]
fn colors_are_disjoint_on_a_sparse_pool(
events in prop::collection::vec(
prop::collection::vec(0usize..200, 1..6),
0..30,
)
) {
check(&events);
}
/// Repeated competitors within one event must not confuse the
/// member-set bookkeeping.
#[test]
fn colors_are_disjoint_with_repeated_members(
events in prop::collection::vec(
prop::collection::vec(0usize..3, 1..8),
0..15,
)
) {
check(&events);
}
}
}
+25 -19
View File
@@ -1,5 +1,4 @@
use crate::{ use crate::{
N_INF,
drift::{ConstantDrift, Drift}, drift::{ConstantDrift, Drift},
gaussian::Gaussian, gaussian::Gaussian,
rating::Rating, rating::Rating,
@@ -8,12 +7,19 @@ use crate::{
/// Per-history, temporal state for someone competing. /// Per-history, temporal state for someone competing.
/// ///
/// Renamed from `Agent` in T2; the former `.player` field is now /// The mutable half of a competitor: `Rating` holds their static
/// `.rating` to match the `Player → Rating` rename. /// configuration, this holds what inference learns as it sweeps.
#[derive(Debug)] #[derive(Debug)]
pub struct Competitor<T: Time = i64, D: Drift<T> = ConstantDrift> { pub struct Competitor<T: Time = i64, D: Drift<T> = ConstantDrift> {
pub rating: Rating<T, D>, pub rating: Rating<T, D>,
pub message: Gaussian, /// The forward message carried from this competitor's last appearance, or
/// `None` before they have appeared anywhere.
///
/// Previously an improper `N_INF` served as the unset sentinel, which made
/// "no message yet" indistinguishable from "a legitimately improper
/// message" at the type level and required every reader to know the
/// convention.
pub message: Option<Gaussian>,
pub last_time: Option<T>, pub last_time: Option<T>,
} }
@@ -21,14 +27,16 @@ impl<T: Time, D: Drift<T>> Competitor<T, D> {
/// Compute the message received at time `now`, with drift accumulated /// Compute the message received at time `now`, with drift accumulated
/// from `self.last_time` (if any) to `now`. /// from `self.last_time` (if any) to `now`.
pub(crate) fn receive(&self, now: &T) -> Gaussian { pub(crate) fn receive(&self, now: &T) -> Gaussian {
if self.message != N_INF { match self.message {
let elapsed_variance = match &self.last_time { Some(message) => {
Some(last) => self.rating.drift.variance_delta(last, now), let elapsed_variance = match &self.last_time {
None => 0.0, Some(last) => self.rating.drift_variance_delta(last, now),
}; None => 0.0,
self.message.forget(elapsed_variance) };
} else {
self.rating.prior message.forget(elapsed_variance)
}
None => self.rating.prior,
} }
} }
@@ -37,11 +45,9 @@ impl<T: Time, D: Drift<T>> Competitor<T, D> {
/// Used in convergence sweeps where the elapsed was cached at slice-construction time /// Used in convergence sweeps where the elapsed was cached at slice-construction time
/// and should not be recomputed from `last_time` (which may have shifted). /// and should not be recomputed from `last_time` (which may have shifted).
pub(crate) fn receive_for_elapsed(&self, elapsed: i64) -> Gaussian { pub(crate) fn receive_for_elapsed(&self, elapsed: i64) -> Gaussian {
if self.message != N_INF { match self.message {
self.message Some(message) => message.forget(self.rating.drift_variance_for_elapsed(elapsed)),
.forget(self.rating.drift.variance_for_elapsed(elapsed)) None => self.rating.prior,
} else {
self.rating.prior
} }
} }
} }
@@ -50,7 +56,7 @@ impl Default for Competitor<i64, ConstantDrift> {
fn default() -> Self { fn default() -> Self {
Self { Self {
rating: Rating::default(), rating: Rating::default(),
message: N_INF, message: None,
last_time: None, last_time: None,
} }
} }
@@ -63,7 +69,7 @@ where
C: Iterator<Item = &'a mut Competitor<T, D>>, C: Iterator<Item = &'a mut Competitor<T, D>>,
{ {
for c in competitors { for c in competitors {
c.message = N_INF; c.message = None;
if last_time { if last_time {
c.last_time = None; c.last_time = None;
} }
+101 -3
View File
@@ -4,9 +4,38 @@ use std::time::Duration;
use smallvec::SmallVec; use smallvec::SmallVec;
#[derive(Clone, Copy, Debug)] /// The stopping rule for the fixed-point loops, plus how hard they are damped.
///
/// Set once per history through
/// [`HistoryBuilder::convergence`](crate::HistoryBuilder::convergence), and
/// carried by `GameOptions` for a single match scored without a history. The
/// defaults are the crate's globals: [`ITERATIONS`](crate::ITERATIONS),
/// [`EPSILON`](crate::EPSILON), and undamped EP.
///
/// Deliberately **not** `#[non_exhaustive]`, unlike [`ConvergenceReport`]. The
/// usual argument for marking an options struct is that `..Default::default()`
/// makes a future field additive — but `Default::default` is not a `const fn`,
/// so marking it would make
/// `const OPTS: ConvergenceOptions = ConvergenceOptions { .. }` impossible from
/// outside the crate, with no workaround. This type is `Copy` and a natural
/// const; that cost is permanent, and adding a field is a one-time major bump.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ConvergenceOptions { pub struct ConvergenceOptions {
/// Hard cap on full forward+backward sweeps.
///
/// A runaway guard, not a budget: the loop exits as soon as the step falls
/// to `epsilon`, so raising this costs nothing on a history that converges.
/// Reaching it is
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged).
pub max_iter: usize, pub max_iter: usize,
/// Convergence threshold, in skill units.
///
/// The sweep stops once *both* components of the step — the largest change
/// a whole iteration made to any competitor's posterior mean, and to any
/// posterior standard deviation — are at or below this. Larger values stop
/// sooner and further from the fixed point. Must be non-negative; NaN is
/// rejected, since every comparison against it is false and the loop would
/// read it as converged.
pub epsilon: f64, pub epsilon: f64,
/// EP damping factor in natural-parameter space: each per-factor /// EP damping factor in natural-parameter space: each per-factor
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is /// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
@@ -20,6 +49,37 @@ pub struct ConvergenceOptions {
pub alpha: f64, pub alpha: f64,
} }
impl ConvergenceOptions {
/// Reject values that would make inference silently meaningless.
///
/// `HistoryBuilder::convergence` asserts these eagerly, but the fields are
/// public and `GameOptions` carries a `ConvergenceOptions` — so a caller
/// can hand `Game::ranked` a set the builder never saw. In release the
/// engine's `debug_assert!`s are gone, and an `alpha` of zero leaves every
/// EP update unapplied: inference returns the priors, with every likelihood
/// uninformative and nothing to indicate anything went wrong.
///
/// # Errors
///
/// `InvalidParameter` if `alpha` is outside `(0.0, 1.0]` or `epsilon` is
/// negative. NaN fails both comparisons and is rejected.
pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> {
if !(self.alpha > 0.0 && self.alpha <= 1.0) {
return Err(crate::InferenceError::InvalidParameter {
parameter: crate::Parameter::Alpha,
value: self.alpha,
});
}
if self.epsilon.is_nan() || self.epsilon < 0.0 {
return Err(crate::InferenceError::InvalidParameter {
parameter: crate::Parameter::Epsilon,
value: self.epsilon,
});
}
Ok(())
}
}
impl Default for ConvergenceOptions { impl Default for ConvergenceOptions {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -31,14 +91,52 @@ impl Default for ConvergenceOptions {
} }
/// Post-hoc summary of a `History::converge` call. /// Post-hoc summary of a `History::converge` call.
#[derive(Clone, Debug)] ///
/// From [`History::converge`](crate::History::converge) this always describes a
/// converged fit — stopping at `max_iter` is
/// [`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 { pub struct ConvergenceReport {
/// Full forward+backward sweeps actually run. `0` for a history with no
/// time slices, which is converged trivially.
pub iterations: usize, pub iterations: usize,
/// How far the last sweep still moved the fit, as `(mean, standard
/// deviation)`.
///
/// Not natural parameters: each component is a componentwise maximum of
/// `|Δmu|` and `|Δsigma|` over every competitor posterior the sweep
/// touched, so both are in skill units and both are non-negative. Each is
/// compared against `epsilon` separately — `converged` means neither
/// exceeds it. `(0.0, 0.0)` for a history with no time slices.
pub final_step: (f64, f64), pub final_step: (f64, f64),
/// Natural log of the model evidence for the whole history at this fit,
/// summed over every time slice.
///
/// The same quantity
/// [`History::log_evidence`](crate::History::log_evidence) returns, taken
/// once the sweep has stopped. Only comparable between fits of the same
/// events; higher means the model explains them better.
pub log_evidence: f64, pub log_evidence: f64,
/// Whether the sweep reached `epsilon` rather than stopping at `max_iter`.
///
/// Always `true` from [`History::converge`](crate::History::converge),
/// which reports the other case as `NotConverged`. From
/// [`History::converge_partial`](crate::History::converge_partial) this is
/// the only thing that distinguishes a finished fit from a capped one.
pub converged: bool, pub converged: bool,
/// Wall-clock time each sweep took, in the order they ran.
///
/// One entry per iteration, so its length equals `iterations`; empty for a
/// history with no time slices. It times the sweeps only, so the final
/// log-evidence pass is not in any entry.
pub per_iteration_time: SmallVec<[Duration; 32]>, pub per_iteration_time: SmallVec<[Duration; 32]>,
pub slices_skipped: usize,
} }
#[cfg(test)] #[cfg(test)]
+52 -2
View File
@@ -21,8 +21,58 @@ pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
/// ///
/// For `Time = i64`: variance added is `(to - from) * gamma^2`. /// For `Time = i64`: variance added is `(to - from) * gamma^2`.
/// For `Time = Untimed`: elapsed is always 0, so drift is always 0. /// For `Time = Untimed`: elapsed is always 0, so drift is always 0.
#[derive(Clone, Copy, Debug)] ///
pub struct ConstantDrift(pub f64); /// # Why the field is private
///
/// `gamma` enters only as `gamma * gamma`, so a negative value is squared away:
/// measured against the old public-field form, `ConstantDrift(-0.0833)` produced
/// results **bit identical** to `ConstantDrift(0.0833)`. The sign was neither
/// rejected nor honoured — it vanished. That is the same sign-absorption `HistoryBuilder::sigma`,
/// `HistoryBuilder::beta`, `Gaussian::from_ms` and `Rating::new` all reject.
///
/// It could not be checked while the field was a public tuple position, because
/// there was no constructor to intercept. Validating inside
/// `variance_for_elapsed` would have been worse: it runs inside the sweep, so a
/// construction-time mistake would panic mid-inference — and `Gaussian::from_ms`
/// is a worked example of why that is the wrong place for a guard, where
/// rejecting NaN turned the `NonFiniteStep` reporting path into a crash.
///
/// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value
/// back with [`ConstantDrift::gamma`].
///
/// A non-finite gamma is caught a second time regardless:
/// `History::converge` validates the drift variance each competitor actually
/// accumulates, which also covers a custom [`Drift`] implementation.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ConstantDrift(f64);
impl ConstantDrift {
/// Drift of `gamma` standard deviations per unit time.
///
/// # Panics
///
/// Panics unless `gamma` is finite and non-negative.
///
/// The field is private and this is the only constructor precisely so that
/// there is somewhere to check. While it was a public tuple field there was
/// nothing to intercept, and a negative gamma was silently squared away —
/// see the type docs.
#[must_use]
pub fn new(gamma: f64) -> Self {
assert!(
gamma.is_finite() && gamma >= 0.0,
"gamma must be finite and non-negative (got {gamma}); it is only ever \
squared, so a negative value would silently behave as its absolute value"
);
Self(gamma)
}
/// Standard deviations of drift accumulated per unit time.
#[must_use]
pub fn gamma(&self) -> f64 {
self.0
}
}
impl<T: Time> Drift<T> for ConstantDrift { impl<T: Time> Drift<T> for ConstantDrift {
fn variance_delta(&self, from: &T, to: &T) -> f64 { fn variance_delta(&self, from: &T, to: &T) -> f64 {
+637 -20
View File
@@ -1,56 +1,673 @@
use std::fmt; use std::fmt;
/// How a prediction should treat a key the history has never seen.
///
/// Configured once per history via
/// [`HistoryBuilder::unknown_keys`](crate::HistoryBuilder::unknown_keys).
/// Neither known consumer wants this to vary between queries — one predicts
/// thousands of candidate matchups in a loop, the other's headline feature is
/// predicting a competitor nobody has faced — so it is a property of how you
/// intend to use the model rather than an argument on five call sites.
///
/// # There is deliberately no `Skip`
///
/// Dropping an unknown member is the obvious third option and it is wrong. A
/// team's performance is the *sum* of its members, so removing one removes its
/// variance too: measured on a two-member team with one unknown, skipping gives
/// a performance sigma of 2.37 where treating the member as unknown gives 6.53.
/// An unknown competitor would make the model *more* certain, which is
/// backwards. `Prior` is also the answer the model already gives for a
/// competitor it knows about but has no evidence for, so it corresponds to a
/// state the model can actually be in; skipping does not.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnknownKeys {
/// Reject the prediction with [`InferenceError::UnknownKey`].
///
/// The default, and the right one when every key is expected to be known:
/// a team of strangers should not silently produce a confident-looking
/// answer.
#[default]
Reject,
/// Treat an unknown competitor as one sitting at the history's configured
/// prior.
///
/// This is the honest Bayesian reading — a competitor you have never
/// observed is exactly the prior — and it makes "predict a matchup
/// involving someone new" a first-class question rather than something a
/// caller fakes with a neutral constant.
Prior,
}
/// Which scalar an [`InferenceError::InvalidParameter`] is about.
///
/// A typed discriminator rather than a `&'static str`, so a caller can branch
/// on it and `Display` can state each parameter's actual valid range. Nine
/// distinct strings used to flow through this position, and the only thing a
/// caller could do with one was print it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Parameter {
/// Prior mean skill. Must be finite.
Mu,
/// Prior standard deviation. Must be finite and strictly positive.
Sigma,
/// Performance noise. Must be finite and non-negative.
Beta,
/// Draw probability. Must be in `[0.0, 1.0)`.
PDraw,
/// Observation noise on a score margin. Must be finite and strictly
/// positive.
ScoreSigma,
/// EP damping factor. Must be in `(0.0, 1.0]`.
Alpha,
/// Convergence threshold. Must be non-negative and not NaN.
Epsilon,
/// A competitor's multiplier on the drift variance. Must be finite and
/// non-negative.
DriftScale,
/// The variance a [`Drift`](crate::Drift) implementation actually produced
/// for a span. Must be finite and non-negative — checked because a custom
/// implementation is the one thing no constructor can validate up front.
DriftVariance,
/// A per-member weight on an event. Must be finite.
Weight,
/// A team's score on a scored event. Must be finite.
Score,
/// A team's rank on a ranked event. Must be finite.
Rank,
/// The winning team's index, as given to `Outcome::winner`. Must be less
/// than the team count.
WinnerIndex,
}
impl Parameter {
/// The range this parameter must lie in, for the `Display` message.
fn range(self) -> &'static str {
match self {
Self::Mu => "must be finite",
Self::Sigma => "must be finite and strictly positive",
Self::Beta => "must be finite and non-negative",
Self::PDraw => "must be in [0.0, 1.0)",
Self::ScoreSigma => "must be finite and strictly positive",
Self::Alpha => "must be in (0.0, 1.0]",
Self::Epsilon => "must be non-negative and not NaN",
Self::DriftScale => "must be finite and non-negative",
Self::DriftVariance => "must be finite and non-negative",
Self::Weight => "must be finite",
Self::Score => "must be finite",
Self::Rank => "must be finite",
Self::WinnerIndex => "must be less than the number of teams",
}
}
}
impl std::fmt::Display for Parameter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
Self::Mu => "mu",
Self::Sigma => "sigma",
Self::Beta => "beta",
Self::PDraw => "p_draw",
Self::ScoreSigma => "score_sigma",
Self::Alpha => "alpha",
Self::Epsilon => "epsilon",
Self::DriftScale => "drift_scale",
Self::DriftVariance => "drift variance",
Self::Weight => "weight",
Self::Score => "score",
Self::Rank => "rank",
Self::WinnerIndex => "winner index",
};
f.write_str(name)
}
}
/// Which two lengths an [`InferenceError::MismatchedShape`] found disagreeing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Shape {
/// The outcome describes a different number of teams than the event has.
OutcomeVsTeams,
/// A per-member weight list does not match the team's membership.
Weights,
/// A call that takes a fixed number of teams got a different number.
Teams,
/// One of `add_events_with_prior`'s parallel arrays disagreed with the
/// others.
///
/// Not reachable through the public API — the arrays are built together at
/// the ingestion chokepoint. Kept as a checked error rather than a
/// `debug_assert!` so it also holds in release, which is where this
/// crate's defects have tended to hide.
Internal,
}
impl std::fmt::Display for Shape {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let what = match self {
Self::OutcomeVsTeams => {
"the outcome describes a different number of teams than the event has"
}
Self::Weights => "the weight list does not match the team's membership",
Self::Teams => "this call takes a fixed number of teams",
Self::Internal => {
"an internal array disagreed with its siblings (this is a bug in trueskill-tt)"
}
};
f.write_str(what)
}
}
/// Which [`Outcome`](crate::Outcome) variant a call found or wanted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum OutcomeKind {
/// [`Outcome::Ranked`](crate::Outcome::Ranked): an ordinal finish.
Ranked,
/// [`Outcome::Scored`](crate::Outcome::Scored): continuous scores.
Scored,
}
impl OutcomeKind {
/// The call that takes this kind, for the `Display` message.
fn constructor(self) -> &'static str {
match self {
Self::Ranked => "Game::ranked",
Self::Scored => "Game::scored",
}
}
/// The adjective form, for prose.
fn adjective(self) -> &'static str {
match self {
Self::Ranked => "ranked",
Self::Scored => "scored",
}
}
}
impl std::fmt::Display for OutcomeKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Ranked => "Outcome::Ranked",
Self::Scored => "Outcome::Scored",
})
}
}
/// Which piece of per-competitor configuration was declared twice.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CompetitorField {
/// The starting skill distribution.
Prior,
/// The multiplier on the drift variance.
DriftScale,
}
impl std::fmt::Display for CompetitorField {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Prior => "prior",
Self::DriftScale => "drift_scale",
})
}
}
/// Every way ingestion, inference or prediction can refuse to answer.
///
/// The crate reports rather than repairs. An input it cannot represent, a fit
/// that never reached its fixed point, a quadrature it cannot resolve — each
/// comes back here instead of as a clamped, skipped or truncated result that
/// would still look like a number. Several variants exist precisely because the
/// silent version was measured and found to return a plausible wrong answer.
///
/// The enum and most of its variants are `#[non_exhaustive]`: new cases and new
/// fields are additive, so match with a `_` arm and construct through the
/// library rather than by literal.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum InferenceError { pub enum InferenceError {
/// Expected and actual lengths of some array-shaped input differ. /// Expected and actual lengths of some array-shaped input differ.
#[non_exhaustive]
MismatchedShape { MismatchedShape {
kind: &'static str, /// Which pair of lengths disagreed.
shape: Shape,
/// The length it had to have, taken from whatever it must line up with
/// (usually the event's team count).
expected: usize, expected: usize,
/// The length actually supplied.
got: usize, got: usize,
}, },
/// A probability value is outside `[0, 1]`. /// An `Outcome` of the wrong variant was supplied for the requested inference.
InvalidProbability { value: f64 }, #[non_exhaustive]
WrongOutcomeKind {
/// The variant the call needs.
expected: OutcomeKind,
/// The variant actually supplied.
got: OutcomeKind,
},
/// A scalar parameter is outside its valid range. /// A scalar parameter is outside its valid range.
InvalidParameter { name: &'static str, value: f64 }, #[non_exhaustive]
/// Convergence exceeded `max_iter` without falling below `epsilon`. InvalidParameter {
ConvergenceFailed { /// Which parameter. `Display` states its valid range.
last_step: (f64, f64), 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 {
/// Positions in the event's team list of the first tied pair, lowest
/// index first. Only one pair is reported — the event is rejected
/// whole, so enumerating the rest would add nothing.
teams: (usize, usize),
},
/// The convergence sweep hit `max_iter` with the step still above
/// `epsilon`.
///
/// A fit that stops short is wrong by a little, which is the worst
/// available failure: every posterior is finite, the ordering looks sensible,
/// and nothing in the numbers says they were still moving. Reported rather
/// than returned as a flag on an `Ok`, because a flag has to be checked
/// and `let _ = h.converge()` is the natural way not to.
///
/// Either the history needs more iterations — raise `max_iter` — or it is
/// oscillating rather than converging, in which case `alpha < 1.0` damps
/// the within-game EP loop. [`History::converge_partial`](crate::History::converge_partial)
/// returns the short fit instead when that is genuinely what is wanted.
#[non_exhaustive]
NotConverged {
/// Full forward+backward sweeps run before the loop gave up.
iterations: usize, 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,
},
/// A convergence sweep produced a non-finite step.
///
/// 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]
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.
///
/// `prior` and `drift_scale` configure a competitor, not an event, so a
/// batch that sets one of them twice with different values has no
/// well-defined meaning: events within a batch are not ordered, so
/// "last one wins" would make the result depend on iteration order.
/// Declaring the same value repeatedly is fine and is the expected shape
/// when a competitor's configuration is a property of the domain.
#[non_exhaustive]
ConflictingCompetitorConfig {
/// The competitor's interned 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,
/// Which piece of configuration was declared twice.
field: CompetitorField,
},
/// A prediction referenced a key the history has no skill for.
///
/// Reported rather than skipped: dropping unknown keys turns a team of
/// strangers into a confident-looking probability about nobody.
///
/// `key` is the offending key's `Debug` rendering. It is carried because
/// the indices alone are not actionable: a caller that logs
/// `UnknownKey { team: 0, member: 0 }` learns nothing about *which* of its
/// keys the history has not seen, and the natural handling — fall back to a
/// neutral value — turns the whole thing into a plausible constant.
#[non_exhaustive]
UnknownKey {
/// Position of the offending team in the supplied matchup. `0` on the
/// queries that take a flat list of keys rather than teams, where
/// there is only one list to index into.
team: usize,
/// Position of the offending key within that team, or within the flat
/// key list.
member: usize,
/// The key's `Debug` rendering, captured because `K` is only required
/// to be `Debug` — see the variant docs for why the indices alone are
/// not enough.
key: String,
},
/// `History::register` was called for a competitor that already exists.
///
/// Registration states a competitor's configuration before anything has
/// been observed about them, so a competitor that already exists has
/// already been configured — by an earlier `register`, or by an event that
/// created them. Silently overwriting would reintroduce exactly the
/// order-dependence registration exists to remove.
///
/// To change an existing competitor's configuration, supply it on an event
/// through `Member`; that refits the whole history.
#[non_exhaustive]
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 {
/// Position of the memberless team in the supplied list.
team: usize,
},
/// The prediction grid cannot resolve the narrowest feature in the matchup.
///
/// `predict_outcome` and `predict_ranking` integrate every team's density
/// on one shared grid, whose resolution is set by the narrowest sigma (or a
/// narrower draw margin). When the widest and narrowest are far enough
/// apart, resolving the narrow one across the wide one's support needs more
/// nodes than the grid is allowed to hold.
///
/// Reported rather than clamped. Clamping is what this replaced, and it
/// returned probabilities greater than one — measured, a `P` of 2.79 and a
/// `Prediction::total()` of 5.41 — because the trapezoid rule stops
/// resolving a density once the step exceeds roughly 1.7 of its sigma.
///
/// `predict_win_probabilities` answers the same matchup through adaptive
/// quadrature and is accurate here; use it when only the per-team win
/// probabilities are needed.
#[non_exhaustive]
GridTooCoarse {
/// Nodes required to resolve the narrowest feature.
needed: usize,
/// Nodes the grid may hold.
max: usize,
},
/// A joint posterior was requested 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 {
/// 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
/// the space holds `n! * 2^(n-1)` members — 1_920 at five teams, 23_040 at
/// six, 322_560 at seven. Past `max` this stops being something to
/// enumerate on a caller's behalf; ask for individual rankings with
/// `predict_ranking`, or for `predict_win_probabilities`, both of which
/// stay cheap at any team count.
#[non_exhaustive]
TooManyTeams {
/// How many teams the outcome distribution was asked for.
got: usize,
/// The largest team count that will be enumerated,
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
max: usize,
}, },
/// Negative precision: a Gaussian with `pi < 0` slipped into an API call.
NegativePrecision { pi: f64 },
} }
impl fmt::Display for InferenceError { impl fmt::Display for InferenceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::MismatchedShape { Self::MismatchedShape {
kind, shape,
expected, expected,
got, got,
} => { } => {
write!(f, "{kind}: expected length {expected}, got {got}") write!(f, "{shape}: expected {expected}, got {got}")
} }
Self::InvalidProbability { value } => { Self::WrongOutcomeKind { expected, got } => {
write!(f, "probability must be in [0, 1]; got {value}") write!(
f,
"expected {expected}, got {got}; call {} for a {} outcome",
got.constructor(),
got.adjective()
)
} }
Self::InvalidParameter { name, value } => { Self::TieWithoutDrawProbability { teams } => {
write!(f, "{name} is invalid: {value}") write!(
f,
"teams {} and {} are tied, but p_draw is 0.0; set a positive draw probability to admit ties",
teams.0, teams.1
)
} }
Self::ConvergenceFailed { Self::NotConverged {
last_step,
iterations, iterations,
final_step,
epsilon,
} => { } => {
write!( write!(
f, f,
"convergence failed after {iterations} iterations; last step = {last_step:?}" "did not converge in {iterations} iterations: final step {final_step:?} \
is still above epsilon {epsilon}; raise max_iter, or damp with \
alpha < 1.0 if it is oscillating"
) )
} }
Self::NegativePrecision { pi } => { Self::NonFiniteStep { context, step } => {
write!(f, "precision must be non-negative; got {pi}") write!(
f,
"{context}: inference produced a non-finite step {step:?}; EP has \
broken down and further iterations cannot recover"
)
}
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!(
f,
"competitor {competitor}: this batch sets {field} to two different values"
)
}
Self::UnknownKey { team, member, key } => {
write!(
f,
"team {team}, member {member}: no skill recorded for key {key} \
(every key must already be known to the history; pre-filter \
with `current_skill` if that is not guaranteed)"
)
}
Self::AlreadyRegistered { key } => {
write!(
f,
"competitor {key} is already registered; registration states \
configuration before anything is observed, so re-registering \
would silently overwrite it"
)
}
Self::EmptyTeam { team } => {
write!(f, "team {team} has no members")
}
Self::GridTooCoarse { needed, max } => {
write!(
f,
"the prediction grid needs {needed} nodes to resolve the narrowest \
team's density across the widest team's support, but may hold only \
{max}; the sigmas in this matchup are too far apart to integrate on \
one grid. Use predict_win_probabilities, which is accurate here"
)
}
Self::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}")
}
Self::TooManyTeams { got, max } => {
write!(
f,
"the outcome distribution over {got} teams is too large to enumerate (limit {max}); \
use predict_ranking or predict_win_probabilities instead"
)
} }
} }
} }
} }
impl std::error::Error for InferenceError {} impl std::error::Error for InferenceError {}
#[cfg(test)]
mod message_tests {
use super::*;
/// Every message must name the problem *and* what to do, which is the
/// standard the good ones set and the three #74 called out did not meet.
#[test]
fn messages_are_actionable() {
let cases = [
InferenceError::InvalidParameter {
parameter: Parameter::Alpha,
value: 0.0,
},
InferenceError::InvalidParameter {
parameter: Parameter::DriftVariance,
value: f64::NAN,
},
InferenceError::InvalidParameter {
parameter: Parameter::PDraw,
value: 1.5,
},
InferenceError::MismatchedShape {
shape: Shape::OutcomeVsTeams,
expected: 3,
got: 2,
},
InferenceError::WrongOutcomeKind {
expected: OutcomeKind::Ranked,
got: OutcomeKind::Scored,
},
InferenceError::EmptyHistory,
InferenceError::JointRequiresScoredEvents,
InferenceError::NotPositiveDefinite,
InferenceError::NoPerformanceVariance,
InferenceError::NonFiniteSkill {
mu: f64::NAN,
sigma: f64::NAN,
},
];
for case in &cases {
let rendered = case.to_string();
eprintln!("{rendered}");
// `InvalidParameter` used to render `drift variance is invalid: NaN`
// — no range, no remedy, no location. Every message must at least
// be a sentence.
assert!(
rendered.len() > 30,
"message is too terse to act on: {rendered}"
);
assert!(!rendered.contains("is invalid:"), "{rendered}");
}
// The three that #74 singled out now state a range or a next step.
assert!(
InferenceError::InvalidParameter {
parameter: Parameter::DriftVariance,
value: f64::NAN,
}
.to_string()
.contains("must be finite and non-negative")
);
assert!(
InferenceError::WrongOutcomeKind {
expected: OutcomeKind::Ranked,
got: OutcomeKind::Scored,
}
.to_string()
.contains("Game::scored")
);
assert!(
InferenceError::JointRequiresScoredEvents
.to_string()
.contains("predict_win_probabilities")
);
}
}
+114 -9
View File
@@ -1,34 +1,69 @@
//! Typed event description for bulk ingestion. //! Typed event description for bulk ingestion.
//! //!
//! `Event<T, K>` is the new public event shape (spec Section 4). Replaces //! `Event<T, K>` is the public event shape taken by `History::add_events`. It
//! the nested `Vec<Vec<Vec<Index>>>`, `Vec<Vec<f64>>`, `Vec<Vec<Vec<f64>>>` //! is a typed front end, not a replacement: `add_events` flattens it into the
//! that the old `add_events_with_prior` took. //! nested `Vec<Vec<Vec<Index>>>` / `Vec<Vec<f64>>` / `Vec<Vec<Vec<f64>>>` that
//! the internal `add_events_with_prior` chokepoint still takes, and which
//! `record_winner` and `record_draw` also route through.
use smallvec::SmallVec; use smallvec::SmallVec;
use crate::{gaussian::Gaussian, outcome::Outcome, time::Time}; use crate::{gaussian::Gaussian, outcome::Outcome, time::Time};
/// A single match at time `time` involving some number of teams. /// A single match at time `time` involving some number of teams.
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq)]
pub struct Event<T: Time, K> { pub struct Event<T: Time, K> {
/// When the match happened, on the history's time axis.
///
/// Events sharing a `time` land in the same time slice and are fitted
/// together, so nothing distinguishes their order. Drift is driven by the
/// gap between a competitor's *consecutive appearances*, not by the gap
/// between slices, so a competitor idle across several slices accumulates
/// the whole span at once when it next plays.
pub time: T, pub time: T,
/// The teams that took part, positionally aligned with `outcome`: team `i`
/// here is the team `outcome` ranks or scores at index `i`.
///
/// Ingestion rejects fewer than two teams (`NotEnoughTeams`) and any team
/// with no members (`EmptyTeam`).
pub teams: SmallVec<[Team<K>; 4]>, pub teams: SmallVec<[Team<K>; 4]>,
/// How the match ended: ranks (lower is better) or per-team scores (higher
/// is better), one entry per entry of `teams`.
///
/// A tie — two equal ranks — needs a positive `p_draw`, otherwise
/// ingestion fails with `TieWithoutDrawProbability`.
pub outcome: Outcome, pub outcome: Outcome,
} }
/// A team: list of members competing together. /// A team: list of members competing together.
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct Team<K> { pub struct Team<K> {
/// The competitors playing together, in no significant order: the team's
/// performance is the weight-scaled sum over its members, which does not
/// depend on how they are listed.
///
/// Must be non-empty — an empty team contributes no performance at all, so
/// ingestion rejects it with `EmptyTeam` rather than returning a plausible
/// posterior for whoever it was matched against.
pub members: SmallVec<[Member<K>; 4]>, pub members: SmallVec<[Member<K>; 4]>,
} }
impl<K> Team<K> { impl<K> Team<K> {
/// A team with no members yet, to be filled through the public `members`
/// field.
///
/// Committing it while still empty is an `EmptyTeam` error.
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
members: SmallVec::new(), members: SmallVec::new(),
} }
} }
/// A team of exactly these competitors.
///
/// Members must be built already — `Member::from(key)` covers the common
/// case of a plain key at default weight with no overrides.
pub fn with_members<I: IntoIterator<Item = Member<K>>>(members: I) -> Self { pub fn with_members<I: IntoIterator<Item = Member<K>>>(members: I) -> Self {
Self { Self {
members: members.into_iter().collect(), members: members.into_iter().collect(),
@@ -44,33 +79,100 @@ impl<K> Default for Team<K> {
/// One member of a team, identified by user key `K`. /// One member of a team, identified by user key `K`.
/// ///
/// `weight` defaults to 1.0; a per-event `prior` can override the competitor's /// `weight` applies per event and defaults to 1.0.
/// current skill estimate for this event only. ///
#[derive(Clone, Debug)] /// `prior` and `drift_scale` are **competitor configuration**, not per-event
/// values. Setting either applies to the competitor for the whole history, not
/// just to this event, and applies whenever it is supplied — including on a key
/// the history already knows. Because configuration lives on the competitor and
/// `converge` refits from competitor state, configuring one late still refits
/// the whole history rather than taking effect only from that event onward.
///
/// Repeating the same value is inert, which is the expected shape when the
/// configuration is a property of the domain. Supplying two *different* values
/// for one competitor within a single batch is
/// `InferenceError::ConflictingCompetitorConfig`: events in a batch have no
/// order, so there would be no well-defined winner.
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct Member<K> { pub struct Member<K> {
/// The competitor's identity. Equal keys across events are the same
/// competitor: `History` interns each distinct key to an internal `Index`
/// the first time it sees it, and every later appearance resolves to that
/// same competitor's temporal state.
pub key: K, pub key: K,
/// This member's share of the team's performance, for this event only.
///
/// The team's performance is the sum of `weight × member performance`, so
/// `1.0` is a full share and `0.5` counts the member half; the message
/// coming back to the member is divided by the same weight. Defaults to
/// `1.0`.
///
/// Must be finite — a NaN or infinite weight is `InvalidParameter` at
/// ingestion. Zero and negative are accepted, both being expressible in
/// the same arithmetic.
pub weight: f64, pub weight: f64,
/// Starting skill for this competitor, replacing the history's `mu`/`sigma`
/// default. `None` keeps the history default.
///
/// Competitor configuration, not a per-event value; see the type docs.
pub prior: Option<Gaussian>, pub prior: Option<Gaussian>,
/// Multiplier on the drift *variance* this competitor accumulates.
/// `None` means 1.0.
pub drift_scale: Option<f64>,
} }
impl<K> Member<K> { impl<K> Member<K> {
/// A competitor taking a full share of its team's performance, with no
/// configuration overrides: the history's prior and drift apply.
pub fn new(key: K) -> Self { pub fn new(key: K) -> Self {
Self { Self {
key, key,
weight: 1.0, weight: 1.0,
prior: None, prior: None,
drift_scale: None,
} }
} }
/// Change how much of the team's performance this member accounts for.
///
/// Unlike `prior` and `drift_scale`, this is genuinely per-event: the same
/// key can carry a different weight in every event it appears in, which is
/// what makes it usable for partial participation — a substitute who
/// played half the match, a doubles partner credited unequally.
pub fn with_weight(mut self, weight: f64) -> Self { pub fn with_weight(mut self, weight: f64) -> Self {
self.weight = weight; self.weight = weight;
self self
} }
/// Set this competitor's starting skill estimate.
///
/// Competitor configuration, not a per-event value: it applies for the
/// whole history and applies whenever it is supplied, including on a key
/// the history already knows. See the type docs.
pub fn with_prior(mut self, prior: Gaussian) -> Self { pub fn with_prior(mut self, prior: Gaussian) -> Self {
self.prior = Some(prior); self.prior = Some(prior);
self self
} }
/// Scale how fast this competitor drifts, relative to the history's drift.
///
/// The scale multiplies the drift *variance*, so it is in the same units as
/// `gamma`: `ConstantDrift::new(g)` at `scale = s` behaves exactly as
/// `ConstantDrift::new(g * s)` would for this competitor alone.
///
/// `0.0` pins the competitor still — useful for a reference point that
/// shares a scale with moving competitors but should not itself move: a bot
/// at a known strength, a rating floor, a course difficulty.
///
/// Applies for the whole history and whenever it is supplied, including on
/// a key the history already knows; see the type docs.
/// Must be finite and non-negative, or ingestion fails with
/// [`InferenceError::InvalidParameter`](crate::InferenceError::InvalidParameter).
pub fn with_drift_scale(mut self, scale: f64) -> Self {
self.drift_scale = Some(scale);
self
}
} }
/// Convenience: a member is a user key with default weight 1.0 and no prior. /// Convenience: a member is a user key with default weight 1.0 and no prior.
@@ -91,15 +193,18 @@ mod tests {
assert_eq!(m.key, "alice"); assert_eq!(m.key, "alice");
assert_eq!(m.weight, 1.0); assert_eq!(m.weight, 1.0);
assert!(m.prior.is_none()); assert!(m.prior.is_none());
assert!(m.drift_scale.is_none());
} }
#[test] #[test]
fn member_builder_methods_chain() { fn member_builder_methods_chain() {
let m = Member::new("alice") let m = Member::new("alice")
.with_weight(0.5) .with_weight(0.5)
.with_prior(Gaussian::from_ms(20.0, 5.0)); .with_prior(Gaussian::from_ms(20.0, 5.0))
.with_drift_scale(0.0);
assert_eq!(m.weight, 0.5); assert_eq!(m.weight, 0.5);
assert!(m.prior.is_some()); assert!(m.prior.is_some());
assert_eq!(m.drift_scale, Some(0.0));
} }
#[test] #[test]
+126 -15
View File
@@ -9,26 +9,65 @@ use crate::{
time::Time, time::Time,
}; };
pub struct EventBuilder<'h, T, D, O, K> /// One match under construction, handed back by [`History::event`].
///
/// Describes a single event a piece at a time — teams, then per-member weights
/// if they differ, then how it ended — instead of assembling an
/// [`Event`] value and passing it to [`History::add_events`]. The two routes
/// ingest through the same chokepoint and accept the same things; this one just
/// reads better for a single match written by hand.
///
/// The builder borrows the history mutably and nothing reaches it until
/// [`EventBuilder::commit`]. A builder that is dropped instead ingests
/// nothing at all, silently — hence the `#[must_use]`, which is the only
/// warning you get. `commit` is also where validation surfaces: the setters
/// return `Self` to keep the chain fluent, so a mismatch such as a weight list
/// the wrong length is recorded while building and returned as an error from
/// `commit`.
///
/// ```
/// # use trueskill_tt::History;
/// let mut h = History::builder().build();
/// h.event(1)
/// .team(["alice", "bob"])
/// .team(["carol"])
/// .ranking([0, 1])
/// .commit()?;
/// assert_eq!(h.event_count(), 1);
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
#[must_use = "an event is only recorded by `.commit()`; a dropped builder \
silently ingests nothing"]
pub struct EventBuilder<'h, T, D, O, K, R>
where where
T: Time, T: Time,
D: Drift<T>, D: Drift<T>,
O: Observer<T>, O: Observer<T>,
K: Eq + std::hash::Hash + Clone, K: Eq + std::hash::Hash + Clone,
R: crate::RatingRule<K>,
{ {
history: &'h mut History<T, D, O, K>, history: &'h mut History<K, T, D, O, R>,
event: Event<T, K>, event: Event<T, K>,
current_team_idx: Option<usize>, current_team_idx: Option<usize>,
/// First validation failure seen while building, surfaced by `commit`.
///
/// The setters return `Self` so the chain stays fluent; they cannot return
/// a `Result` without breaking that. Recording the failure and reporting it
/// at `commit` keeps the check enforced in release, where the previous
/// `debug_assert!` was compiled out and a mismatched event was ingested
/// silently.
error: Option<InferenceError>,
} }
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K> impl<'h, T, D, O, K, R> EventBuilder<'h, T, D, O, K, R>
where where
T: Time, T: Time,
D: Drift<T>, D: Drift<T>,
O: Observer<T>, O: Observer<T>,
K: Eq + std::hash::Hash + Clone, K: Eq + std::hash::Hash + Clone,
R: crate::RatingRule<K>,
{ {
pub(crate) fn new(history: &'h mut History<T, D, O, K>, time: T) -> Self { pub(crate) fn new(history: &'h mut History<K, T, D, O, R>, time: T) -> Self {
Self { Self {
history, history,
event: Event { event: Event {
@@ -37,10 +76,13 @@ where
outcome: Outcome::Ranked(SmallVec::new()), outcome: Outcome::Ranked(SmallVec::new()),
}, },
current_team_idx: None, current_team_idx: None,
error: None,
} }
} }
/// Add a team by its member keys (weight 1.0 each, no prior overrides). /// Add a team by its member keys (weight 1.0 each, no prior overrides).
///
/// Use [`EventBuilder::members`] to set `prior` or `drift_scale`.
pub fn team<I: IntoIterator<Item = K>>(mut self, keys: I) -> Self { pub fn team<I: IntoIterator<Item = K>>(mut self, keys: I) -> Self {
let members: SmallVec<[Member<K>; 4]> = keys.into_iter().map(Member::new).collect(); let members: SmallVec<[Member<K>; 4]> = keys.into_iter().map(Member::new).collect();
self.event.teams.push(Team { members }); self.event.teams.push(Team { members });
@@ -48,24 +90,72 @@ where
self self
} }
/// Add a team from fully-specified [`Member`] values.
///
/// [`EventBuilder::team`] is the common case and builds members with
/// `Member::new`, which leaves `prior` and `drift_scale` unset. This is the
/// escape hatch for when they matter:
///
/// ```
/// # use trueskill_tt::{Gaussian, History, Member};
/// # let mut h = History::builder().build();
/// h.event(0)
/// .team(["player"])
/// .members([Member::new("layout_7")
/// .with_drift_scale(0.0)
/// .with_prior(Gaussian::from_ms(0.0, 1.0))])
/// .ranking([0, 1])
/// .commit()?;
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
///
/// One method rather than a `priors` and a `drift_scales` setter beside
/// `weights`: those would have to grow a parallel array — and a parallel
/// length check — every time `Member` gains a field, and each one would be
/// a new way to get the lengths wrong. `Member`'s own builder already
/// expresses all of it.
///
/// `prior` and `drift_scale` are competitor configuration rather than
/// per-event values; see [`Member`] for what that means for a key the
/// history already knows.
pub fn members<I: IntoIterator<Item = Member<K>>>(mut self, members: I) -> Self {
self.event.teams.push(Team::with_members(members));
self.current_team_idx = Some(self.event.teams.len() - 1);
self
}
/// Set per-member weights for the most recently added team. /// Set per-member weights for the most recently added team.
/// ///
/// Panics in debug builds if called before `.team(...)` or if the length /// A length mismatch is recorded and returned by [`EventBuilder::commit`]
/// doesn't match the team's member count. /// as `InferenceError::MismatchedShape`, in both debug and release. The
/// weights are not applied in that case, so a partially-weighted team
/// cannot reach the history.
///
/// # Panics
///
/// Panics if called before any `.team(...)`.
pub fn weights<I: IntoIterator<Item = f64>>(mut self, weights: I) -> Self { pub fn weights<I: IntoIterator<Item = f64>>(mut self, weights: I) -> Self {
let idx = self let idx = self
.current_team_idx .current_team_idx
.expect(".weights(...) called before any .team(...)"); .expect(".weights(...) called before any .team(...)");
let ws: Vec<f64> = weights.into_iter().collect(); let ws: Vec<f64> = weights.into_iter().collect();
let team = &mut self.event.teams[idx]; let team = &mut self.event.teams[idx];
debug_assert_eq!(
ws.len(), if ws.len() != team.members.len() {
team.members.len(), self.error.get_or_insert(InferenceError::MismatchedShape {
"weights length must match team size" shape: crate::Shape::Weights,
); expected: team.members.len(),
got: ws.len(),
});
return self;
}
for (m, w) in team.members.iter_mut().zip(ws) { for (m, w) in team.members.iter_mut().zip(ws) {
m.weight = w; m.weight = w;
} }
self self
} }
@@ -83,10 +173,21 @@ where
/// Set explicit per-team continuous scores with a per-event noise override. /// Set explicit per-team continuous scores with a per-event noise override.
/// ///
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only. /// `score_sigma` is the observation noise on the *score margin*, not a
/// Must be `> 0.0`; debug-asserts otherwise via `Outcome::scores_with_sigma`. /// skill sigma, and it overrides `HistoryBuilder::score_sigma` for this
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self { /// event only. A small value takes the margin near-literally; a large one
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma); /// 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 self
} }
@@ -103,7 +204,17 @@ where
} }
/// Commit the event to the history. /// Commit the event to the history.
///
/// # Errors
///
/// Returns the first validation failure recorded while building — see
/// [`EventBuilder::weights`] — otherwise forwards to
/// [`History::add_events`] and returns its errors.
pub fn commit(self) -> Result<(), InferenceError> { pub fn commit(self) -> Result<(), InferenceError> {
if let Some(error) = self.error {
return Err(error);
}
self.history.add_events(std::iter::once(self.event)) self.history.add_events(std::iter::once(self.event))
} }
} }
+6 -71
View File
@@ -20,6 +20,9 @@ pub struct VarStore {
} }
impl VarStore { impl VarStore {
/// Test-only: inference allocates its store through `ScratchArena`.
#[cfg(test)]
#[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
@@ -28,14 +31,13 @@ impl VarStore {
self.marginals.clear(); self.marginals.clear();
} }
/// Test-only, as `new`.
#[cfg(test)]
#[must_use]
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.marginals.len() self.marginals.len()
} }
pub fn is_empty(&self) -> bool {
self.marginals.is_empty()
}
pub fn alloc(&mut self, init: Gaussian) -> VarId { pub fn alloc(&mut self, init: Gaussian) -> VarId {
let id = VarId(self.marginals.len() as u32); let id = VarId(self.marginals.len() as u32);
self.marginals.push(init); self.marginals.push(init);
@@ -51,58 +53,7 @@ impl VarStore {
} }
} }
/// A factor in the EP graph.
///
/// Factors hold their own outgoing messages and propagate them by reading
/// connected variable marginals from a `VarStore` and writing back updated
/// marginals.
pub trait Factor: Send + Sync {
/// Update outgoing messages and write back to the var store.
///
/// Returns the max delta `(|Δmu|, |Δsigma|)` across writes this
/// propagation. Used by the `Schedule` to detect convergence.
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64);
/// Optional log-evidence contribution. Default 0.0 (no contribution).
fn log_evidence(&self, _vars: &VarStore) -> f64 {
0.0
}
}
/// Enum dispatcher for the built-in factor types.
///
/// Using an enum instead of `Box<dyn Factor>` keeps factor data inline and
/// avoids virtual-call overhead in the hot inference loop.
#[derive(Debug)]
pub enum BuiltinFactor {
TeamSum(team_sum::TeamSumFactor),
RankDiff(rank_diff::RankDiffFactor),
Trunc(trunc::TruncFactor),
Margin(margin::MarginFactor),
}
impl Factor for BuiltinFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
match self {
Self::TeamSum(f) => f.propagate(vars),
Self::RankDiff(f) => f.propagate(vars),
Self::Trunc(f) => f.propagate(vars),
Self::Margin(f) => f.propagate(vars),
}
}
fn log_evidence(&self, vars: &VarStore) -> f64 {
match self {
Self::Trunc(f) => f.log_evidence(vars),
Self::Margin(f) => f.log_evidence(vars),
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
}
}
}
pub mod margin; pub mod margin;
pub mod rank_diff;
pub mod team_sum;
pub mod trunc; pub mod trunc;
#[cfg(test)] #[cfg(test)]
@@ -149,20 +100,4 @@ mod tests {
assert_eq!(store.len(), 0); assert_eq!(store.len(), 0);
assert_eq!(store.marginals.capacity(), cap); assert_eq!(store.marginals.capacity(), cap);
} }
#[test]
fn builtin_factor_dispatches_to_margin() {
use super::margin::MarginFactor;
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = BuiltinFactor::Margin(MarginFactor::new(diff, 5.0, 1.0));
f.propagate(&mut vars);
let result = vars.get(diff);
assert!((result.mu() - 4.864864864864865).abs() < 1e-12);
let logz = f.log_evidence(&vars);
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
}
} }
+45 -21
View File
@@ -1,8 +1,8 @@
use crate::{ use crate::{
N_INF, N_INF,
factor::{Factor, VarId, VarStore}, factor::{VarId, VarStore},
gaussian::Gaussian, gaussian::Gaussian,
pdf, ln_pdf,
}; };
/// Gaussian observation factor on a diff variable. /// Gaussian observation factor on a diff variable.
@@ -16,10 +16,11 @@ pub struct MarginFactor {
pub m_obs: f64, pub m_obs: f64,
pub sigma: f64, pub sigma: f64,
pub(crate) msg: Gaussian, pub(crate) msg: Gaussian,
pub(crate) evidence_cached: Option<f64>, pub(crate) log_evidence_cached: Option<f64>,
} }
impl MarginFactor { impl MarginFactor {
#[must_use]
pub fn new(diff: VarId, m_obs: f64, sigma: f64) -> Self { pub fn new(diff: VarId, m_obs: f64, sigma: f64) -> Self {
debug_assert!(sigma > 0.0, "score sigma must be positive"); debug_assert!(sigma > 0.0, "score sigma must be positive");
Self { Self {
@@ -27,7 +28,7 @@ impl MarginFactor {
m_obs, m_obs,
sigma, sigma,
msg: N_INF, msg: N_INF,
evidence_cached: None, log_evidence_cached: None,
} }
} }
} }
@@ -38,35 +39,58 @@ impl MarginFactor {
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`. /// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) { pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
let marginal = vars.get(self.diff); let marginal = vars.get(self.diff);
let cavity = marginal / self.msg; let cavity = marginal.cavity(self.msg);
if self.evidence_cached.is_none() { if self.log_evidence_cached.is_none() {
self.evidence_cached = Some(cavity_evidence(cavity, self.m_obs, self.sigma)); self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.m_obs, self.sigma));
} }
let new_msg = Gaussian::from_ms(self.m_obs, self.sigma); let new_msg = Gaussian::from_ms(self.m_obs, self.sigma);
let damped = self.msg.damp_natural(new_msg, alpha); let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg; let old_msg = self.msg;
self.msg = damped; self.msg = damped;
vars.set(self.diff, cavity * damped); vars.set(self.diff, cavity.ep_product(damped));
old_msg.delta(damped) old_msg.delta(damped)
} }
} }
impl Factor for MarginFactor { /// Undamped wrappers, used by this module's tests. Inference drives these
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { /// factors through `propagate_with_alpha` and reads the cached log evidence
/// directly, so these are not on any production path.
#[cfg(test)]
impl MarginFactor {
pub(crate) fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
self.propagate_with_alpha(vars, 1.0) self.propagate_with_alpha(vars, 1.0)
} }
fn log_evidence(&self, _vars: &VarStore) -> f64 { pub(crate) fn log_evidence(&self) -> f64 {
self.evidence_cached.unwrap_or(1.0).ln() self.log_evidence_cached.unwrap_or(0.0)
} }
} }
fn cavity_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 { /// `ln` of the observed margin's density under the cavity.
let combined_sigma = (cavity.sigma().powi(2) + sigma.powi(2)).sqrt(); ///
pdf(m_obs, cavity.mu(), combined_sigma) /// Computed in log space rather than as `pdf(..).ln()`. The density underflows
/// to zero past about 38 sigma of separation, and clamping that to
/// `f64::MIN_POSITIVE` reported -708 nats however far out the observation
/// actually was — 4292 nats adrift at 100 sigma, and unbounded beyond. A score
/// far from what the model expected is exactly the observation a log-evidence
/// figure exists to notice.
fn cavity_log_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
// `hypot`, not `sqrt(a^2 + b^2)`: squaring overflows to infinity above a
// sigma of ~1.3e154 and flushes to zero below ~1.5e-154, and `Gaussian`'s
// constructors are public so a caller can reach both.
let combined_sigma = libm::hypot(cavity.sigma(), sigma);
let value = ln_pdf(m_obs, cavity.mu(), combined_sigma);
// A degenerate cavity (infinite sigma) is the only way to reach a
// non-finite result; fall back to the old floor rather than emit -inf.
if value.is_finite() {
value
} else {
libm::log(f64::MIN_POSITIVE)
}
} }
#[cfg(test)] #[cfg(test)]
@@ -108,16 +132,16 @@ mod tests {
let mut vars = VarStore::new(); let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0)); let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = MarginFactor::new(diff, 5.0, 1.0); let mut f = MarginFactor::new(diff, 5.0, 1.0);
assert!(f.evidence_cached.is_none()); assert!(f.log_evidence_cached.is_none());
f.propagate(&mut vars); f.propagate(&mut vars);
let z = f.evidence_cached.unwrap(); let z = f.log_evidence_cached.unwrap();
// pdf(5, 0, sqrt(37)) 0.046783 // ln pdf(5, 0, sqrt(37)) = ln(0.046783...)
assert!((z - 0.04678300292616668).abs() < 1e-10); assert!((z.exp() - 0.04678300292616668).abs() < 1e-10);
// Subsequent propagations don't change it. // Subsequent propagations don't change it.
f.propagate(&mut vars); f.propagate(&mut vars);
assert_eq!(f.evidence_cached.unwrap(), z); assert_eq!(f.log_evidence_cached.unwrap(), z);
} }
#[test] #[test]
@@ -126,7 +150,7 @@ mod tests {
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0)); let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = MarginFactor::new(diff, 5.0, 1.0); let mut f = MarginFactor::new(diff, 5.0, 1.0);
f.propagate(&mut vars); f.propagate(&mut vars);
let logz = f.log_evidence(&vars); let logz = f.log_evidence();
assert!((logz - (-3.062235327364623)).abs() < 1e-10); assert!((logz - (-3.062235327364623)).abs() < 1e-10);
} }
-95
View File
@@ -1,95 +0,0 @@
use crate::factor::{Factor, VarId, VarStore};
/// Maintains the constraint `diff = team_a - team_b` between three vars.
///
/// On each propagation:
/// - Reads marginals at `team_a` and `team_b` (which already incorporate any
/// incoming messages from neighboring factors).
/// - Computes `new_diff = team_a - team_b` (variance addition; see Gaussian::Sub).
/// - Writes the new marginal to `diff`.
/// - Returns the delta against the previous diff value.
///
/// This factor does NOT store an outgoing message; the diff variable is
/// effectively replaced on each propagation. The TruncFactor on the same diff
/// var holds the EP-divide message that produces the cavity.
#[derive(Debug)]
pub struct RankDiffFactor {
pub team_a: VarId,
pub team_b: VarId,
pub diff: VarId,
}
impl Factor for RankDiffFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
let a = vars.get(self.team_a);
let b = vars.get(self.team_b);
let new_diff = a - b;
let old = vars.get(self.diff);
vars.set(self.diff, new_diff);
old.delta(new_diff)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{N_INF, gaussian::Gaussian};
#[test]
fn diff_of_two_known_gaussians() {
let mut vars = VarStore::new();
let team_a = vars.alloc(Gaussian::from_ms(25.0, 3.0));
let team_b = vars.alloc(Gaussian::from_ms(20.0, 4.0));
let diff = vars.alloc(N_INF);
let mut f = RankDiffFactor {
team_a,
team_b,
diff,
};
f.propagate(&mut vars);
let result = vars.get(diff);
// mu = 25 - 20 = 5; var = 9 + 16 = 25; sigma = 5
assert!((result.mu() - 5.0).abs() < 1e-12);
assert!((result.sigma() - 5.0).abs() < 1e-12);
}
#[test]
fn delta_zero_on_repeat() {
let mut vars = VarStore::new();
let team_a = vars.alloc(Gaussian::from_ms(10.0, 2.0));
let team_b = vars.alloc(Gaussian::from_ms(8.0, 1.0));
let diff = vars.alloc(N_INF);
let mut f = RankDiffFactor {
team_a,
team_b,
diff,
};
f.propagate(&mut vars);
let (dmu, dsig) = f.propagate(&mut vars);
assert!(dmu < 1e-12);
assert!(dsig < 1e-12);
}
#[test]
fn delta_reflects_team_change() {
let mut vars = VarStore::new();
let team_a = vars.alloc(Gaussian::from_ms(10.0, 1.0));
let team_b = vars.alloc(Gaussian::from_ms(0.0, 1.0));
let diff = vars.alloc(N_INF);
let mut f = RankDiffFactor {
team_a,
team_b,
diff,
};
f.propagate(&mut vars);
// change team_a, repropagate; delta should be positive
vars.set(team_a, Gaussian::from_ms(15.0, 1.0));
let (dmu, _dsig) = f.propagate(&mut vars);
assert!(dmu > 4.0, "expected ~5 delta, got {}", dmu);
}
}
-98
View File
@@ -1,98 +0,0 @@
use crate::{
N00,
factor::{Factor, VarId, VarStore},
gaussian::Gaussian,
};
/// Computes the weighted sum of player performances into a team-perf var.
///
/// Inputs are pre-computed player performance Gaussians (i.e., rating priors
/// already with beta² noise added via `Rating::performance()`). The factor
/// runs once per game and writes the weighted sum to the output var.
#[derive(Debug)]
pub struct TeamSumFactor {
pub inputs: Vec<(Gaussian, f64)>,
pub out: VarId,
}
impl Factor for TeamSumFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
let perf = self.inputs.iter().fold(N00, |acc, (g, w)| acc + (*g * *w));
let old = vars.get(self.out);
vars.set(self.out, perf);
old.delta(perf)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::N_INF;
#[test]
fn single_player_unit_weight() {
let mut vars = VarStore::new();
let out = vars.alloc(N_INF);
let g = Gaussian::from_ms(25.0, 5.0);
let mut f = TeamSumFactor {
inputs: vec![(g, 1.0)],
out,
};
f.propagate(&mut vars);
let result = vars.get(out);
assert!((result.mu() - 25.0).abs() < 1e-12);
assert!((result.sigma() - 5.0).abs() < 1e-12);
}
#[test]
fn two_players_summed() {
let mut vars = VarStore::new();
let out = vars.alloc(N_INF);
let g1 = Gaussian::from_ms(20.0, 3.0);
let g2 = Gaussian::from_ms(30.0, 4.0);
let mut f = TeamSumFactor {
inputs: vec![(g1, 1.0), (g2, 1.0)],
out,
};
f.propagate(&mut vars);
let result = vars.get(out);
// sum: mu = 20 + 30 = 50, var = 9 + 16 = 25, sigma = 5
assert!((result.mu() - 50.0).abs() < 1e-12);
assert!((result.sigma() - 5.0).abs() < 1e-12);
}
#[test]
fn weighted_inputs() {
let mut vars = VarStore::new();
let out = vars.alloc(N_INF);
let g = Gaussian::from_ms(10.0, 2.0);
let mut f = TeamSumFactor {
inputs: vec![(g, 2.0)],
out,
};
f.propagate(&mut vars);
let result = vars.get(out);
// g * 2.0: mu = 10*2 = 20, sigma = 2*2 = 4
assert!((result.mu() - 20.0).abs() < 1e-12);
assert!((result.sigma() - 4.0).abs() < 1e-12);
}
#[test]
fn delta_is_zero_on_repeat_propagate() {
let mut vars = VarStore::new();
let out = vars.alloc(N_INF);
let g = Gaussian::from_ms(5.0, 1.0);
let mut f = TeamSumFactor {
inputs: vec![(g, 1.0)],
out,
};
f.propagate(&mut vars);
let (dmu, dsig) = f.propagate(&mut vars);
assert!(dmu < 1e-12, "expected ~0 delta on repeat, got {}", dmu);
assert!(dsig < 1e-12);
}
}
+118 -28
View File
@@ -1,7 +1,8 @@
use crate::{ use crate::{
N_INF, approx, cdf, N_INF, approx,
factor::{Factor, VarId, VarStore}, factor::{VarId, VarStore},
gaussian::Gaussian, gaussian::Gaussian,
ln_interval, ln_sf,
}; };
/// EP truncation factor on a diff variable. /// EP truncation factor on a diff variable.
@@ -15,20 +16,21 @@ pub struct TruncFactor {
pub diff: VarId, pub diff: VarId,
pub margin: f64, pub margin: f64,
pub tie: bool, pub tie: bool,
/// Outgoing message to the diff variable (initial: N_INF, the EP identity). /// Outgoing message to the diff variable (initial: `N_INF`, the EP identity).
pub(crate) msg: Gaussian, pub(crate) msg: Gaussian,
/// Cached evidence (linear, not log) computed from the cavity on first propagation. /// Cached evidence (linear, not log) computed from the cavity on first propagation.
pub(crate) evidence_cached: Option<f64>, pub(crate) log_evidence_cached: Option<f64>,
} }
impl TruncFactor { impl TruncFactor {
#[must_use]
pub fn new(diff: VarId, margin: f64, tie: bool) -> Self { pub fn new(diff: VarId, margin: f64, tie: bool) -> Self {
Self { Self {
diff, diff,
margin, margin,
tie, tie,
msg: N_INF, msg: N_INF,
evidence_cached: None, log_evidence_cached: None,
} }
} }
} }
@@ -39,14 +41,14 @@ impl TruncFactor {
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`. /// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) { pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
let marginal = vars.get(self.diff); let marginal = vars.get(self.diff);
let cavity = marginal / self.msg; let cavity = marginal.cavity(self.msg);
if self.evidence_cached.is_none() { if self.log_evidence_cached.is_none() {
self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie)); self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.margin, self.tie));
} }
let trunc = approx(cavity, self.margin, self.tie); let trunc = approx(cavity, self.margin, self.tie);
let new_msg = trunc / cavity; let new_msg = trunc.cavity(cavity);
let damped = self.msg.damp_natural(new_msg, alpha); let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg; let old_msg = self.msg;
@@ -55,28 +57,45 @@ impl TruncFactor {
// marginal_new = cavity * stored_msg. With alpha = 1.0 this equals // marginal_new = cavity * stored_msg. With alpha = 1.0 this equals
// `trunc` (since cavity * new_msg = trunc by construction); with // `trunc` (since cavity * new_msg = trunc by construction); with
// alpha < 1.0 it reflects the partially-applied update. // alpha < 1.0 it reflects the partially-applied update.
vars.set(self.diff, cavity * damped); vars.set(self.diff, cavity.ep_product(damped));
old_msg.delta(damped) old_msg.delta(damped)
} }
} }
impl Factor for TruncFactor { /// Undamped wrappers, used by this module's tests. Inference drives these
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { /// factors through `propagate_with_alpha` and reads the cached log evidence
/// directly, so these are not on any production path.
#[cfg(test)]
impl TruncFactor {
pub(crate) fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
self.propagate_with_alpha(vars, 1.0) self.propagate_with_alpha(vars, 1.0)
} }
fn log_evidence(&self, _vars: &VarStore) -> f64 {
self.evidence_cached.unwrap_or(1.0).ln()
}
} }
/// P(diff > margin) for non-tie, P(|diff| < margin) for tie. /// `ln P(diff > margin)` for a win, `ln P(|diff| < margin)` for a tie.
fn cavity_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 { ///
if tie { /// Computed in log space throughout. Two earlier shapes both lost the tail:
cdf(margin, diff.mu(), diff.sigma()) - cdf(-margin, diff.mu(), diff.sigma()) /// `1 - cdf(..)` cancelled away every digit of an unlikely outcome, and even
/// once that was fixed the linear probability underflows to zero past about 38
/// sigma, where clamping reported -708 nats regardless of the truth. An upset
/// is the observation a log-evidence figure exists to notice, so it has to stay
/// exact precisely where it is smallest.
fn cavity_log_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 {
let (mu, sigma) = (diff.mu(), diff.sigma());
let value = if tie {
ln_interval(-margin, margin, mu, sigma)
} else { } else {
1.0 - cdf(margin, diff.mu(), diff.sigma()) ln_sf(margin, mu, sigma)
};
// A degenerate cavity is the only route to a non-finite result; keep the
// old floor for it rather than letting -inf poison the whole history's sum.
if value.is_finite() {
value
} else {
libm::log(f64::MIN_POSITIVE)
} }
} }
@@ -108,19 +127,90 @@ mod tests {
let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0)); let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f = TruncFactor::new(diff, 0.0, false); let mut f = TruncFactor::new(diff, 0.0, false);
assert!(f.evidence_cached.is_none()); assert!(f.log_evidence_cached.is_none());
f.propagate(&mut vars); f.propagate(&mut vars);
assert!(f.evidence_cached.is_some()); assert!(f.log_evidence_cached.is_some());
let first = f.evidence_cached.unwrap(); let first = f.log_evidence_cached.unwrap();
// Evidence should be P(diff > 0) for diff ~ N(2, 9) ≈ 0.748 // Evidence should be P(diff > 0) for diff ~ N(2, 9) ≈ 0.748
assert!(first > 0.7); assert!(first.exp() > 0.7);
assert!(first < 0.8); assert!(first.exp() < 0.8);
// Subsequent propagations don't change it. // Subsequent propagations don't change it.
f.propagate(&mut vars); f.propagate(&mut vars);
assert_eq!(f.evidence_cached.unwrap(), first); assert_eq!(f.log_evidence_cached.unwrap(), first);
}
/// The defect this guards: `1 - cdf` collapsed to zero for a surprising
/// result, the clamp turned that into `f64::MIN_POSITIVE`, and
/// `log_evidence` reported ln of *that* — about -708 whatever the truth
/// was. An upset is the observation a model-comparison score exists to
/// notice, so it was wrong exactly where it mattered.
#[test]
fn evidence_of_an_upset_is_not_flattened_to_the_clamp_floor() {
// diff ~ N(-9, 1) with margin 0: the favoured side lost by nine sigma.
let evidence = cavity_log_evidence(Gaussian::from_ms(-9.0, 1.0), 0.0, false).exp();
assert!(
evidence > f64::MIN_POSITIVE,
"evidence collapsed onto the clamp floor: {evidence}"
);
// P(X > 0) for X ~ N(-9, 1) is the standard normal tail at 9 sigma.
assert!(
(evidence - 1.128_588e-19).abs() / 1.128_588e-19 < 1e-6,
"expected ~1.13e-19, got {evidence}"
);
assert!(
(evidence.ln() + 43.628).abs() < 1e-2,
"log evidence {} should be about -43.6, not -708",
evidence.ln()
);
}
/// Evidence must stay finite and positive however extreme the mismatch,
/// since `log_evidence` sums across the whole history and one `-inf` or
/// `NaN` poisons all of it.
///
/// Finiteness alone is too weak a bar — the clamped version was finite too,
/// and wrong by hundreds of nats. `log_evidence_tracks_the_analytic_tail`
/// below is the assertion that actually holds this up.
#[test]
fn evidence_stays_positive_and_finite_at_any_separation() {
for mu in [-300.0f64, -50.0, -9.0, 0.0, 9.0, 50.0, 300.0] {
for tie in [false, true] {
let ln_e = cavity_log_evidence(Gaussian::from_ms(mu, 1.0), 1.0, tie);
assert!(
ln_e.is_finite() && ln_e <= 0.0,
"mu={mu} tie={tie}: log evidence {ln_e} is not a log-probability"
);
}
}
}
/// The clamp used to floor everything past ~38 sigma at `ln(MIN_POSITIVE)`
/// = -708, however far out the real observation was. In log space the
/// answer is a polynomial and stays exact: at 1000 sigma the truth is about
/// -500_000 nats, and -708 is not a rounding error.
#[test]
fn log_evidence_tracks_the_analytic_tail() {
for mu in [-40.0f64, -60.0, -100.0, -1000.0] {
// P(diff > 0) for diff ~ N(mu, 1), mu far below zero.
let got = cavity_log_evidence(Gaussian::from_ms(mu, 1.0), 0.0, false);
// ln Phi(mu) ~ -mu^2/2 - ln(-mu) - ln(sqrt(2 pi)) for mu << 0.
let z = -mu;
let approx = -0.5 * z * z - z.ln() - (2.0 * std::f64::consts::PI).sqrt().ln();
assert!(
got < libm::log(f64::MIN_POSITIVE),
"mu={mu}: {got} is still stuck on the old clamp floor"
);
assert!(
(got - approx).abs() / approx.abs() < 1e-3,
"mu={mu}: got {got}, asymptotic expectation {approx}"
);
}
} }
#[test] #[test]
@@ -132,7 +222,7 @@ mod tests {
f.propagate(&mut vars); f.propagate(&mut vars);
// For diff ~ N(0, 4), tie=true with margin=1: P(-1 < diff < 1) ≈ 0.383 // For diff ~ N(0, 4), tie=true with margin=1: P(-1 < diff < 1) ≈ 0.383
let ev = f.evidence_cached.unwrap(); let ev = f.log_evidence_cached.unwrap().exp();
assert!(ev > 0.35 && ev < 0.42); assert!(ev > 0.35 && ev < 0.42);
} }
-13
View File
@@ -1,13 +0,0 @@
//! Factor-graph public API.
//!
//! Power users can construct custom factor graphs via `Game::custom` (T2
//! minimal; full ergonomics in T4) and drive them with custom `Schedule`
//! implementations.
pub use crate::{
factor::{
BuiltinFactor, Factor, VarId, VarStore, margin::MarginFactor, rank_diff::RankDiffFactor,
team_sum::TeamSumFactor, trunc::TruncFactor,
},
schedule::{EpsilonOrMax, Schedule, ScheduleReport},
};
+422 -202
View File
File diff suppressed because it is too large Load Diff
+341 -66
View File
@@ -1,5 +1,3 @@
use std::ops;
use crate::{MU, N_INF, SIGMA}; use crate::{MU, N_INF, SIGMA};
/// A Gaussian distribution stored in natural parameters. /// A Gaussian distribution stored in natural parameters.
@@ -11,6 +9,7 @@ use crate::{MU, N_INF, SIGMA};
/// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and /// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and
/// `sigma()` are accessors computed on demand. /// `sigma()` are accessors computed on demand.
#[derive(Clone, Copy, PartialEq, Debug)] #[derive(Clone, Copy, PartialEq, Debug)]
#[must_use]
pub struct Gaussian { pub struct Gaussian {
pi: f64, pi: f64,
tau: f64, tau: f64,
@@ -18,7 +17,43 @@ pub struct Gaussian {
impl Gaussian { impl Gaussian {
/// Construct from mean and standard deviation. /// Construct from mean and standard deviation.
///
/// # Panics
///
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a
/// broken fit produces one, and `converge` reports that as
/// `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
/// `sigma * sigma`. The sign was not rejected and not honoured; it simply
/// vanished. That is the same defect `HistoryBuilder::sigma`,
/// `HistoryBuilder::beta` and `Member::with_drift_scale` already reject.
///
/// # Very small sigma
///
/// `pi = 1 / sigma^2` leaves `f64`'s range below about `1.5e-154`, and
/// `tau = mu * pi` overflows sooner still — at a threshold that depends on
/// `mu`, so there is a band where `pi` is finite and only `tau` is not.
/// Both land on the same point-mass representation the `sigma == 0.0`
/// branch produces, and a point mass with a non-zero mean has `mu() = NaN`,
/// because `tau / pi` is `inf / inf`.
///
/// This is not rejected, because `approx` legitimately produces a very
/// small truncated sigma and inference must not panic. It is worth knowing
/// that such a `Gaussian` is not equal to itself, so two identical
/// declarations of one can be reported as conflicting.
pub const fn from_ms(mu: f64, sigma: f64) -> Self { 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 `NonFiniteStep` guard, not to
// panic inside inference. Rejecting it here turned that reporting path
// into a crash, which two tests caught immediately.
assert!(
sigma >= 0.0 || sigma.is_nan(),
"sigma must not be negative; it is only ever squared, so a negative \
value would silently behave as its absolute value"
);
if sigma == f64::INFINITY { if sigma == f64::INFINITY {
Self { pi: 0.0, tau: 0.0 } Self { pi: 0.0, tau: 0.0 }
} else if sigma == 0.0 { } else if sigma == 0.0 {
@@ -35,23 +70,63 @@ 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 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 fn from_mv(mu: f64, var: f64) -> Self {
if var == f64::INFINITY {
Self { pi: 0.0, tau: 0.0 }
} else if var == 0.0 {
// Point mass at mu; see `from_ms` for the tau convention.
Self {
pi: f64::INFINITY,
tau: if mu == 0.0 { 0.0 } else { f64::INFINITY },
}
} else {
let pi = 1.0 / var;
Self { pi, tau: mu * pi }
}
}
/// Construct directly from natural parameters. /// Construct directly from natural parameters.
#[inline] #[inline]
pub(crate) const fn from_natural(pi: f64, tau: f64) -> Self { pub(crate) const fn from_natural(pi: f64, tau: f64) -> Self {
Self { pi, tau } Self { pi, tau }
} }
/// Precision, `1 / sigma^2` — one of the two natural parameters.
///
/// This is the representation the type actually stores, which is why the EP
/// product and cavity (`Mul` / `Div`) are plain adds and subtracts. Larger
/// means more certain; `0.0` is an improper, uninformative message and
/// `inf` is a point mass.
#[inline] #[inline]
pub fn pi(&self) -> f64 { pub(crate) fn pi(&self) -> f64 {
self.pi self.pi
} }
/// Precision-adjusted mean, `mu / sigma^2` — the other natural parameter.
///
/// Stored rather than derived, for the same reason as [`Gaussian::pi`].
/// Meaningful only alongside `pi`: on its own it is not a location.
#[inline] #[inline]
pub fn tau(&self) -> f64 { pub(crate) fn tau(&self) -> f64 {
self.tau self.tau
} }
/// Mean skill: the point estimate.
///
/// Derived from the natural parameters as `tau / pi`. An improper message
/// (`pi <= 0`) has no defined mean and reports `0.0` — see
/// [`Gaussian::sigma`], which reports `inf` for the same state, and read
/// the two together before treating a mean as informative.
#[inline] #[inline]
#[must_use]
pub fn mu(&self) -> f64 { pub fn mu(&self) -> f64 {
// A non-positive precision is an improper (uninformative) Gaussian — its mean is // A non-positive precision is an improper (uninformative) Gaussian — its mean is
// undefined. Treat it like `pi == 0` and return 0. EP message cancellation can land // undefined. Treat it like `pi == 0` and return 0. EP message cancellation can land
@@ -64,7 +139,31 @@ impl Gaussian {
} }
} }
/// Variance, without the root-and-square of `sigma().powi(2)`.
///
/// 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] #[inline]
#[must_use]
pub fn variance(&self) -> f64 {
if self.pi <= 0.0 {
f64::INFINITY
} else if self.pi.is_infinite() {
0.0
} else {
1.0 / self.pi
}
}
/// 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 { pub fn sigma(&self) -> f64 {
// A non-positive precision is improper → infinite standard deviation. Guarding // A non-positive precision is improper → infinite standard deviation. Guarding
// `pi <= 0.0` (not just `== 0.0`) keeps `1.0 / pi.sqrt()` from returning NaN when EP // `pi <= 0.0` (not just `== 0.0`) keeps `1.0 / pi.sqrt()` from returning NaN when EP
@@ -78,7 +177,25 @@ impl Gaussian {
} }
} }
/// How far this Gaussian moved from `other`, as `(|d mu|, |d sigma|)`.
///
/// Identical messages have not moved, whatever their parameters, and that
/// case is answered in natural space before touching `mu()`/`sigma()`. An
/// improper message has `pi == 0`, so `sigma()` is infinite — and
/// `inf - inf` is NaN, a NaN *change* for a message that did not change at
/// all. (`mu()` is guarded and returns 0.0 here, so the mean component was
/// never the problem; the sigma component alone produced `(0.0, NaN)`.)
///
/// That is reachable in ordinary inference: once a pairing is more than
/// about nine cavity-sigma apart the truncation is a no-op, `trunc / cavity`
/// is exactly the identity message, and the chain compares one identity
/// against another. Before this guard that produced `(0.0, NaN)`, which
/// silently disabled the sigma half of the convergence test.
pub(crate) fn delta(&self, other: Gaussian) -> (f64, f64) { pub(crate) fn delta(&self, other: Gaussian) -> (f64, f64) {
if self.pi == other.pi && self.tau == other.tau {
return (0.0, 0.0);
}
( (
(self.mu() - other.mu()).abs(), (self.mu() - other.mu()).abs(),
(self.sigma() - other.sigma()).abs(), (self.sigma() - other.sigma()).abs(),
@@ -86,22 +203,60 @@ impl Gaussian {
} }
pub(crate) fn exclude(&self, other: Gaussian) -> Self { pub(crate) fn exclude(&self, other: Gaussian) -> Self {
let var = self.sigma().powi(2) - other.sigma().powi(2); let var = self.variance() - other.variance();
if var <= 0.0 { if var <= 0.0 {
// When sigma_self ≈ sigma_other (including ULP-level rounding differences // When sigma_self ≈ sigma_other (including ULP-level rounding differences
// from the pi→sigma accessor round-trip), the excluded contribution is N00. // from the pi→sigma accessor round-trip), the excluded contribution is N00.
// Computing from_ms(tiny_mu, 0.0) would give {pi:inf, tau:inf}, whose // Computing from_ms(tiny_mu, 0.0) would give {pi:inf, tau:inf}, whose
// mu() = inf/inf = NaN. Returning N00 is correct: when both Gaussians // mu() = inf/inf = NaN. Returning N00 is correct: when both Gaussians
// carry the same variance, the residual is a point mass at 0. // carry the same variance, the residual is a point mass at 0.
return Gaussian::from_ms(0.0, 0.0); return Gaussian::from_mv(0.0, 0.0);
} }
let mu = self.mu() - other.mu();
Self::from_ms(mu, var.sqrt()) Self::from_mv(self.mu() - other.mu(), var)
} }
pub(crate) fn forget(&self, variance_delta: f64) -> Self { pub(crate) fn forget(&self, variance_delta: f64) -> Self {
let var = self.sigma().powi(2) + variance_delta; Self::from_mv(self.mu(), self.variance() + variance_delta)
Self::from_ms(self.mu(), var.sqrt()) }
/// `P(X < x)` under this Gaussian.
///
/// The question a stopping rule asks: *how sure am I that this competitor's
/// true skill is below the cutoff?* Expressing that as a probability keeps
/// its meaning as sigma changes, where a `mu + z * sigma` band silently
/// means different confidence at different uncertainties — which is exactly
/// the regime a stopping rule operates in.
///
/// Accurate in the *lower* tail. For the upper tail use
/// [`Gaussian::probability_above`] rather than `1.0 - probability_below(x)`,
/// which cancels away every significant digit once the result is small.
///
/// An improper Gaussian (non-positive precision) has no defined mean, so
/// this returns `0.5` — the same convention `mu()` and `sigma()` follow.
#[must_use]
pub fn probability_below(&self, x: f64) -> f64 {
if self.pi <= 0.0 {
return 0.5;
}
crate::cdf(x, self.mu(), self.sigma())
}
/// `P(X > x)` under this Gaussian.
///
/// Computed as a survival function rather than `1 - cdf`, so it keeps full
/// relative precision in the upper tail: `1 - cdf` returns exactly zero
/// past about 8.3 sigma, where the true value is still 1e-19 and perfectly
/// representable. A stopping rule is evaluated precisely there — the
/// interesting cases are the ones near certainty.
///
/// An improper Gaussian returns `0.5`, as [`Gaussian::probability_below`].
#[must_use]
pub fn probability_above(&self, x: f64) -> f64 {
if self.pi <= 0.0 {
return 0.5;
}
crate::sf(x, self.mu(), self.sigma())
} }
/// EP damping in natural-parameter space: `α·new + (1−α)·self`. /// EP damping in natural-parameter space: `α·new + (1−α)·self`.
@@ -109,7 +264,7 @@ impl Gaussian {
/// Used by within-game inference to stabilise oscillating fixed-point /// Used by within-game inference to stabilise oscillating fixed-point
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly; /// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
/// `alpha < 1.0` shrinks each per-step update. /// `alpha < 1.0` shrinks each per-step update.
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian { pub(crate) fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
Gaussian::from_natural( Gaussian::from_natural(
alpha * new.pi() + (1.0 - alpha) * self.pi(), alpha * new.pi() + (1.0 - alpha) * self.pi(),
alpha * new.tau() + (1.0 - alpha) * self.tau(), alpha * new.tau() + (1.0 - alpha) * self.tau(),
@@ -123,45 +278,72 @@ impl Default for Gaussian {
} }
} }
impl ops::Add<Gaussian> for Gaussian { impl Gaussian {
type Output = Gaussian; /// The EP factor **product**: multiply two messages about the same
/// Variance addition: (mu1 + mu2, sqrt(σ1² + σ2²)). /// variable.
/// Used for combining performance and noise; rare relative to mul/div. ///
fn add(self, rhs: Gaussian) -> Self::Output { /// Two natural-parameter additions and no square root, which is why the
let mu = self.mu() + rhs.mu(); /// type stores `pi` and `tau` rather than `mu` and `sigma`. This is the
let var = self.sigma().powi(2) + rhs.sigma().powi(2); /// hot path.
Self::from_ms(mu, var.sqrt()) ///
} /// 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.
impl ops::Sub<Gaussian> for Gaussian { #[inline]
type Output = Gaussian; pub(crate) fn ep_product(self, rhs: Gaussian) -> Gaussian {
/// (mu1 - mu2, sqrt(σ1² + σ2²)). Same sigma combination as Add.
fn sub(self, rhs: Gaussian) -> Self::Output {
let mu = self.mu() - rhs.mu();
let var = self.sigma().powi(2) + rhs.sigma().powi(2);
Self::from_ms(mu, var.sqrt())
}
}
impl ops::Mul<Gaussian> for Gaussian {
type Output = Gaussian;
/// Factor product: nat-param add. Hot path — two f64 additions, no sqrt.
fn mul(self, rhs: Gaussian) -> Self::Output {
Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau) Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau)
} }
}
impl ops::Mul<f64> for Gaussian { /// The EP **cavity**: divide out a message this belief already absorbed.
type Output = Gaussian; ///
fn mul(self, scalar: f64) -> Self::Output { /// The inverse of [`ep_product`](Gaussian::ep_product), and two
/// subtractions rather than two additions.
///
/// **May return an improper result.** Cancelling a message that carried
/// most of the precision leaves `pi <= 0`, which is not a distribution.
/// `mu()` reports `0.0` and `sigma()` reports `inf` for such a value —
/// both are the accessors' policy for "undefined", not answers. Measured:
/// `N(10, 2).cavity(N(1, 1))` has `pi = -0.75`, and its `mu()` prints a
/// confident `0`. That is why this is not a public operator.
#[inline]
pub(crate) fn cavity(self, rhs: Gaussian) -> Gaussian {
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
}
/// Convolve two independent Gaussians: `N(mu1 + mu2, sqrt(v1 + v2))`.
///
/// The distribution of a *sum* of independent variables, so the variances
/// add — the result is always wider than either input. Used to combine a
/// skill with performance noise. Goes through `from_mv` and takes no root.
#[inline]
pub(crate) fn convolve(self, rhs: Gaussian) -> Gaussian {
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
}
/// Convolve a *difference*: `N(mu1 - mu2, sqrt(v1 + v2))`.
///
/// The means subtract and the variances still **add**, because a
/// difference of independent variables is no more certain than a sum. That
/// is the half that made the old `Sub` impl misleading: `a - b` grew the
/// sigma from 2 to `sqrt(4 + 9)`.
#[inline]
pub(crate) fn convolve_diff(self, rhs: Gaussian) -> Gaussian {
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
}
/// Scale by a constant: `mu` by `scalar`, `sigma` by `|scalar|`.
///
/// The one operation that *is* ordinary arithmetic — it is the
/// distribution of `scalar * X`. Used for per-member weights.
#[inline]
pub(crate) fn scale(self, scalar: f64) -> Gaussian {
if !scalar.is_finite() { if !scalar.is_finite() {
return N_INF; return N_INF;
} }
if scalar == 0.0 { if scalar == 0.0 {
// Scaling by 0 collapses to a point mass at 0 (sigma' = 0, mu' = 0). // Scaling by 0 collapses to a point mass at 0 (sigma' = 0, mu' = 0).
// This is N00, the additive identity, NOT N_INF. // This is N00, the additive identity, NOT N_INF.
return Gaussian::from_ms(0.0, 0.0); return Gaussian::from_mv(0.0, 0.0);
} }
// sigma' = sigma * |scalar| => pi' = pi / scalar² // sigma' = sigma * |scalar| => pi' = pi / scalar²
// mu' = mu * scalar => tau' = tau / scalar // mu' = mu * scalar => tau' = tau / scalar
@@ -169,16 +351,44 @@ impl ops::Mul<f64> for Gaussian {
} }
} }
impl ops::Div<Gaussian> for Gaussian {
type Output = Gaussian;
/// Cavity: nat-param sub. Hot path — two f64 subtractions, no sqrt.
fn div(self, rhs: Gaussian) -> Self::Output {
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// A message that did not change must report no change, even when it is
/// improper. `mu()` of an improper Gaussian is `0/0 = NaN` and `sigma()` is
/// infinite, so the mean/sigma form reported `(NaN, NaN)` for two identical
/// identity messages — which silently disabled the sigma half of the
/// convergence test in `run_chain`.
#[test]
fn delta_of_two_identical_improper_messages_is_zero() {
let improper = crate::N_INF;
// `mu()` is guarded and returns 0.0 for an improper Gaussian, so the
// mean component was always fine. The NaN came from the sigma
// component alone: `inf - inf`. The pre-fix value was `(0.0, NaN)`.
assert!(improper.sigma().is_infinite(), "premise: sigma is infinite");
assert_eq!(improper.mu(), 0.0, "premise: mu is guarded, not NaN");
assert!(
(improper.sigma() - improper.sigma()).is_nan(),
"premise: the unguarded sigma difference is NaN"
);
assert_eq!(improper.delta(improper), (0.0, 0.0));
}
#[test]
fn delta_of_identical_proper_messages_is_zero() {
let g = Gaussian::from_ms(25.0, 8.0);
assert_eq!(g.delta(g), (0.0, 0.0));
}
/// The shortcut must not swallow a real difference.
#[test]
fn delta_still_measures_a_real_move() {
let a = Gaussian::from_ms(25.0, 8.0);
let b = Gaussian::from_ms(26.0, 9.0);
let (dmu, dsigma) = a.delta(b);
assert!((dmu - 1.0).abs() < 1e-12, "{dmu}");
assert!((dsigma - 1.0).abs() < 1e-12, "{dsigma}");
}
use super::*; use super::*;
#[test] #[test]
@@ -198,64 +408,64 @@ mod tests {
// Subtracting such a message must not produce NaN (the original failure path). // Subtracting such a message must not produce NaN (the original failure path).
let proper = Gaussian::from_ms(9.75, 1.256); let proper = Gaussian::from_ms(9.75, 1.256);
let diff = proper - tiny_neg; let diff = proper.convolve_diff(tiny_neg);
assert!(diff.pi().is_finite() && !diff.pi().is_nan()); assert!(diff.pi().is_finite() && !diff.pi().is_nan());
assert!(diff.tau().is_finite() && !diff.tau().is_nan()); assert!(diff.tau().is_finite() && !diff.tau().is_nan());
} }
#[test] #[test]
fn test_add() { fn convolve_adds_variances() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0); let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(0.0, 1.0); let m = Gaussian::from_ms(0.0, 1.0);
let r = n + m; let r = n.convolve(m);
assert!((r.mu() - 25.0).abs() < 1e-12); assert!((r.mu() - 25.0).abs() < 1e-12);
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10); assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
} }
#[test] #[test]
fn test_sub() { fn convolve_diff_subtracts_means_and_adds_variances() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0); let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(1.0, 1.0); let m = Gaussian::from_ms(1.0, 1.0);
let r = n - m; let r = n.convolve_diff(m);
assert!((r.mu() - 24.0).abs() < 1e-12); assert!((r.mu() - 24.0).abs() < 1e-12);
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10); assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
} }
#[test] #[test]
fn test_mul() { fn ep_product_is_not_arithmetic() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0); let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(0.0, 1.0); let m = Gaussian::from_ms(0.0, 1.0);
let r = n * m; let r = n.ep_product(m);
assert!((r.mu() - 0.35488958990536273).abs() < 1e-10); assert!((r.mu() - 0.35488958990536273).abs() < 1e-10);
assert!((r.sigma() - 0.992876838486922).abs() < 1e-10); assert!((r.sigma() - 0.992876838486922).abs() < 1e-10);
} }
#[test] #[test]
fn test_div() { fn cavity_undoes_a_product() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0); let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(0.0, 1.0); let m = Gaussian::from_ms(0.0, 1.0);
let r = m / n; let r = m.cavity(n);
assert!((r.mu() - (-0.3652597402597402)).abs() < 1e-10); assert!((r.mu() - (-0.3652597402597402)).abs() < 1e-10);
assert!((r.sigma() - 1.0072787050317253).abs() < 1e-10); assert!((r.sigma() - 1.0072787050317253).abs() < 1e-10);
} }
#[test] #[test]
fn test_n00_is_add_identity() { fn test_n00_is_add_identity() {
// N00 (sigma=0) is the additive identity for the variance-convolution Add op. // N00 (sigma=0) is the identity for `convolve`.
// N_INF (sigma=inf) is the identity for the EP-product Mul op. // N_INF (sigma=inf) is the identity for `ep_product`.
let g = Gaussian::from_ms(3.0, 2.0); let g = Gaussian::from_ms(3.0, 2.0);
let n00 = Gaussian::from_ms(0.0, 0.0); let n00 = Gaussian::from_ms(0.0, 0.0);
let r = n00 + g; let r = n00.convolve(g);
assert!((r.mu() - g.mu()).abs() < 1e-12); assert!((r.mu() - g.mu()).abs() < 1e-12);
assert!((r.sigma() - g.sigma()).abs() < 1e-12); assert!((r.sigma() - g.sigma()).abs() < 1e-12);
} }
#[test] #[test]
fn test_mul_is_factor_product() { fn ep_product_adds_natural_parameters() {
// n * m in nat-params should be pi_n + pi_m, tau_n + tau_m // `ep_product` in nat-params should be pi_n + pi_m, tau_n + tau_m
let n = Gaussian::from_ms(2.0, 3.0); let n = Gaussian::from_ms(2.0, 3.0);
let m = Gaussian::from_ms(1.0, 2.0); let m = Gaussian::from_ms(1.0, 2.0);
let r = n * m; let r = n.ep_product(m);
let expected_pi = n.pi() + m.pi(); let expected_pi = n.pi() + m.pi();
let expected_tau = n.tau() + m.tau(); let expected_tau = n.tau() + m.tau();
assert!((r.pi() - expected_pi).abs() < 1e-15); assert!((r.pi() - expected_pi).abs() < 1e-15);
@@ -263,10 +473,10 @@ mod tests {
} }
#[test] #[test]
fn test_div_is_cavity() { fn cavity_subtracts_natural_parameters() {
let n = Gaussian::from_ms(2.0, 1.0); let n = Gaussian::from_ms(2.0, 1.0);
let m = Gaussian::from_ms(1.0, 2.0); let m = Gaussian::from_ms(1.0, 2.0);
let r = n / m; let r = n.cavity(m);
let expected_pi = n.pi() - m.pi(); let expected_pi = n.pi() - m.pi();
let expected_tau = n.tau() - m.tau(); let expected_tau = n.tau() - m.tau();
assert!((r.pi() - expected_pi).abs() < 1e-15); assert!((r.pi() - expected_pi).abs() < 1e-15);
@@ -302,3 +512,68 @@ mod tests {
assert!((damped.tau() - expected_tau).abs() < 1e-12); assert!((damped.tau() - expected_tau).abs() < 1e-12);
} }
} }
#[cfg(test)]
mod tail_probability_tests {
use super::*;
#[test]
fn probability_below_matches_published_quantiles() {
let g = Gaussian::from_ms(0.0, 1.0);
for (x, expected) in [
(-1.959_963_984_540_054, 0.025),
(0.0, 0.5),
(1.281_551_565_544_6, 0.9),
(1.959_963_984_540_054, 0.975),
] {
let got = g.probability_below(x);
assert!(
(got - expected).abs() < 1e-12,
"P(X < {x}) = {got}, expected {expected}"
);
}
}
#[test]
fn the_two_tails_partition_the_mass() {
let g = Gaussian::from_ms(3.0, 2.0);
for x in [-4.0f64, 0.0, 3.0, 7.5] {
let total = g.probability_below(x) + g.probability_above(x);
assert!((total - 1.0).abs() < 1e-15, "at {x}: {total}");
}
}
/// The reason `probability_above` exists rather than `1 - probability_below`.
#[test]
fn probability_above_keeps_precision_where_the_complement_collapses() {
let g = Gaussian::from_ms(0.0, 1.0);
for (x, expected) in [(9.0f64, 1.128_588e-19), (20.0, 2.753_624e-89)] {
let got = g.probability_above(x);
assert!(
(got - expected).abs() / expected < 1e-6,
"P(X > {x}) = {got}, expected ~{expected}"
);
assert_eq!(
1.0 - g.probability_below(x),
0.0,
"the complement should still collapse at {x}"
);
}
}
#[test]
fn a_scaled_gaussian_shifts_and_stretches() {
let g = Gaussian::from_ms(25.0, 6.0);
assert!((g.probability_below(25.0) - 0.5).abs() < 1e-15);
// One sigma either side of the mean.
assert!((g.probability_below(31.0) - 0.841_344_746_068_543).abs() < 1e-12);
assert!((g.probability_above(19.0) - 0.841_344_746_068_543).abs() < 1e-12);
}
#[test]
fn an_improper_gaussian_is_uninformative_rather_than_nan() {
let improper = Gaussian::from_ms(0.0, f64::INFINITY);
assert_eq!(improper.probability_below(5.0), 0.5);
assert_eq!(improper.probability_above(5.0), 0.5);
}
}
+2652 -312
View File
File diff suppressed because it is too large Load Diff
+510
View File
@@ -0,0 +1,510 @@
//! 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
//! covariance of two contrasts is `c^T L^-1 a`. None of them wants `L^-1 c`
//! itself, which is what makes the shape here worth stating explicitly.
//!
//! Writing the precision as `A = L L^T`,
//!
//! ```text
//! c^T A^-1 a = c^T L^-T L^-1 a = (L^-1 c) . (L^-1 a)
//! ```
//!
//! so a single forward substitution per contrast answers everything, and the
//! back substitution a general solve would do is wasted work. That halves the
//! cost of a query, and it removes a failure mode: a variance computed as
//! `c . (A^-1 c)` is a difference of products that can round to a small
//! negative number, where the same quantity as `|L^-1 c|^2` is a sum of
//! squares and cannot.
//!
//! # 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 {
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 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 — 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(),
});
}
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 p = next[k];
next[k] += 1;
row_idx[p] = k;
val[p] = d.sqrt();
}
Some(Self {
n,
inv,
col_ptr,
row_idx,
val,
})
}
/// 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 = 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
}
}
/// `b^T A^-1 b'`, given the two whitened contrasts.
pub(crate) fn bilinear(y: &[f64], y_prime: &[f64]) -> f64 {
y.iter().zip(y_prime).map(|(a, b)| a * b).sum()
}
#[cfg(test)]
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 = 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);
}
/// Whitening `e_i` recovers the inverse's diagonal, which is the variance
/// of a single variable.
#[test]
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 = [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;
let y = c.whiten(&e);
assert!((bilinear(&y, &y) - expected).abs() < 1e-12, "row {i}");
}
}
/// The off-diagonal bilinear form is symmetric and matches the inverse.
#[test]
fn recovers_an_off_diagonal_covariance() {
// Same A; (A^-1)_{0,1} = 0.5.
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);
assert!((bilinear(&y1, &y0) - 0.5).abs() < 1e-12);
}
/// A variance can never come out negative, because it is a sum of squares.
#[test]
fn a_quadratic_form_is_never_negative() {
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!(dense(&[1.0, 2.0, 2.0, 4.0], 2).is_none());
}
}
+41 -24
View File
@@ -12,59 +12,76 @@ use crate::Index;
/// crate. Power users can promote `&K` to `Index` via `get_or_create` and /// crate. Power users can promote `&K` to `Index` via `get_or_create` and
/// skip the lookup on subsequent hot-path calls. /// skip the lookup on subsequent hot-path calls.
#[derive(Debug)] #[derive(Debug)]
pub struct KeyTable<K>(HashMap<K, Index>); pub struct KeyTable<K> {
forward: HashMap<K, Index>,
/// Reverse mapping, indexed by `Index.0`.
///
/// Indices are handed out densely and sequentially, so position *is* the
/// index and `key()` is a lookup rather than a scan over every entry.
reverse: Vec<K>,
}
impl<K> KeyTable<K> impl<K> KeyTable<K>
where where
K: Eq + Hash, K: Eq + Hash + Clone,
{ {
pub fn new() -> Self { #[must_use]
Self(HashMap::new()) pub(crate) fn new() -> Self {
Self {
forward: HashMap::new(),
reverse: Vec::new(),
}
} }
pub fn get<Q: ?Sized + Hash + Eq>(&self, k: &Q) -> Option<Index> pub(crate) fn get<Q: ?Sized + Hash + Eq>(&self, k: &Q) -> Option<Index>
where where
K: Borrow<Q>, K: Borrow<Q>,
{ {
self.0.get(k).cloned() self.forward.get(k).cloned()
} }
pub fn get_or_create<Q: ?Sized + Hash + Eq + ToOwned<Owned = K>>(&mut self, k: &Q) -> Index pub(crate) fn get_or_create<Q: ?Sized + Hash + Eq + ToOwned<Owned = K>>(
&mut self,
k: &Q,
) -> Index
where where
K: Borrow<Q>, K: Borrow<Q>,
{ {
if let Some(idx) = self.0.get(k) { if let Some(idx) = self.forward.get(k) {
*idx *idx
} else { } else {
let idx = Index::from(self.0.len()); let idx = Index::from(self.reverse.len());
self.0.insert(k.to_owned(), idx); let owned = k.to_owned();
self.reverse.push(owned.clone());
self.forward.insert(owned, idx);
idx idx
} }
} }
pub fn key(&self, idx: Index) -> Option<&K> { #[must_use]
self.0 pub(crate) fn key(&self, idx: Index) -> Option<&K> {
.iter() self.reverse.get(idx.0)
.find(|&(_, value)| *value == idx)
.map(|(key, _)| key)
} }
pub fn keys(&self) -> impl Iterator<Item = &K> { /// Every key, in the order they were first interned.
self.0.keys() ///
/// Iterates the dense reverse table rather than the forward `HashMap`.
/// Rust seeds its default hasher per process, so a `HashMap` walk yields a
/// different order on every run — which is fine for membership but not for
/// anything a caller might sum, sort or print.
pub(crate) fn keys(&self) -> impl ExactSizeIterator<Item = &K> {
self.reverse.iter()
} }
pub fn len(&self) -> usize { #[must_use]
self.0.len() pub(crate) fn len(&self) -> usize {
} self.reverse.len()
pub fn is_empty(&self) -> bool {
self.0.is_empty()
} }
} }
impl<K> Default for KeyTable<K> impl<K> Default for KeyTable<K>
where where
K: Eq + Hash, K: Eq + Hash + Clone,
{ {
fn default() -> Self { fn default() -> Self {
KeyTable::new() KeyTable::new()
+1149 -63
View File
File diff suppressed because it is too large Load Diff
+355 -117
View File
@@ -1,29 +1,13 @@
//! Minimal dense matrix used by `quality()`.
//!
//! `determinant` and `inverse` go through one LU decomposition with partial
//! pivoting — O(n³) and numerically stable. The previous implementation
//! expanded cofactors recursively (O(n!), allocating a `Vec` per minor) and
//! only implemented `inverse` for the 1×1 case, which limited `quality()` to
//! exactly two rating groups.
use std::ops; use std::ops;
fn det(m: &[f64], x: usize) -> f64 {
if x == 1 {
m[0]
} else if x == 2 {
m[0] * m[3] - m[1] * m[2]
} else {
let mut d = 0.0;
for n in 0..x {
let ms = m
.iter()
.enumerate()
.skip(x)
.filter(|(i, _)| (i % x) != n)
.map(|(_, v)| *v)
.collect::<Vec<_>>();
d += (-1.0f64).powi(n as i32) * m[n] * det(&ms, x - 1);
}
d
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Matrix { pub struct Matrix {
data: Box<[f64]>, data: Box<[f64]>,
@@ -31,8 +15,132 @@ pub struct Matrix {
width: usize, width: usize,
} }
/// LU decomposition with partial pivoting: `PA = LU`, stored compactly.
///
/// `lu` holds `L` below the diagonal (unit diagonal implied) and `U` on and
/// above it. `sign` is the determinant sign contributed by row swaps, or 0.0
/// when the matrix is singular.
struct Lu {
lu: Vec<f64>,
perm: Vec<usize>,
n: usize,
sign: f64,
}
impl Lu {
fn decompose(m: &Matrix) -> Self {
debug_assert_eq!(m.width, m.height, "LU requires a square matrix");
let n = m.width;
let mut lu = m.data.to_vec();
let mut perm: Vec<usize> = (0..n).collect();
let mut sign = 1.0;
for col in 0..n {
// Partial pivot: take the largest-magnitude candidate to limit
// growth of round-off in the elimination below.
let mut pivot_row = col;
let mut pivot_max = lu[col * n + col].abs();
for row in (col + 1)..n {
let candidate = lu[row * n + col].abs();
if candidate > pivot_max {
pivot_max = candidate;
pivot_row = row;
}
}
if pivot_max == 0.0 {
sign = 0.0;
continue;
}
if pivot_row != col {
for k in 0..n {
lu.swap(col * n + k, pivot_row * n + k);
}
perm.swap(col, pivot_row);
sign = -sign;
}
let pivot = lu[col * n + col];
for row in (col + 1)..n {
let factor = lu[row * n + col] / pivot;
lu[row * n + col] = factor;
for k in (col + 1)..n {
lu[row * n + k] -= factor * lu[col * n + k];
}
}
}
Self { lu, perm, n, sign }
}
fn determinant(&self) -> f64 {
if self.sign == 0.0 {
return 0.0;
}
let mut det = self.sign;
for i in 0..self.n {
det *= self.lu[i * self.n + i];
}
det
}
/// `ln |det|`, accumulated term by term rather than multiplied out.
///
/// The determinant of an `n x n` Gram matrix is a product of `n` diagonal
/// entries, so it leaves `f64`'s range long before the quantities built
/// from it do. `quality()` only ever wants a *ratio* of two determinants,
/// and that ratio is perfectly representable while the determinants
/// themselves are not — measured, at 250 rating groups both overflow and
/// the ratio came back `NaN` where the true answer is `9.51e-88`.
///
/// Returns `-inf` for a singular matrix, so `exp` of it is zero.
fn ln_abs_determinant(&self) -> f64 {
if self.sign == 0.0 {
return f64::NEG_INFINITY;
}
let mut acc = 0.0;
for i in 0..self.n {
acc += libm::log(self.lu[i * self.n + i].abs());
}
acc
}
/// Solve `Ax = b` for a single column of the identity, giving one column
/// of the inverse.
fn solve_column(&self, col: usize, out: &mut [f64]) {
let n = self.n;
// Forward substitution through L, applying the row permutation.
for i in 0..n {
let mut sum = if self.perm[i] == col { 1.0 } else { 0.0 };
for (k, &solved) in out.iter().enumerate().take(i) {
sum -= self.lu[i * n + k] * solved;
}
out[i] = sum;
}
// Back substitution through U.
for i in (0..n).rev() {
let mut sum = out[i];
for (k, &solved) in out.iter().enumerate().skip(i + 1) {
sum -= self.lu[i * n + k] * solved;
}
out[i] = sum / self.lu[i * n + i];
}
}
}
impl Matrix { impl Matrix {
pub fn new(height: usize, width: usize) -> Matrix { pub(crate) fn new(height: usize, width: usize) -> Matrix {
Matrix { Matrix {
data: vec![0.0; height * width].into_boxed_slice(), data: vec![0.0; height * width].into_boxed_slice(),
height, height,
@@ -40,7 +148,7 @@ impl Matrix {
} }
} }
pub fn transpose(&self) -> Matrix { pub(crate) fn transpose(&self) -> Matrix {
let mut matrix = Matrix::new(self.width, self.height); let mut matrix = Matrix::new(self.width, self.height);
for c in 0..self.width { for c in 0..self.width {
@@ -52,73 +160,77 @@ impl Matrix {
matrix matrix
} }
pub fn minor(&self, row_n: usize, col_n: usize) -> Matrix { /// Determinant of a square matrix. The 0×0 determinant is 1 by convention
let mut matrix = Matrix::new(self.height - 1, self.width - 1); /// (the empty product).
///
/// # Panics
///
/// Panics if the matrix is not square.
pub(crate) fn determinant(&self) -> f64 {
assert_eq!(
self.width, self.height,
"determinant requires a square matrix, got {}x{}",
self.height, self.width
);
let mut nr = 0; if self.width == 0 {
return 1.0;
for r in 0..self.height {
if r == row_n {
continue;
}
let mut nc = 0;
for c in 0..self.width {
if c == col_n {
continue;
}
matrix[(nr, nc)] = self[(r, c)];
nc += 1;
}
nr += 1;
} }
matrix Lu::decompose(self).determinant()
} }
pub fn determinant(&self) -> f64 { /// `ln |det|` of a square matrix; `-inf` when singular.
debug_assert!(self.width == self.height); ///
/// See [`Lu::ln_abs_determinant`] for why a ratio of determinants must be
/// taken this way.
pub(crate) fn ln_abs_determinant(&self) -> f64 {
assert_eq!(
self.width, self.height,
"determinant requires a square matrix, got {}x{}",
self.height, self.width
);
det(&self.data, self.width) if self.width == 0 {
return 0.0;
}
Lu::decompose(self).ln_abs_determinant()
} }
pub fn adjugate(&self) -> Matrix { /// Matrix inverse via LU decomposition.
debug_assert!(self.width == self.height); ///
/// # Panics
///
/// Panics if the matrix is not square or is singular.
pub(crate) fn inverse(&self) -> Matrix {
assert_eq!(
self.width, self.height,
"inverse requires a square matrix, got {}x{}",
self.height, self.width
);
let mut matrix = Matrix::new(self.height, self.width); let n = self.width;
let mut inverse = Matrix::new(n, n);
if matrix.height == 2 { if n == 0 {
matrix[(0, 0)] = self[(1, 1)]; return inverse;
matrix[(0, 1)] = -self[(0, 1)]; }
matrix[(1, 0)] = -self[(1, 0)];
matrix[(1, 1)] = self[(0, 0)];
} else {
for r in 0..matrix.height {
for c in 0..matrix.width {
let sign = if (r + c) % 2 == 0 { 1.0 } else { -1.0 };
matrix[(r, c)] = self.minor(r, c).determinant() * sign; let lu = Lu::decompose(self);
} assert!(lu.sign != 0.0, "cannot invert a singular matrix");
let mut column = vec![0.0; n];
for c in 0..n {
lu.solve_column(c, &mut column);
for (r, &value) in column.iter().enumerate() {
inverse[(r, c)] = value;
} }
} }
matrix inverse
}
pub fn inverse(&self) -> Matrix {
let mut matrix = Matrix::new(self.width, self.height);
if self.height == self.width && self.height == 1 {
matrix[(0, 0)] = 1.0 / self[(0, 0)];
} else {
panic!("eh, okey")
}
matrix
} }
} }
@@ -126,20 +238,62 @@ impl ops::Index<(usize, usize)> for Matrix {
type Output = f64; type Output = f64;
fn index(&self, pos: (usize, usize)) -> &Self::Output { fn index(&self, pos: (usize, usize)) -> &Self::Output {
debug_assert!(
pos.0 < self.height && pos.1 < self.width,
"index ({}, {}) out of bounds for {}x{} matrix",
pos.0,
pos.1,
self.height,
self.width
);
&self.data[(self.width * pos.0) + pos.1] &self.data[(self.width * pos.0) + pos.1]
} }
} }
impl ops::IndexMut<(usize, usize)> for Matrix { impl ops::IndexMut<(usize, usize)> for Matrix {
fn index_mut(&mut self, pos: (usize, usize)) -> &mut Self::Output { fn index_mut(&mut self, pos: (usize, usize)) -> &mut Self::Output {
debug_assert!(
pos.0 < self.height && pos.1 < self.width,
"index ({}, {}) out of bounds for {}x{} matrix",
pos.0,
pos.1,
self.height,
self.width
);
&mut self.data[(self.width * pos.0) + pos.1] &mut self.data[(self.width * pos.0) + pos.1]
} }
} }
impl<'a> ops::Mul<&'a Matrix> for f64 { fn multiply(lhs: &Matrix, rhs: &Matrix) -> Matrix {
assert_eq!(
lhs.width, rhs.height,
"cannot multiply {}x{} by {}x{}",
lhs.height, lhs.width, rhs.height, rhs.width
);
let mut matrix = Matrix::new(lhs.height, rhs.width);
for r in 0..matrix.height {
for c in 0..matrix.width {
let mut value = 0.0;
for x in 0..lhs.width {
value += lhs[(r, x)] * rhs[(x, c)];
}
matrix[(r, c)] = value;
}
}
matrix
}
impl ops::Mul<&Matrix> for f64 {
type Output = Matrix; type Output = Matrix;
fn mul(self, rhs: &'a Matrix) -> Matrix { fn mul(self, rhs: &Matrix) -> Matrix {
let mut matrix = Matrix::new(rhs.height, rhs.width); let mut matrix = Matrix::new(rhs.height, rhs.width);
for r in 0..rhs.height { for r in 0..rhs.height {
@@ -152,54 +306,35 @@ impl<'a> ops::Mul<&'a Matrix> for f64 {
} }
} }
impl<'a> ops::Mul<&'a Matrix> for Matrix { impl ops::Mul<&Matrix> for Matrix {
type Output = Matrix; type Output = Matrix;
fn mul(self, rhs: &'a Matrix) -> Matrix { fn mul(self, rhs: &Matrix) -> Matrix {
let mut matrix = Matrix::new(self.height, rhs.width); multiply(&self, rhs)
for r in 0..matrix.height {
for c in 0..matrix.width {
let mut value = 0.0;
for x in 0..self.width {
value += self[(r, x)] * rhs[(x, c)];
}
matrix[(r, c)] = value;
}
}
matrix
} }
} }
impl<'a> ops::Mul<&'a Matrix> for &'a Matrix { impl ops::Mul<&Matrix> for &Matrix {
type Output = Matrix; type Output = Matrix;
fn mul(self, rhs: &'a Matrix) -> Matrix { fn mul(self, rhs: &Matrix) -> Matrix {
let mut matrix = Matrix::new(self.height, rhs.width); multiply(self, rhs)
for r in 0..matrix.height {
for c in 0..matrix.width {
let mut value = 0.0;
for x in 0..self.width {
value += self[(r, x)] * rhs[(x, c)];
}
matrix[(r, c)] = value;
}
}
matrix
} }
} }
impl<'a> ops::Add<&'a Matrix> for &'a Matrix { impl ops::Add<&Matrix> for &Matrix {
type Output = Matrix; type Output = Matrix;
fn add(self, rhs: &'a Matrix) -> Matrix { fn add(self, rhs: &Matrix) -> Matrix {
assert!(
self.height == rhs.height && self.width == rhs.width,
"cannot add {}x{} to {}x{}",
self.height,
self.width,
rhs.height,
rhs.width
);
let mut matrix = Matrix::new(self.height, self.width); let mut matrix = Matrix::new(self.height, self.width);
for r in 0..matrix.height { for r in 0..matrix.height {
@@ -211,3 +346,106 @@ impl<'a> ops::Add<&'a Matrix> for &'a Matrix {
matrix matrix
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn from_rows(rows: &[&[f64]]) -> Matrix {
let mut m = Matrix::new(rows.len(), rows[0].len());
for (r, row) in rows.iter().enumerate() {
for (c, &v) in row.iter().enumerate() {
m[(r, c)] = v;
}
}
m
}
#[test]
fn determinant_1x1() {
assert!((from_rows(&[&[3.0]]).determinant() - 3.0).abs() < 1e-12);
}
#[test]
fn determinant_2x2() {
let m = from_rows(&[&[1.0, 2.0], &[3.0, 4.0]]);
assert!((m.determinant() - (-2.0)).abs() < 1e-12);
}
#[test]
fn determinant_3x3() {
let m = from_rows(&[&[6.0, 1.0, 1.0], &[4.0, -2.0, 5.0], &[2.0, 8.0, 7.0]]);
assert!((m.determinant() - (-306.0)).abs() < 1e-10);
}
#[test]
fn determinant_requires_no_pivot_at_origin() {
// A zero in the top-left forces a row swap; the sign must follow.
let m = from_rows(&[&[0.0, 1.0], &[1.0, 0.0]]);
assert!((m.determinant() - (-1.0)).abs() < 1e-12);
}
#[test]
fn determinant_of_singular_is_zero() {
let m = from_rows(&[&[1.0, 2.0], &[2.0, 4.0]]);
assert!(m.determinant().abs() < 1e-12);
}
#[test]
fn inverse_1x1() {
let inv = from_rows(&[&[4.0]]).inverse();
assert!((inv[(0, 0)] - 0.25).abs() < 1e-12);
}
#[test]
fn inverse_times_original_is_identity() {
for rows in [
vec![vec![1.0, 2.0], vec![3.0, 4.0]],
vec![
vec![6.0, 1.0, 1.0],
vec![4.0, -2.0, 5.0],
vec![2.0, 8.0, 7.0],
],
vec![
vec![2.0, 0.0, 1.0, 3.0],
vec![1.0, 5.0, 2.0, 0.0],
vec![0.0, 1.0, 4.0, 1.0],
vec![3.0, 2.0, 0.0, 6.0],
],
] {
let refs: Vec<&[f64]> = rows.iter().map(|r| r.as_slice()).collect();
let m = from_rows(&refs);
let product = &m * &m.inverse();
for r in 0..product.height {
for c in 0..product.width {
let expected = if r == c { 1.0 } else { 0.0 };
assert!(
(product[(r, c)] - expected).abs() < 1e-9,
"({r},{c}) = {} expected {expected}",
product[(r, c)]
);
}
}
}
}
#[test]
#[should_panic(expected = "singular")]
fn inverse_of_singular_panics() {
let _ = from_rows(&[&[1.0, 2.0], &[2.0, 4.0]]).inverse();
}
#[test]
fn empty_determinant_is_one() {
assert!((Matrix::new(0, 0).determinant() - 1.0).abs() < 1e-12);
}
#[test]
fn transpose_round_trips() {
let m = from_rows(&[&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]]);
let t = m.transpose();
assert_eq!((t.height, t.width), (3, 2));
assert_eq!(t.transpose()[(1, 2)], m[(1, 2)]);
}
}
+85 -2
View File
@@ -14,13 +14,95 @@ pub trait Observer<T: Time>: Send + Sync {
/// Called after each convergence iteration across the whole history. /// Called after each convergence iteration across the whole history.
fn on_iteration_end(&self, _iter: usize, _max_step: (f64, f64)) {} fn on_iteration_end(&self, _iter: usize, _max_step: (f64, f64)) {}
/// Called after each time slice is processed within an iteration. /// Called after each time slice is swept within an iteration.
fn on_batch_processed(&self, _time: &T, _slice_idx: usize, _n_events: usize) {} ///
/// A convergence iteration sweeps every slice twice — once travelling
/// backward through the history and once forward — so a multi-slice
/// history fires this twice per slice per iteration. A single-slice
/// history is swept once and fires once.
fn on_slice_processed(&self, _time: &T, _slice_idx: usize, _n_events: usize) {}
/// Called once when convergence completes (or max iters is reached). /// Called once when convergence completes (or max iters is reached).
fn on_converged(&self, _iters: usize, _final_step: (f64, f64), _converged: bool) {} fn on_converged(&self, _iters: usize, _final_step: (f64, f64), _converged: bool) {}
} }
/// Shared and boxed observers forward to what they point at.
///
/// `History` takes its observer by value, so a caller who wants to *read* what
/// an observer recorded has to keep a handle to it. Without these impls the
/// natural spelling does not compile:
///
/// ```
/// # use std::sync::{Arc, Mutex};
/// # use trueskill_tt::{History, Observer};
/// #[derive(Default)]
/// struct Recorder {
/// iterations: Mutex<Vec<usize>>,
/// }
///
/// impl Observer<i64> for Recorder {
/// fn on_iteration_end(&self, iter: usize, _step: (f64, f64)) {
/// self.iterations.lock().unwrap().push(iter);
/// }
/// }
///
/// let recorder = Arc::new(Recorder::default());
/// let mut h = History::builder().observer(Arc::clone(&recorder)).build();
/// h.record_winner(&"a", &"b", 1).unwrap();
/// h.converge().unwrap();
///
/// // The caller's handle sees what the history's copy recorded.
/// assert!(!recorder.iterations.lock().unwrap().is_empty());
/// ```
///
/// The alternative was for every observer to wrap each of its own fields in an
/// `Arc` and derive `Clone` — one allocation and one lock per field, and a
/// pattern each implementor had to rediscover.
///
/// `?Sized` is deliberate: it makes `Arc<dyn Observer<T>>` and
/// `Box<dyn Observer<T>>` work, so observers can be chosen at runtime.
impl<T: Time, O: Observer<T> + ?Sized> Observer<T> for std::sync::Arc<O> {
fn on_iteration_end(&self, iter: usize, max_step: (f64, f64)) {
(**self).on_iteration_end(iter, max_step);
}
fn on_slice_processed(&self, time: &T, slice_idx: usize, n_events: usize) {
(**self).on_slice_processed(time, slice_idx, n_events);
}
fn on_converged(&self, iters: usize, final_step: (f64, f64), converged: bool) {
(**self).on_converged(iters, final_step, converged);
}
}
impl<T: Time, O: Observer<T> + ?Sized> Observer<T> for Box<O> {
fn on_iteration_end(&self, iter: usize, max_step: (f64, f64)) {
(**self).on_iteration_end(iter, max_step);
}
fn on_slice_processed(&self, time: &T, slice_idx: usize, n_events: usize) {
(**self).on_slice_processed(time, slice_idx, n_events);
}
fn on_converged(&self, iters: usize, final_step: (f64, f64), converged: bool) {
(**self).on_converged(iters, final_step, converged);
}
}
impl<T: Time, O: Observer<T> + ?Sized> Observer<T> for &O {
fn on_iteration_end(&self, iter: usize, max_step: (f64, f64)) {
(**self).on_iteration_end(iter, max_step);
}
fn on_slice_processed(&self, time: &T, slice_idx: usize, n_events: usize) {
(**self).on_slice_processed(time, slice_idx, n_events);
}
fn on_converged(&self, iters: usize, final_step: (f64, f64), converged: bool) {
(**self).on_converged(iters, final_step, converged);
}
}
/// ZST no-op observer; the default when none is configured. /// ZST no-op observer; the default when none is configured.
#[derive(Copy, Clone, Debug, Default)] #[derive(Copy, Clone, Debug, Default)]
pub struct NullObserver; pub struct NullObserver;
@@ -35,6 +117,7 @@ mod tests {
fn null_observer_compiles_for_i64() { fn null_observer_compiles_for_i64() {
let o = NullObserver; let o = NullObserver;
<NullObserver as Observer<i64>>::on_iteration_end(&o, 1, (0.0, 0.0)); <NullObserver as Observer<i64>>::on_iteration_end(&o, 1, (0.0, 0.0));
<NullObserver as Observer<i64>>::on_slice_processed(&o, &7, 0, 3);
<NullObserver as Observer<i64>>::on_converged(&o, 5, (1e-6, 1e-6), true); <NullObserver as Observer<i64>>::on_converged(&o, 5, (1e-6, 1e-6), true);
} }
+94 -18
View File
@@ -1,6 +1,6 @@
//! Outcome of a match. //! Outcome of a match.
//! //!
//! `Ranked(ranks)` for ordinal results; `Scored { scores, sigma }` for //! `Ranked(ranks)` for ordinal results; `Scored { scores, score_sigma }` for
//! continuous per-team scores (engages `MarginFactor` in the engine). //! continuous per-team scores (engages `MarginFactor` in the engine).
use smallvec::SmallVec; use smallvec::SmallVec;
@@ -10,30 +10,86 @@ use smallvec::SmallVec;
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those /// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
/// teams. `ranks.len()` must equal the number of teams in the event. /// teams. `ranks.len()` must equal the number of teams in the event.
/// ///
/// `Scored { scores, sigma }`: higher score = better. Adjacent (sorted) pairs /// `Scored { scores, score_sigma }`: higher score = better. Adjacent (sorted) pairs
/// feed observed margins to `MarginFactor`. `scores.len()` must equal the /// feed observed margins to `MarginFactor`. `scores.len()` must equal the
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma` /// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
/// when `Some`; `None` inherits the history default. /// when `Some`; `None` inherits the history default.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
#[non_exhaustive] #[non_exhaustive]
#[must_use]
pub enum Outcome { pub enum Outcome {
/// An ordinal finish: one rank per team, in the order the teams were given.
///
/// Lower is better, `0` is first, and equal values are a tie between those
/// teams — which needs `p_draw > 0`, or ingestion rejects the event with
/// [`InferenceError::TieWithoutDrawProbability`](crate::InferenceError::TieWithoutDrawProbability).
///
/// Only the ordering and the equalities are used. Ranks need not be dense
/// or start at zero: inference sorts the teams and compares rank-adjacent
/// pairs against a margin set by `p_draw`, so `[0, 1, 2]` and `[0, 5, 90]`
/// are the same observation. A gap does not mean a bigger win — use
/// `Scored` when the size of the difference is evidence.
Ranked(SmallVec<[u32; 4]>), Ranked(SmallVec<[u32; 4]>),
/// A continuous finish: one score per team, higher is better.
///
/// Unlike `Ranked`, the *sizes* of the differences are evidence. Teams are
/// sorted by score and each adjacent pair's observed gap is fed to a
/// `MarginFactor` as a measurement with standard deviation `score_sigma`,
/// so
/// beating a team by ten says more than beating them by one.
#[non_exhaustive]
Scored { Scored {
/// Per-team scores, in the order the teams were given; higher is
/// better. Must have one entry per team, and every entry finite.
scores: SmallVec<[f64; 4]>, scores: SmallVec<[f64; 4]>,
/// Per-event noise override. `None` means inherit /// Per-event noise override. `None` means inherit
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`. /// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
sigma: Option<f64>, score_sigma: Option<f64>,
}, },
} }
impl Outcome { impl Outcome {
/// `n`-team outcome where team `winner` won and everyone else tied for last. /// `n`-team outcome where team `winner` won and everyone else tied for last.
/// ///
/// Panics if `winner >= n`. /// Note this ties every loser, so for `n >= 3` it needs a positive
/// `p_draw` — see `InferenceError::TieWithoutDrawProbability`.
///
/// # Panics
///
/// Panics if `winner >= n`. Use [`Outcome::try_winner`] when the index
/// comes from data rather than a literal.
///
/// This is the one constructor here that validates, and deliberately so.
/// Its siblings build freely and let ingestion reject what it cannot use,
/// which works because a malformed rank vector stays recognisable. An
/// out-of-range winner does not: `winner(5, 2)` would produce ranks
/// `[1, 1]`, an all-tied draw that ingestion accepts without complaint when
/// `p_draw > 0`. Asking "team 5 won" and silently getting "everyone drew"
/// is exactly the class of quiet wrong answer this crate keeps removing, so
/// the check happens here where the mistake is.
pub fn winner(winner: u32, n: u32) -> Self { pub fn winner(winner: u32, n: u32) -> Self {
assert!(winner < n, "winner index {winner} out of range 0..{n}"); Self::try_winner(winner, n)
.unwrap_or_else(|_| panic!("winner index {winner} out of range 0..{n}"))
}
/// `n`-team outcome where team `winner` won, or an error if `winner` is not
/// a valid team index.
///
/// The fallible form of [`Outcome::winner`], for when the index is computed
/// or parsed rather than written literally.
///
/// # Errors
///
/// `InvalidParameter` if `winner >= n`.
pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> {
if winner >= n {
return Err(crate::InferenceError::InvalidParameter {
parameter: crate::Parameter::WinnerIndex,
value: f64::from(winner),
});
}
let ranks: SmallVec<[u32; 4]> = (0..n).map(|i| if i == winner { 0 } else { 1 }).collect(); let ranks: SmallVec<[u32; 4]> = (0..n).map(|i| if i == winner { 0 } else { 1 }).collect();
Self::Ranked(ranks) Ok(Self::Ranked(ranks))
} }
/// All `n` teams tied. /// All `n` teams tied.
@@ -51,21 +107,35 @@ impl Outcome {
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self { pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
Self::Scored { Self::Scored {
scores: scores.into_iter().collect(), scores: scores.into_iter().collect(),
sigma: None, score_sigma: None,
} }
} }
/// Explicit per-team continuous scores with a per-event noise override. /// Explicit per-team continuous scores with a per-event noise override.
/// ///
/// `sigma` must be `> 0.0`; debug-asserts otherwise. /// The noise is on the *observed score margin*, in the units of the scores
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, sigma: f64) -> Self { /// themselves — it is not a skill sigma, which is what the old name
debug_assert!(sigma > 0.0, "score_sigma must be > 0.0 (got {sigma})"); /// `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_noise<I: IntoIterator<Item = f64>>(scores: I, score_sigma: f64) -> Self {
Self::Scored { Self::Scored {
scores: scores.into_iter().collect(), scores: scores.into_iter().collect(),
sigma: Some(sigma), score_sigma: Some(score_sigma),
} }
} }
/// How many teams this outcome describes — the number of ranks, or of
/// scores.
///
/// Ingestion checks it against the event's own team list and rejects a
/// disagreement with `MismatchedShape`, so this is the cheap way to check
/// an outcome built elsewhere before committing the event.
#[must_use]
pub fn team_count(&self) -> usize { pub fn team_count(&self) -> usize {
match self { match self {
Self::Ranked(r) => r.len(), Self::Ranked(r) => r.len(),
@@ -146,7 +216,7 @@ mod tests {
#[test] #[test]
fn scores_with_sigma_round_trips() { fn scores_with_sigma_round_trips() {
let o = Outcome::scores_with_sigma([10.0, 4.0], 0.5); let o = Outcome::scores_with_noise([10.0, 4.0], 0.5);
assert_eq!(o.team_count(), 2); assert_eq!(o.team_count(), 2);
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..])); assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
} }
@@ -155,23 +225,29 @@ mod tests {
fn scores_constructor_leaves_sigma_unset() { fn scores_constructor_leaves_sigma_unset() {
let o = Outcome::scores([3.0, 1.0]); let o = Outcome::scores([3.0, 1.0]);
match o { match o {
Outcome::Scored { scores: _, sigma } => assert!(sigma.is_none()), Outcome::Scored { score_sigma, .. } => assert!(score_sigma.is_none()),
Outcome::Ranked(_) => panic!("expected Scored variant"), Outcome::Ranked(_) => panic!("expected Scored variant"),
} }
} }
#[test] #[test]
fn scores_with_sigma_sets_sigma_some() { fn scores_with_sigma_sets_sigma_some() {
let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0); let o = Outcome::scores_with_noise([3.0, 1.0], 2.0);
match o { match o {
Outcome::Scored { scores: _, sigma } => assert_eq!(sigma, Some(2.0)), Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(2.0)),
Outcome::Ranked(_) => panic!("expected Scored variant"), Outcome::Ranked(_) => panic!("expected Scored variant"),
} }
} }
/// Construction accepts any sigma; the value is validated at ingestion so
/// callers receive an `InferenceError` rather than a panic. See
/// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`.
#[test] #[test]
#[should_panic(expected = "score_sigma must be > 0.0")] fn scores_with_sigma_defers_validation_to_ingestion() {
fn scores_with_sigma_rejects_zero() { let o = Outcome::scores_with_noise([3.0, 1.0], 0.0);
let _ = Outcome::scores_with_sigma([3.0, 1.0], 0.0); match o {
Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(0.0)),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
} }
} }
+760
View File
@@ -0,0 +1,760 @@
//! Outcome prediction: who wins, and how likely is a given finishing order.
//!
//! Prediction runs on *performances*, not skills. A competitor's skill is
//! inflated by their performance noise `beta` before any comparison, which is
//! what separates "how good are they" from "how will they do today".
//!
//! Two questions, two algorithms:
//!
//! - **Who finishes first.** Because performances are independent Gaussians,
//! the probability that team `i` beats every other team separates into a
//! *one-dimensional* integral — no multivariate orthant integral is
//! involved. [`quadrature::integrate`] evaluates it to near machine
//! precision for a few hundred `cdf` calls.
//! - **A specific finishing order.** The factor graph only ever constrains
//! rank-*adjacent* teams (see `Game::run_chain`), so the joint probability
//! of a full order is a chain of local constraints rather than a general
//! orthant probability. That chain collapses into a sequential recursion:
//! one cumulative integral per adjacent pair, `O(teams * grid)` overall.
//!
//! Both are deterministic. A sampler would have been easier to write and
//! would have made every `predict_*` call return a slightly different number,
//! which is not a property a rating library should have.
use crate::{Gaussian, InferenceError, quadrature};
/// Teams beyond this count make the outcome enumeration impractical.
///
/// Each realisation sorts into exactly one (permutation, tie-pattern) event,
/// so the space has `n! * 2^(n-1)` members: 24 at 3 teams, 192 at 4, 1_920 at
/// 5, 23_040 at 6. The jump to 322_560 at 7 is where enumerating stops being
/// a reasonable thing to do on a caller's behalf.
pub(crate) const MAX_TEAMS_FOR_DISTRIBUTION: usize = 6;
/// Relative tolerance for the first-place integrals.
///
/// The adaptive integrator reaches the exact two-team closed form to ~1e-15 at
/// this tolerance, which is round-off for a probability. `cdf` is no longer the
/// limit — it went to ~1 ULP when `erfc` moved to `libm` — so this is the
/// integrator's own floor.
const WIN_TOLERANCE: f64 = 1e-8;
/// Nodes for the ranking grid, and the floor below which a grid is pointless.
///
/// The recursion converges as O(h^2), so this trades nodes against accuracy
/// directly. Measured against the exact two-team closed form, 2_048 nodes leave
/// ~1.2e-6 of discretisation error and 8_192 reach ~1e-7.
///
/// Unlike the adaptive path there is no approximation floor underneath this any
/// more — `cdf` is accurate to ~1 ULP since `erfc` moved to `libm` — so the
/// error here is purely the grid, and a caller who needs more can only get it
/// by paying for more nodes. 8_192 is the accuracy/cost point chosen, not a
/// point where refining stops helping.
const MIN_GRID_POINTS: usize = 8_192;
const MAX_GRID_POINTS: usize = 262_144;
/// Nodes requested across the narrowest feature the recursion must resolve.
const NODES_PER_FEATURE: f64 = 12.0;
/// Nodes below which the trapezoid rule stops resolving that feature at all.
const MIN_NODES_PER_FEATURE: f64 = 4.0;
/// How many standard deviations of support the grid and integrals cover.
///
/// The normal density is below 1e-18 of its peak past nine sigma, far under
/// the precision of everything else here.
const SUPPORT_SIGMAS: f64 = 9.0;
/// Standard normal CDF at `z`.
fn phi(z: f64) -> f64 {
crate::cdf(z, 0.0, 1.0)
}
/// Normal density of `x` under `g`.
fn density(g: Gaussian, x: f64) -> f64 {
let sigma = g.sigma();
let z = (x - g.mu()) / sigma;
libm::exp(-0.5 * z * z) / (sigma * (2.0 * std::f64::consts::PI).sqrt())
}
/// Per-pair draw margins.
///
/// The margin is *not* a single number for the whole game: inference derives
/// it per rank-adjacent pair from those two teams' betas (`Game::likelihoods`).
/// Prediction has to use the same per-pair values or it answers a question
/// about a different model than the one that will actually be fitted.
pub(crate) struct Margins {
n: usize,
values: Vec<f64>,
}
impl Margins {
/// Build from a per-pair margin function.
pub(crate) fn new<F: Fn(usize, usize) -> f64>(n: usize, f: F) -> Self {
let mut values = vec![0.0; n * n];
for i in 0..n {
for j in 0..n {
if i != j {
values[i * n + j] = f(i, j);
}
}
}
Self { n, values }
}
fn get(&self, i: usize, j: usize) -> f64 {
self.values[i * self.n + j]
}
/// True when no pair can draw, so every tie has probability zero.
fn all_zero(&self) -> bool {
self.values.iter().all(|&v| v == 0.0)
}
}
/// `P(team i finishes strictly first)` for every team.
///
/// Strictly means beating each rival by more than that pair's draw margin, so
/// with a non-zero margin these sum to less than one; the shortfall is the
/// probability that the top place is shared.
pub(crate) fn win_probabilities(perf: &[Gaussian], margins: &Margins) -> Vec<f64> {
(0..perf.len())
.map(|i| {
let (mu, sigma) = (perf[i].mu(), perf[i].sigma());
let (lo, hi) = (mu - SUPPORT_SIGMAS * sigma, mu + SUPPORT_SIGMAS * sigma);
// Each rival's CDF turns over near its own mean plus the margin.
// Seeding there is what keeps a rival with a tiny sigma — a step
// function in disguise — from being stepped over.
let mut seeds = Vec::with_capacity(3 * perf.len());
for (j, rival) in perf.iter().enumerate().filter(|&(j, _)| j != i) {
let centre = rival.mu() + margins.get(i, j);
seeds.extend_from_slice(&[centre - rival.sigma(), centre, centre + rival.sigma()]);
}
quadrature::integrate(
|x| {
let d = density(perf[i], x);
if d == 0.0 {
return 0.0;
}
let beaten: f64 = (0..perf.len())
.filter(|&j| j != i)
.map(|j| phi((x - margins.get(i, j) - perf[j].mu()) / perf[j].sigma()))
.product();
d * beaten
},
lo,
hi,
&seeds,
WIN_TOLERANCE,
)
})
.collect()
}
/// Grid bounds and resolution covering every team's support.
///
/// Resolution is set by the *smallest* feature in play — the narrowest sigma,
/// or a draw margin narrower still — because that is what the recursion has to
/// resolve. A grid sized off the widest team would step over the narrow one.
fn grid_shape(perf: &[Gaussian], margins: &Margins) -> Result<(f64, f64, usize), InferenceError> {
let lo = perf
.iter()
.map(|g| g.mu() - SUPPORT_SIGMAS * g.sigma())
.fold(f64::INFINITY, f64::min);
let hi = perf
.iter()
.map(|g| g.mu() + SUPPORT_SIGMAS * g.sigma())
.fold(f64::NEG_INFINITY, f64::max);
let narrowest = perf
.iter()
.map(Gaussian::sigma)
.fold(f64::INFINITY, f64::min);
let smallest_margin = margins
.values
.iter()
.copied()
.filter(|&m| m > 0.0)
.fold(f64::INFINITY, f64::min);
let feature = narrowest.min(smallest_margin);
let wanted = if feature.is_finite() && feature > 0.0 {
((hi - lo) / (feature / NODES_PER_FEATURE)).ceil()
} else {
MIN_GRID_POINTS as f64
};
if !wanted.is_finite() {
return Ok((lo, hi, MIN_GRID_POINTS));
}
// Report rather than clamp. Clamping is what this replaced: it silently
// handed the recursion a grid too coarse for the narrowest density, and the
// trapezoid rule then returned probabilities greater than one — measured, a
// `P` of 2.79 and a total of 5.41. Trapezoid error on a Gaussian is
// `~exp(-2 pi^2 (sigma/h)^2)`, which is 1e-12 at `h/sigma = 0.86` and O(1)
// by `h/sigma = 17`, so the cliff is sharp and there is no useful answer on
// the far side of it.
//
// The floor is `MIN_NODES_PER_FEATURE` rather than the `NODES_PER_FEATURE`
// asked for, because the request carries a large margin: measured accurate
// to 2.2e-12 at 1.4 nodes per sigma, and wrong by 1.2e-3 at 0.7.
let needed = wanted as usize;
let floor = ((hi - lo) / (feature / MIN_NODES_PER_FEATURE)).ceil();
if floor.is_finite() && floor as usize > MAX_GRID_POINTS {
return Err(InferenceError::GridTooCoarse {
needed,
max: MAX_GRID_POINTS,
});
}
Ok((lo, hi, needed.clamp(MIN_GRID_POINTS, MAX_GRID_POINTS)))
}
/// Densities of each team sampled on the shared grid.
struct Sampled {
lo: f64,
step: f64,
points: usize,
density: Vec<Vec<f64>>,
}
impl Sampled {
fn new(perf: &[Gaussian], margins: &Margins) -> Result<Self, InferenceError> {
let (lo, hi, points) = grid_shape(perf, margins)?;
let step = (hi - lo) / (points - 1) as f64;
let density = perf
.iter()
.map(|&g| {
(0..points)
.map(|i| density(g, lo + i as f64 * step))
.collect()
})
.collect();
Ok(Self {
lo,
step,
points,
density,
})
}
fn node(&self, i: usize) -> f64 {
self.lo + i as f64 * self.step
}
}
/// `P(order[0] >= order[1] >= ... )` with the given adjacency pattern.
///
/// `tied[k]` says whether `order[k]` and `order[k + 1]` finish within that
/// pair's draw margin. The recursion runs bottom-up: `carry` holds, for each
/// grid node, the probability that everything *below* the current team holds
/// given that team landed on that node. A strict gap reads a cumulative
/// integral; a tie reads a window. Both are O(1) against one prefix array,
/// so each level costs O(grid) and the whole order costs O(teams * grid).
fn order_probability(margins: &Margins, sampled: &Sampled, order: &[usize], tied: &[bool]) -> f64 {
let mut carry = vec![1.0; sampled.points];
for k in (0..order.len() - 1).rev() {
let below = order[k + 1];
let above = order[k];
let margin = margins.get(above, below);
let integrand: Vec<f64> = (0..sampled.points)
.map(|i| sampled.density[below][i] * carry[i])
.collect();
let cumulative = quadrature::Grid::from_values(sampled.lo, sampled.step, integrand);
carry = (0..sampled.points)
.map(|i| {
let x = sampled.node(i);
if tied[k] {
// Sorted order already implies `below <= above`, so the
// tie window is one-sided: [x - margin, x].
cumulative.integral_between(x - margin, x)
} else {
cumulative.integral_to(x - margin)
}
})
.collect();
}
let top = order[0];
let integrand: Vec<f64> = (0..sampled.points)
.map(|i| sampled.density[top][i] * carry[i])
.collect();
quadrature::Grid::from_values(sampled.lo, sampled.step, integrand).total()
}
/// Dense ranks implied by a sorted order and its tie pattern.
fn ranks_of(order: &[usize], tied: &[bool], n: usize) -> Vec<u32> {
let mut ranks = vec![0u32; n];
let mut rank = 0u32;
ranks[order[0]] = 0;
for k in 0..order.len() - 1 {
if !tied[k] {
rank += 1;
}
ranks[order[k + 1]] = rank;
}
ranks
}
/// Every (order, tie-pattern) event, or only the strict ones when no pair can
/// draw — a tie then has probability exactly zero and is not worth integrating.
fn events(n: usize, strict_only: bool) -> Vec<(Vec<usize>, Vec<bool>)> {
fn permute(current: &mut Vec<usize>, k: usize, out: &mut Vec<Vec<usize>>) {
if k == current.len() {
out.push(current.clone());
return;
}
for i in k..current.len() {
current.swap(k, i);
permute(current, k + 1, out);
current.swap(k, i);
}
}
let mut orders = Vec::new();
permute(&mut (0..n).collect(), 0, &mut orders);
let patterns: Vec<Vec<bool>> = if strict_only {
vec![vec![false; n - 1]]
} else {
(0..(1u32 << (n - 1)))
.map(|mask| (0..n - 1).map(|i| mask >> i & 1 == 1).collect())
.collect()
};
let mut out = Vec::with_capacity(orders.len() * patterns.len());
for order in orders {
for pattern in &patterns {
out.push((order.clone(), pattern.clone()));
}
}
out
}
/// The full distribution over finishing orders, aggregated by rank vector.
///
/// Orders that differ only *within* a tied group describe the same finishing
/// order, so their probabilities are summed into one entry.
pub(crate) fn outcome_distribution(
perf: &[Gaussian],
margins: &Margins,
) -> Result<Vec<(Vec<u32>, f64)>, InferenceError> {
let n = perf.len();
let sampled = Sampled::new(perf, margins)?;
let mut aggregated: Vec<(Vec<u32>, f64)> = Vec::new();
for (order, tied) in events(n, margins.all_zero()) {
let p = order_probability(margins, &sampled, &order, &tied);
let ranks = ranks_of(&order, &tied, n);
match aggregated.iter_mut().find(|(r, _)| *r == ranks) {
Some((_, acc)) => *acc += p,
None => aggregated.push((ranks, p)),
}
}
aggregated.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(aggregated)
}
/// All permutations of `items`.
fn permutations(items: &[usize]) -> Vec<Vec<usize>> {
fn go(current: &mut Vec<usize>, k: usize, out: &mut Vec<Vec<usize>>) {
if k == current.len() {
out.push(current.clone());
return;
}
for i in k..current.len() {
current.swap(k, i);
go(current, k + 1, out);
current.swap(k, i);
}
}
let mut out = Vec::new();
go(&mut items.to_vec(), 0, &mut out);
out
}
/// Every (order, tie-pattern) event consistent with a grouping by rank.
///
/// Teams sharing a rank may finish in any internal order, so this is the
/// product of each group's permutations. Adjacencies inside a group are ties;
/// the adjacency joining one group to the next is not.
fn orders_for_groups(groups: &[Vec<usize>]) -> Vec<(Vec<usize>, Vec<bool>)> {
let per_group: Vec<Vec<Vec<usize>>> = groups.iter().map(|g| permutations(g)).collect();
let mut out = Vec::new();
let mut choice = vec![0usize; groups.len()];
loop {
let mut order = Vec::new();
let mut tied = Vec::new();
for (gi, group) in per_group.iter().enumerate() {
for (offset, &member) in group[choice[gi]].iter().enumerate() {
if !order.is_empty() {
tied.push(offset != 0);
}
order.push(member);
}
}
out.push((order, tied));
let mut k = 0;
loop {
if k == choice.len() {
return out;
}
choice[k] += 1;
if choice[k] < per_group[k].len() {
break;
}
choice[k] = 0;
k += 1;
}
}
}
/// Probability of one specific rank vector.
///
/// Ties in `ranks` mean the tied teams may finish in any internal order, so
/// this sums the orders consistent with the requested ranking rather than
/// picking one.
pub(crate) fn ranking_probability(
perf: &[Gaussian],
margins: &Margins,
ranks: &[u32],
) -> Result<f64, InferenceError> {
let n = perf.len();
let sampled = Sampled::new(perf, margins)?;
let mut distinct: Vec<u32> = ranks.to_vec();
distinct.sort_unstable();
distinct.dedup();
let groups: Vec<Vec<usize>> = distinct
.iter()
.map(|&r| (0..n).filter(|&i| ranks[i] == r).collect())
.collect();
Ok(orders_for_groups(&groups)
.iter()
.map(|(order, tied)| order_probability(margins, &sampled, order, tied))
.sum())
}
/// A distribution over the ways a contest could finish.
///
/// Each entry pairs a rank vector — the same shape [`crate::Outcome::ranking`]
/// takes, with equal ranks meaning a tie — against its probability. Entries
/// are ordered most likely first, and cover the whole outcome space, so the
/// probabilities sum to one.
///
/// The rank vectors compose directly with inference: feeding one to
/// `Game::ranked` asks "what would we believe if *this* happened", which is
/// what an expected-information-gain calculation needs alongside the weight.
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct Prediction {
outcomes: Vec<(Vec<u32>, f64)>,
}
impl Prediction {
pub(crate) fn new(outcomes: Vec<(Vec<u32>, f64)>) -> Self {
Self { outcomes }
}
/// Every possible finishing order and its probability, most likely first.
#[must_use]
pub fn outcomes(&self) -> impl ExactSizeIterator<Item = (&[u32], f64)> {
self.outcomes.iter().map(|(r, p)| (r.as_slice(), *p))
}
/// The single most likely finishing order.
#[must_use]
pub fn most_likely(&self) -> Option<(&[u32], f64)> {
self.outcomes.first().map(|(r, p)| (r.as_slice(), *p))
}
/// Probability of one specific finishing order, or zero if it cannot occur.
#[must_use]
pub fn probability_of(&self, ranks: &[u32]) -> f64 {
self.outcomes
.iter()
.find(|(r, _)| r.as_slice() == ranks)
.map_or(0.0, |(_, p)| *p)
}
/// `P(team i finishes strictly first)`, for each team.
///
/// Sums to less than one exactly when the top place can be shared; the
/// shortfall is [`Prediction::shared_first_place`].
#[must_use]
pub fn win_probabilities(&self) -> Vec<f64> {
let n = self.outcomes.first().map_or(0, |(r, _)| r.len());
let mut wins = vec![0.0; n];
for (ranks, p) in &self.outcomes {
let leaders = ranks.iter().filter(|&&r| r == 0).count();
if leaders == 1 {
let winner = ranks.iter().position(|&r| r == 0).expect("a rank-0 team");
wins[winner] += p;
}
}
wins
}
/// Probability that two or more teams share first place.
#[must_use]
pub fn shared_first_place(&self) -> f64 {
self.outcomes
.iter()
.filter(|(r, _)| r.iter().filter(|&&x| x == 0).count() > 1)
.map(|(_, p)| p)
.sum()
}
/// Total probability mass, which should be one.
///
/// Exposed because it is a genuine check on the numerics rather than a
/// formality: the outcome space is exhaustive and disjoint by construction,
/// so any drift from one is integration error and nothing else.
#[must_use]
pub fn total(&self) -> f64 {
self.outcomes.iter().map(|(_, p)| p).sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn g(mu: f64, sigma: f64) -> Gaussian {
Gaussian::from_ms(mu, sigma)
}
fn flat(n: usize, eps: f64) -> Margins {
Margins::new(n, |_, _| eps)
}
/// Exact two-team result: `P(a first) = Phi((mu_a - mu_b - eps) / sd)`.
fn closed_form_two(a: Gaussian, b: Gaussian, eps: f64) -> (f64, f64) {
let sd = a.sigma().hypot(b.sigma());
(
phi((a.mu() - b.mu() - eps) / sd),
phi((b.mu() - a.mu() - eps) / sd),
)
}
#[test]
fn two_team_win_probabilities_match_the_closed_form() {
for (ma, sa, mb, sb, eps) in [
(0.0, 6.0, 0.0, 6.0, 0.0),
(3.0, 6.0, -2.0, 1.0, 0.0),
(0.0, 6.0, 0.0, 6.0, 2.0),
(3.0, 6.0, -2.0, 1.0, 1.5),
(40.0, 1.0, 0.0, 1.0, 0.0),
] {
let perf = [g(ma, sa), g(mb, sb)];
let got = win_probabilities(&perf, &flat(2, eps));
let (wa, wb) = closed_form_two(perf[0], perf[1], eps);
assert!(
(got[0] - wa).abs() < 1e-12 && (got[1] - wb).abs() < 1e-12,
"mu=({ma},{mb}) sigma=({sa},{sb}) eps={eps}: got {got:?}, want [{wa}, {wb}]"
);
}
}
/// The identity that a wrong-but-plausible implementation cannot fake:
/// with no draw margin, exactly one team finishes first.
#[test]
fn win_probabilities_sum_to_one_without_a_draw_margin() {
for perf in [
vec![g(0.0, 6.0), g(0.0, 6.0)],
vec![g(5.0, 6.0), g(0.0, 3.0), g(-5.0, 1.0)],
vec![
g(8.0, 2.0),
g(3.0, 6.0),
g(0.0, 1.0),
g(-3.0, 4.0),
g(-8.0, 6.0),
],
] {
let sum: f64 = win_probabilities(&perf, &flat(perf.len(), 0.0))
.iter()
.sum();
assert!(
(sum - 1.0).abs() < 1e-7,
"{} teams: sum = {sum}",
perf.len()
);
}
}
/// A rival with a tiny sigma is a step function in disguise. Fixed-node
/// quadrature steps over it and lands ~1e-2 out while still looking like a
/// probability; this is the case that rules that approach out.
#[test]
fn win_probabilities_survive_a_rival_with_a_tiny_sigma() {
let perf = [g(0.0, 0.001), g(0.5, 6.0), g(-0.5, 6.0)];
let got = win_probabilities(&perf, &flat(3, 0.0));
let sum: f64 = got.iter().sum();
assert!((sum - 1.0).abs() < 1e-6, "sum = {sum}, probs = {got:?}");
}
#[test]
fn a_stronger_team_is_more_likely_to_win() {
let perf = [g(10.0, 3.0), g(0.0, 3.0), g(-10.0, 3.0)];
let p = win_probabilities(&perf, &flat(3, 0.0));
assert!(p[0] > p[1] && p[1] > p[2], "not monotone: {p:?}");
}
#[test]
fn identical_teams_are_equally_likely_to_win() {
let perf = [g(1.0, 4.0), g(1.0, 4.0), g(1.0, 4.0)];
let p = win_probabilities(&perf, &flat(3, 0.0));
for probs in p.windows(2) {
assert!((probs[0] - probs[1]).abs() < 1e-9, "asymmetric: {p:?}");
}
}
/// Every realisation sorts into exactly one finishing order, so the whole
/// distribution must sum to one — with or without a draw margin.
#[test]
fn outcome_distribution_sums_to_one() {
for (perf, eps) in [
(vec![g(0.0, 6.0), g(0.0, 6.0)], 0.0),
(vec![g(0.0, 6.0), g(0.0, 6.0)], 2.0),
(vec![g(0.0, 6.0), g(0.0, 6.0), g(0.0, 6.0)], 0.0),
(vec![g(5.0, 6.0), g(0.0, 3.0), g(-5.0, 1.0)], 1.5),
(vec![g(0.0, 0.05), g(0.5, 6.0), g(-0.5, 6.0)], 1.0),
(
vec![g(6.0, 2.0), g(2.0, 6.0), g(-2.0, 1.0), g(-6.0, 4.0)],
1.0,
),
] {
let n = perf.len();
let dist = outcome_distribution(&perf, &flat(n, eps)).unwrap();
let sum: f64 = dist.iter().map(|(_, p)| p).sum();
assert!(
(sum - 1.0).abs() < 1e-6,
"{n} teams, eps={eps}: sum = {sum} over {} outcomes",
dist.len()
);
assert!(dist.iter().all(|(_, p)| *p >= 0.0), "negative probability");
}
}
/// With two teams the distribution is the exact win/draw/loss triple.
#[test]
fn two_team_distribution_matches_the_closed_form() {
let perf = [g(3.0, 6.0), g(-2.0, 1.0)];
let eps = 1.5;
let dist = outcome_distribution(&perf, &flat(2, eps)).unwrap();
let (wa, wb) = closed_form_two(perf[0], perf[1], eps);
let find = |ranks: &[u32]| {
dist.iter()
.find(|(r, _)| r == ranks)
.map_or(0.0, |(_, p)| *p)
};
assert!(
(find(&[0, 1]) - wa).abs() < 1e-6,
"a wins: {}",
find(&[0, 1])
);
assert!(
(find(&[1, 0]) - wb).abs() < 1e-6,
"b wins: {}",
find(&[1, 0])
);
assert!(
(find(&[0, 0]) - (1.0 - wa - wb)).abs() < 1e-6,
"draw: {}",
find(&[0, 0])
);
}
/// Asking for one ranking must agree with that ranking's entry in the
/// full distribution — the two use different code paths to the same value.
#[test]
fn ranking_probability_agrees_with_the_distribution() {
let perf = [g(5.0, 6.0), g(0.0, 3.0), g(-5.0, 1.0)];
let eps = 1.5;
let margins = flat(3, eps);
let dist = outcome_distribution(&perf, &margins).unwrap();
for (ranks, expected) in &dist {
let direct = ranking_probability(&perf, &margins, ranks).unwrap();
assert!(
(direct - expected).abs() < 1e-9,
"ranks {ranks:?}: direct {direct} vs distribution {expected}"
);
}
}
/// Tie mass is controlled by the draw margin. Only the *all-tied* outcome
/// is monotone in it: every one of its constraints is a window that widens
/// with the margin. A partially-tied outcome like `[0, 0, 1]` is not, and
/// must not be asserted to be — widening the margin makes its tie easier
/// but its "and the last team is strictly behind by more than the margin"
/// clause harder, so it peaks and then falls.
#[test]
fn all_tied_probability_grows_with_the_draw_margin() {
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
let mut previous = 0.0;
for eps in [0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 24.0] {
let p = ranking_probability(&perf, &flat(3, eps), &[0, 0, 0]).unwrap();
assert!(p >= previous, "eps={eps}: {p} < {previous}");
if eps == 0.0 {
assert!(p < 1e-12, "a tie needs a margin, got {p}");
}
previous = p;
}
assert!(
previous > 0.9,
"a very wide margin ties everyone: {previous}"
);
}
/// The converse, stated as the non-property it is: a partially-tied
/// outcome is non-monotone in the margin. Pinning this down stops a future
/// change from "fixing" it into monotonicity and quietly breaking the model.
#[test]
fn a_partially_tied_outcome_peaks_in_the_middle() {
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
let sweep: Vec<f64> = [0.5, 2.0, 4.0, 8.0, 16.0]
.iter()
.map(|&eps| ranking_probability(&perf, &flat(3, eps), &[0, 0, 1]).unwrap())
.collect();
let peak = sweep
.iter()
.enumerate()
.fold(
(0, 0.0),
|(bi, bv), (i, &v)| if v > bv { (i, v) } else { (bi, bv) },
)
.0;
assert!(
peak > 0 && peak < sweep.len() - 1,
"expected an interior peak: {sweep:?}"
);
}
/// With no draw margin a tie has probability exactly zero, and the
/// enumeration must not waste work pretending otherwise.
#[test]
fn ties_are_impossible_without_a_draw_margin() {
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(0.0, 4.0)];
let dist = outcome_distribution(&perf, &flat(3, 0.0)).unwrap();
assert_eq!(dist.len(), 6, "expected only the 6 strict orders: {dist:?}");
assert!(dist.iter().all(|(r, _)| {
let mut seen = r.clone();
seen.sort_unstable();
seen.dedup();
seen.len() == r.len()
}));
}
}
+322
View File
@@ -0,0 +1,322 @@
//! Deterministic numerical integration for the prediction paths.
//!
//! Prediction asks two questions that have no closed form beyond two teams:
//! "who finishes first" and "how likely is this exact finishing order". Both
//! reduce to integrals over a single performance variable, so neither needs a
//! sampler — and that matters, because a Monte Carlo predictor would make
//! `predict_*` non-reproducible and would answer a slightly different question
//! on every call.
//!
//! Two routines live here:
//!
//! - [`integrate`], adaptive Gauss-Kronrod G7-K15, for the first-place
//! marginals. It carries its own error estimate, so it can refine where the
//! integrand actually bends instead of guessing a node count up front.
//! - [`Grid`], a uniform grid with trapezoid prefix sums, for the ranking
//! chain recursion, where each level needs the *running* integral of the
//! level below at arbitrary points rather than one definite integral.
//!
//! Fixed-node Gauss-Hermite is the obvious tool for the first of these and is
//! a trap: the integrand is a product of normal CDFs, and when one team's
//! sigma is much smaller than the integrating team's, that product turns into
//! a near-step function narrower than the node spacing. The nodes step over
//! it and the result is wrong by ~1e-2 while still looking like a probability.
//! Adaptive refinement is what makes the small-sigma case safe.
/// Kronrod 15-point abscissae, non-negative half, descending.
const XGK: [f64; 8] = [
0.991_455_371_120_813,
0.949_107_912_342_759,
0.864_864_423_359_769,
0.741_531_185_599_394,
0.586_087_235_467_691,
0.405_845_151_377_397,
0.207_784_955_007_898,
0.0,
];
/// Kronrod 15-point weights, matching [`XGK`].
const WGK: [f64; 8] = [
0.022_935_322_010_529,
0.063_092_092_629_979,
0.104_790_010_322_250,
0.140_653_259_715_525,
0.169_004_726_639_267,
0.190_350_578_064_785,
0.204_432_940_075_298,
0.209_482_141_084_728,
];
/// Gauss 7-point weights, applying to the odd-indexed [`XGK`] entries.
const WG: [f64; 4] = [
0.129_484_966_168_870,
0.279_705_391_489_277,
0.381_830_050_505_119,
0.417_959_183_673_469,
];
/// Panels are bisected worst-first; this bounds the work on a pathological
/// integrand rather than letting it spin.
const MAX_SUBDIVISIONS: usize = 200;
/// One G7-K15 panel over `[a, b]`: `(integral, absolute error estimate)`.
///
/// The error estimate is the gap between the embedded 7-point Gauss rule and
/// the 15-point Kronrod extension. It is the only reason this is preferable
/// to a fixed rule: it tells the caller *where* the integrand is hard.
fn gk15<F: Fn(f64) -> f64>(f: &F, a: f64, b: f64) -> (f64, f64) {
let centre = 0.5 * (a + b);
let half = 0.5 * (b - a);
let mut kronrod = 0.0;
let mut gauss = 0.0;
for i in 0..8 {
let offset = XGK[i] * half;
// XGK[7] is the centre node and must not be counted twice.
let sum = if i == 7 {
f(centre)
} else {
f(centre - offset) + f(centre + offset)
};
kronrod += WGK[i] * sum;
if i % 2 == 1 {
gauss += WG[i / 2] * sum;
}
}
(kronrod * half, ((kronrod - gauss) * half).abs())
}
/// Adaptively integrate `f` over `[a, b]` to relative tolerance `tol`.
///
/// `seeds` are interior points where the integrand is known to bend sharply —
/// for a product of normal CDFs, each rival's transition centre. Splitting
/// there up front costs nothing and saves the adaptive loop from having to
/// discover a step by bisection.
///
/// Returns the integral. The error estimate is consumed internally rather
/// than returned: callers here integrate probability densities, where the
/// meaningful check is the sum-to-one identity over a whole outcome space,
/// not a per-integral residual.
pub(crate) fn integrate<F: Fn(f64) -> f64>(f: F, a: f64, b: f64, seeds: &[f64], tol: f64) -> f64 {
// Explicit rather than `!(b > a)`: a NaN bound must fall through to zero
// rather than being read as a valid ordering.
if a.partial_cmp(&b) != Some(std::cmp::Ordering::Less) {
return 0.0;
}
let mut edges: Vec<f64> = Vec::with_capacity(seeds.len() + 2);
edges.push(a);
edges.push(b);
for &s in seeds {
if s > a && s < b {
edges.push(s);
}
}
edges.sort_by(|p, q| p.partial_cmp(q).expect("integration bounds are finite"));
edges.dedup();
// (lo, hi, integral, error)
let mut panels: Vec<(f64, f64, f64, f64)> = edges
.windows(2)
.map(|w| {
let (v, e) = gk15(&f, w[0], w[1]);
(w[0], w[1], v, e)
})
.collect();
for _ in 0..MAX_SUBDIVISIONS {
let total: f64 = panels.iter().map(|p| p.2).sum();
let error: f64 = panels.iter().map(|p| p.3).sum();
// Absolute floor as well as relative: these integrands are
// probabilities, so an absolute 1e-15 is already past the useful
// precision of the underlying `cdf`.
if error <= tol * total.abs().max(1e-12) || error < 1e-15 {
break;
}
let worst = panels
.iter()
.enumerate()
.fold((0usize, f64::NEG_INFINITY), |(bi, be), (i, p)| {
if p.3 > be { (i, p.3) } else { (bi, be) }
})
.0;
let (lo, hi, _, _) = panels[worst];
let mid = 0.5 * (lo + hi);
// Bisection has hit the floating-point floor; refining further would
// loop without reducing the error.
if !(mid > lo && mid < hi) {
break;
}
let (v1, e1) = gk15(&f, lo, mid);
let (v2, e2) = gk15(&f, mid, hi);
panels[worst] = (lo, mid, v1, e1);
panels.push((mid, hi, v2, e2));
}
panels.iter().map(|p| p.2).sum()
}
/// A uniform grid carrying trapezoid prefix sums of one integrand.
///
/// The ranking recursion needs, at every level, the running integral of the
/// level below evaluated at arbitrary points — a cumulative integral, not a
/// definite one. Prefix sums give that in O(1) per query after an O(G) build,
/// which is what keeps a full ranking probability linear in the team count.
pub(crate) struct Grid {
lo: f64,
step: f64,
/// Integrand sampled at each node.
values: Vec<f64>,
/// `prefix[i]` is the integral from `lo` to node `i`.
prefix: Vec<f64>,
}
impl Grid {
/// Build directly from already-sampled values.
///
/// The ranking recursion evaluates every level on the same nodes, so the
/// per-team densities are sampled once and reused; re-evaluating `exp`
/// per level would dominate the cost.
pub(crate) fn from_values(lo: f64, step: f64, values: Vec<f64>) -> Self {
let mut prefix = vec![0.0; values.len()];
for i in 1..values.len() {
prefix[i] = prefix[i - 1] + 0.5 * step * (values[i - 1] + values[i]);
}
Self {
lo,
step,
values,
prefix,
}
}
/// Integral from the grid's lower bound up to `x`.
///
/// Clamped at both ends: the caller sizes the grid to cover the whole
/// support, so a query outside it is asking for a tail that is zero (below)
/// or the whole mass (above).
pub(crate) fn integral_to(&self, x: f64) -> f64 {
let last = self.values.len() - 1;
if x <= self.lo {
return 0.0;
}
if x >= self.lo + last as f64 * self.step {
return self.prefix[last];
}
let scaled = (x - self.lo) / self.step;
let i = scaled.floor() as usize;
let frac = scaled - i as f64;
// Whole cells, plus the trapezoid over the partial cell. The integrand
// is linear within a cell under the trapezoid rule, so the partial
// piece is exact with respect to that same approximation.
self.prefix[i]
+ frac
* self.step
* (self.values[i] + 0.5 * frac * (self.values[i + 1] - self.values[i]))
}
/// Integral over `[from, to]`.
pub(crate) fn integral_between(&self, from: f64, to: f64) -> f64 {
(self.integral_to(to) - self.integral_to(from)).max(0.0)
}
/// Total integral over the whole grid.
pub(crate) fn total(&self) -> f64 {
self.prefix[self.values.len() - 1]
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOL: f64 = 1e-10;
/// Sample `f` over `[lo, hi]` at `points` nodes.
fn sample<F: FnMut(f64) -> f64>(lo: f64, hi: f64, points: usize, mut f: F) -> Grid {
let step = (hi - lo) / (points - 1) as f64;
Grid::from_values(
lo,
step,
(0..points).map(|i| f(lo + i as f64 * step)).collect(),
)
}
#[test]
fn integrates_a_polynomial_exactly() {
// G7-K15 is exact for polynomials well past cubic, so a single panel
// should already be at round-off.
let v = integrate(|x| 3.0 * x * x + 2.0 * x + 1.0, 0.0, 2.0, &[], TOL);
assert!((v - 14.0).abs() < 1e-12, "got {v}");
}
#[test]
fn integrates_a_gaussian_density_to_one() {
let f = |x: f64| (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt();
let v = integrate(f, -10.0, 10.0, &[], TOL);
assert!((v - 1.0).abs() < 1e-12, "got {v}");
}
#[test]
fn resolves_a_step_far_narrower_than_the_initial_panel() {
// The failure mode that rules out fixed-node quadrature: a transition
// 1e-4 wide inside a range of 20. A fixed rule steps over it.
let f = |x: f64| if x < 0.5 { 0.0 } else { 1.0 };
let v = integrate(f, -10.0, 10.0, &[0.5], TOL);
assert!((v - 9.5).abs() < 1e-6, "got {v}");
}
#[test]
fn seeds_do_not_change_the_value_of_a_smooth_integrand() {
let f = |x: f64| (-0.5 * x * x).exp();
let plain = integrate(f, -8.0, 8.0, &[], TOL);
let seeded = integrate(f, -8.0, 8.0, &[-3.0, 0.25, 5.5], TOL);
assert!((plain - seeded).abs() < 1e-12, "{plain} vs {seeded}");
}
#[test]
fn empty_or_inverted_range_integrates_to_zero() {
assert_eq!(integrate(|_| 1.0, 1.0, 1.0, &[], TOL), 0.0);
assert_eq!(integrate(|_| 1.0, 2.0, 1.0, &[], TOL), 0.0);
}
#[test]
fn grid_prefix_matches_a_known_cumulative_integral() {
// f(x) = x over [0, 4]; integral to x is x^2/2.
let g = sample(0.0, 4.0, 4001, |x| x);
for probe in [0.0, 0.5, 1.0, 2.5, 3.75, 4.0] {
let want = probe * probe / 2.0;
let got = g.integral_to(probe);
assert!(
(got - want).abs() < 1e-9,
"at {probe}: got {got}, want {want}"
);
}
assert!((g.total() - 8.0).abs() < 1e-9);
}
#[test]
fn grid_between_is_the_difference_of_two_prefixes() {
let g = sample(-5.0, 5.0, 8001, |x| (-0.5 * x * x).exp());
let whole = g.integral_between(-5.0, 5.0);
let split = g.integral_between(-5.0, 0.3) + g.integral_between(0.3, 5.0);
assert!((whole - split).abs() < 1e-12, "{whole} vs {split}");
}
#[test]
fn grid_clamps_queries_outside_its_support() {
let g = sample(0.0, 1.0, 101, |_| 1.0);
assert_eq!(g.integral_to(-3.0), 0.0);
assert!((g.integral_to(9.0) - 1.0).abs() < 1e-12);
// Reversed bounds must not produce negative probability mass.
assert_eq!(g.integral_between(0.8, 0.2), 0.0);
}
}
+75 -4
View File
@@ -9,26 +9,96 @@ use crate::{
/// Static rating configuration: prior skill, performance noise `beta`, drift. /// Static rating configuration: prior skill, performance noise `beta`, drift.
/// ///
/// Renamed from `Player` in T2; `Rating` better describes the data /// A configuration rather than a person: the per-history temporal state
/// (a configuration) vs. a person (who's a `Competitor` with state). /// (messages, last appearance) lives on `Competitor`.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> { pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
pub(crate) prior: Gaussian, pub(crate) prior: Gaussian,
pub(crate) beta: f64, pub(crate) beta: f64,
pub(crate) drift: D, pub(crate) drift: D,
/// Multiplier on the drift *variance* this competitor accumulates; 1.0 is
/// the neutral default. Set per competitor via `Member::with_drift_scale`.
pub(crate) drift_scale: f64,
pub(crate) _time: PhantomData<T>, pub(crate) _time: PhantomData<T>,
} }
impl<T: Time, D: Drift<T>> Rating<T, D> { impl<T: Time, D: Drift<T>> Rating<T, D> {
/// # Panics
///
/// Panics unless `beta` is finite and non-negative, matching
/// `HistoryBuilder::beta`.
///
/// Zero is allowed and meaningful — performance is then exactly skill, and
/// the fit differs measurably from a positive beta rather than degenerating.
/// Negative is rejected because `beta` enters only as `beta^2`: measured, a
/// negative beta returned results **bit identical** to its absolute value,
/// and a NaN beta reached `Game::ranked`, which returned `Ok` carrying a
/// `Gaussian { pi: NaN, tau: NaN }` — there is no `converge` on that path to
/// catch it.
pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self { pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self {
assert!(
beta.is_finite() && beta >= 0.0,
"beta must be finite and non-negative (got {beta}); it is only ever \
squared, so a negative value would silently behave as its absolute value"
);
Self { Self {
prior, prior,
beta, beta,
drift, drift,
drift_scale: 1.0,
_time: PhantomData, _time: PhantomData,
} }
} }
/// Scale how fast this competitor drifts, relative to `drift`.
///
/// Multiplies the drift *variance*, so the scale is in the same units as
/// `gamma`. `0.0` pins the competitor still.
#[must_use]
pub fn with_drift_scale(mut self, drift_scale: f64) -> Self {
self.drift_scale = drift_scale;
self
}
/// The configured prior skill estimate.
pub fn prior(&self) -> Gaussian {
self.prior
}
/// Performance noise: how much a single showing varies around the skill.
#[must_use]
pub fn beta(&self) -> f64 {
self.beta
}
/// The drift model governing how skill may move between events.
#[must_use]
pub fn drift(&self) -> D {
self.drift
}
/// This competitor's multiplier on the drift variance; 1.0 is neutral.
#[must_use]
pub fn drift_scale(&self) -> f64 {
self.drift_scale
}
/// Drift variance accumulated over `from -> to`, scaled for this competitor.
///
/// The single place the scale is applied for a `Time`-typed span. Callers
/// must go through this rather than `self.drift` directly, so a competitor's
/// scale cannot be silently skipped.
pub(crate) fn drift_variance_delta(&self, from: &T, to: &T) -> f64 {
self.drift.variance_delta(from, to) * self.drift_scale * self.drift_scale
}
/// Drift variance for a cached elapsed count, scaled for this competitor.
///
/// The counterpart of `drift_variance_delta` for the cached-elapsed paths.
pub(crate) fn drift_variance_for_elapsed(&self, elapsed: i64) -> f64 {
self.drift.variance_for_elapsed(elapsed) * self.drift_scale * self.drift_scale
}
pub(crate) fn performance(&self) -> Gaussian { pub(crate) fn performance(&self) -> Gaussian {
self.prior.forget(self.beta.powi(2)) self.prior.forget(self.beta.powi(2))
} }
@@ -39,7 +109,8 @@ impl Default for Rating<i64, ConstantDrift> {
Self { Self {
prior: Gaussian::default(), prior: Gaussian::default(),
beta: BETA, beta: BETA,
drift: ConstantDrift(GAMMA), drift: ConstantDrift::new(GAMMA),
drift_scale: 1.0,
_time: PhantomData, _time: PhantomData,
} }
} }
+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)
}
}
-126
View File
@@ -1,126 +0,0 @@
//! Schedule trait and built-in implementations.
//!
//! A schedule drives factor propagation to convergence. The default
//! `EpsilonOrMax` performs one TeamSum sweep (setup) then alternating
//! forward/backward sweeps over the iterating factors until the max
//! delta drops below epsilon or `max` iterations is reached.
use crate::factor::{BuiltinFactor, Factor, VarStore};
/// Result returned by a `Schedule::run` call.
#[derive(Debug, Clone, Copy)]
pub struct ScheduleReport {
pub iterations: usize,
pub final_step: (f64, f64),
pub converged: bool,
}
/// Drives factor propagation to convergence.
pub trait Schedule: Send + Sync {
fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport;
}
/// Default schedule: sweep forward then backward until step ≤ eps or iter == max.
///
/// Matches the existing `Game::likelihoods` loop bit-for-bit when given the
/// same factor layout (TeamSums first, then alternating RankDiff/Trunc pairs).
#[derive(Debug, Clone, Copy)]
pub struct EpsilonOrMax {
pub eps: f64,
pub max: usize,
}
impl Default for EpsilonOrMax {
fn default() -> Self {
// Matches today's hard-coded tolerance and iteration cap.
Self { eps: 1e-6, max: 10 }
}
}
impl Schedule for EpsilonOrMax {
fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport {
// Partition: leading run of TeamSum factors run exactly once (setup).
let n_setup = factors
.iter()
.position(|f| !matches!(f, BuiltinFactor::TeamSum(_)))
.unwrap_or(factors.len());
for f in factors[..n_setup].iter_mut() {
f.propagate(vars);
}
let mut iterations = 0;
let mut final_step = (f64::INFINITY, f64::INFINITY);
let mut converged = false;
if n_setup < factors.len() {
for _ in 0..self.max {
let mut step = (0.0_f64, 0.0_f64);
// Forward sweep over iterating factors.
for f in factors[n_setup..].iter_mut() {
let d = f.propagate(vars);
step.0 = step.0.max(d.0);
step.1 = step.1.max(d.1);
}
// Backward sweep.
for f in factors[n_setup..].iter_mut().rev() {
let d = f.propagate(vars);
step.0 = step.0.max(d.0);
step.1 = step.1.max(d.1);
}
iterations += 1;
final_step = step;
if step.0 <= self.eps && step.1 <= self.eps {
converged = true;
break;
}
}
}
ScheduleReport {
iterations,
final_step,
converged,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{N_INF, factor::team_sum::TeamSumFactor, gaussian::Gaussian};
#[test]
fn schedule_runs_setup_factors_once() {
// Single TeamSum factor; schedule should propagate it exactly once and report 0 iterations.
let mut vars = VarStore::new();
let out = vars.alloc(N_INF);
let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor {
inputs: vec![(Gaussian::from_ms(5.0, 1.0), 1.0)],
out,
})];
let schedule = EpsilonOrMax::default();
let report = schedule.run(&mut factors, &mut vars);
assert_eq!(report.iterations, 0);
// The team-perf var should hold the sum.
let result = vars.get(out);
assert!((result.mu() - 5.0).abs() < 1e-12);
}
#[test]
fn report_marks_converged_when_no_iterating_factors() {
// No iterating factors → 0 iterations, converged stays false (loop never ran).
let mut vars = VarStore::new();
let out = vars.alloc(N_INF);
let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor {
inputs: vec![(Gaussian::from_ms(0.0, 1.0), 1.0)],
out,
})];
let report = EpsilonOrMax::default().run(&mut factors, &mut vars);
assert_eq!(report.iterations, 0);
}
}
+10 -12
View File
@@ -2,7 +2,7 @@ use crate::{Index, competitor::Competitor, drift::Drift, time::Time};
/// Dense Vec-backed store for competitor state in History. /// Dense Vec-backed store for competitor state in History.
/// ///
/// Indexed directly by Index.0, eliminating HashMap hashing in the /// Indexed directly by Index.0, eliminating `HashMap` hashing in the
/// forward/backward sweep. Uses `Vec<Option<Competitor<T, D>>>` so slots can be /// forward/backward sweep. Uses `Vec<Option<Competitor<T, D>>>` so slots can be
/// absent without an explicit present mask. /// absent without an explicit present mask.
#[derive(Debug)] #[derive(Debug)]
@@ -21,6 +21,7 @@ impl<T: Time, D: Drift<T>> Default for CompetitorStore<T, D> {
} }
impl<T: Time, D: Drift<T>> CompetitorStore<T, D> { impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
#[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
@@ -39,6 +40,7 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
self.competitors[idx.0] = Some(competitor); self.competitors[idx.0] = Some(competitor);
} }
#[must_use]
pub fn get(&self, idx: Index) -> Option<&Competitor<T, D>> { pub fn get(&self, idx: Index) -> Option<&Competitor<T, D>> {
self.competitors.get(idx.0).and_then(|slot| slot.as_ref()) self.competitors.get(idx.0).and_then(|slot| slot.as_ref())
} }
@@ -49,18 +51,21 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
.and_then(|slot| slot.as_mut()) .and_then(|slot| slot.as_mut())
} }
#[must_use]
pub fn contains(&self, idx: Index) -> bool { pub fn contains(&self, idx: Index) -> bool {
self.get(idx).is_some() self.get(idx).is_some()
} }
/// Test-only: no code path in the crate needs a count.
#[cfg(test)]
#[must_use]
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.n_present self.n_present
} }
pub fn is_empty(&self) -> bool { /// Test-only: iterating every competitor is an assertion helper, not part
self.n_present == 0 /// of inference, which walks slices rather than the store.
} #[cfg(test)]
pub fn iter(&self) -> impl Iterator<Item = (Index, &Competitor<T, D>)> { pub fn iter(&self) -> impl Iterator<Item = (Index, &Competitor<T, D>)> {
self.competitors self.competitors
.iter() .iter()
@@ -68,13 +73,6 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
.filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a))) .filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a)))
} }
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Competitor<T, D>)> {
self.competitors
.iter_mut()
.enumerate()
.filter_map(|(i, slot)| slot.as_mut().map(|a| (Index(i), a)))
}
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut Competitor<T, D>> { pub fn values_mut(&mut self) -> impl Iterator<Item = &mut Competitor<T, D>> {
self.competitors.iter_mut().filter_map(|s| s.as_mut()) self.competitors.iter_mut().filter_map(|s| s.as_mut())
} }
+110 -51
View File
@@ -1,15 +1,27 @@
use std::collections::HashMap;
use crate::{Index, time_slice::Skill}; use crate::{Index, time_slice::Skill};
/// Dense Vec-backed store for per-agent skill state within a TimeSlice. /// Compact per-slice store for skill state, addressed by a slice-local slot.
/// ///
/// Indexed directly by Index.0, eliminating HashMap hashing in the inner /// `skills` holds one entry per competitor **in this slice**, so memory is
/// convergence loop. Uses a parallel `present` mask so iteration skips /// O(competitors in the slice). It used to be a dense `Vec<Skill>` indexed by
/// absent slots without incurring per-slot Option overhead in the hot path. /// the global `Index.0`, which made a slice's footprint O(largest index it
/// touches): a single 1v1 game between competitors 19998 and 19999 reserved
/// 20,000 slots.
///
/// The dense layout existed to keep `HashMap` hashing out of the inner
/// convergence loop, and that property is preserved. `slots` is consulted only
/// while building a slice; every hot-path access goes through
/// [`SkillStore::at`] / [`SkillStore::at_mut`] with a slot resolved once at
/// ingestion and cached on the event's `Item`.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct SkillStore { pub struct SkillStore {
skills: Vec<Skill>, skills: Vec<Skill>,
present: Vec<bool>, /// Slot -> global index, parallel to `skills`, so iteration can report the
n_present: usize, /// global index without a reverse lookup.
indices: Vec<Index>,
slots: HashMap<Index, u32>,
} }
impl SkillStore { impl SkillStore {
@@ -17,76 +29,99 @@ impl SkillStore {
Self::default() Self::default()
} }
fn ensure_capacity(&mut self, idx: usize) { /// Resolve a global index to this slice's slot, if the competitor is here.
if idx >= self.skills.len() { ///
self.skills.resize_with(idx + 1, Skill::default); /// This hashes. Call it at ingestion and cache the result; do not call it
self.present.resize(idx + 1, false); /// from the convergence loop.
} pub fn slot_of(&self, idx: Index) -> Option<u32> {
self.slots.get(&idx).copied()
} }
pub fn insert(&mut self, idx: Index, skill: Skill) { /// Skill at a slot resolved earlier by [`SkillStore::slot_of`].
self.ensure_capacity(idx.0); ///
if !self.present[idx.0] { /// # Panics
self.n_present += 1; ///
/// Panics if `slot` is out of range, which means it came from a different
/// slice's store.
pub fn at(&self, slot: u32) -> &Skill {
&self.skills[slot as usize]
}
/// Mutable counterpart to [`SkillStore::at`].
///
/// # Panics
///
/// Panics if `slot` is out of range.
pub fn at_mut(&mut self, slot: u32) -> &mut Skill {
&mut self.skills[slot as usize]
}
/// Insert or overwrite a competitor's skill, returning its slot.
pub fn insert(&mut self, idx: Index, skill: Skill) -> u32 {
match self.slots.get(&idx) {
Some(&slot) => {
self.skills[slot as usize] = skill;
slot
}
None => {
let slot = u32::try_from(self.skills.len())
.expect("a time slice cannot hold more than u32::MAX competitors");
self.skills.push(skill);
self.indices.push(idx);
self.slots.insert(idx, slot);
slot
}
} }
self.skills[idx.0] = skill;
self.present[idx.0] = true;
} }
pub fn get(&self, idx: Index) -> Option<&Skill> { pub fn get(&self, idx: Index) -> Option<&Skill> {
if idx.0 < self.present.len() && self.present[idx.0] { self.slot_of(idx).map(|slot| self.at(slot))
Some(&self.skills[idx.0])
} else {
None
}
} }
pub fn get_mut(&mut self, idx: Index) -> Option<&mut Skill> { pub fn get_mut(&mut self, idx: Index) -> Option<&mut Skill> {
if idx.0 < self.present.len() && self.present[idx.0] { self.slot_of(idx)
Some(&mut self.skills[idx.0]) .map(|slot| &mut self.skills[slot as usize])
} else {
None
}
} }
#[allow(dead_code)] /// Whether a competitor is present in this slice. Test-only.
#[cfg(test)]
pub fn contains(&self, idx: Index) -> bool { pub fn contains(&self, idx: Index) -> bool {
idx.0 < self.present.len() && self.present[idx.0] self.slots.contains_key(&idx)
} }
#[allow(dead_code)] /// Number of competitors in this slice. Test-only.
#[cfg(test)]
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.n_present self.skills.len()
} }
#[allow(dead_code)] /// Slots actually allocated — the quantity #17 is about, and NOT the same
pub fn is_empty(&self) -> bool { /// as `len` for every possible implementation.
self.n_present == 0 ///
/// A store indexed by the global `Index` must report `max_index + 1` here
/// while reporting the true competitor count from `len`, which is exactly
/// how the original defect hid. Tests that mean to pin the footprint must
/// assert on this.
#[cfg(test)]
pub fn allocated_slots(&self) -> usize {
self.skills.len()
} }
/// Iterate in slot order — the order competitors were first seen in this
/// slice. Deterministic for a given event order, which is what the
/// cross-thread determinism test relies on.
pub fn iter(&self) -> impl Iterator<Item = (Index, &Skill)> { pub fn iter(&self) -> impl Iterator<Item = (Index, &Skill)> {
self.present.iter().enumerate().filter_map(|(i, &p)| { self.indices.iter().copied().zip(self.skills.iter())
if p {
Some((Index(i), &self.skills[i]))
} else {
None
}
})
} }
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Skill)> { pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Skill)> {
self.skills self.indices.iter().copied().zip(self.skills.iter_mut())
.iter_mut()
.zip(self.present.iter())
.enumerate()
.filter_map(|(i, (s, &p))| if p { Some((Index(i), s)) } else { None })
} }
pub fn keys(&self) -> impl Iterator<Item = Index> + '_ { pub fn keys(&self) -> impl Iterator<Item = Index> + '_ {
self.present self.indices.iter().copied()
.iter()
.enumerate()
.filter_map(|(i, &p)| if p { Some(Index(i)) } else { None })
} }
} }
@@ -112,7 +147,7 @@ mod tests {
} }
#[test] #[test]
fn iter_skips_absent_slots() { fn iter_reports_global_indices() {
let mut store = SkillStore::new(); let mut store = SkillStore::new();
store.insert(Index(0), Skill::default()); store.insert(Index(0), Skill::default());
store.insert(Index(5), Skill::default()); store.insert(Index(5), Skill::default());
@@ -127,4 +162,28 @@ mod tests {
store.insert(Index(2), Skill::default()); store.insert(Index(2), Skill::default());
assert_eq!(store.len(), 1); assert_eq!(store.len(), 1);
} }
/// The defect in #17: a slice holding two competitors must cost the same
/// whether their indices are small or large.
#[test]
fn footprint_is_independent_of_index_magnitude() {
let mut low = SkillStore::new();
low.insert(Index(0), Skill::default());
low.insert(Index(1), Skill::default());
let mut high = SkillStore::new();
high.insert(Index(19_998), Skill::default());
high.insert(Index(19_999), Skill::default());
assert_eq!(low.len(), high.len());
assert_eq!(low.skills.capacity(), high.skills.capacity());
}
#[test]
fn slot_survives_reinsert() {
let mut store = SkillStore::new();
let first = store.insert(Index(7), Skill::default());
let again = store.insert(Index(7), Skill::default());
assert_eq!(first, again);
}
} }
+533 -218
View File
File diff suppressed because it is too large Load Diff
+147
View File
@@ -0,0 +1,147 @@
//! What an additive model does to uncertainty, and why "add the marginals" is
//! unsafe in one direction and merely wasteful in the other.
//!
//! Structurally this is the shape a joint player/layout model takes: every
//! observation measures a *sum* of nodes against a reference, so the data pins
//! differences and leaves the overall level to the prior. That is the classic
//! rating-scale indeterminacy, not a defect.
//!
//! The consequence for a consumer is that combining marginals is wrong in
//! opposite directions depending on the combination, which is worth pinning
//! because the unsafe direction is not the one you would guess:
//!
//! - **Differences** (`a - b`): the shared level cancels, so the exact width is
//! small — and adding marginals lands within a couple of percent of it here,
//! because the loopy underestimate offsets the ignored correlation.
//! - **Sums** (`a + b`): the shared level does *not* cancel, so the exact width
//! is large, and adding marginals is roughly five times too narrow. That is
//! overconfident, and it is the direction that publishes a claim the data
//! does not support.
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
#[test]
fn additive_structure_makes_sums_wide_and_differences_tight() {
// Structurally like ustat: every round is (player + hole) measured against
// a fixed reference. Only SUMS are pinned by the data; the split between
// player and hole is pinned only by the prior.
let players = ["p0", "p1", "p2"];
let holes = ["h0", "h1"];
let mut h: History = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.0))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-12,
alpha: 1.0,
})
.build();
let mut seed = 3u64;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
// true skills, so we know what the data encodes
let truth_p = [2.0, 0.0, -2.0];
let truth_h = [1.0, -1.0];
let mut events = Vec::new();
for _ in 0..60 {
let p = (rnd() as usize) % 3;
let q = (rnd() as usize) % 2;
let noise = ((rnd() % 1000) as f64 / 1000.0 - 0.5) * 2.0;
let score = truth_p[p] + truth_h[q] + noise;
events.push(Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new(players[p]), Member::new(holes[q])]),
Team::with_members([Member::new("reference")]),
],
outcome: Outcome::scores([score, 0.0]),
});
}
h.add_events(events).unwrap();
let r = h.converge().unwrap();
assert!(r.converged, "{:?}", r.final_step);
println!("\n== marginals (what current_skill reports) ==");
for k in players.iter().chain(holes.iter()) {
let g = h.current_skill(k).unwrap();
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
}
println!("\n== the same nodes via posterior_of (exact marginal) ==");
for k in players.iter().chain(holes.iter()) {
let g = h.joint().unwrap().posterior_of(&[(k, 1.0)]).unwrap();
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
}
println!("\n== combinations the data actually pins ==");
for (label, terms) in [
("p0 + h0 (a round)", vec![(&"p0", 1.0), (&"h0", 1.0)]),
(
"p0 - p1 (rank two players)",
vec![(&"p0", 1.0), (&"p1", -1.0)],
),
("p0 - p2", vec![(&"p0", 1.0), (&"p2", -1.0)]),
(
"h0 - h1 (rank two holes)",
vec![(&"h0", 1.0), (&"h1", -1.0)],
),
] {
let joint = h.joint().unwrap().posterior_of(&terms).unwrap();
// what a consumer gets today by adding marginals
let naive: f64 = terms
.iter()
.map(|(k, c)| c * c * h.current_skill(*k).unwrap().sigma().powi(2))
.sum::<f64>()
.sqrt();
println!(
" {label:<28} exact sigma {:>7.4} adding marginals {:>7.4} {:>5.2}x over",
joint.sigma(),
naive,
naive / joint.sigma()
);
let ratio = naive / joint.sigma();
if label.contains('+') {
assert!(
ratio < 0.5,
"{label}: adding marginals should be badly OVERconfident for a \
sum, got {ratio:.3}x"
);
} else {
assert!(
(0.8..1.25).contains(&ratio),
"{label}: adding marginals happens to be close for a difference, \
got {ratio:.3}x"
);
}
}
// A single node in an additive model is weakly identified: its exact
// posterior is far wider than message passing reports, because the level it
// 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
.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 \
{bp} in an additive model"
);
}
}
+71 -24
View File
@@ -11,7 +11,7 @@ fn add_events_bulk_via_iter() {
.sigma(2.0) .sigma(2.0)
.beta(1.0) .beta(1.0)
.p_draw(0.0) .p_draw(0.0)
.drift(ConstantDrift(0.0)) .drift(ConstantDrift::new(0.0))
.convergence(ConvergenceOptions { .convergence(ConvergenceOptions {
max_iter: 30, max_iter: 30,
epsilon: 1e-6, epsilon: 1e-6,
@@ -41,9 +41,9 @@ fn add_events_bulk_via_iter() {
h.add_events(events).unwrap(); h.add_events(events).unwrap();
let report = h.converge().unwrap(); let report = h.converge().unwrap();
assert!(report.converged); assert!(report.converged);
assert!(h.lookup(&"a").is_some()); assert!(h.current_skill("a").is_some());
assert!(h.lookup(&"b").is_some()); assert!(h.current_skill("b").is_some());
assert!(h.lookup(&"c").is_some()); assert!(h.current_skill("c").is_some());
} }
#[test] #[test]
@@ -53,7 +53,7 @@ fn add_events_draw() {
.sigma(25.0 / 3.0) .sigma(25.0 / 3.0)
.beta(25.0 / 6.0) .beta(25.0 / 6.0)
.p_draw(0.25) .p_draw(0.25)
.drift(ConstantDrift(25.0 / 300.0)) .drift(ConstantDrift::new(25.0 / 300.0))
.build(); .build();
let events: Vec<Event<i64, &'static str>> = vec![Event { let events: Vec<Event<i64, &'static str>> = vec![Event {
@@ -65,7 +65,7 @@ fn add_events_draw() {
outcome: Outcome::draw(2), outcome: Outcome::draw(2),
}]; }];
h.add_events(events).unwrap(); h.add_events(events).unwrap();
h.converge().unwrap(); let _ = h.converge().unwrap();
} }
#[test] #[test]
@@ -103,9 +103,9 @@ fn fluent_event_builder_basic() {
let report = h.converge().unwrap(); let report = h.converge().unwrap();
assert!(report.converged); assert!(report.converged);
assert!(h.lookup(&"alice").is_some()); assert!(h.current_skill("alice").is_some());
assert!(h.lookup(&"bob").is_some()); assert!(h.current_skill("bob").is_some());
assert!(h.lookup(&"carol").is_some()); assert!(h.current_skill("carol").is_some());
} }
#[test] #[test]
@@ -123,7 +123,7 @@ fn fluent_event_builder_winner_convenience() {
.winner(0) .winner(0)
.commit() .commit()
.unwrap(); .unwrap();
h.converge().unwrap(); let _ = h.converge().unwrap();
} }
#[test] #[test]
@@ -141,7 +141,7 @@ fn fluent_event_builder_draw() {
.draw() .draw()
.commit() .commit()
.unwrap(); .unwrap();
h.converge().unwrap(); let _ = h.converge().unwrap();
} }
#[test] #[test]
@@ -155,14 +155,14 @@ fn current_skill_and_learning_curve() {
.build(); .build();
h.record_winner(&"a", &"b", 1).unwrap(); h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"a", &"b", 2).unwrap(); h.record_winner(&"a", &"b", 2).unwrap();
h.converge().unwrap(); let _ = h.converge().unwrap();
let a = h.current_skill(&"a").unwrap(); let a = h.current_skill(&"a").unwrap();
assert!(a.mu() > 25.0); assert!(a.mu() > 25.0);
let b = h.current_skill(&"b").unwrap(); let b = h.current_skill(&"b").unwrap();
assert!(b.mu() < 25.0); assert!(b.mu() < 25.0);
let a_curve = h.learning_curve(&"a"); let a_curve = h.learning_curve(&"a").unwrap();
assert_eq!(a_curve.len(), 2); assert_eq!(a_curve.len(), 2);
assert_eq!(a_curve[0].0, 1); assert_eq!(a_curve[0].0, 1);
assert_eq!(a_curve[1].0, 2); assert_eq!(a_curve[1].0, 2);
@@ -181,12 +181,12 @@ fn log_evidence_total_vs_subset() {
.sigma(6.0) .sigma(6.0)
.beta(1.0) .beta(1.0)
.p_draw(0.0) .p_draw(0.0)
.drift(ConstantDrift(0.0)) .drift(ConstantDrift::new(0.0))
.build(); .build();
h.record_winner(&"a", &"b", 1).unwrap(); h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"b", &"a", 2).unwrap(); h.record_winner(&"b", &"a", 2).unwrap();
let total = h.log_evidence(); let total = h.log_evidence();
let a_only = h.log_evidence_for(&[&"a"]); let a_only = h.log_evidence_for(&[&"a"]).unwrap();
assert!(total.is_finite()); assert!(total.is_finite());
assert!(a_only.is_finite()); assert!(a_only.is_finite());
} }
@@ -201,9 +201,9 @@ fn predict_quality_two_teams() {
.p_draw(0.0) .p_draw(0.0)
.build(); .build();
h.record_winner(&"a", &"b", 1).unwrap(); h.record_winner(&"a", &"b", 1).unwrap();
h.converge().unwrap(); let _ = h.converge().unwrap();
let q = h.predict_quality(&[&[&"a"], &[&"b"]]); let q = h.quality(&[&[&"a"], &[&"b"]]).unwrap();
assert!(q > 0.0 && q <= 1.0); assert!(q > 0.0 && q <= 1.0);
} }
@@ -217,12 +217,16 @@ fn predict_outcome_two_teams_sums_to_one() {
.p_draw(0.0) .p_draw(0.0)
.build(); .build();
h.record_winner(&"a", &"b", 1).unwrap(); h.record_winner(&"a", &"b", 1).unwrap();
h.converge().unwrap(); let _ = h.converge().unwrap();
let p = h.predict_outcome(&[&[&"a"], &[&"b"]]); let p = h.predict_outcome(&[&[&"a"], &[&"b"]]).unwrap();
assert_eq!(p.len(), 2); let wins = p.win_probabilities();
assert!((p[0] + p[1] - 1.0).abs() < 1e-9); assert_eq!(wins.len(), 2);
assert!(p[0] > p[1]); // With p_draw == 0 there is no draw outcome, so the two win
// probabilities are the whole space.
assert!((p.total() - 1.0).abs() < 1e-9, "total = {}", p.total());
assert!((wins[0] + wins[1] - 1.0).abs() < 1e-9);
assert!(wins[0] > wins[1]);
} }
#[test] #[test]
@@ -232,7 +236,7 @@ fn fluent_event_builder_scores() {
.mu(25.0) .mu(25.0)
.sigma(25.0 / 3.0) .sigma(25.0 / 3.0)
.beta(25.0 / 6.0) .beta(25.0 / 6.0)
.drift(ConstantDrift(0.0)) .drift(ConstantDrift::new(0.0))
.build(); .build();
h.event(1) h.event(1)
@@ -241,9 +245,52 @@ fn fluent_event_builder_scores() {
.scores([12.0, 4.0]) .scores([12.0, 4.0])
.commit() .commit()
.unwrap(); .unwrap();
h.converge().unwrap(); let _ = h.converge().unwrap();
let a = h.current_skill(&"alice").unwrap(); let a = h.current_skill(&"alice").unwrap();
let b = h.current_skill(&"bob").unwrap(); let b = h.current_skill(&"bob").unwrap();
assert!(a.mu() > b.mu()); assert!(a.mu() > b.mu());
} }
/// Every field of `ConvergenceReport` must carry real information.
///
/// `slices_skipped` was public, hardcoded to `0`, and reported a plausible
/// value for a feature that never existed — the same shape as the inert
/// `online` flag in #19. It was removed in #33. This pins the remaining fields
/// so the next always-constant member has to survive an assertion rather than
/// just a reviewer's attention.
#[test]
fn every_convergence_report_field_is_populated() {
let mut h = History::builder().build();
for time in 1..=6i64 {
h.record_winner(&"a", &"b", time).unwrap();
}
let report = h.converge().unwrap();
assert!(
report.iterations > 0,
"iterations is zero on a real converge"
);
assert!(report.converged, "fixture must converge");
assert!(
report.final_step.0.is_finite() && report.final_step.1.is_finite(),
"final_step is not finite: {:?}",
report.final_step
);
assert!(
report.log_evidence.is_finite() && report.log_evidence < 0.0,
"log_evidence is not a finite negative log probability: {}",
report.log_evidence
);
assert_eq!(
report.per_iteration_time.len(),
report.iterations,
"per_iteration_time must carry one duration per iteration"
);
}
+38
View File
@@ -0,0 +1,38 @@
//! Helpers shared across the integration suites.
//!
//! Each integration file is its own binary, so `mod common;` compiles a copy
//! per suite. Anything unused in a given suite would warn, hence the
//! `#![allow(dead_code)]`.
#![allow(dead_code)]
use trueskill_tt::Gaussian;
/// A posterior must be finite with a strictly positive sigma.
///
/// A non-finite posterior is the failure mode this crate is most prone to —
/// EP breaking down produces NaN rather than an error — and a zero or negative
/// sigma means the precision went non-positive, which `Gaussian::sigma` reports
/// as improper rather than trapping.
pub fn assert_finite(g: Gaussian, what: &str) {
assert!(
g.mu().is_finite(),
"{what}: mu is not finite (mu={}, sigma={})",
g.mu(),
g.sigma()
);
assert!(
g.sigma().is_finite() && g.sigma() > 0.0,
"{what}: sigma must be finite and positive (mu={}, sigma={})",
g.mu(),
g.sigma()
);
}
/// Every point on every learning curve must be finite.
pub fn assert_curve_finite(curve: &[(i64, Gaussian)], who: &str) {
for (time, g) in curve {
assert_finite(*g, &format!("{who} at t={time}"));
}
}
+225
View File
@@ -0,0 +1,225 @@
//! `Member::with_prior` / `with_drift_scale` — competitor configuration.
//!
//! Both were previously consumed only on the branch that *creates* a
//! competitor, so configuration supplied for a key the history already knew was
//! dropped with no error. `with_prior` had no coverage in this directory at
//! all, which is how that survived.
use smallvec::smallvec;
use trueskill_tt::{
ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome, Team,
};
const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
max_iter: 2_000,
epsilon: 1e-12,
alpha: 1.0,
};
fn history() -> History {
History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.convergence(CONVERGENCE)
.build()
}
/// One event, optionally configuring `a`.
fn bout(
a: &'static str,
b: &'static str,
time: i64,
prior: Option<Gaussian>,
scale: Option<f64>,
) -> Event<i64, &'static str> {
let mut member = Member::new(a);
if let Some(p) = prior {
member = member.with_prior(p);
}
if let Some(s) = scale {
member = member.with_drift_scale(s);
}
Event {
time,
teams: smallvec![
Team::with_members([member]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::winner(0, 2),
}
}
fn skill_of(h: &History, key: &str) -> Gaussian {
h.current_skill(&key).expect("key in history")
}
/// Baseline: the mechanism works at all on a competitor's first appearance.
#[test]
fn a_prior_applies_to_a_new_competitor() {
let seeded = Gaussian::from_ms(40.0, 1.0);
let mut with = history();
with.add_events(vec![bout("a", "b", 0, Some(seeded), None)])
.unwrap();
let _ = with.converge().unwrap();
let mut without = history();
without
.add_events(vec![bout("a", "b", 0, None, None)])
.unwrap();
let _ = without.converge().unwrap();
assert!(
(skill_of(&with, "a").mu() - skill_of(&without, "a").mu()).abs() > 1.0,
"a seeded prior should move the fit"
);
}
/// The defect in #10: a prior supplied for a competitor the history already
/// knows was silently discarded, and the caller got output computed from the
/// default prior with no indication anything had been dropped.
#[test]
fn a_prior_applies_to_a_competitor_the_history_already_knows() {
let seeded = Gaussian::from_ms(40.0, 1.0);
let mut late = history();
late.add_events(vec![bout("a", "b", 0, None, None)])
.unwrap();
// "a" now exists. Configuring it here used to do nothing whatsoever.
late.add_events(vec![bout("a", "b", 1, Some(seeded), None)])
.unwrap();
let _ = late.converge().unwrap();
let mut never = history();
never
.add_events(vec![
bout("a", "b", 0, None, None),
bout("a", "b", 1, None, None),
])
.unwrap();
let _ = never.converge().unwrap();
assert!(
(skill_of(&late, "a").mu() - skill_of(&never, "a").mu()).abs() > 1.0,
"a late prior must not be silently dropped: {} vs {}",
skill_of(&late, "a").mu(),
skill_of(&never, "a").mu()
);
}
/// Configuration is competitor-scoped, not event-scoped, and `converge` refits
/// from competitor state — so seeding late reaches the same fit as seeding from
/// the start. This is the documented scope, asserted rather than assumed.
#[test]
fn a_prior_is_whole_history_scoped_not_per_event() {
let seeded = Gaussian::from_ms(40.0, 1.0);
let mut late = history();
late.add_events(vec![bout("a", "b", 0, None, None)])
.unwrap();
late.add_events(vec![bout("a", "b", 1, Some(seeded), None)])
.unwrap();
let _ = late.converge().unwrap();
let mut early = history();
early
.add_events(vec![
bout("a", "b", 0, Some(seeded), None),
bout("a", "b", 1, Some(seeded), None),
])
.unwrap();
let _ = early.converge().unwrap();
let (l, e) = (skill_of(&late, "a"), skill_of(&early, "a"));
assert!(
(l.mu() - e.mu()).abs() < 1e-9 && (l.sigma() - e.sigma()).abs() < 1e-9,
"late seeding should refit the whole history: {l:?} vs {e:?}"
);
}
#[test]
fn repeating_the_same_prior_is_inert() {
let seeded = Gaussian::from_ms(40.0, 1.0);
let mut once = history();
once.add_events(vec![
bout("a", "b", 0, Some(seeded), None),
bout("a", "b", 1, None, None),
])
.unwrap();
let _ = once.converge().unwrap();
let mut every_time = history();
every_time
.add_events(vec![
bout("a", "b", 0, Some(seeded), None),
bout("a", "b", 1, Some(seeded), None),
])
.unwrap();
let _ = every_time.converge().unwrap();
let (o, e) = (skill_of(&once, "a"), skill_of(&every_time, "a"));
assert!(
(o.mu() - e.mu()).abs() < 1e-12 && (o.sigma() - e.sigma()).abs() < 1e-12,
"declaring the same prior repeatedly changed the fit: {o:?} vs {e:?}"
);
}
/// Events within a batch have no order, so two different values for one
/// competitor have no well-defined winner. Rejecting is what keeps the answer
/// independent of iteration order.
#[test]
fn a_batch_declaring_two_different_priors_is_rejected() {
let mut h = history();
let err = h
.add_events(vec![
bout("a", "b", 0, Some(Gaussian::from_ms(40.0, 1.0)), None),
bout("a", "b", 1, Some(Gaussian::from_ms(10.0, 1.0)), None),
])
.expect_err("two different priors for one competitor in one batch");
assert!(
matches!(
err,
InferenceError::ConflictingCompetitorConfig {
field: trueskill_tt::CompetitorField::Prior,
..
}
),
"got {err:?}"
);
}
/// A member setting only `drift_scale` must not also assert the default prior,
/// or it would silently undo a prior seeded earlier. This is why the collected
/// configuration tracks each field separately rather than a merged `Rating`.
#[test]
fn setting_one_field_late_leaves_the_other_alone() {
let seeded = Gaussian::from_ms(40.0, 1.0);
let mut h = history();
h.add_events(vec![bout("a", "b", 0, Some(seeded), None)])
.unwrap();
// Only the scale this time — the prior above must survive.
h.add_events(vec![bout("a", "b", 1, None, Some(0.5))])
.unwrap();
let _ = h.converge().unwrap();
let mut both_upfront = history();
both_upfront
.add_events(vec![
bout("a", "b", 0, Some(seeded), Some(0.5)),
bout("a", "b", 1, None, None),
])
.unwrap();
let _ = both_upfront.converge().unwrap();
let (a, b) = (skill_of(&h, "a"), skill_of(&both_upfront, "a"));
assert!(
(a.mu() - b.mu()).abs() < 1e-9 && (a.sigma() - b.sigma()).abs() < 1e-9,
"setting drift_scale late clobbered the earlier prior: {a:?} vs {b:?}"
);
}
+232
View File
@@ -0,0 +1,232 @@
//! Every public entry point that takes a magnitude, in one place.
//!
//! This defect class was closed three times in one session and reopened twice,
//! because each fix validated the layer it had just touched and inferred the
//! rest: `HistoryBuilder` first, then `Game`'s own entry points, then the
//! constructors beneath both. A per-site fix cannot notice the site nobody
//! thought of.
//!
//! So this enumerates them. `sigma`, `beta` and `gamma` all enter inference
//! only as squares, which means a negative value does not fail — it behaves as
//! its absolute value, bit for bit, and the sign vanishes with no diagnostic.
//! Non-finite values poison every posterior derived from them.
//!
//! Adding a public constructor that takes one of these and not adding it here
//! is the failure this file exists to make harder.
use std::panic::{AssertUnwindSafe, catch_unwind};
use trueskill_tt::{ConstantDrift, Gaussian, History, Member, Outcome, Rating};
/// Did the entry point refuse the value, by panic or by `Err`?
fn refuses(f: impl FnOnce() -> bool) -> bool {
catch_unwind(AssertUnwindSafe(f)).unwrap_or(true)
}
/// One entry point, as a name and a closure that applies a value to it.
type Case = (&'static str, Box<dyn Fn(f64) -> bool>);
/// Entry points that must reject a negative magnitude.
///
/// Each closure returns `true` if it refused by returning an error; a panic is
/// also a refusal and is caught.
#[test]
fn every_magnitude_parameter_rejects_a_negative_value() {
let cases: Vec<Case> = vec![
(
"Gaussian::from_ms(sigma)",
Box::new(|v| {
let _ = Gaussian::from_ms(25.0, v);
false
}),
),
(
"Rating::new(beta)",
Box::new(|v| {
let _ = Rating::<i64, ConstantDrift>::new(
Gaussian::default(),
v,
ConstantDrift::new(0.0),
);
false
}),
),
(
"ConstantDrift::new(gamma)",
Box::new(|v| {
let _ = ConstantDrift::new(v);
false
}),
),
(
"HistoryBuilder::sigma",
Box::new(|v| {
let _ = History::builder().sigma(v);
false
}),
),
(
"HistoryBuilder::beta",
Box::new(|v| {
let _ = History::builder().beta(v);
false
}),
),
(
"HistoryBuilder::score_sigma",
Box::new(|v| {
let _ = History::builder().score_sigma(v);
false
}),
),
(
"HistoryBuilder::p_draw",
Box::new(|v| {
let _ = History::builder().p_draw(v);
false
}),
),
(
"Member::with_drift_scale (at ingestion)",
Box::new(|v| {
let mut h = History::builder().build();
h.add_events(vec![trueskill_tt::Event {
time: 1i64,
teams: smallvec::smallvec![
trueskill_tt::Team::with_members([Member::new("a").with_drift_scale(v)]),
trueskill_tt::Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
}])
.is_err()
}),
),
(
"Outcome::scores_with_noise (at ingestion)",
Box::new(|v| {
let mut h = History::builder().build();
h.add_events(vec![trueskill_tt::Event {
time: 1i64,
teams: smallvec::smallvec![
trueskill_tt::Team::with_members([Member::new("a")]),
trueskill_tt::Team::with_members([Member::new("b")]),
],
outcome: Outcome::scores_with_noise([3.0, 1.0], v),
}])
.is_err()
}),
),
];
let mut accepted = Vec::new();
for (name, f) in &cases {
if !refuses(|| f(-1.0)) {
accepted.push(*name);
}
}
assert!(
accepted.is_empty(),
"these accepted a negative magnitude, which is squared away silently \
rather than honoured or refused:\n {}",
accepted.join("\n ")
);
}
/// Same set, for NaN and infinity.
///
/// `Gaussian::from_ms` is deliberately absent: a broken fit produces a NaN
/// sigma legitimately and `converge` reports it as `NonFiniteResult`. Rejecting
/// it in the constructor turned that reporting path into a panic inside
/// inference — see the comment on `from_ms`.
#[test]
fn every_magnitude_parameter_rejects_a_non_finite_value() {
let cases: Vec<Case> = vec![
(
"Rating::new(beta)",
Box::new(|v| {
let _ = Rating::<i64, ConstantDrift>::new(
Gaussian::default(),
v,
ConstantDrift::new(0.0),
);
false
}),
),
(
"ConstantDrift::new(gamma)",
Box::new(|v| {
let _ = ConstantDrift::new(v);
false
}),
),
(
"HistoryBuilder::sigma",
Box::new(|v| {
let _ = History::builder().sigma(v);
false
}),
),
(
"HistoryBuilder::beta",
Box::new(|v| {
let _ = History::builder().beta(v);
false
}),
),
(
"HistoryBuilder::mu",
Box::new(|v| {
let _ = History::builder().mu(v);
false
}),
),
(
"HistoryBuilder::score_sigma",
Box::new(|v| {
let _ = History::builder().score_sigma(v);
false
}),
),
(
"HistoryBuilder::p_draw",
Box::new(|v| {
let _ = History::builder().p_draw(v);
false
}),
),
];
let mut accepted = Vec::new();
for (name, f) in &cases {
for bad in [f64::NAN, f64::INFINITY] {
if !refuses(|| f(bad)) {
accepted.push(format!("{name} accepted {bad}"));
}
}
}
assert!(
accepted.is_empty(),
"these accepted a non-finite magnitude:\n {}",
accepted.join("\n ")
);
}
/// The suite must not pass by refusing everything.
#[test]
fn ordinary_values_are_still_accepted() {
let _ = Gaussian::from_ms(25.0, 8.33);
let _ = Rating::<i64, ConstantDrift>::new(Gaussian::default(), 4.17, ConstantDrift::new(0.05));
let _ = ConstantDrift::new(0.0833);
let _ = History::builder()
.mu(25.0)
.sigma(8.33)
.beta(4.17)
.score_sigma(1.0)
.p_draw(0.1);
// Zero beta and zero gamma are legitimate, not degenerate.
let _ = ConstantDrift::new(0.0);
let _ = Rating::<i64, ConstantDrift>::new(Gaussian::default(), 0.0, ConstantDrift::new(0.0));
}
+154
View File
@@ -0,0 +1,154 @@
//! Stopping short of convergence is an error, not a flag on a success.
//!
//! A fit that hits `max_iter` is wrong by a little: every rating is finite,
//! the ordering looks sensible, and nothing in the numbers says they were
//! still moving. When that was `Ok` with `converged: false`, detecting it was
//! opt-in and `let _ = h.converge()` was the natural way to opt out — which is
//! how a real defect once hid in this crate's own suite.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
};
type H = History;
fn duel(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> {
Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([3.0, 1.0]),
}
}
fn capped(max_iter: usize) -> H {
History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.5))
.convergence(ConvergenceOptions {
max_iter,
epsilon: 1e-13,
alpha: 1.0,
})
.build()
}
fn fill(h: &mut H) {
h.add_events((1..=6).map(|t| duel("a", "b", t)).collect::<Vec<_>>())
.unwrap();
}
#[test]
fn hitting_the_cap_is_an_error() {
let mut h = capped(1);
fill(&mut h);
let err = h.converge().unwrap_err();
match err {
InferenceError::NotConverged {
iterations,
final_step,
epsilon,
..
} => {
assert_eq!(iterations, 1);
assert!(
final_step.0 > epsilon || final_step.1 > epsilon,
"{final_step:?}"
);
}
other => panic!("expected NotConverged, got {other:?}"),
}
}
/// The message has to name what to do about it, since the fit looks fine.
#[test]
fn the_error_says_how_to_fix_it() {
let mut h = capped(1);
fill(&mut h);
let text = h.converge().unwrap_err().to_string();
assert!(text.contains("did not converge in 1 iterations"), "{text}");
assert!(text.contains("max_iter"), "{text}");
assert!(text.contains("alpha"), "{text}");
}
/// The escape hatch: a deliberately capped fit is still reachable.
#[test]
fn converge_partial_returns_the_short_fit() {
let mut h = capped(1);
fill(&mut h);
let report = h.converge_partial().unwrap();
assert_eq!(report.iterations, 1);
assert!(!report.converged);
assert!(h.current_skill(&"a").is_some());
}
/// Both agree when the fit does converge, so the strict path costs nothing.
#[test]
fn the_two_agree_on_a_converged_fit() {
let mut strict = capped(20_000);
fill(&mut strict);
let a = strict.converge().unwrap();
let mut partial = capped(20_000);
fill(&mut partial);
let b = partial.converge_partial().unwrap();
assert!(a.converged && b.converged);
assert_eq!(a.iterations, b.iterations);
assert_eq!(a.final_step, b.final_step);
}
/// The default cap must be high enough that an ordinary history clears it.
/// 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<String> = History::builder()
.key_type::<String>()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.05))
.build();
let mut events = Vec::new();
for t in 0..20i64 {
for j in 0..8usize {
let k = (t as usize) * 8 + j;
events.push(Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(format!("p{}", k % 100))]),
Team::with_members([Member::new(format!("p{}", (k + 37) % 100))]),
],
outcome: Outcome::scores([3.0, 1.0]),
});
}
}
h.add_events(events).unwrap();
let report = h
.converge()
.expect("an ordinary history must converge by default");
assert!(
report.iterations > 30,
"needed {} sweeps",
report.iterations
);
assert!(report.iterations < trueskill_tt::ITERATIONS);
}
/// An empty history converges trivially rather than erroring.
#[test]
fn an_empty_history_converges() {
let mut h = capped(1);
let report = h.converge().unwrap();
assert!(report.converged);
assert_eq!(report.iterations, 0);
}
+161
View File
@@ -0,0 +1,161 @@
//! Determinism across *processes*, which an in-process test cannot see.
//!
//! Rust seeds its default hasher once per process, so every `HashMap`
//! iteration order is fixed for a run and varies between runs. A test that
//! compares results within one process therefore cannot detect a float sum
//! whose order comes from a map — all its samples share one seed.
//!
//! That is not hypothetical. `tests/determinism.rs` compares four thread counts
//! inside one process and passed throughout, while `posterior_of` was returning
//! two distinct bit patterns across 40 separate runs on identical input.
//!
//! This re-executes the test binary and compares `f64::to_bits`.
use std::{env, process::Command};
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team, UnknownKeys,
};
/// Set in the child so it reports instead of re-spawning.
const CHILD: &str = "TSTT_DETERMINISM_CHILD";
const RUNS: usize = 40;
type H = History<String>;
fn fitted() -> H {
let mut h: H = History::builder()
.key_type::<String>()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.05))
.unknown_keys(UnknownKeys::Prior)
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build();
let mut events = Vec::new();
for t in 0..12i64 {
for k in 0..6usize {
let a = format!("p{}", (t as usize * 6 + k) % 10);
let b = format!("p{}", (t as usize * 6 + k + 4) % 10);
events.push(Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([3.0, 1.0]),
});
}
}
h.add_events(events).unwrap();
assert!(h.converge().unwrap().converged);
h
}
/// Every quantity that could plausibly depend on iteration order, as bits.
fn fingerprint() -> String {
let h = fitted();
// Unknown keys with UNEQUAL but COMPARABLE coefficients, which is what
// makes the sum order-sensitive.
//
// Equal terms sum order-independently and would make this pass vacuously.
// Terms of wildly different magnitudes are no better: the small ones fall
// below the running total's ULP and are absorbed whatever the order —
// measured, spreading these over nine decades dropped the detection rate
// to roughly one run in forty. Comparable sizes keep every term able to
// change the last bits.
let ghosts: Vec<String> = (0..24).map(|i| format!("ghost{i}")).collect();
let mut terms: Vec<(&String, f64)> = ghosts
.iter()
.enumerate()
.map(|(i, k)| (k, 1.0 + i as f64 * 0.37))
.collect();
let known = "p0".to_string();
terms.push((&known, -1.0));
let posterior = h.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
.joint()
.unwrap()
.expected_variance_reduction(&teams, &target)
.unwrap();
let curves = h.learning_curves();
let mut curve_bits: u64 = 0;
let mut keys: Vec<&String> = curves.keys().collect();
keys.sort();
for key in keys {
for (t, g) in &curves[key] {
curve_bits ^= (*t as u64).rotate_left(17)
^ g.mu().to_bits().rotate_left(31)
^ g.sigma().to_bits();
}
}
format!(
"post={:016x} evr={:016x} le={:016x} curves={curve_bits:016x}",
posterior.sigma().to_bits(),
evr.to_bits(),
h.log_evidence().to_bits(),
)
}
#[test]
fn results_are_identical_across_processes() {
if env::var(CHILD).is_ok() {
println!("FINGERPRINT {}", fingerprint());
return;
}
let exe = env::current_exe().expect("current exe");
let mut seen: Vec<String> = Vec::new();
for run in 0..RUNS {
let out = Command::new(&exe)
.args([
"results_are_identical_across_processes",
"--exact",
"--nocapture",
])
.env(CHILD, "1")
.output()
.expect("spawn child");
assert!(
out.status.success(),
"child {run} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let line = stdout
.lines()
.find_map(|l| l.strip_prefix("FINGERPRINT "))
.unwrap_or_else(|| panic!("child {run} printed no fingerprint:\n{stdout}"))
.to_string();
seen.push(line);
}
let first = &seen[0];
let differing: Vec<&String> = seen.iter().filter(|s| *s != first).collect();
assert!(
differing.is_empty(),
"results differ across processes on identical input.\n {} of {RUNS} runs differed\n \
first: {first}\n differing: {}",
differing.len(),
differing[0]
);
}
+438
View File
@@ -0,0 +1,438 @@
//! Degenerate, boundary, and error-path coverage.
//!
//! These run in both debug and release: the defects they pin were all
//! guarded only by `debug_assert!`, so a debug-only suite never saw them.
mod common;
use common::assert_finite;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError,
Outcome, Rating,
};
type R = Rating<i64, ConstantDrift>;
fn rating() -> R {
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
)
}
#[test]
fn record_draw_without_draw_probability_is_rejected() {
let mut h = History::default();
let err = h.record_draw(&"a", &"b", 1).unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
}
#[test]
fn builder_draw_without_draw_probability_is_rejected() {
let mut h = History::default();
let err = h
.event(1)
.team(["a"])
.team(["b"])
.draw()
.commit()
.unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
}
#[test]
fn draw_with_positive_draw_probability_is_finite() {
let mut h = History::builder().p_draw(0.25).build();
h.record_draw(&"a", &"b", 1).unwrap();
let report = h.converge().unwrap();
assert_finite(h.current_skill("a").unwrap(), "drawn competitor skill");
assert_finite(h.current_skill("b").unwrap(), "drawn competitor skill");
assert!(report.log_evidence.is_finite());
assert!(report.converged);
}
#[test]
fn game_ranked_rejects_tie_without_draw_probability() {
let a = [rating()];
let b = [rating()];
let teams: Vec<&[R]> = vec![&a, &b];
let err = Game::ranked(&teams, Outcome::draw(2), &GameOptions::default()).unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
}
/// `Outcome::winner(w, n)` ties every loser, so any n >= 3 free-for-all hits
/// the tie path even though the caller never asked for a draw.
#[test]
fn winner_of_three_or_more_requires_draw_probability() {
let a = [rating()];
let b = [rating()];
let c = [rating()];
let teams: Vec<&[R]> = vec![&a, &b, &c];
let err = Game::ranked(&teams, Outcome::winner(0, 3), &GameOptions::default()).unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
let opts = GameOptions {
p_draw: 0.1,
..GameOptions::default()
};
let game = Game::ranked(&teams, Outcome::winner(0, 3), &opts).unwrap();
for team in game.posteriors() {
for skill in team {
assert_finite(skill, "3-team winner posterior");
}
}
}
#[test]
fn full_ranking_without_ties_needs_no_draw_probability() {
let a = [rating()];
let b = [rating()];
let c = [rating()];
let teams: Vec<&[R]> = vec![&a, &b, &c];
let game = Game::ranked(&teams, Outcome::ranking([0, 1, 2]), &GameOptions::default()).unwrap();
for team in game.posteriors() {
for skill in team {
assert_finite(skill, "strict ranking posterior");
}
}
}
#[test]
fn empty_history_converges_trivially() {
let mut h = History::default();
let report = h.converge().unwrap();
assert_eq!(report.iterations, 0);
assert!(report.converged);
}
/// Issue #27's exact reproduction: a non-default key type reaching `converge`
/// with no events at all. The underflow it reported trapped in debug and
/// 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<String> = History::builder()
.key_type::<String>()
.score_sigma(5.0)
.build();
let report = history.converge().unwrap();
assert_eq!(report.iterations, 0);
assert!(report.converged);
}
/// A weights/team length mismatch used to be a `debug_assert!`, so release
/// builds ingested the event with the weights silently unapplied. This file's
/// CI job runs in release too, which is the point of pinning it here.
#[test]
fn event_builder_rejects_a_weights_length_mismatch() {
let mut h = History::default();
let err = h
.event(1)
.team(["a"])
.weights([1.0, 2.0])
.team(["b"])
.winner(0)
.commit()
.unwrap_err();
assert!(
matches!(
err,
InferenceError::MismatchedShape {
shape: trueskill_tt::Shape::Weights,
expected: 1,
got: 2,
..
}
),
"expected a weights MismatchedShape, got {err:?}"
);
}
/// The mismatch must not be applied even partially — a half-weighted team
/// reaching the history would be worse than the error.
#[test]
fn event_builder_weights_mismatch_leaves_the_history_untouched() {
let mut h = History::default();
// Two teams, so ingestion would otherwise succeed. A one-team event is
// rejected as `NotEnoughTeams` before the weights are ever examined, so
// building this with one team would pass vacuously.
let _ = h
.event(1)
.team(["a"])
.weights([1.0, 2.0])
.team(["b"])
.winner(0)
.commit();
// The rejected event never reached the history, so "a" was never interned.
// `None` is the honest answer, and it is distinguishable from a competitor
// that IS known but has no appearances yet.
assert!(h.learning_curve("a").is_none());
}
#[test]
fn empty_event_stream_then_converge() {
let mut h = History::default();
h.add_events(std::iter::empty()).unwrap();
let report = h.converge().unwrap();
assert_eq!(report.iterations, 0);
}
#[test]
fn empty_history_queries_do_not_panic() {
let h = History::default();
assert!(h.learning_curves().is_empty());
assert!(h.learning_curve("nobody").is_none());
assert!(h.current_skill("nobody").is_none());
}
#[test]
fn single_event_history_converges() {
let mut h = History::default();
h.record_winner(&"a", &"b", 1).unwrap();
let report = h.converge().unwrap();
assert!(report.converged);
assert_finite(h.current_skill("a").unwrap(), "single-event skill");
}
#[test]
fn scored_event_rejects_non_positive_sigma() {
let mut h = History::builder().score_sigma(2.0).build();
let err = h
.event(1)
.team(["a"])
.team(["b"])
.scores_with_noise([3.0, 1.0], f64::NAN)
.commit()
.unwrap_err();
assert!(matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::ScoreSigma,
..
}
));
}
#[test]
fn convergence_reports_are_finite_across_many_teams() {
let opts = GameOptions {
p_draw: 0.1,
convergence: ConvergenceOptions::default(),
..GameOptions::default()
};
let holders: Vec<[R; 1]> = (0..12).map(|_| [rating()]).collect();
let teams: Vec<&[R]> = holders.iter().map(|t| t.as_slice()).collect();
let game = Game::ranked(&teams, Outcome::ranking(0..12), &opts).unwrap();
assert!(
game.log_evidence().is_finite(),
"12-team log-evidence must be finite, got {}",
game.log_evidence()
);
for team in game.posteriors() {
for skill in team {
assert_finite(skill, "12-team posterior");
}
}
}
/// A long diff chain underflows a linear evidence product: each link
/// contributes a probability in (0, 1], so ~1000 links flush the product to
/// exactly 0.0 and `ln(0.0)` is `-inf`. Accumulating in log space keeps it
/// finite.
#[test]
fn log_evidence_survives_a_long_diff_chain() {
let holders: Vec<[R; 1]> = (0..1200).map(|_| [rating()]).collect();
let teams: Vec<&[R]> = holders.iter().map(|t| t.as_slice()).collect();
let game = Game::ranked(
&teams,
Outcome::ranking(0..holders.len() as u32),
&GameOptions::default(),
)
.unwrap();
let log_evidence = game.log_evidence();
assert!(
log_evidence.is_finite(),
"1200-team log-evidence must be finite, got {log_evidence}"
);
assert!(
log_evidence < 0.0,
"log-evidence of a probability must be negative, got {log_evidence}"
);
}
/// A near-certain outcome rounds the losing tail to exactly zero in the
/// `erfc` approximation; the evidence floor keeps `ln` finite.
#[test]
fn log_evidence_finite_for_near_certain_outcome() {
let overwhelming = R::new(
Gaussian::from_ms(5_000.0, 0.5),
1.0,
ConstantDrift::new(0.0),
);
let hopeless = R::new(
Gaussian::from_ms(-5_000.0, 0.5),
1.0,
ConstantDrift::new(0.0),
);
let a = [overwhelming];
let b = [hopeless];
let teams: Vec<&[R]> = vec![&a, &b];
let game = Game::ranked(&teams, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
assert!(
game.log_evidence().is_finite(),
"got {}",
game.log_evidence()
);
// And the reverse — a colossal upset — must also stay finite.
let upset = Game::ranked(&teams, Outcome::winner(1, 2), &GameOptions::default()).unwrap();
assert!(
upset.log_evidence().is_finite(),
"upset log-evidence must be finite, got {}",
upset.log_evidence()
);
}
#[test]
fn empty_history_has_no_filtered_estimates() {
let history: History = History::builder().build();
assert_eq!(history.filtered_log_evidence(), 0.0);
assert!(history.filtered_learning_curves().is_empty());
assert!(history.filtered_learning_curve("nobody").is_none());
}
// --- Boundary inputs (#26) ----------------------------------------------
fn tight() -> ConvergenceOptions {
ConvergenceOptions {
max_iter: 2_000,
epsilon: 1e-12,
..ConvergenceOptions::default()
}
}
fn assert_curve_finite(h: &History, keys: &[&str], what: &str) {
for key in keys {
for (time, g) in h.learning_curve(*key).unwrap() {
assert!(
g.mu().is_finite() && g.sigma().is_finite(),
"{what}: non-finite posterior for {key} at t={time} (mu={} sigma={})",
g.mu(),
g.sigma()
);
}
}
}
/// A zero weight reaches `(m - performance.exclude(..)) * (1.0 / w)`, i.e. a
/// division by zero. The commit is accepted today, so this pins that the
/// resulting posterior is still finite rather than quietly NaN.
#[test]
fn zero_weight_does_not_produce_a_non_finite_posterior() {
let mut h = History::builder().build();
h.event(1)
.team(["a"])
.weights([0.0])
.team(["b"])
.winner(0)
.commit()
.expect("a zero weight is accepted today; update this test if that changes");
let _ = h.converge().unwrap();
assert_curve_finite(&h, &["a", "b"], "zero weight");
}
#[test]
fn negative_weight_does_not_produce_a_non_finite_posterior() {
let mut h = History::builder().build();
h.event(1)
.team(["a"])
.weights([-1.0])
.team(["b"])
.winner(0)
.commit()
.expect("a negative weight is accepted today; update this test if that changes");
let _ = h.converge().unwrap();
assert_curve_finite(&h, &["a", "b"], "negative weight");
}
/// Events supplied newest-first must land in the same slices as oldest-first:
/// ingestion sorts by time rather than trusting arrival order.
#[test]
fn out_of_order_timestamps_converge_to_the_same_answer() {
fn build(descending: bool) -> History {
let mut h = History::builder().convergence(tight()).build();
let mut times: Vec<i64> = (1..=6).collect();
if descending {
times.reverse();
}
for time in times {
h.record_winner(&"a", &"b", time).unwrap();
}
let _ = h.converge().unwrap();
h
}
let ascending = build(false);
let descending = build(true);
let one = ascending.current_skill("a").unwrap();
let other = descending.current_skill("a").unwrap();
assert!(
(one.mu() - other.mu()).abs() < 1e-8 && (one.sigma() - other.sigma()).abs() < 1e-8,
"arrival order changed the answer: ascending mu={} sigma={}, descending mu={} sigma={}",
one.mu(),
one.sigma(),
other.mu(),
other.sigma()
);
}
#[test]
fn extreme_beta_and_sigma_stay_finite() {
for (beta, sigma) in [(1e-6, 1e-6), (1e6, 1e6), (1e-6, 1e6), (1e6, 1e-6)] {
let mut h = History::builder().beta(beta).sigma(sigma).build();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"a", &"b", 2).unwrap();
let _ = h.converge().unwrap();
assert_curve_finite(&h, &["a", "b"], &format!("beta={beta} sigma={sigma}"));
}
}
+166 -65
View File
@@ -1,101 +1,202 @@
//! Determinism tests: identical posteriors across RAYON_NUM_THREADS //! Determinism across `RAYON_NUM_THREADS`, on a workload that actually reaches
//! values. Only compiled with the `rayon` feature. //! the parallel path.
//!
//! This test previously proved less than it appeared to. `sweep_color_groups`
//! takes its `par_iter` branch only for colour groups of at least
//! `RAYON_THRESHOLD` (64) events, and the old fixture built 20 slices of 10
//! events — a colour group is a subset of one slice's events, so it could never
//! exceed 10. The branch was unreachable, confirmed by CPU-vs-wall time:
//! `user 0.64` on eight threads is one core.
//!
//! It also compared a single competitor's curve out of forty, and never
//! compared `log_evidence`, `final_step` or `iterations`.
//!
//! The fixture below guarantees the parallel branch **by construction**: within
//! a slice every event uses a disjoint pair of competitors, so greedy colouring
//! puts all of them in colour 0, and that group is `EVENTS_PER_SLICE` long.
//! Competitors recur across slices, so the fit still has temporal coupling and
//! drift rather than being a set of independent duels.
#![cfg(feature = "rayon")] #![cfg(feature = "rayon")]
use smallvec::smallvec; use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team}; use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team,
};
/// Build a deterministic workload using a simple LCG (no external rand crate). /// Comfortably above the crate's internal `RAYON_THRESHOLD` of 64.
fn build_and_converge(seed: u64) -> Vec<(i64, trueskill_tt::Gaussian)> { const EVENTS_PER_SLICE: usize = 96;
let mut h = History::<i64, _, _, String>::builder_with_key() const SLICES: i64 = 8;
/// Two per event, all disjoint within a slice.
const COMPETITORS: usize = EVENTS_PER_SLICE * 2;
/// Everything a thread count could plausibly perturb.
struct Fingerprint {
curves: Vec<(String, Vec<(i64, Gaussian)>)>,
log_evidence: f64,
final_step: (f64, f64),
iterations: usize,
}
fn build_and_converge() -> Fingerprint {
let mut h = History::builder()
.key_type::<String>()
.mu(25.0) .mu(25.0)
.sigma(25.0 / 3.0) .sigma(25.0 / 3.0)
.beta(25.0 / 6.0) .beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0)) .drift(ConstantDrift::new(25.0 / 300.0))
.convergence(ConvergenceOptions { .convergence(ConvergenceOptions {
max_iter: 30, max_iter: 20_000,
epsilon: 1e-6, epsilon: 1e-9,
alpha: 1.0, alpha: 1.0,
}) })
.build(); .build();
// LCG for deterministic pseudo-random ints. let mut events: Vec<Event<i64, String>> = Vec::new();
let mut rng = seed; for slice in 0..SLICES {
let mut next = || { for e in 0..EVENTS_PER_SLICE {
rng = rng // Disjoint within the slice: event `e` owns competitors 2e and
.wrapping_mul(6364136223846793005) // 2e+1. Rotating by the slice index makes the pairings differ
.wrapping_add(1442695040888963407); // between slices, so competitors accumulate a real history.
rng let a = (2 * e + slice as usize) % COMPETITORS;
}; let b = (2 * e + 1 + slice as usize * 3) % COMPETITORS;
if a == b {
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(200); continue;
for ev_i in 0..200 { }
let a = (next() % 40) as usize; events.push(Event {
let mut b = (next() % 40) as usize; time: slice + 1,
while b == a { teams: smallvec![
b = (next() % 40) as usize; Team::with_members([Member::new(format!("p{a}"))]),
Team::with_members([Member::new(format!("p{b}"))]),
],
outcome: Outcome::winner(u32::from((e + slice as usize) % 2 == 0), 2),
});
} }
// ~10 events per slice so color groups have material parallelism.
events.push(Event {
time: (ev_i as i64 / 10) + 1,
teams: smallvec![
Team::with_members([Member::new(format!("p{a}"))]),
Team::with_members([Member::new(format!("p{b}"))]),
],
outcome: Outcome::winner((next() % 2) as u32, 2),
});
} }
h.add_events(events).unwrap(); h.add_events(events).unwrap();
h.converge().unwrap();
// Sample one competitor's curve for the comparison. let report = h.converge().expect("fixture must converge");
h.learning_curve("p0")
let mut curves: Vec<(String, Vec<(i64, Gaussian)>)> = h
.learning_curves()
.into_iter()
.map(|(k, v)| (k.clone(), v))
.collect();
curves.sort_by(|a, b| a.0.cmp(&b.0));
Fingerprint {
curves,
log_evidence: h.log_evidence(),
final_step: report.final_step,
iterations: report.iterations,
}
} }
#[test] #[test]
fn posteriors_identical_across_thread_counts() { fn posteriors_identical_across_thread_counts() {
let sizes = [1usize, 2, 4, 8]; let sizes = [1usize, 2, 4, 8];
let mut results: Vec<Vec<(i64, trueskill_tt::Gaussian)>> = Vec::new(); let mut results: Vec<Fingerprint> = Vec::new();
for &n in &sizes { for &n in &sizes {
let pool = rayon::ThreadPoolBuilder::new() let pool = rayon::ThreadPoolBuilder::new()
.num_threads(n) .num_threads(n)
.build() .build()
.expect("rayon pool build"); .expect("rayon pool build");
let curve = pool.install(|| build_and_converge(42)); results.push(pool.install(build_and_converge));
results.push(curve);
} }
let reference = &results[0]; let reference = &results[0];
for (i, curve) in results.iter().enumerate().skip(1) {
// Guard against the failure this test previously had: passing while
// measuring almost nothing.
assert!(
reference.curves.len() > 100,
"expected every competitor's curve, got {}",
reference.curves.len()
);
for (i, got) in results.iter().enumerate().skip(1) {
let n = sizes[i];
assert_eq!( assert_eq!(
curve.len(), got.iterations, reference.iterations,
reference.len(), "iterations differ at {n} threads"
"curve length differs at {n} threads",
n = sizes[i],
); );
for (j, (&(t_ref, g_ref), &(t, g))) in reference.iter().zip(curve.iter()).enumerate() { assert_eq!(
got.final_step.0.to_bits(),
reference.final_step.0.to_bits(),
"final_step.0 differs at {n} threads: {:?} vs {:?}",
reference.final_step,
got.final_step
);
assert_eq!(
got.final_step.1.to_bits(),
reference.final_step.1.to_bits(),
"final_step.1 differs at {n} threads"
);
assert_eq!(
got.log_evidence.to_bits(),
reference.log_evidence.to_bits(),
"log_evidence differs at {n} threads: {} vs {}",
reference.log_evidence,
got.log_evidence
);
assert_eq!(
got.curves.len(),
reference.curves.len(),
"competitor count differs at {n} threads"
);
for ((ref_key, ref_curve), (key, curve)) in reference.curves.iter().zip(got.curves.iter()) {
assert_eq!(ref_key, key, "competitor order differs at {n} threads");
assert_eq!( assert_eq!(
t_ref, curve.len(),
t, ref_curve.len(),
"time point {j} differs at {n} threads: ref={t_ref} vs got={t}", "curve length differs for {key} at {n} threads"
n = sizes[i],
);
assert_eq!(
g_ref.mu().to_bits(),
g.mu().to_bits(),
"mu bits differ at {n} threads, time {t}: ref={ref_mu} got={got_mu}",
n = sizes[i],
ref_mu = g_ref.mu(),
got_mu = g.mu(),
);
assert_eq!(
g_ref.sigma().to_bits(),
g.sigma().to_bits(),
"sigma bits differ at {n} threads, time {t}: ref={ref_sigma} got={got_sigma}",
n = sizes[i],
ref_sigma = g_ref.sigma(),
got_sigma = g.sigma(),
); );
for (&(t_ref, g_ref), &(t, g)) in ref_curve.iter().zip(curve.iter()) {
assert_eq!(t_ref, t, "time point differs for {key} at {n} threads");
assert_eq!(
g_ref.mu().to_bits(),
g.mu().to_bits(),
"mu differs for {key} at t={t}, {n} threads: {} vs {}",
g_ref.mu(),
g.mu()
);
assert_eq!(
g_ref.sigma().to_bits(),
g.sigma().to_bits(),
"sigma differs for {key} at t={t}, {n} threads: {} vs {}",
g_ref.sigma(),
g.sigma()
);
}
} }
} }
} }
/// The fixture must keep reaching the parallel branch.
///
/// `RAYON_THRESHOLD` is private, so this pins the property that makes the
/// branch reachable rather than the branch itself: within a slice every event
/// uses a disjoint competitor pair, so greedy colouring puts all
/// `EVENTS_PER_SLICE` of them in one colour group. If someone shrinks the
/// fixture, this fails rather than the suite quietly going back to testing the
/// sequential path.
#[test]
fn the_fixture_still_exceeds_the_rayon_threshold() {
const RAYON_THRESHOLD: usize = 64;
const {
assert!(
EVENTS_PER_SLICE >= RAYON_THRESHOLD,
"a colour group holds at most EVENTS_PER_SLICE events, which must \
reach the crate's RAYON_THRESHOLD for the parallel sweep to run"
);
}
// Measured by instrumenting `sweep_color_groups`: this fixture produces
// one colour group of 96 events and takes the parallel branch on all 872
// sweeps. The old fixture's 10-event slices could not reach 64 at all.
assert_eq!(EVENTS_PER_SLICE, 96);
}
+497
View File
@@ -0,0 +1,497 @@
//! Per-competitor drift scaling via `Member::with_drift_scale`.
//!
//! The scale multiplies the *variance* the history's `Drift` contributes for
//! that competitor, so `scale` is in the same units as `gamma`:
//! `ConstantDrift::new(g)` at `scale = s` behaves as `ConstantDrift::new(g * s)` would.
//! `scale = 0.0` pins a competitor still — an anchor, a rating floor, a course
//! difficulty — while everyone around them keeps drifting.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
Team,
};
type Fit = History;
const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
max_iter: 64,
epsilon: 1e-9,
alpha: 1.0,
};
/// Two events separated by a long gap, so drift has room to matter.
fn distant_pair(anchor_scale: Option<f64>) -> Vec<Event<i64, &'static str>> {
let anchor = |s: Option<f64>| match s {
Some(scale) => Member::new("anchor").with_drift_scale(scale),
None => Member::new("anchor"),
};
vec![
Event {
time: 0,
teams: smallvec![
Team::with_members([anchor(anchor_scale)]),
Team::with_members([Member::new("player")]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 1000,
teams: smallvec![
Team::with_members([anchor(anchor_scale)]),
Team::with_members([Member::new("player")]),
],
outcome: Outcome::winner(1, 2),
},
]
}
fn fit(events: Vec<Event<i64, &'static str>>, gamma: f64) -> Fit {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.drift(ConstantDrift::new(gamma))
.convergence(CONVERGENCE)
.build();
h.add_events(events).unwrap();
let _ = h.converge().unwrap();
h
}
fn curve(h: &Fit, key: &str) -> Vec<(i64, Gaussian)> {
let mut c = h.learning_curves().remove(key).expect("key in curves");
c.sort_by_key(|(t, _)| *t);
c
}
/// A competitor at `scale = 0.0` is one latent skill observed twice, so the
/// posterior is the same distribution at both times — and strictly tighter
/// than the same competitor left to drift.
#[test]
fn zero_scale_pins_a_competitor_still() {
let pinned = fit(distant_pair(Some(0.0)), 25.0 / 300.0);
let drifting = fit(distant_pair(None), 25.0 / 300.0);
let pinned_curve = curve(&pinned, "anchor");
assert_eq!(pinned_curve.len(), 2);
let (t0, first) = pinned_curve[0];
let (t1, second) = pinned_curve[1];
assert_eq!((t0, t1), (0, 1000));
assert!(
(first.sigma() - second.sigma()).abs() < 1e-9,
"a pinned competitor's uncertainty must not move between t=0 and t=1000: \
{} vs {}",
first.sigma(),
second.sigma()
);
assert!(
(first.mu() - second.mu()).abs() < 1e-9,
"a pinned competitor's mean must not move: {} vs {}",
first.mu(),
second.mu()
);
let drifting_curve = curve(&drifting, "anchor");
assert!(
drifting_curve[0].1.sigma() > first.sigma() + 1e-6,
"drift must leave the anchor less certain than pinning does: {} vs {}",
drifting_curve[0].1.sigma(),
first.sigma()
);
}
/// The scale is composable with `gamma`: scaling every competitor by `s` is
/// exactly the same fit as scaling the history's drift by `s`.
#[test]
fn scale_is_equivalent_to_scaling_gamma() {
let scaled: Vec<Event<i64, &'static str>> = vec![
Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("a").with_drift_scale(0.5)]),
Team::with_members([Member::new("b").with_drift_scale(0.5)]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 400,
teams: smallvec![
Team::with_members([Member::new("b").with_drift_scale(0.5)]),
Team::with_members([Member::new("a").with_drift_scale(0.5)]),
],
outcome: Outcome::winner(0, 2),
},
];
let plain: Vec<Event<i64, &'static str>> = vec![
Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 400,
teams: smallvec![
Team::with_members([Member::new("b")]),
Team::with_members([Member::new("a")]),
],
outcome: Outcome::winner(0, 2),
},
];
let by_scale = fit(scaled, 0.3);
let by_gamma = fit(plain, 0.15);
for key in ["a", "b"] {
let lhs = curve(&by_scale, key);
let rhs = curve(&by_gamma, key);
assert_eq!(lhs.len(), rhs.len());
for ((t_l, g_l), (t_r, g_r)) in lhs.iter().zip(rhs.iter()) {
assert_eq!(t_l, t_r);
assert!(
(g_l.mu() - g_r.mu()).abs() < 1e-9 && (g_l.sigma() - g_r.sigma()).abs() < 1e-9,
"ConstantDrift::new(0.3) at scale 0.5 must equal ConstantDrift::new(0.15) for {key} at \
t={t_l}: ({}, {}) vs ({}, {})",
g_l.mu(),
g_l.sigma(),
g_r.mu(),
g_r.sigma()
);
}
}
}
/// `None` means 1.0: an explicit unit scale changes nothing.
#[test]
fn unset_scale_matches_an_explicit_unit_scale() {
let implicit = fit(distant_pair(None), 25.0 / 300.0);
let explicit = fit(distant_pair(Some(1.0)), 25.0 / 300.0);
for key in ["anchor", "player"] {
let lhs = curve(&implicit, key);
let rhs = curve(&explicit, key);
assert_eq!(lhs.len(), rhs.len());
for ((t_l, g_l), (t_r, g_r)) in lhs.iter().zip(rhs.iter()) {
assert_eq!(t_l, t_r);
assert_eq!(
(g_l.mu(), g_l.sigma()),
(g_r.mu(), g_r.sigma()),
"an explicit scale of 1.0 must be bit-identical to leaving it unset, \
for {key} at t={t_l}"
);
}
}
}
/// The use case from the issue: a static difficulty alongside drifting players,
/// in one graph. The anchor must hold still without absorbing drift through its
/// neighbours, and everything must stay finite.
#[test]
fn mixed_static_and_drifting_graph_converges() {
let mut events: Vec<Event<i64, &'static str>> = Vec::new();
let players = ["p0", "p1", "p2"];
for (i, p) in players.iter().cycle().take(9).enumerate() {
events.push(Event {
time: (i as i64) * 100,
teams: smallvec![
Team::with_members([Member::new(*p)]),
Team::with_members([Member::new("layout").with_drift_scale(0.0)]),
],
outcome: Outcome::winner((i % 2) as u32, 2),
});
}
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.drift(ConstantDrift::new(25.0 / 300.0))
.convergence(CONVERGENCE)
.build();
h.add_events(events).unwrap();
let report = h.converge().unwrap();
assert!(report.converged, "mixed graph must converge: {report:?}");
let curves = h.learning_curves();
for (key, points) in &curves {
for (t, g) in points {
assert!(
g.mu().is_finite() && g.sigma().is_finite() && g.sigma() > 0.0,
"{key} at t={t} is not a usable posterior: mu={}, sigma={}",
g.mu(),
g.sigma()
);
}
}
let layout = curve(&h, "layout");
assert_eq!(layout.len(), 9);
let (_, first) = layout[0];
for (t, g) in &layout {
assert!(
(g.sigma() - first.sigma()).abs() < 1e-9,
"a static layout must not accumulate uncertainty; t={t} has sigma {} vs {}",
g.sigma(),
first.sigma()
);
}
let p0 = curve(&h, "p0");
assert!(
p0.last().unwrap().1.sigma() > 0.0,
"a drifting player should still have a proper posterior"
);
}
fn reject(scale: f64) -> InferenceError {
let mut h = History::builder()
.drift(ConstantDrift::new(25.0 / 300.0))
.build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("a").with_drift_scale(scale)]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
}];
h.add_events(events)
.expect_err("an out-of-range drift_scale must be rejected")
}
#[test]
fn negative_scale_is_rejected() {
assert!(matches!(
reject(-1.0),
InferenceError::InvalidParameter { parameter: trueskill_tt::Parameter::DriftScale, value, .. }
if value == -1.0
));
}
#[test]
fn non_finite_scale_is_rejected() {
for scale in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert!(
matches!(
reject(scale),
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::DriftScale,
..
}
),
"a drift_scale of {scale} must be rejected as an invalid parameter"
);
}
}
/// The scale must reach the filtering pass too, not just `converge()`.
/// `filtered_learning_curves` runs its own drift application, so a pinned
/// competitor has to stay pinned there as well.
#[test]
fn zero_scale_pins_a_competitor_in_the_filtered_pass() {
let pinned = fit(distant_pair(Some(0.0)), 25.0 / 300.0);
let drifting = fit(distant_pair(None), 25.0 / 300.0);
let filtered = |h: &Fit| -> Vec<(i64, Gaussian)> {
let mut c = h
.filtered_learning_curves()
.remove("anchor")
.expect("anchor in filtered curves");
c.sort_by_key(|(t, _)| *t);
c
};
let pinned_curve = filtered(&pinned);
let drifting_curve = filtered(&drifting);
assert_eq!(pinned_curve.len(), 2);
assert_eq!(drifting_curve.len(), 2);
assert!(
pinned_curve[1].1.sigma() < pinned_curve[0].1.sigma(),
"a pinned competitor's filtered uncertainty must shrink with a second \
observation, not be re-inflated by drift: {} then {}",
pinned_curve[0].1.sigma(),
pinned_curve[1].1.sigma()
);
assert!(
pinned_curve[1].1.sigma() < drifting_curve[1].1.sigma() - 1e-6,
"pinning must leave the filtered estimate tighter than drifting does: \
{} vs {}",
pinned_curve[1].1.sigma(),
drifting_curve[1].1.sigma()
);
}
/// `drift_scale` is competitor configuration, and configuration supplied for a
/// competitor the history already knows is now *applied* rather than dropped.
///
/// This test previously asserted the opposite. It was written as a deliberate
/// change-detector — "moving the capture would be a visible break, not a silent
/// one" — and that is exactly what happened: the capture moved, and the
/// assertion inverted rather than being deleted.
///
/// Because configuration lives on the competitor and `converge` refits from
/// competitor state, a late pin applies to the *whole* history, not just to
/// events after it. So a scale set on the second batch must reach the same fit
/// as one set from the very first event.
#[test]
fn drift_scale_applies_when_set_after_first_appearance() {
let mut late = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.drift(ConstantDrift::new(25.0 / 300.0))
.convergence(CONVERGENCE)
.build();
// First batch creates "anchor" with the default scale.
late.add_events(vec![Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("anchor")]),
Team::with_members([Member::new("player")]),
],
outcome: Outcome::winner(0, 2),
}])
.unwrap();
// Second batch asks for a pin. No longer too late.
late.add_events(vec![Event {
time: 1000,
teams: smallvec![
Team::with_members([Member::new("anchor").with_drift_scale(0.0)]),
Team::with_members([Member::new("player")]),
],
outcome: Outcome::winner(1, 2),
}])
.unwrap();
let _ = late.converge().unwrap();
let applied = curve(&late, "anchor");
let pinned_from_the_start = curve(&fit(distant_pair(Some(0.0)), 25.0 / 300.0), "anchor");
let never_pinned = curve(&fit(distant_pair(None), 25.0 / 300.0), "anchor");
for ((t_l, g_l), (t_r, g_r)) in applied.iter().zip(pinned_from_the_start.iter()) {
assert_eq!(t_l, t_r);
assert!(
(g_l.sigma() - g_r.sigma()).abs() < 1e-9,
"a late pin should refit the whole history: t={t_l}, {} vs {}",
g_l.sigma(),
g_r.sigma()
);
}
// And it must actually have done something.
assert!(
applied
.iter()
.zip(never_pinned.iter())
.any(|((_, a), (_, b))| (a.sigma() - b.sigma()).abs() > 1e-9),
"the pin had no effect at all — the silent drop is back"
);
}
/// Re-declaring the same configuration must be inert. This is the shape a
/// caller gets when the configuration is a property of the domain — "layouts
/// are static" — so every ingestion path repeats it on every event.
///
/// Both histories see exactly the same events; only how many times the scale
/// is declared differs.
#[test]
fn repeating_the_same_configuration_changes_nothing() {
let events = |declare_every_time: bool| {
let anchor = |first: bool| {
if first || declare_every_time {
Member::new("anchor").with_drift_scale(0.0)
} else {
Member::new("anchor")
}
};
vec![
Event {
time: 0,
teams: smallvec![
Team::with_members([anchor(true)]),
Team::with_members([Member::new("player")]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 1000,
teams: smallvec![
Team::with_members([anchor(false)]),
Team::with_members([Member::new("player")]),
],
outcome: Outcome::winner(1, 2),
},
]
};
let once = curve(&fit(events(false), 25.0 / 300.0), "anchor");
let every_time = curve(&fit(events(true), 25.0 / 300.0), "anchor");
for ((t_l, a), (t_r, b)) in once.iter().zip(every_time.iter()) {
assert_eq!(t_l, t_r);
assert!(
(a.sigma() - b.sigma()).abs() < 1e-12,
"t={t_l}: declaring the same scale repeatedly changed the fit, {} vs {}",
a.sigma(),
b.sigma()
);
}
}
#[test]
fn a_batch_that_contradicts_itself_is_rejected() {
let mut h = History::builder().convergence(CONVERGENCE).build();
let err = h
.add_events(vec![
Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("anchor").with_drift_scale(0.0)]),
Team::with_members([Member::new("player")]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("anchor").with_drift_scale(1.0)]),
Team::with_members([Member::new("player")]),
],
outcome: Outcome::winner(0, 2),
},
])
.expect_err("two different scales for one competitor in one batch");
assert!(
matches!(
err,
InferenceError::ConflictingCompetitorConfig {
field: trueskill_tt::CompetitorField::DriftScale,
..
}
),
"got {err:?}"
);
}
+14 -13
View File
@@ -12,14 +12,21 @@ use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
type R = Rating<i64, ConstantDrift>; type R = Rating<i64, ConstantDrift>;
fn ts_rating(mu: f64, sigma: f64, beta: f64, gamma: f64) -> R { fn ts_rating(mu: f64, sigma: f64, beta: f64, gamma: f64) -> R {
R::new(Gaussian::from_ms(mu, sigma), beta, ConstantDrift(gamma)) R::new(
Gaussian::from_ms(mu, sigma),
beta,
ConstantDrift::new(gamma),
)
} }
#[test] #[test]
fn game_1v1_golden_matches_historical() { fn game_1v1_golden_matches_historical() {
let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0); let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0); let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
let (a_post, b_post) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2)).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): // Historical golden from pre-T2 test_1vs1 (team 0 wins):
assert_ulps_eq!( assert_ulps_eq!(
a_post, a_post,
@@ -48,15 +55,9 @@ fn game_1v1_draw_golden() {
) )
.unwrap(); .unwrap();
let p = g.posteriors(); let p = g.posteriors();
// Historical golden from pre-T2 test_1vs1_draw: // Historical golden from pre-T2 test_1vs1_draw. The mean is 25.0 exactly
assert_ulps_eq!( // by symmetry — two identical competitors drawing cannot move apart — and
p[0][0], // the reference's 24.999999 is that value transcribed to six decimals.
Gaussian::from_ms(24.999999, 6.469480), assert_ulps_eq!(p[0][0], Gaussian::from_ms(25.0, 6.469480), epsilon = 1e-6);
epsilon = 1e-6 assert_ulps_eq!(p[1][0], Gaussian::from_ms(25.0, 6.469480), epsilon = 1e-6);
);
assert_ulps_eq!(
p[1][0],
Gaussian::from_ms(24.999999, 6.469480),
epsilon = 1e-6
);
} }
+197
View File
@@ -0,0 +1,197 @@
//! `EventBuilder::members` must reach exactly what the typed path reaches.
//!
//! Before this existed, `EventBuilder` could set weights and nothing else, so
//! `prior` and `drift_scale` were expressible only through `Event`/`Team`/
//! `Member` + `add_events`. Which ingestion route a competitor arrived through
//! decided whether it could be configured at all.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
Team,
};
type H = History;
fn history() -> H {
History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.5))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build()
}
const PRIOR: Gaussian = Gaussian::from_ms(3.0, 1.5);
/// The contract that makes the escape hatch worth having: same configuration,
/// same fit, bit for bit.
#[test]
fn members_matches_the_typed_path_exactly() {
let mut typed = history();
typed
.add_events(vec![Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("player")]),
Team::with_members([Member::new("layout_7")
.with_drift_scale(0.0)
.with_prior(PRIOR)]),
],
outcome: Outcome::scores([5.0, 2.0]),
}])
.unwrap();
assert!(typed.converge().unwrap().converged);
let mut fluent = history();
fluent
.event(1)
.team(["player"])
.members([Member::new("layout_7")
.with_drift_scale(0.0)
.with_prior(PRIOR)])
.scores([5.0, 2.0])
.commit()
.unwrap();
assert!(fluent.converge().unwrap().converged);
for key in ["player", "layout_7"] {
let a = typed.current_skill(&key).unwrap();
let b = fluent.current_skill(&key).unwrap();
// 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");
}
}
/// The configuration has to actually take effect, not merely round-trip: a
/// competitor pinned with `drift_scale = 0.0` must not move across slices,
/// where an unpinned one does.
///
/// The comparison is against a control rather than against a fixed epsilon.
/// Pinned marginals are not bit-identical across slices — each slice combines
/// its own forward and backward messages, so the arithmetic order differs and
/// the last bit moves. What "pinned" promises is that no drift variance
/// accumulates, and the control is what makes that measurable.
#[test]
fn a_drift_scale_set_through_members_is_applied() {
fn spread(h: &H, key: &'static str) -> f64 {
let curve = h.learning_curve(&key).unwrap();
assert!(curve.len() >= 2, "{key}: expected several appearances");
let (lo, hi) = curve.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (_, g)| {
(lo.min(g.sigma()), hi.max(g.sigma()))
});
(hi - lo) / hi
}
let mut h = history();
for t in 1..=4 {
h.event(t)
.team(["player"])
.members([Member::new("pinned").with_drift_scale(0.0)])
.scores([5.0, 2.0])
.commit()
.unwrap();
// Same shape, no pinning: the control.
h.event(t)
.team(["rival"])
.team(["drifting"])
.scores([5.0, 2.0])
.commit()
.unwrap();
}
assert!(h.converge().unwrap().converged);
let pinned = spread(&h, "pinned");
let drifting = spread(&h, "drifting");
assert!(pinned < 1e-9, "pinned competitor moved: {pinned:e}");
assert!(
drifting > 1e-3,
"control did not move, so the test proves nothing: {drifting:e}"
);
}
/// `weights` still applies to a team added through `members`, and still
/// records a mismatch rather than partially applying it.
#[test]
fn weights_still_guards_a_members_team() {
let mut h = history();
let err = h
.event(1)
.team(["a"])
.members([Member::new("b"), Member::new("c")])
.weights([1.0])
.winner(0)
.commit()
.unwrap_err();
assert!(
matches!(
err,
InferenceError::MismatchedShape {
shape: trueskill_tt::Shape::Weights,
expected: 2,
got: 1,
..
}
),
"{err:?}"
);
assert!(h.current_skill(&"b").is_none(), "nothing may reach history");
}
/// An invalid `drift_scale` surfaces from `commit`, not from a panic and not
/// silently.
#[test]
fn an_invalid_drift_scale_surfaces_from_commit() {
for bad in [-1.0, f64::NAN, f64::INFINITY] {
let mut h = history();
let err = h
.event(1)
.team(["a"])
.members([Member::new("b").with_drift_scale(bad)])
.winner(0)
.commit()
.unwrap_err();
assert!(
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::DriftScale,
..
}
),
"{bad}: {err:?}"
);
assert!(h.current_skill(&"b").is_none(), "{bad} reached the history");
}
}
/// `members` and `team` compose in either order.
#[test]
fn members_and_team_interleave() {
let mut h = history();
h.event(1)
.members([Member::new("a").with_prior(PRIOR)])
.team(["b"])
.scores([3.0, 1.0])
.commit()
.unwrap();
h.event(2)
.team(["b"])
.members([Member::new("c").with_prior(PRIOR)])
.scores([2.0, 4.0])
.commit()
.unwrap();
assert!(h.converge().unwrap().converged);
for key in ["a", "b", "c"] {
assert!(h.current_skill(&key).is_some(), "{key} missing");
}
}
+149
View File
@@ -0,0 +1,149 @@
//! The evidence accessors span two independent axes — smoothed vs forward-only,
//! all-keys vs key-restricted — and all four corners must exist and differ.
//!
//! `filtered_log_evidence_for` was the missing corner: the one a per-competitor
//! prequential score needs.
use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
type H = History;
/// Two disjoint cohorts, so a key restriction is guaranteed to leave events out.
fn two_cohorts() -> H {
let mut h = H::default();
let mut events = Vec::new();
for t in 1..=6 {
for (x, y) in [("a", "b"), ("c", "d")] {
events.push(Event {
time: t,
teams: [
Team::with_members([Member::new(x)]),
Team::with_members([Member::new(y)]),
]
.into_iter()
.collect(),
outcome: Outcome::winner(0, 2),
});
}
}
h.add_events(events).expect("fixture ingests");
h.converge().expect("fixture converges");
h
}
#[test]
fn all_four_corners_are_distinct_quantities() {
let h = two_cohorts();
let smoothed_all = h.log_evidence();
let smoothed_ab = h.log_evidence_for(&[&"a", &"b"]).unwrap();
let filtered_all = h.filtered_log_evidence();
let filtered_ab = h.filtered_log_evidence_for(&[&"a", &"b"]).unwrap();
for (name, v) in [
("smoothed_all", smoothed_all),
("smoothed_ab", smoothed_ab),
("filtered_all", filtered_all),
("filtered_ab", filtered_ab),
] {
assert!(
v.is_finite() && v <= 0.0,
"{name} = {v} is not a log probability"
);
}
// Restricting to one cohort must drop the other cohort's events. Half the
// events, and the two cohorts are symmetric, so it lands near half.
assert!(
smoothed_ab > smoothed_all,
"restricting must drop evidence terms: {smoothed_ab} vs {smoothed_all}"
);
assert!(filtered_ab > filtered_all);
// The forward-only corner is a genuinely different quantity from the
// smoothed one, not an alias for it.
assert!(
(filtered_ab - smoothed_ab).abs() > 1e-9,
"filtered and smoothed restricted evidence coincide ({filtered_ab} vs {smoothed_ab}); \
one of them is not computing what it claims"
);
}
#[test]
fn restricting_to_both_cohorts_recovers_the_unrestricted_value() {
let h = two_cohorts();
// Control on the filter itself: naming every competitor must restrict
// nothing, so this catches a filter that drops events it should keep.
let all_named = h
.filtered_log_evidence_for(&[&"a", &"b", &"c", &"d"])
.unwrap();
assert!(
(all_named - h.filtered_log_evidence()).abs() < 1e-12,
"naming everyone changed the answer: {all_named} vs {}",
h.filtered_log_evidence()
);
}
/// The restriction selects *events*, not competitors: naming one member of a
/// pair that only ever plays each other selects the same events as naming both.
#[test]
fn naming_either_member_of_a_pair_selects_the_same_events() {
let h = two_cohorts();
let ab = h.filtered_log_evidence_for(&[&"a"]).unwrap();
let ab_pair = h.filtered_log_evidence_for(&[&"a", &"b"]).unwrap();
assert!(
(ab - ab_pair).abs() < 1e-12,
"a and b only ever play each other, so naming either or both selects \
the same events: {ab} vs {ab_pair}"
);
}
#[test]
fn an_unknown_key_is_an_error_here_too() {
let h = two_cohorts();
let err = h
.filtered_log_evidence_for(&[&"typo"])
.expect_err("unknown key");
assert!(matches!(err, InferenceError::UnknownKey { .. }), "{err:?}");
// Control: the same call on a known key succeeds.
h.filtered_log_evidence_for(&[&"a"]).expect("a is known");
}
#[test]
fn current_skills_agrees_with_current_skill() {
let h = two_cohorts();
let all = h.current_skills();
assert_eq!(all.len(), 4, "four competitors played");
for key in ["a", "b", "c", "d"] {
let one = h.current_skill(key).expect("played");
let from_map = all[key];
assert_eq!(
(one.mu(), one.sigma()),
(from_map.mu(), from_map.sigma()),
"current_skills disagrees with current_skill for {key}"
);
}
}
#[test]
fn current_skills_omits_a_registered_but_unplayed_competitor() {
let mut h = two_cohorts();
h.register(Member::new("e")).expect("e is new");
let all = h.current_skills();
assert!(
!all.contains_key("e"),
"a competitor with no appearances has no posterior to report"
);
assert!(
h.current_skill("e").is_none(),
"control: the singular agrees"
);
assert_eq!(all.len(), 4);
}
+254
View File
@@ -0,0 +1,254 @@
//! Forward-only (filtering) estimates: what the model knew at the time,
//! as opposed to the smoothed posteriors `learning_curve` reports.
use smallvec::smallvec;
use trueskill_tt::{ConvergenceOptions, Event, History, Member, Outcome, Team};
/// `games` one-on-one matches at successive times, won by "a" every time,
/// built with the given convergence options.
fn repeated_winner_with(games: i64, convergence: ConvergenceOptions) -> History {
let mut history = History::builder().convergence(convergence).build();
for time in 1..=games {
history
.add_events([Event {
time,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
}])
.unwrap();
}
history
}
/// `games` one-on-one matches at successive times, won by "a" every time.
///
/// This is the fixture from issue #19, where `online(true)` reported
/// `games * ln(0.5)`.
fn repeated_winner(games: i64) -> History {
repeated_winner_with(games, ConvergenceOptions::default())
}
/// The default 30-iteration cap leaves a residual around 1e-6, which would
/// swamp these comparisons. Drive both sides well past the fixed point.
fn tight() -> ConvergenceOptions {
ConvergenceOptions {
max_iter: 2_000,
epsilon: 1e-12,
..ConvergenceOptions::default()
}
}
#[test]
fn filtered_evidence_sits_between_coin_flip_and_batch() {
let mut history = repeated_winner(5);
let _ = history.converge().unwrap();
let coin_flip = 5.0 * 0.5f64.ln();
let batch = history.log_evidence();
let filtered = history.filtered_log_evidence();
assert!(
filtered > coin_flip,
"filtered evidence {filtered} is at or below {coin_flip}, the all-coin-flip \
value the inert online flag reported; game one is a coin flip but games two \
through five are not"
);
assert!(
filtered < batch,
"filtered evidence {filtered} is not below the smoothed {batch}; filtering \
scores each game on strictly less information than smoothing does"
);
}
#[test]
fn filtered_first_point_is_less_certain_than_smoothed() {
let mut history = repeated_winner(12);
let _ = history.converge().unwrap();
let smoothed = history.learning_curve("a").unwrap();
let filtered = history.filtered_learning_curve("a").unwrap();
assert_eq!(
smoothed.len(),
filtered.len(),
"both curves must cover the same time points"
);
let (smoothed_time, first_smoothed) = smoothed[0];
let (filtered_time, first_filtered) = filtered[0];
assert_eq!(smoothed_time, filtered_time);
assert!(
first_filtered.sigma() > first_smoothed.sigma(),
"filtered sigma {} at the first point is not above smoothed {}; the smoother \
collapses uncertainty before the first round is drawn, which is the whole \
reason this method exists",
first_filtered.sigma(),
first_smoothed.sigma()
);
assert!(
first_filtered.sigma() < trueskill_tt::SIGMA,
"filtered sigma {} at the first point is not below the prior {}; one game was \
played, so some uncertainty must have been resolved",
first_filtered.sigma(),
trueskill_tt::SIGMA
);
for pair in filtered.windows(2) {
assert!(
pair[1].1.mu() > pair[0].1.mu(),
"filtered mu must climb at every step for a competitor who wins every \
game: t={} mu={} then t={} mu={}",
pair[0].0,
pair[0].1.mu(),
pair[1].0,
pair[1].1.mu()
);
}
}
#[test]
fn filtered_curves_plural_agrees_with_singular() {
let mut history = repeated_winner(4);
let _ = history.converge().unwrap();
let curves = history.filtered_learning_curves();
assert_eq!(
curves["b"],
history.filtered_learning_curve("b").unwrap(),
"the plural form must agree with the singular for the same key"
);
}
#[test]
fn filtered_evidence_is_invariant_to_convergence() {
let mut history = repeated_winner_with(6, tight());
let before = history.filtered_log_evidence();
let report = history.converge().unwrap();
assert!(
report.converged,
"fixture must converge: {:?}",
report.final_step
);
let after = history.filtered_log_evidence();
assert!(
(before - after).abs() < 1e-8,
"filtered evidence moved across converge(): {before} -> {after}. The pass must \
carry its own forward messages; anything reading skill.forward shows exactly \
this drift, because converge() contaminates it with backward information."
);
}
#[test]
fn single_slice_filtered_matches_smoothed() {
let mut history = History::builder().convergence(tight()).build();
history
.add_events([
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("c")]),
Team::with_members([Member::new("d")]),
],
outcome: Outcome::winner(0, 2),
},
])
.unwrap();
let _ = history.converge().unwrap();
let smoothed = history.learning_curve("a").unwrap();
let filtered = history.filtered_learning_curve("a").unwrap();
assert_eq!(smoothed.len(), 1);
assert_eq!(filtered.len(), 1);
assert!(
(smoothed[0].1.mu() - filtered[0].1.mu()).abs() < 1e-8
&& (smoothed[0].1.sigma() - filtered[0].1.sigma()).abs() < 1e-8,
"one slice has no future to propagate back, so filtered and smoothed must \
agree: smoothed mu={} sigma={}, filtered mu={} sigma={}",
smoothed[0].1.mu(),
smoothed[0].1.sigma(),
filtered[0].1.mu(),
filtered[0].1.sigma()
);
}
#[test]
fn filtered_curves_do_not_depend_on_ingestion_order() {
let events = |time: i64, winner: &'static str, loser: &'static str| Event {
time,
teams: smallvec![
Team::with_members([Member::new(winner)]),
Team::with_members([Member::new(loser)]),
],
outcome: Outcome::winner(0, 2),
};
let all = vec![
events(1, "a", "b"),
events(1, "c", "d"),
events(1, "a", "c"),
events(1, "b", "d"),
events(2, "a", "d"),
events(2, "b", "c"),
events(2, "a", "b"),
];
let mut batched = History::builder().convergence(tight()).build();
batched.add_events(all.clone()).unwrap();
let _ = batched.converge().unwrap();
let mut incremental = History::builder().convergence(tight()).build();
for event in all {
incremental.add_events([event]).unwrap();
}
let _ = incremental.converge().unwrap();
let from_batched = batched.filtered_learning_curve("a").unwrap();
let from_incremental = incremental.filtered_learning_curve("a").unwrap();
assert_eq!(from_batched.len(), from_incremental.len());
for ((time_b, gaussian_b), (time_i, gaussian_i)) in
from_batched.iter().zip(from_incremental.iter())
{
assert_eq!(time_b, time_i);
assert!(
(gaussian_b.mu() - gaussian_i.mu()).abs() < 1e-8
&& (gaussian_b.sigma() - gaussian_i.sigma()).abs() < 1e-8,
"at t={time_b}: batched mu={} sigma={}, incremental mu={} sigma={}",
gaussian_b.mu(),
gaussian_b.sigma(),
gaussian_i.mu(),
gaussian_i.sigma()
);
}
}
+176 -5
View File
@@ -8,7 +8,7 @@ fn default_rating() -> R {
R::new( R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0), Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0, 25.0 / 6.0,
ConstantDrift(25.0 / 300.0), ConstantDrift::new(25.0 / 300.0),
) )
} }
@@ -32,14 +32,21 @@ fn game_ranked_1v1_golden() {
fn game_one_v_one_shortcut() { fn game_one_v_one_shortcut() {
let a = default_rating(); let a = default_rating();
let b = default_rating(); let b = default_rating();
let (a_post, b_post) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2)).unwrap(); 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!(a_post.mu() > 25.0);
assert!(b_post.mu() < 25.0); assert!(b_post.mu() < 25.0);
// It returns a game like every other constructor, so evidence is askable.
// Two identical ratings make either result equally likely.
assert!((game.log_evidence() - 0.5_f64.ln()).abs() < 1e-12);
} }
#[test] #[test]
fn game_ranked_rejects_bad_p_draw() { fn game_ranked_rejects_bad_p_draw() {
let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0)); let a = R::new(Gaussian::default(), 1.0, ConstantDrift::new(0.0));
let err = Game::<i64, _>::ranked( let err = Game::<i64, _>::ranked(
&[&[a], &[a]], &[&[a], &[a]],
Outcome::winner(0, 2), Outcome::winner(0, 2),
@@ -50,12 +57,12 @@ fn game_ranked_rejects_bad_p_draw() {
}, },
) )
.unwrap_err(); .unwrap_err();
assert!(matches!(err, InferenceError::InvalidProbability { .. })); assert!(matches!(err, InferenceError::InvalidParameter { .. }));
} }
#[test] #[test]
fn game_ranked_rejects_mismatched_ranks() { fn game_ranked_rejects_mismatched_ranks() {
let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0)); let a = R::new(Gaussian::default(), 1.0, ConstantDrift::new(0.0));
let err = Game::<i64, _>::ranked( let err = Game::<i64, _>::ranked(
&[&[a], &[a]], &[&[a], &[a]],
Outcome::ranking([0, 1, 2]), Outcome::ranking([0, 1, 2]),
@@ -95,3 +102,167 @@ fn game_log_evidence_is_finite() {
assert!(g.log_evidence().is_finite()); assert!(g.log_evidence().is_finite());
assert!(g.log_evidence() < 0.0); assert!(g.log_evidence() < 0.0);
} }
/// `one_v_one` used to hardcode `GameOptions::default()`, so a 1v1 could
/// never set `p_draw` and a drawn 1v1 was unreachable through it.
#[test]
fn one_v_one_honours_the_draw_probability_it_is_given() {
let a = default_rating();
let b = default_rating();
// Default options still reject a draw, because the default p_draw is zero.
let err = Game::<i64, _>::one_v_one(&a, &b, Outcome::draw(2), &GameOptions::default())
.expect_err("a draw needs a positive p_draw");
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
// With a draw probability supplied it succeeds — which was impossible
// before the signature took options.
let options = GameOptions {
p_draw: 0.25,
..GameOptions::default()
};
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);
assert!(a_post.sigma() < 25.0 / 3.0);
}
/// Convergence options reach the 1v1 path too, not just `p_draw`.
#[test]
fn one_v_one_honours_convergence_options() {
let a = default_rating();
let b = default_rating();
let options = GameOptions {
convergence: ConvergenceOptions::default(),
..GameOptions::default()
};
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
/// ingestion chokepoint, so it needs its own boundary — and did not have one.
///
/// A one-team game panicked at `src/game.rs:317` with "range start index 1 out
/// of range for slice of length 0", in release, from safe API. This is the
/// same defect `tests/ingestion_shape.rs` covers for `History`; fixing that
/// path left this one open, because they share no validation.
mod malformed_games {
use super::*;
#[test]
fn a_one_team_ranked_game_is_an_error_not_a_panic() {
let a = default_rating();
let err = Game::<i64, _>::ranked(&[&[a]], Outcome::winner(0, 1), &GameOptions::default())
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}"
);
}
#[test]
fn a_one_team_scored_game_is_an_error_not_a_panic() {
let a = default_rating();
let err = Game::<i64, _>::scored(
&[&[a]],
Outcome::scores([1.0]),
&GameOptions {
score_sigma: 1.0,
..GameOptions::default()
},
)
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}"
);
}
#[test]
fn a_zero_team_game_is_an_error() {
let err =
Game::<i64, ConstantDrift>::ranked(&[], Outcome::ranking([]), &GameOptions::default())
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
"{err:?}"
);
}
/// The quiet half: an empty team contributed no performance, so the game
/// returned a finite posterior for its opponent as though it had won one.
#[test]
fn an_empty_team_is_an_error() {
let a = default_rating();
let err =
Game::<i64, _>::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default())
.unwrap_err();
assert!(
matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
"{err:?}"
);
}
#[test]
fn a_non_finite_score_is_an_error() {
let a = default_rating();
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let err = Game::<i64, _>::scored(
&[&[a], &[a]],
Outcome::scores([bad, 1.0]),
&GameOptions {
score_sigma: 1.0,
..GameOptions::default()
},
)
.unwrap_err();
assert!(
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Score,
..
}
),
"{bad}: {err:?}"
);
}
}
/// `free_for_all` and `one_v_one` build their teams internally, so they
/// must keep working — the check must not catch well-formed games.
#[test]
fn well_formed_games_are_untouched() {
let a = default_rating();
assert!(
Game::<i64, _>::ranked(
&[&[a], &[a]],
Outcome::winner(0, 2),
&GameOptions::default()
)
.is_ok()
);
assert!(
Game::<i64, _>::free_for_all(
&[&a, &a, &a],
Outcome::ranking([0, 1, 2]),
&GameOptions::default()
)
.is_ok()
);
assert!(
Game::<i64, _>::one_v_one(&a, &a, Outcome::winner(0, 2), &GameOptions::default())
.is_ok()
);
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Per-key queries must distinguish "I have never heard of this key" from a
//! genuine, empty-but-real answer.
//!
//! Each test carries a control: the same call on a key the history *does* know,
//! so it cannot pass merely because everything returns the same thing.
use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
type H = History;
fn history() -> H {
let mut h = H::default();
h.add_events((1..=4).map(|t| {
Event {
time: t,
teams: [
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
]
.into_iter()
.collect(),
outcome: Outcome::winner(0, 2),
}
}))
.expect("fixture ingests");
h.converge().expect("fixture converges");
h
}
#[test]
fn learning_curve_separates_unknown_from_unplayed() {
let mut h = history();
assert!(h.learning_curve("typo").is_none(), "unknown key is None");
assert_eq!(
h.learning_curve("a").expect("a is known").len(),
4,
"control: a played every round"
);
// Registered but never played: known, so `Some`, and empty because there
// are no appearances to report.
h.register(Member::new("c")).expect("c is new");
assert_eq!(
h.learning_curve("c").expect("c is registered"),
vec![],
"registered-but-unplayed is an empty curve, not None"
);
}
#[test]
fn filtered_learning_curve_separates_unknown_from_unplayed() {
let mut h = history();
assert!(h.filtered_learning_curve("typo").is_none());
assert_eq!(
h.filtered_learning_curve("a").expect("a is known").len(),
4,
"control"
);
h.register(Member::new("c")).expect("c is new");
assert_eq!(
h.filtered_learning_curve("c").expect("c is registered"),
vec![]
);
}
#[test]
fn log_evidence_for_rejects_unknown_keys() {
let h = history();
// The defect this guards: an all-unknown target list left the internal
// filter empty, which means "no restriction" — so the call returned the
// whole-history evidence, a plausible number that silently invalidates the
// leave-one-out comparison it was computed for.
let whole = h.log_evidence();
let err = h
.log_evidence_for(&[&"typo"])
.expect_err("unknown key is an error");
assert!(
matches!(err, InferenceError::UnknownKey { .. }),
"expected UnknownKey, got {err:?}"
);
// Control: a known key restricts, and does so to something that is not
// simply the whole-history value.
let restricted = h.log_evidence_for(&[&"a"]).expect("a is known");
assert!(restricted.is_finite());
assert!(restricted <= 0.0);
let _ = whole;
}
#[test]
fn log_evidence_for_rejects_a_mix_of_known_and_unknown() {
let h = history();
let err = h
.log_evidence_for(&[&"a", &"typo"])
.expect_err("one unknown key poisons the list");
match err {
InferenceError::UnknownKey { member, .. } => {
assert_eq!(member, 1, "the reported position is the offending key's");
}
other => panic!("expected UnknownKey, got {other:?}"),
}
h.log_evidence_for(&[&"a", &"b"])
.expect("control: both known");
}
+227
View File
@@ -0,0 +1,227 @@
//! Ingesting the same events must give the same answer however they were
//! batched.
//!
//! The numerical goldens all ingest in a single call with one slice per
//! timestamp, so they never exercise the "append to an existing slice" path.
//! These do.
use smallvec::smallvec;
use trueskill_tt::{ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team};
/// Converge tightly: the default cap of 30 iterations leaves a residual around
/// 1e-6, which would swamp the comparison. Both paths must reach the same
/// fixed point, so drive both well past it.
fn tight() -> ConvergenceOptions {
ConvergenceOptions {
max_iter: 2_000,
epsilon: 1e-12,
..ConvergenceOptions::default()
}
}
fn event(a: &str, b: &str, time: i64) -> Event<i64, String> {
Event {
time,
teams: smallvec![
Team::with_members([Member::new(a.to_string())]),
Team::with_members([Member::new(b.to_string())]),
],
outcome: Outcome::winner(0, 2),
}
}
/// Like [`event`], but `a` carries competitor configuration.
///
/// `prior` and `drift_scale` configure the competitor rather than the event, so
/// they are the part of ingestion most exposed to order: they are consumed once,
/// where the competitor's state is written.
fn configured_event(a: &str, b: &str, time: i64, scale: f64) -> Event<i64, String> {
Event {
time,
teams: smallvec![
Team::with_members([Member::new(a.to_string()).with_drift_scale(scale)]),
Team::with_members([Member::new(b.to_string())]),
],
outcome: Outcome::winner(0, 2),
}
}
fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> {
let mut h: History<String> = History::builder()
.key_type::<String>()
.convergence(tight())
.build();
if batched {
h.add_events(events).unwrap();
} else {
for ev in events {
h.add_events(std::iter::once(ev)).unwrap();
}
}
let report = h.converge().unwrap();
assert!(
report.converged,
"fixture must converge before results can be compared; final step {:?}",
report.final_step
);
let mut skills: Vec<(String, Gaussian)> = h
.learning_curves()
.into_iter()
.map(|(key, curve)| (key, curve.last().unwrap().1))
.collect();
skills.sort_by(|a, b| a.0.cmp(&b.0));
skills
}
fn assert_same(batched: &[(String, Gaussian)], incremental: &[(String, Gaussian)], what: &str) {
assert_eq!(
batched.len(),
incremental.len(),
"{what}: competitor count differs"
);
for ((kb, gb), (ki, gi)) in batched.iter().zip(incremental.iter()) {
assert_eq!(kb, ki, "{what}: key order differs");
assert!(
(gb.mu() - gi.mu()).abs() < 1e-8 && (gb.sigma() - gi.sigma()).abs() < 1e-8,
"{what}: {kb} differs — batched mu={} sigma={}, incremental mu={} sigma={}",
gb.mu(),
gb.sigma(),
gi.mu(),
gi.sigma()
);
}
}
/// All events share one timestamp, so incremental ingestion repeatedly appends
/// to an existing slice.
#[test]
fn same_slice_incremental_matches_batched() {
let events = vec![
event("a", "b", 1),
event("c", "d", 1),
event("e", "f", 1),
event("a", "c", 1),
event("b", "e", 1),
];
let batched = converged_skills(events.clone(), true);
let incremental = converged_skills(events, false);
assert_same(&batched, &incremental, "single shared slice");
}
/// Distinct timestamps, so each append lands in a fresh slice appended after
/// the existing ones.
#[test]
fn distinct_slices_incremental_matches_batched() {
let events = vec![
event("a", "b", 1),
event("b", "c", 2),
event("c", "a", 3),
event("a", "c", 4),
];
let batched = converged_skills(events.clone(), true);
let incremental = converged_skills(events, false);
assert_same(&batched, &incremental, "distinct slices");
}
/// Several events per timestamp across several timestamps — appends to
/// existing slices interleaved with new ones.
#[test]
fn mixed_slices_incremental_matches_batched() {
let events = vec![
event("a", "b", 1),
event("c", "d", 1),
event("a", "c", 2),
event("b", "d", 2),
event("a", "d", 3),
event("b", "c", 3),
];
let batched = converged_skills(events.clone(), true);
let incremental = converged_skills(events, false);
assert_same(&batched, &incremental, "mixed slices");
}
/// Appending an event to a slice that is *not* the most recent one exercises
/// the forward refresh of every later slice.
#[test]
fn back_dated_event_matches_batched() {
let events = vec![
event("a", "b", 1),
event("b", "c", 5),
event("c", "a", 9),
// arrives last, but belongs to the middle slice
event("a", "c", 5),
];
let batched = converged_skills(events.clone(), true);
let incremental = converged_skills(events, false);
assert_same(&batched, &incremental, "back-dated event");
}
/// The invariant this file protects was only ever checked for *unconfigured*
/// competitors — every helper above built members with `Member::new`.
///
/// Configuration is the part most exposed to ordering, because it is consumed
/// once at the point the competitor's state is written rather than replayed per
/// event. These cover it.
#[test]
fn configured_competitors_are_order_independent() {
let events = vec![
configured_event("a", "b", 0, 0.0),
configured_event("a", "c", 1, 0.0),
configured_event("a", "b", 2, 0.0),
event("b", "c", 3),
];
assert_same(
&converged_skills(events.clone(), true),
&converged_skills(events, false),
"configuration repeated on every appearance",
);
}
/// Configuration supplied only on a *later* event is the case that used to be
/// silently dropped. It must now reach the same fit either way it is ingested.
#[test]
fn late_configuration_is_order_independent() {
let events = vec![
event("a", "b", 0),
configured_event("a", "c", 1, 0.0),
event("a", "b", 2),
];
assert_same(
&converged_skills(events.clone(), true),
&converged_skills(events, false),
"configuration supplied after first appearance",
);
}
/// And it must actually be doing something — an implementation that dropped
/// configuration entirely would pass both tests above.
#[test]
fn configuration_changes_the_fit_however_it_is_ingested() {
let configured = vec![
event("a", "b", 0),
configured_event("a", "c", 1, 0.0),
event("a", "b", 2),
];
let plain = vec![event("a", "b", 0), event("a", "c", 1), event("a", "b", 2)];
for batched in [true, false] {
let with = converged_skills(configured.clone(), batched);
let without = converged_skills(plain.clone(), batched);
assert!(
with.iter()
.zip(&without)
.any(|((_, x), (_, y))| (x.sigma() - y.sigma()).abs() > 1e-9),
"batched={batched}: configuration had no effect, so the order tests are vacuous"
);
}
}
+201
View File
@@ -0,0 +1,201 @@
//! Malformed events must be rejected at the ingestion boundary.
//!
//! Every case here was reachable from safe public API in a release build. Two
//! of them are the two shapes this crate's defects keep taking: a panic from
//! deep inside inference, and a finite, plausible-looking posterior computed
//! from an event that should never have been accepted.
//!
//! `InferenceError::NotEnoughTeams` and `EmptyTeam` already existed when these
//! were found — they were checked on the prediction paths and nowhere else, so
//! ingestion could still manufacture the states they describe.
use smallvec::smallvec;
use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
type Ev = Event<i64, &'static str>;
fn history() -> History {
History::builder().score_sigma(1.0).build()
}
fn teams(names: &[&[&'static str]]) -> smallvec::SmallVec<[Team<&'static str>; 4]> {
names
.iter()
.map(|team| Team::with_members(team.iter().map(|k| Member::new(*k))))
.collect()
}
/// The regression this file exists for: `run_chain` builds one diff link per
/// adjacent pair of teams, so a one-team event left it indexing `links[1..]`
/// on an empty vector and panicked — in release, from `History::add_events`.
#[test]
fn a_one_team_event_is_an_error_not_a_panic() {
let mut h = history();
let err = h
.add_events(vec![Ev {
time: 1,
teams: teams(&[&["a"]]),
outcome: Outcome::winner(0, 1),
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}"
);
}
#[test]
fn a_zero_team_event_is_an_error() {
let mut h = history();
let err = h
.add_events(vec![Ev {
time: 1,
teams: smallvec![],
outcome: Outcome::ranking([]),
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
"{err:?}"
);
}
/// The quiet half. An empty team contributes no performance, so before this
/// was rejected the event converged and handed back a finite posterior for its
/// opponent — a plausible constant computed from nothing.
#[test]
fn an_empty_team_is_an_error_rather_than_a_free_win() {
let mut h = history();
let err = h
.add_events(vec![Ev {
time: 1,
teams: teams(&[&[], &["b"]]),
outcome: Outcome::winner(0, 2),
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
"{err:?}"
);
// Nothing was recorded, so the history is still empty.
assert!(h.current_skill(&"b").is_none());
}
#[test]
fn an_empty_team_is_reported_by_position() {
let mut h = history();
let err = h
.add_events(vec![Ev {
time: 1,
teams: teams(&[&["a"], &[]]),
outcome: Outcome::winner(0, 2),
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::EmptyTeam { team: 1, .. }),
"{err:?}"
);
}
/// A NaN score used to ingest cleanly. `converge` reported `NonFiniteResult`,
/// but a caller who read `current_skill` first was handed `tau: NaN` with
/// nothing to say so.
#[test]
fn a_non_finite_score_is_rejected_at_ingestion() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut h = history();
let err = h
.add_events(vec![Ev {
time: 1,
teams: teams(&[&["a"], &["b"]]),
outcome: Outcome::scores([bad, 0.0]),
}])
.unwrap_err();
assert!(
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Score,
..
}
),
"{bad}: {err:?}"
);
assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway");
}
}
/// A non-finite weight behaved exactly as `0.0` — the member contributed
/// nothing — while `converge` reported `converged: true` after one iteration
/// with a step of `(0.0, 0.0)`. So a NaN arriving from a division or a parse
/// was indistinguishable from a deliberate zero, and looked like a clean fit.
#[test]
fn a_non_finite_weight_is_rejected_at_ingestion() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut h = history();
let err = h
.event(1)
.team(["a"])
.weights([bad])
.team(["b"])
.winner(0)
.commit()
.unwrap_err();
assert!(
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Weight,
..
}
),
"{bad}: {err:?}"
);
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
}
}
/// Zero and negative weights are expressible choices about how much a member
/// contributes, not malformed input, and `tests/degenerate_inputs.rs` pins
/// their behaviour deliberately. Rejecting non-finite values must not catch
/// them too.
#[test]
fn zero_and_negative_weights_still_ingest() {
for w in [0.0, -1.0, 0.5] {
let mut h = history();
h.event(1)
.team(["a"])
.weights([w])
.team(["b"])
.winner(0)
.commit()
.unwrap_or_else(|e| panic!("weight {w} should ingest: {e:?}"));
assert!(h.current_skill(&"a").is_some(), "weight {w}");
}
}
/// The fluent builder routes through the same chokepoint, so it inherits the
/// checks rather than needing its own.
#[test]
fn the_event_builder_inherits_the_shape_checks() {
let mut h = history();
let err = h.event(1).team(["a"]).winner(0).commit().unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}"
);
}
/// A well-formed event is untouched by any of this.
#[test]
fn a_well_formed_event_still_ingests() {
let mut h = history();
h.add_events(vec![Ev {
time: 1,
teams: teams(&[&["a"], &["b"]]),
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
assert!(h.converge().unwrap().converged);
assert!(h.current_skill(&"a").unwrap().mu() > h.current_skill(&"b").unwrap().mu());
}
+353
View File
@@ -0,0 +1,353 @@
//! `History::joint` factorises once and answers many questions.
//!
//! The contract that matters is *identity*: a `Joint` must return exactly what
//! the one-shot call returns, bit for bit. A faster path that quietly disagreed
//! with the slow one would be worse than no fast path — a caller would get
//! different numbers depending on how many questions they happened to ask.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
UnknownKeys,
};
type H = History;
fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> {
Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([sa, sb]),
}
}
fn ranked(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> {
Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::winner(0, 2),
}
}
fn history(unknown: UnknownKeys) -> H {
History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.5))
.unknown_keys(unknown)
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build()
}
/// Several slices, competitors with different last appearances, so `latest`
/// and `at_slice` both have work to do.
fn fitted(unknown: UnknownKeys) -> H {
let mut h = history(unknown);
h.add_events(vec![
duel("a", "b", 1, 5.0, 2.0),
duel("c", "d", 1, 3.0, 3.5),
duel("a", "c", 2, 6.0, 1.0),
duel("b", "d", 3, 4.0, 3.0),
duel("a", "d", 4, 7.0, 2.0),
duel("b", "c", 5, 2.0, 4.0),
])
.unwrap();
let report = h.converge().unwrap();
assert!(report.converged, "fixture must converge");
h
}
const PAIRS: [(&str, &str); 6] = [
("a", "b"),
("a", "c"),
("a", "d"),
("b", "c"),
("b", "d"),
("c", "d"),
];
/// A joint reused across questions answers exactly what a fresh one per
/// question does. That is the whole correctness claim behind caching the
/// factorisation (#51); it used to be checked against the `History` one-shot
/// wrappers, which were deleted in #78, so it is checked against a fresh
/// factorisation instead — the same comparison, without the wrapper.
#[test]
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.joint().unwrap().posterior_of(&terms).unwrap();
let cached = joint.posterior_of(&terms).unwrap();
assert_eq!(one_shot.mu(), cached.mu(), "{a} - {b}");
assert_eq!(one_shot.variance(), cached.variance(), "{a} - {b}");
}
}
#[test]
fn a_joint_agrees_at_a_pinned_time_too() {
let h = fitted(UnknownKeys::Reject);
let joint = h.joint().unwrap();
for time in 1..=5 {
for (a, b) in PAIRS {
let terms = [(&a, 1.0), (&b, -1.0)];
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.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:?}"),
}
}
}
}
#[test]
fn a_joint_scores_candidate_matchups_identically() {
let h = fitted(UnknownKeys::Reject);
let joint = h.joint().unwrap();
let (a, b) = ("a", "b");
let target = [(&a, 1.0), (&b, -1.0)];
for (x, y) in PAIRS {
let teams: [&[&&str]; 2] = [&[&x], &[&y]];
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}");
}
}
/// The whole point: a competitor appears once per slice, so the joint is over
/// appearances rather than competitors, and a caller sizing a batch needs to
/// know which.
#[test]
fn variables_counts_appearances_not_competitors() {
let h = fitted(UnknownKeys::Reject);
let joint = h.joint().unwrap();
// Four competitors, twelve appearances across five slices, all with
// positive drift between them, so no two collapse.
assert_eq!(joint.variables(), 12);
}
/// How much the collapse is worth, which is the part a caller has to plan
/// around: a drift-free competitor contributes **one** variable however long
/// the history, so the same events at `gamma = 0` and `gamma > 0` differ by
/// roughly the slice count in problem size — and by its cube in solve time.
///
/// Reported by a consumer as an 8x difference in solve time on a ~2,000-node,
/// 76-slice model (787 ms career against 6,214 ms drifting). This pins the
/// mechanism behind that so a change to the collapse rule cannot quietly
/// remove it.
#[test]
fn drift_free_competitors_shrink_the_joint_by_the_slice_count() {
fn variables(gamma: f64) -> usize {
let mut h = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(gamma))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build();
h.add_events(
(1..=10)
.map(|t| duel("a", "b", t, 5.0, 2.0))
.collect::<Vec<_>>(),
)
.unwrap();
let _ = h.converge().unwrap();
h.joint().unwrap().variables()
}
let drifting = variables(0.5);
let career = variables(0.0);
// Two competitors over ten slices: twenty appearances, or two variables.
assert_eq!(drifting, 20);
assert_eq!(career, 2);
assert_eq!(
drifting / career,
10,
"collapse should track the slice count"
);
}
/// With `drift = 0` consecutive appearances are the same latent variable, so
/// the joint is smaller than the appearance count.
#[test]
fn pinned_competitors_collapse_consecutive_appearances() {
let mut h = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.0))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build();
h.add_events(vec![
duel("a", "b", 1, 5.0, 2.0),
duel("a", "b", 2, 4.0, 3.0),
duel("a", "b", 3, 6.0, 1.0),
])
.unwrap();
assert!(h.converge().unwrap().converged);
assert_eq!(h.joint().unwrap().variables(), 2);
}
#[test]
fn a_ranked_history_has_no_exact_joint() {
let mut h = history(UnknownKeys::Reject);
h.add_events(vec![duel("a", "b", 1, 5.0, 2.0), ranked("a", "b", 2)])
.unwrap();
let _ = h.converge().unwrap();
assert!(matches!(
h.joint().unwrap_err(),
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::EmptyHistory
));
}
/// Unknown keys are decided per query, not when the joint is factorised — the
/// factorisation does not depend on the question.
#[test]
fn unknown_keys_are_rejected_per_query() {
let h = fitted(UnknownKeys::Reject);
let joint = h.joint().unwrap();
let (a, z) = ("a", "nobody");
assert!(matches!(
joint.posterior_of(&[(&a, 1.0), (&z, -1.0)]).unwrap_err(),
InferenceError::UnknownKey { .. }
));
// The handle is still usable afterwards.
let b = "b";
assert!(joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).is_ok());
}
/// Under `Prior`, an unseen competitor is independent of everything in the
/// history, and a reused joint must add the same prior variance a fresh one
/// does.
#[test]
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.joint().unwrap().posterior_of(&terms).unwrap();
let cached = joint.posterior_of(&terms).unwrap();
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.
///
/// The collapse rule used to fire only at `drift <= 0.0` exactly. Anything
/// smaller-but-positive got an explicit `1.0 / drift` precision, and at
/// `drift = 1e-16` that entry is `1e16` — so `1e16 + 0.28` rounds back to
/// `1e16` and the prior and contrasts are annihilated in the stored `f64`.
///
/// Measured before the fix, at `drift_scale = 1e-10` this returned a variance
/// **12 000x too small** (a 111x overconfident interval) as `Ok`, with a band
/// just above it returning a misleading `JointUnavailable`.
#[test]
fn a_drift_too_small_to_represent_collapses_rather_than_corrupting() {
fn variance(scale: f64) -> f64 {
let mut h: History<String> = History::builder()
.key_type::<String>()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.5))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build();
let mut events = Vec::new();
for t in 0..15i64 {
for k in 0..4usize {
let x = format!("p{}", (t as usize * 4 + k) % 8);
let y = format!("p{}", (t as usize * 4 + k + 3) % 8);
events.push(Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(x).with_drift_scale(scale)]),
Team::with_members([Member::new(y).with_drift_scale(scale)]),
],
outcome: Outcome::scores([3.0, 1.0]),
});
}
}
h.add_events(events).unwrap();
assert!(h.converge().unwrap().converged);
let (a, b) = ("p0".to_string(), "p1".to_string());
let joint = h
.joint()
.expect("a tiny drift must not make the joint unavailable");
let g = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).unwrap();
g.sigma() * g.sigma()
}
let collapsed = variance(0.0);
// Below the threshold every scale must reach the collapsed answer exactly,
// and none may error.
for scale in [1e-3, 1e-4, 1e-6, 1e-8, 1e-10, 1e-12] {
let v = variance(scale);
assert_eq!(
v.to_bits(),
collapsed.to_bits(),
"drift_scale {scale:e}: {v} vs collapsed {collapsed}"
);
}
// Above it, real drift is still modelled — otherwise this test would pass
// by collapsing everything.
let drifting = variance(1e-2);
assert!(
(drifting - collapsed).abs() / collapsed > 1e-5,
"a drift of 1e-2 must still move the answer: {drifting} vs {collapsed}"
);
}
+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();
}
+5 -4
View File
@@ -3,7 +3,7 @@
//! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the //! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the
//! moment-space `Sub` in the game chain propagated into every skill once the slice grew past //! moment-space `Sub` in the game chain propagated into every skill once the slice grew past
//! ~75 competitors (e.g. a real ranking dataset with hundreds of players). //! ~75 competitors (e.g. a real ranking dataset with hundreds of players).
use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS, NullObserver}; use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS};
/// Tiny deterministic LCG — avoids a dev-dependency on `rand`. /// Tiny deterministic LCG — avoids a dev-dependency on `rand`.
struct Lcg(u64); struct Lcg(u64);
@@ -24,10 +24,11 @@ impl Lcg {
} }
fn nan_after_fit(players: usize) -> usize { fn nan_after_fit(players: usize) -> usize {
let mut h: History<i64, ConstantDrift, NullObserver, String> = History::builder_with_key() let mut h: History<String> = History::builder()
.key_type::<String>()
.beta(1.0) .beta(1.0)
.sigma(6.0) .sigma(6.0)
.drift(ConstantDrift(0.1)) .drift(ConstantDrift::new(0.1))
.convergence(ConvergenceOptions { .convergence(ConvergenceOptions {
max_iter: ITERATIONS, max_iter: ITERATIONS,
epsilon: EPSILON, epsilon: EPSILON,
@@ -46,7 +47,7 @@ fn nan_after_fit(players: usize) -> usize {
let (w, l) = if rng.coin() { (a, b) } else { (b, a) }; let (w, l) = if rng.coin() { (a, b) } else { (b, a) };
h.record_winner(&ids[w], &ids[l], 0).unwrap(); h.record_winner(&ids[w], &ids[l], 0).unwrap();
} }
h.converge().unwrap(); let _ = h.converge().unwrap();
ids.iter() ids.iter()
.filter(|id| { .filter(|id| {
+175
View File
@@ -0,0 +1,175 @@
//! The libm rule, enforced rather than asserted in prose.
//!
//! CLAUDE.md requires transcendentals to go through `libm`, not `std`:
//!
//! > IEEE 754 pins the basic operations and `sqrt` but says nothing about
//! > `exp`/`log`/`erf`, and `std` delegates to the *system* math library —
//! > measured, `f64::exp` and `libm::exp` disagree on 9.7% of inputs by one
//! > ULP. Since inference is an iterative fixed point, one ULP can change an
//! > iteration count.
//!
//! The rule was stated clearly and still violated in three production sites,
//! one of them `hypot` on the path of every scored event — whose measured
//! divergence, 12.1%, is *higher* than the `exp` figure the rule cites as its
//! own justification. Prose is evidently not enough, so this is a test.
//!
//! Tests may use either, which the crate documents, so `#[cfg(test)]` blocks
//! are excluded.
use std::{fs, path::Path};
/// Method-call spellings that reach the system math library.
///
/// `sqrt` is deliberately absent: IEEE 754 specifies it exactly, so `std` and
/// `libm` cannot disagree. `abs`, `recip`, `powi` and `mul_add` are likewise
/// exact or specified.
const FORBIDDEN: &[&str] = &[
"exp", "exp2", "exp_m1", "ln", "ln_1p", "log", "log2", "log10", "powf", "sin", "cos", "tan",
"asin", "acos", "atan", "atan2", "sinh", "cosh", "tanh", "hypot", "cbrt", "erf", "erfc",
];
/// Strip `#[cfg(test)]` items by brace matching, plus comments and string
/// literals, so a mention in prose is not mistaken for a call.
fn production_code(source: &str) -> String {
let mut out = String::with_capacity(source.len());
let bytes: Vec<char> = source.chars().collect();
let mut i = 0;
while i < bytes.len() {
let rest: String = bytes[i..].iter().take(16).collect();
if rest.starts_with("#[cfg(test)]") {
// Skip to the opening brace of the guarded item, then past its
// matching close.
let mut j = i;
while j < bytes.len() && bytes[j] != '{' {
j += 1;
}
let mut depth = 0usize;
while j < bytes.len() {
match bytes[j] {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
j += 1;
break;
}
}
_ => {}
}
j += 1;
}
i = j;
continue;
}
if rest.starts_with("//") {
while i < bytes.len() && bytes[i] != '\n' {
i += 1;
}
continue;
}
if rest.starts_with("/*") {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == '*' && bytes[i + 1] == '/') {
i += 1;
}
i += 2;
continue;
}
if bytes[i] == '"' {
i += 1;
while i < bytes.len() && bytes[i] != '"' {
if bytes[i] == '\\' {
i += 1;
}
i += 1;
}
i += 1;
continue;
}
out.push(bytes[i]);
i += 1;
}
out
}
fn rust_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for entry in fs::read_dir(dir).expect("read src") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
#[test]
fn production_code_never_calls_a_std_transcendental() {
let mut files = Vec::new();
rust_files(Path::new("src"), &mut files);
assert!(files.len() > 10, "expected to find the crate's sources");
let mut offences = Vec::new();
for path in &files {
let source = fs::read_to_string(path).expect("read source");
let code = production_code(&source);
for (n, line) in code.lines().enumerate() {
for name in FORBIDDEN {
let needle = format!(".{name}(");
if line.contains(&needle) {
offences.push(format!("{}:{}: {}", path.display(), n + 1, line.trim()));
}
}
}
}
assert!(
offences.is_empty(),
"production code must call libm, not std, for transcendentals \
(`sqrt` is exempt IEEE 754 specifies it):\n{}",
offences.join("\n")
);
}
/// The stripper has to actually strip, or the test above passes vacuously.
#[test]
fn the_test_module_stripper_works() {
let source = r#"
fn production() { let _ = libm::exp(1.0); }
#[cfg(test)]
mod tests {
fn allowed() { let x = 1.0f64.exp(); }
}
fn also_production() {}
"#;
let code = production_code(source);
assert!(
code.contains("also_production"),
"stripped too much: {code}"
);
assert!(
!code.contains(".exp()"),
"failed to strip cfg(test): {code}"
);
}
/// And it must not strip a doc comment's worth of prose into oblivion, nor
/// mistake prose for a call.
#[test]
fn prose_is_not_mistaken_for_a_call() {
let source = "/// Uses `x.exp()` in the docs.\nfn f() { let _ = libm::exp(1.0); }\n";
let code = production_code(source);
assert!(!code.contains(".exp()"), "doc comment leaked: {code}");
assert!(code.contains("libm::exp"), "stripped real code: {code}");
}
+370
View File
@@ -0,0 +1,370 @@
//! Calibration of the crate's marginals against the EXACT posterior.
//!
//! A scored history is linear-Gaussian — `MarginFactor` encodes
//! `score_a - score_b ~ N(perf_a - perf_b, score_sigma^2)` — so the true joint
//! posterior has a closed form and the crate can be checked against ground
//! truth rather than against intuition. That is not possible for ranked
//! outcomes, whose truncation likelihood EP genuinely approximates.
//!
//! Two things are pinned here, and one is deliberately only recorded.
//!
//! **Pinned: on a tree the crate is exact**, means and variances both. Message
//! passing has no approximation to make when the factor graph has no cycles, so
//! any drift here would be a real defect.
//!
//! **Pinned: means are exact even with cycles.** This is the standard result
//! for Gaussian belief propagation (Weiss & Freeman 2001) and it is what makes
//! ratings trustworthy.
//!
//! **Recorded, not asserted: with cycles, marginal variances are too narrow.**
//! Measured on the round-robin fixture below, the crate reports sigma 1.430
//! where the exact posterior is 2.851 — a ratio of 0.502. That is the known
//! behaviour of loopy Gaussian BP, not a bug in this crate, and it is left
//! unasserted because fixing it is exactly what #46 proposes.
//!
//! Why that matters for a consumer, and why #46 cannot be implemented as "add
//! a covariance accessor": the exact correlation between two nodes here is
//! +0.857, so a consumer computing `sqrt(sa^2 + sb^2)` for a difference
//! overstates its width. But the too-narrow marginals partially cancel that,
//! leaving 1.327x rather than 2.646x. Adding true correlations to these
//! marginals without also correcting them would give 0.765 against a true
//! 1.524 — *overconfident*, which is the unsafe direction.
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
const N: usize = 5;
const MU0: f64 = 0.0;
const SIGMA0: f64 = 6.0;
const BETA: f64 = 1.0;
const SCORE_SIGMA: f64 = 2.0;
/// A STAR: every event touches c0, so the node-event graph is a tree and
/// Gaussian BP is exact. Any discrepancy here is not caused by loops.
fn tree_fixture() -> Vec<(usize, usize, f64)> {
vec![(0, 1, 3.0), (0, 2, 5.0), (0, 3, 4.0), (0, 4, 6.0)]
}
/// (winner, loser, score_diff)
fn fixture() -> Vec<(usize, usize, f64)> {
vec![
(0, 1, 3.0),
(0, 2, 5.0),
(1, 2, 2.0),
(3, 4, 1.0),
(0, 3, 4.0),
(1, 4, 2.5),
(2, 3, 0.5),
(0, 4, 6.0),
(1, 3, 1.5),
(2, 4, 3.0),
]
}
/// Invert a small symmetric positive-definite matrix by Gauss-Jordan.
fn inverse(mut a: Vec<Vec<f64>>) -> Vec<Vec<f64>> {
let n = a.len();
let mut inv: Vec<Vec<f64>> = (0..n)
.map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
.collect();
for col in 0..n {
// partial pivot
let mut piv = col;
for r in col + 1..n {
if a[r][col].abs() > a[piv][col].abs() {
piv = r;
}
}
a.swap(col, piv);
inv.swap(col, piv);
let d = a[col][col];
for j in 0..n {
a[col][j] /= d;
inv[col][j] /= d;
}
for r in 0..n {
if r == col {
continue;
}
let f = a[r][col];
for j in 0..n {
a[r][j] -= f * a[col][j];
inv[r][j] -= f * inv[col][j];
}
}
}
inv
}
/// The exact posterior of a linear-Gaussian model:
/// precision = prior precision + sum of a_k a_k^T / v_k.
fn exact_for(obs: &[(usize, usize, f64)]) -> (Vec<f64>, Vec<Vec<f64>>) {
let mut lambda = vec![vec![0.0; N]; N];
let mut eta = [0.0; N];
for (i, row) in lambda.iter_mut().enumerate() {
row[i] = 1.0 / (SIGMA0 * SIGMA0);
eta[i] = MU0 / (SIGMA0 * SIGMA0);
}
// Each 1v1 observation: d ~ N(x_a - x_b, score_sigma^2 + 2 beta^2)
let v = SCORE_SIGMA * SCORE_SIGMA + 2.0 * BETA * BETA;
for &(a, b, d) in obs {
let mut vec_a = [0.0; N];
vec_a[a] = 1.0;
vec_a[b] = -1.0;
for i in 0..N {
for j in 0..N {
lambda[i][j] += vec_a[i] * vec_a[j] / v;
}
eta[i] += vec_a[i] * d / v;
}
}
let cov = inverse(lambda);
let mean: Vec<f64> = (0..N)
.map(|i| (0..N).map(|j| cov[i][j] * eta[j]).sum())
.collect();
(mean, cov)
}
fn key(i: usize) -> &'static str {
["c0", "c1", "c2", "c3", "c4"][i]
}
/// Returns (worst mean error, worst sd ratio).
fn fitted(obs: &[(usize, usize, f64)]) -> History {
let mut h: History = History::builder()
.mu(MU0)
.sigma(SIGMA0)
.beta(BETA)
.score_sigma(SCORE_SIGMA)
.drift(ConstantDrift::new(0.0))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build();
let events: Vec<Event<i64, &'static str>> = obs
.iter()
.copied()
.map(|(a, b, d)| Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new(key(a))]),
Team::with_members([Member::new(key(b))]),
],
outcome: Outcome::scores([d, 0.0]),
})
.collect();
h.add_events(events).unwrap();
let report = h.converge().unwrap();
assert!(
report.converged,
"fixture must converge: {:?}",
report.final_step
);
h
}
/// Returns (worst mean error, worst sd ratio gap).
fn run(name: &str, obs: Vec<(usize, usize, f64)>) -> (f64, f64) {
println!("\n########## {name} ##########");
let h = fitted(&obs);
let (mean, cov) = exact_for(&obs);
println!("\n== marginals: crate vs the exact linear-Gaussian posterior ==");
println!(
"{:>4} {:>12} {:>12} {:>12} {:>12} {:>8}",
"node", "crate mu", "exact mu", "crate sd", "exact sd", "sd ratio"
);
for i in 0..N {
let g = h.current_skill(&key(i)).unwrap();
let exact_sd = cov[i][i].sqrt();
println!(
"{:>4} {:>12.6} {:>12.6} {:>12.6} {:>12.6} {:>8.3}",
key(i),
g.mu(),
mean[i],
g.sigma(),
exact_sd,
g.sigma() / exact_sd
);
}
let mut worst_mean = 0.0f64;
let mut worst_ratio_gap = 0.0f64;
for i in 0..N {
let g = h.current_skill(&key(i)).unwrap();
worst_mean = worst_mean.max((g.mu() - mean[i]).abs());
worst_ratio_gap = worst_ratio_gap.max((g.sigma() / cov[i][i].sqrt() - 1.0).abs());
}
println!("\n== what a consumer actually computes for a DIFFERENCE ==");
println!(
"{:>8} {:>12} {:>14} {:>14} {:>12}",
"pair", "exact", "naive(exact)", "naive(crate)", "crate err"
);
for i in 0..N {
for j in i + 1..N {
if i != 0 && j != 1 {
continue;
}
let gi = h.current_skill(&key(i)).unwrap();
let gj = h.current_skill(&key(j)).unwrap();
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
let naive_exact = (cov[i][i] + cov[j][j]).sqrt();
let naive_crate = (gi.sigma().powi(2) + gj.sigma().powi(2)).sqrt();
let corr = cov[i][j] / (cov[i][i].sqrt() * cov[j][j].sqrt());
println!(
"{:>8} {:>12.6} {:>14.6} {:>14.6} {:>11.3}x (corr {corr:.4})",
format!("{}-{}", key(i), key(j)),
exact_sd,
naive_exact,
naive_crate,
naive_crate / exact_sd
);
}
}
(worst_mean, worst_ratio_gap)
}
/// With no cycles there is nothing for message passing to approximate.
#[test]
fn on_a_tree_the_marginals_are_exact() {
let (mean_err, sd_gap) = run("TREE (star: no loops, BP is exact)", tree_fixture());
assert!(
mean_err < 1e-9,
"tree means should be exact, worst error {mean_err}"
);
assert!(
sd_gap < 1e-9,
"tree sigmas should be exact, worst ratio gap {sd_gap}"
);
}
/// With cycles the means stay exact — the property ratings depend on — while
/// the variances do not. The variance gap is measured and reported rather than
/// asserted; see the module docs.
#[test]
fn with_cycles_the_means_stay_exact_but_the_variances_shrink() {
let (mean_err, sd_gap) = run("LOOPY (round robin)", fixture());
assert!(
mean_err < 1e-9,
"loopy means must still be exact, worst error {mean_err}"
);
assert!(
sd_gap > 0.1,
"the loopy variance gap is the premise of #46; if it has closed, that \
issue and these docs need revisiting (worst ratio gap {sd_gap})"
);
}
/// The point of #46: `posterior_of` must reproduce the exact joint, including
/// the correlation that marginals cannot express.
#[test]
fn posterior_of_matches_the_exact_joint() {
for (name, obs) in [("tree", tree_fixture()), ("loopy", fixture())] {
let h = fitted(&obs);
let (_, cov) = exact_for(&obs);
println!("\n== posterior_of vs exact ({name}) ==");
println!(
"{:>12} {:>14} {:>14} {:>10}",
"functional", "posterior_of", "exact", "ratio"
);
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();
println!(
"{:>12} {:>14.6} {:>14.6} {:>10.4}",
format!("{}-{}", key(i), key(j)),
got.sigma(),
exact_sd,
got.sigma() / exact_sd
);
assert!(
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
"{name} {}-{}: posterior_of gave {} where the exact joint is {exact_sd}",
key(i),
key(j),
got.sigma()
);
}
// A single competitor: this is where the loopy marginal was 2x narrow.
for (i, row) in cov.iter().enumerate() {
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,
"{name} {}: posterior_of gave {} where exact is {exact_sd}",
key(i),
got.sigma()
);
}
println!(" single-competitor marginals also exact");
}
}
/// Cost of the dense solve as the slice grows. Recorded, not asserted.
#[test]
#[ignore = "timing probe, run explicitly"]
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<String> = History::builder()
.key_type::<String>()
.score_sigma(2.0)
.drift(ConstantDrift::new(0.0))
.convergence(ConvergenceOptions {
max_iter: 200,
epsilon: 1e-8,
alpha: 1.0,
})
.build();
let mut seed = 5u64;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let events: Vec<Event<i64, String>> = (0..n * 4)
.map(|_| {
let a = (rnd() as usize) % n;
let mut b = (rnd() as usize) % n;
if b == a {
b = (b + 1) % n;
}
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new(names[a].clone())]),
Team::with_members([Member::new(names[b].clone())]),
],
outcome: Outcome::scores([1.0, 0.0]),
}
})
.collect();
h.add_events(events).unwrap();
let _ = h.converge().unwrap();
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());
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Inference must report numerical breakdown rather than call it convergence.
//!
//! The boundary rejects inputs that are *not numbers*, but finite inputs can
//! still overflow during inference — `beta.powi(2)` at 1e300 is infinite, and
//! infinity minus infinity is NaN. `NonFiniteResult` is the guard for that, and
//! it matters because the alternative is silent: NaN fails every comparison, so
//! a naive `step < epsilon` check reads a NaN step as *converged*.
//!
//! That is why the crate has `step_converged` / `step_is_finite` rather than
//! `!tuple_gt(..)`. These tests pin the guard from outside.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, Event, Gaussian, History, InferenceError, Member, Outcome, Team,
};
fn scored_fit(
sigma: f64,
beta: f64,
score_sigma: f64,
scores: [f64; 2],
) -> Result<bool, InferenceError> {
let mut h = History::builder()
.mu(0.0)
.sigma(sigma)
.beta(beta)
.score_sigma(score_sigma)
.build();
h.add_events(vec![Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::scores(scores),
}])?;
h.converge().map(|r| r.converged)
}
/// Every one of these is built from finite, individually legal parameters. The
/// overflow happens inside inference, which is exactly the case the boundary
/// checks cannot catch.
///
/// Matched rather than merely `is_err()`: an assertion that only checks "some
/// error" would keep passing if these started failing at the boundary for an
/// unrelated reason, and would then be testing nothing.
#[test]
fn overflow_during_inference_is_reported_not_hidden() {
let cases: [(&str, f64, f64, f64, [f64; 2]); 5] = [
("huge sigma", 1e300, 1.0, 1.0, [3.0, 1.0]),
("huge beta", 6.0, 1e300, 1.0, [3.0, 1.0]),
("tiny sigma", 1e-300, 1.0, 1.0, [3.0, 1.0]),
("tiny score_sigma", 6.0, 1.0, 1e-300, [3.0, 1.0]),
("huge scores", 6.0, 1.0, 1.0, [1e308, -1e308]),
];
for (name, sigma, beta, score_sigma, scores) in cases {
match scored_fit(sigma, beta, score_sigma, scores) {
Err(InferenceError::NonFiniteStep { context, step, .. }) => {
assert_eq!(context, "History::converge", "{name}");
assert!(
!step.0.is_finite() || !step.1.is_finite(),
"{name}: reported NonFiniteResult with a finite step {step:?}"
);
}
other => panic!("{name}: expected NonFiniteResult, got {other:?}"),
}
}
}
/// The trap the invariant exists for: NaN fails every comparison, so a naive
/// `step < epsilon` test reads a NaN step as converged. A breakdown must never
/// come back as a successful fit.
#[test]
fn a_broken_fit_is_never_reported_as_converged() {
let mut h = History::builder().build();
h.add_events(vec![Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(1e300, 1e-300))]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
}])
.unwrap();
let err = h.converge().unwrap_err();
assert!(
matches!(err, InferenceError::NonFiniteStep { .. }),
"a breakdown must not be reported as convergence: {err:?}"
);
// `converge_partial` must not launder it into an `Ok` either — the
// permissive path is permissive about *stopping short*, not about NaN.
let mut h2 = History::builder().build();
h2.add_events(vec![Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(1e300, 1e-300))]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
}])
.unwrap();
assert!(matches!(
h2.converge_partial().unwrap_err(),
InferenceError::NonFiniteStep { .. }
));
}
/// The neighbouring case, so the tests above cannot pass by the fit simply
/// always failing: ordinary extreme-but-workable parameters still converge.
#[test]
fn merely_extreme_parameters_still_converge() {
assert!(scored_fit(1e6, 1.0, 1.0, [3.0, 1.0]).unwrap());
assert!(scored_fit(1e-6, 1.0, 1.0, [3.0, 1.0]).unwrap());
assert!(scored_fit(6.0, 1.0, 1e6, [3.0, 1.0]).unwrap());
assert!(scored_fit(6.0, 1.0, 1.0, [1e150, -1e150]).unwrap());
}
/// A NaN in one competitor must not be masked by a healthy competitor reduced
/// after it.
///
/// The convergence step is a fold over a `HashMap`, so which competitor is
/// reduced last is per-process hash order. Before the fix, `tuple_max` dropped
/// a NaN accumulator in favour of the next finite delta and this returned
/// `Ok(converged: true)` with a NaN posterior in **16 of 30 runs** on identical
/// input. Deterministic now, but note this test can only ever sample one hash
/// order per run — the ordering guarantee itself is pinned by
/// `tuple_max_propagates_a_nan_from_any_position` in the crate's unit tests.
#[test]
fn a_nan_competitor_is_not_masked_by_a_healthy_one() {
let mut h = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.p_draw(0.1)
.build();
h.add_events(vec![
Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(0.0, 1e-200))]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
},
// A healthy pair in the same slice, to be reduced alongside the NaN.
Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("c")]),
Team::with_members([Member::new("d")]),
],
outcome: Outcome::winner(0, 2),
},
])
.unwrap();
let err = h
.converge()
.expect_err("a NaN fit must never be reported as converged");
assert!(
matches!(err, InferenceError::NonFiniteStep { .. }),
"{err:?}"
);
}
/// A tie observed with a narrow draw margin between far-apart competitors must
/// produce a fit, not NaN skills.
///
/// The tie branch forms the truncated variance from `v^2 - u`, and both grow as
/// `alpha^2` while their difference stays `O(1)`. Deep enough into the tail
/// that subtraction had four digits left: measured, it returned `1 - w`
/// negative and `sqrt` of it was NaN. The half-line escape hatch did not cover
/// it, because that keys on how many window-widths from the mean the window
/// sits and a narrow window fails that however deep it is.
///
/// These parameters are ordinary for a precise-scoring domain, and the
/// neighbouring wider-margin case always worked — so this was a cliff, not
/// "extreme inputs break".
#[test]
fn a_narrow_draw_margin_far_into_the_tail_still_fits() {
for (beta, p_draw, sd, gap) in [
(1e-2, 1e-8, 1e-2, 10.0),
(1e-3, 1e-9, 1e-3, 1.0),
(1e-4, 1e-12, 1e-4, 1.0),
] {
let mut h = History::builder()
.mu(0.0)
.sigma(sd)
.beta(beta)
.p_draw(p_draw)
.drift(ConstantDrift::new(0.0))
.build();
h.add_events(vec![Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(0.0, sd))]),
Team::with_members([Member::new("b").with_prior(Gaussian::from_ms(gap, sd))]),
],
outcome: Outcome::draw(2),
}])
.unwrap();
let report = h
.converge()
.unwrap_or_else(|e| panic!("beta {beta:e}, p_draw {p_draw:e}: {e:?}"));
assert!(report.converged);
let skill = h.current_skill(&"a").unwrap();
assert!(
skill.mu().is_finite() && skill.sigma().is_finite() && skill.sigma() > 0.0,
"beta {beta:e}, p_draw {p_draw:e}: {skill:?}"
);
}
}
+161
View File
@@ -0,0 +1,161 @@
//! `Observer` callbacks must actually fire.
//!
//! `on_slice_processed` (formerly `on_batch_processed`) was declared on the
//! trait and never called from anywhere, so implementors wired up a callback
//! that could not run. These tests exist so that cannot silently recur.
use std::sync::{Arc, Mutex};
use trueskill_tt::{History, Observer};
/// Plain fields. `Arc<O>` implements `Observer`, so the caller shares the
/// observer itself rather than wrapping each field in its own `Arc`.
#[derive(Default)]
struct Recorder {
iterations: Mutex<Vec<usize>>,
slices: Mutex<Vec<(i64, usize, usize)>>,
converged: Mutex<Vec<(usize, bool)>>,
}
impl Observer<i64> for Recorder {
fn on_iteration_end(&self, iter: usize, _max_step: (f64, f64)) {
self.iterations.lock().unwrap().push(iter);
}
fn on_slice_processed(&self, time: &i64, slice_idx: usize, n_events: usize) {
self.slices
.lock()
.unwrap()
.push((*time, slice_idx, n_events));
}
fn on_converged(&self, iters: usize, _final_step: (f64, f64), converged: bool) {
self.converged.lock().unwrap().push((iters, converged));
}
}
#[test]
fn every_observer_callback_fires() {
let recorder = Arc::new(Recorder::default());
let mut h = History::builder().observer(Arc::clone(&recorder)).build();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"b", &"c", 2).unwrap();
h.record_winner(&"c", &"a", 3).unwrap();
let _ = h.converge().unwrap();
assert!(
!recorder.iterations.lock().unwrap().is_empty(),
"on_iteration_end never fired"
);
assert!(
!recorder.converged.lock().unwrap().is_empty(),
"on_converged never fired"
);
assert!(
!recorder.slices.lock().unwrap().is_empty(),
"on_slice_processed never fired — the defect this test exists for"
);
}
#[test]
fn slice_callbacks_report_the_slice_they_swept() {
let recorder = Arc::new(Recorder::default());
let mut h = History::builder().observer(Arc::clone(&recorder)).build();
h.record_winner(&"a", &"b", 10).unwrap();
h.record_winner(&"a", &"b", 20).unwrap();
let _ = h.converge().unwrap();
let slices = recorder.slices.lock().unwrap();
// Only the times actually in the history, and each with its own events.
for &(time, idx, events) in slices.iter() {
assert!(time == 10 || time == 20, "unexpected slice time {time}");
assert!(idx < 2, "slice index {idx} out of range");
assert_eq!(events, 1, "each slice holds exactly one event");
}
// Both slices must be reported, not just one end of the sweep.
assert!(
slices.iter().any(|&(t, ..)| t == 10),
"slice 10 never reported"
);
assert!(
slices.iter().any(|&(t, ..)| t == 20),
"slice 20 never reported"
);
}
#[test]
fn a_single_slice_history_still_reports_its_sweep() {
let recorder = Arc::new(Recorder::default());
let mut h = History::builder().observer(Arc::clone(&recorder)).build();
h.record_winner(&"a", &"b", 1).unwrap();
let _ = h.converge().unwrap();
let slices = recorder.slices.lock().unwrap();
assert!(
!slices.is_empty(),
"the single-slice path must report its sweep too"
);
assert!(slices.iter().all(|&(t, idx, _)| t == 1 && idx == 0));
}
/// The gap #40 closed: without `impl Observer for Arc<O>`, an observer that
/// accumulates anything had to wrap every field in its own `Arc` and derive
/// `Clone`, because `History` consumes the observer and never hands it back.
#[test]
fn a_shared_observer_reaches_the_callers_handle() {
let recorder = Arc::new(Recorder::default());
let mut h = History::builder().observer(Arc::clone(&recorder)).build();
h.record_winner(&"a", &"b", 1).unwrap();
let _ = h.converge().unwrap();
assert!(!recorder.iterations.lock().unwrap().is_empty());
assert!(!recorder.slices.lock().unwrap().is_empty());
assert!(!recorder.converged.lock().unwrap().is_empty());
}
/// `?Sized` on the blanket impls means the observer can be chosen at runtime.
#[test]
fn a_trait_object_observer_works() {
let boxed: Box<dyn Observer<i64>> = Box::new(Recorder::default());
let mut h = History::builder().observer(boxed).build();
h.record_winner(&"a", &"b", 1).unwrap();
let _ = h.converge().unwrap();
let shared: Arc<dyn Observer<i64>> = Arc::new(Recorder::default());
let mut h = History::builder().observer(Arc::clone(&shared)).build();
h.record_winner(&"a", &"b", 1).unwrap();
let _ = h.converge().unwrap();
}
/// A non-shared observer can be reclaimed after convergence instead.
#[test]
fn into_observer_returns_the_accumulated_state() {
let mut h = History::builder().observer(Recorder::default()).build();
h.record_winner(&"a", &"b", 1).unwrap();
let _ = h.converge().unwrap();
// Readable in place...
assert!(!h.observer().iterations.lock().unwrap().is_empty());
// ...and reclaimable by value.
let recorder = h.into_observer();
assert!(!recorder.slices.lock().unwrap().is_empty());
}
/// Borrowing works too, for an observer that outlives the history.
#[test]
fn a_borrowed_observer_works() {
let recorder = Recorder::default();
{
let mut h = History::builder().observer(&recorder).build();
h.record_winner(&"a", &"b", 1).unwrap();
let _ = h.converge().unwrap();
}
assert!(!recorder.iterations.lock().unwrap().is_empty());
}
+151
View File
@@ -0,0 +1,151 @@
//! `predict_margin`: the predictive distribution of a scored matchup.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
UnknownKeys,
};
fn builder(policy: UnknownKeys) -> History {
History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.0))
.unknown_keys(policy)
.convergence(ConvergenceOptions {
max_iter: 5_000,
epsilon: 1e-12,
alpha: 1.0,
})
.build()
}
fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> {
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([sa, sb]),
}
}
/// A history where "veteran" and "regular" are well observed and "novice"
/// appears once.
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))
.collect();
events.push(round("veteran", "novice", 10.0, 6.0));
h.add_events(events).unwrap();
let _ = h.converge().unwrap();
h
}
/// The property #48 exists for: the interval must widen when the model knows
/// less. Their hand-fitted noise law quoted the same sigma for a competitor
/// with forty rounds and one with none.
#[test]
fn the_interval_widens_as_the_model_knows_less() {
let h = fitted(UnknownKeys::Prior);
let well_known = h
.predict_margin(&[&[&"veteran"], &[&"regular"]])
.unwrap()
.sigma();
let thin = h
.predict_margin(&[&[&"veteran"], &[&"novice"]])
.unwrap()
.sigma();
let unseen = h
.predict_margin(&[&[&"veteran"], &[&"stranger"]])
.unwrap()
.sigma();
assert!(
well_known < thin && thin < unseen,
"margin width should grow as evidence thins: {well_known} < {thin} < {unseen}"
);
}
/// #48's second requirement: an unseen competitor is a legitimate question, not
/// an error, and the answer should come from the prior rather than be faked.
#[test]
fn an_unseen_competitor_is_answered_from_the_prior() {
let h = fitted(UnknownKeys::Prior);
let g = h.predict_margin(&[&[&"nobody"], &[&"no_one"]]).unwrap();
// Two unknowns: the gap is centred on zero and carries both priors plus
// both performance noises plus the observation noise.
assert!(g.mu().abs() < 1e-9, "mu {}", g.mu());
let expected = (2.0 * 36.0 + 2.0 * 1.0 + 4.0f64).sqrt();
assert!(
(g.sigma() - expected).abs() < 1e-9,
"sigma {} vs expected {expected}",
g.sigma()
);
}
#[test]
fn reject_still_rejects() {
let h = fitted(UnknownKeys::Reject);
assert!(matches!(
h.predict_margin(&[&[&"veteran"], &[&"stranger"]]),
Err(InferenceError::UnknownKey { .. })
));
}
/// The margin is the *difference*, so it must be antisymmetric in the teams.
#[test]
fn swapping_the_teams_negates_the_margin() {
let h = fitted(UnknownKeys::Prior);
let forward = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
let reverse = h.predict_margin(&[&[&"regular"], &[&"veteran"]]).unwrap();
assert!((forward.mu() + reverse.mu()).abs() < 1e-9);
assert!((forward.sigma() - reverse.sigma()).abs() < 1e-12);
}
/// The predictive interval must be wider than the skill gap alone: it also
/// carries per-event performance noise and the observation noise.
#[test]
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();
assert!(
(predictive.mu() - skill_gap.mu()).abs() < 1e-12,
"means agree"
);
// beta^2 twice plus score_sigma^2 = 2 + 4.
let expected = (skill_gap.sigma().powi(2) + 6.0).sqrt();
assert!((predictive.sigma() - expected).abs() < 1e-12);
assert!(predictive.sigma() > skill_gap.sigma());
}
#[test]
fn shape_errors_are_reported() {
let h = fitted(UnknownKeys::Prior);
assert!(matches!(
h.predict_margin(&[&[&"veteran"]]),
Err(InferenceError::MismatchedShape {
expected: 2,
got: 1,
..
})
));
let empty: [&&str; 0] = [];
assert!(matches!(
h.predict_margin(&[&[&"veteran"], &empty]),
Err(InferenceError::EmptyTeam { team: 1, .. })
));
}
+415
View File
@@ -0,0 +1,415 @@
//! Prediction API: N-team outcomes, draw mass, and the error paths that used
//! to be panics or silent wrong answers.
use trueskill_tt::{History, InferenceError, MAX_PREDICTED_TEAMS};
fn history_with(names: &[&'static str], p_draw: f64) -> History {
let mut h = History::builder().p_draw(p_draw).build();
// Give every competitor a recorded skill by playing a small round robin.
for pair in names.windows(2) {
h.record_winner(&pair[0], &pair[1], 1).unwrap();
}
let _ = h.converge().unwrap();
h
}
#[test]
fn unknown_keys_are_reported_not_silently_dropped() {
let h = history_with(&["a", "b"], 0.0);
let err = h
.predict_outcome(&[&[&"a"], &[&"ghost"]])
.expect_err("an unknown key must not yield a confident prediction");
assert!(
matches!(
&err,
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
if key == "\"ghost\""
),
"{err:?}"
);
// Every prediction entry point, not just one.
assert!(
h.predict_win_probabilities(&[&[&"a"], &[&"ghost"]])
.is_err()
);
assert!(h.quality(&[&[&"a"], &[&"ghost"]]).is_err());
assert!(h.predict_ranking(&[&[&"a"], &[&"ghost"]], &[0, 1]).is_err());
}
#[test]
fn an_entirely_unknown_team_is_an_error() {
let h = history_with(&["a", "b"], 0.0);
let err = h.predict_outcome(&[&[&"a"], &[&"x", &"y"]]).unwrap_err();
assert!(
matches!(
&err,
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
if key == "\"x\""
),
"{err:?}"
);
}
#[test]
fn degenerate_team_shapes_are_errors_rather_than_panics() {
let h = history_with(&["a", "b"], 0.0);
assert!(matches!(
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(none).unwrap_err(),
InferenceError::NotEnoughTeams { got: 0, .. }
),);
assert!(matches!(
h.predict_outcome(&[&[&"a"], &[]]).unwrap_err(),
InferenceError::EmptyTeam { team: 1, .. }
));
}
#[test]
fn more_than_two_teams_no_longer_panics() {
let h = history_with(&["a", "b", "c"], 0.0);
let p = h
.predict_outcome(&[&[&"a"], &[&"b"], &[&"c"]])
.expect("three teams must be supported");
assert!((p.total() - 1.0).abs() < 1e-6, "total = {}", p.total());
// Three teams, no draws possible: exactly the six strict orderings.
assert_eq!(p.outcomes().len(), 6);
}
#[test]
fn the_outcome_space_is_capped_rather_than_hanging() {
let names: Vec<&'static str> = vec!["a", "b", "c", "d", "e", "f", "g", "h"];
let h = history_with(&names, 0.0);
let teams: Vec<&[&&'static str]> = Vec::new();
let _ = teams;
let too_many: Vec<Vec<&&str>> = names.iter().map(|n| vec![n]).collect();
let refs: Vec<&[&&str]> = too_many.iter().map(Vec::as_slice).collect();
let err = h.predict_outcome(&refs).unwrap_err();
assert!(matches!(
err,
InferenceError::TooManyTeams { got: 8, max, .. } if max == MAX_PREDICTED_TEAMS
));
// The cheap paths stay available at any size.
let wins = h.predict_win_probabilities(&refs).unwrap();
assert_eq!(wins.len(), 8);
assert!(
(wins.iter().sum::<f64>() - 1.0).abs() < 1e-6,
"win probabilities must still sum to one: {wins:?}"
);
}
/// The defect that made every draw-enabled prediction wrong: `[p, 1 - p]`
/// allocated no mass to a draw even with `p_draw > 0`.
#[test]
fn a_draw_carries_probability_mass_when_p_draw_is_positive() {
let h = history_with(&["a", "b"], 0.25);
let p = h.predict_outcome(&[&[&"a"], &[&"b"]]).unwrap();
let draw = p.probability_of(&[0, 0]);
assert!(draw > 0.0, "a draw-enabled model must give draws mass");
assert!((p.total() - 1.0).abs() < 1e-6, "total = {}", p.total());
let wins = p.win_probabilities();
assert!(
(wins.iter().sum::<f64>() + draw - 1.0).abs() < 1e-6,
"wins {wins:?} plus draw {draw} must be the whole space"
);
assert!(
(p.shared_first_place() - draw).abs() < 1e-12,
"a two-team draw is a shared first place"
);
}
#[test]
fn a_zero_draw_probability_admits_no_ties() {
let h = history_with(&["a", "b"], 0.0);
let p = h.predict_outcome(&[&[&"a"], &[&"b"]]).unwrap();
assert_eq!(p.probability_of(&[0, 0]), 0.0);
assert!(p.shared_first_place() < 1e-12);
}
/// The two routes to a win probability run through entirely different
/// algorithms — adaptive quadrature versus the enumerated chain recursion —
/// so agreement between them is a real cross-check, not a tautology.
#[test]
fn the_cheap_and_exhaustive_paths_agree() {
for p_draw in [0.0, 0.1] {
let h = history_with(&["a", "b", "c"], p_draw);
let teams: &[&[&&str]] = &[&[&"a"], &[&"b"], &[&"c"]];
let cheap = h.predict_win_probabilities(teams).unwrap();
let exhaustive = h.predict_outcome(teams).unwrap().win_probabilities();
for (i, (a, b)) in cheap.iter().zip(&exhaustive).enumerate() {
assert!(
(a - b).abs() < 1e-6,
"p_draw={p_draw} team {i}: quadrature {a} vs enumeration {b}"
);
}
}
}
#[test]
fn predict_ranking_agrees_with_the_distribution() {
let h = history_with(&["a", "b", "c"], 0.1);
let teams: &[&[&&str]] = &[&[&"a"], &[&"b"], &[&"c"]];
let dist = h.predict_outcome(teams).unwrap();
for (ranks, expected) in dist.outcomes() {
let direct = h.predict_ranking(teams, ranks).unwrap();
assert!(
(direct - expected).abs() < 1e-9,
"ranks {ranks:?}: {direct} vs {expected}"
);
}
}
#[test]
fn predict_ranking_checks_its_shape() {
let h = history_with(&["a", "b"], 0.0);
let err = h
.predict_ranking(&[&[&"a"], &[&"b"]], &[0, 1, 2])
.unwrap_err();
assert!(matches!(
err,
InferenceError::MismatchedShape {
expected: 2,
got: 3,
..
}
));
}
#[test]
fn the_stronger_competitor_is_favoured() {
let mut h = History::builder().build();
for t in 1..=10 {
h.record_winner(&"strong", &"weak", t).unwrap();
}
let _ = h.converge().unwrap();
let p = h.predict_outcome(&[&[&"strong"], &[&"weak"]]).unwrap();
let (best, _) = p.most_likely().expect("a most likely outcome");
assert_eq!(best, &[0, 1], "the winner should be favoured");
let wins = p.win_probabilities();
assert!(wins[0] > wins[1], "{wins:?}");
}
/// Unequal team sizes change the draw margin, because inference derives it
/// from the teams' betas. Prediction has to follow, or it describes a
/// different model than the one that will be fitted.
#[test]
fn team_size_affects_the_prediction() {
let mut h = History::builder().p_draw(0.2).build();
h.event(1)
.team(["a", "b"])
.team(["c"])
.winner(0)
.commit()
.unwrap();
let _ = h.converge().unwrap();
let p = h.predict_outcome(&[&[&"a", &"b"], &[&"c"]]).unwrap();
assert!((p.total() - 1.0).abs() < 1e-6, "total = {}", p.total());
assert!(p.probability_of(&[0, 0]) > 0.0);
}
// ---------------------------------------------------------------------------
// Expected information gain
// ---------------------------------------------------------------------------
/// The whole point of #39: "which comparison should I run next?" is a
/// different question from "who will win?" or "is this fair?".
#[test]
fn information_gain_prefers_the_uncertain_pairing() {
let mut h = History::builder().build();
// "known" and "rival" have played a lot; "newcomer" has played once.
for t in 1..=15 {
h.record_winner(&"known", &"rival", t).unwrap();
h.record_winner(&"rival", &"known", t + 100).unwrap();
}
h.record_winner(&"known", &"newcomer", 500).unwrap();
let _ = h.converge().unwrap();
let settled = h
.expected_information_gain(&[&[&"known"], &[&"rival"]])
.unwrap();
let unknown = h
.expected_information_gain(&[&[&"known"], &[&"newcomer"]])
.unwrap();
assert!(
unknown > settled,
"pairing against the newcomer should teach more: {unknown} vs {settled}"
);
}
/// The analytic ceiling, through the `History` entry point rather than the
/// standalone one.
#[test]
fn information_gain_respects_the_entropy_ceiling() {
let h = history_with(&["a", "b", "c"], 0.0);
let two = h.expected_information_gain(&[&[&"a"], &[&"b"]]).unwrap();
assert!(
(0.0..=std::f64::consts::LN_2).contains(&two),
"two-team EIG {two} outside [0, ln 2]"
);
let three = h
.expected_information_gain(&[&[&"a"], &[&"b"], &[&"c"]])
.unwrap();
assert!(
(0.0..=6.0f64.ln()).contains(&three),
"three-team EIG {three} outside [0, ln 6]"
);
}
#[test]
fn information_gain_reports_unknown_keys() {
let h = history_with(&["a", "b"], 0.0);
assert!(matches!(
&h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
.unwrap_err(),
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
if key == "\"ghost\""
));
}
/// A draw-enabled history has three outcomes to weigh rather than two, so the
/// draw branch must actually be reachable through this path.
#[test]
fn information_gain_accounts_for_draws() {
let with_draws = history_with(&["a", "b"], 0.25);
let g = with_draws
.expected_information_gain(&[&[&"a"], &[&"b"]])
.unwrap();
assert!(g > 0.0 && g <= 3.0f64.ln(), "{g}");
// The draw outcome carries mass, so it is genuinely being weighed.
let dist = with_draws.predict_outcome(&[&[&"a"], &[&"b"]]).unwrap();
assert!(dist.probability_of(&[0, 0]) > 0.0);
}
/// The defect that cost a consumer a day: `UnknownKey { team: 0, member: 0 }`
/// says nothing about *which* key is unknown, so the natural handling — log it,
/// fall back to a neutral value — converts a total miss into a plausible
/// constant. The key has to be in the error, and in its `Display`.
#[test]
fn unknown_key_names_the_key_it_could_not_find() {
let h = history_with(&["a", "b"], 0.0);
let err = h.predict_outcome(&[&[&"a"], &[&"never_seen"]]).unwrap_err();
match &err {
InferenceError::UnknownKey { key, .. } => {
assert!(
key.contains("never_seen"),
"the error should name the key, got {key}"
);
}
other => panic!("expected UnknownKey, got {other:?}"),
}
let rendered = err.to_string();
assert!(
rendered.contains("never_seen"),
"Display should name the key: {rendered}"
);
assert!(
rendered.contains("pre-filter"),
"Display should say what to do about it: {rendered}"
);
}
// ---------------------------------------------------------------------------
// UnknownKeys policy
// ---------------------------------------------------------------------------
fn history_with_policy(names: &[&'static str], policy: trueskill_tt::UnknownKeys) -> History {
let mut h = History::builder().unknown_keys(policy).build();
for pair in names.windows(2) {
h.record_winner(&pair[0], &pair[1], 1).unwrap();
}
let _ = h.converge().unwrap();
h
}
#[test]
fn reject_is_the_default() {
let h = history_with(&["a", "b"], 0.0);
assert!(matches!(
h.predict_outcome(&[&[&"a"], &[&"ghost"]]),
Err(InferenceError::UnknownKey { .. })
));
}
#[test]
fn prior_answers_instead_of_erroring() {
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
let p = h
.predict_outcome(&[&[&"a"], &[&"ghost"]])
.expect("Prior should answer rather than reject");
assert!((p.total() - 1.0).abs() < 1e-6);
}
/// Two competitors the model has never seen are genuinely a coin flip. The
/// point is that this is now *derived* rather than a constant a caller
/// substitutes after swallowing an error.
#[test]
fn two_unknown_competitors_are_an_honest_coin_flip() {
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
let wins = h
.predict_win_probabilities(&[&[&"nobody"], &[&"no_one"]])
.unwrap();
assert!((wins[0] - 0.5).abs() < 1e-9, "{wins:?}");
assert!((wins[1] - 0.5).abs() < 1e-9, "{wins:?}");
}
/// The property that rules out a `Skip` mode: an unknown member must make a
/// team *less* certain, never more. Skipping would drop the member's variance
/// from the sum and narrow the team, which is backwards.
#[test]
fn an_unknown_member_widens_its_team_rather_than_narrowing_it() {
let h = history_with_policy(&["a", "b", "c"], trueskill_tt::UnknownKeys::Prior);
// "a" alone against "b" — then "a" plus an unknown partner against "b".
let solo = h.predict_win_probabilities(&[&[&"a"], &[&"b"]]).unwrap();
let with_unknown = h
.predict_win_probabilities(&[&[&"a", &"stranger"], &[&"b"]])
.unwrap();
// Adding an unknown partner pulls the outcome toward even, because the
// team's performance spread grew.
assert!(
(with_unknown[0] - 0.5).abs() < (solo[0] - 0.5).abs(),
"an unknown partner should make the result less certain: solo {solo:?}, \
with unknown {with_unknown:?}"
);
}
#[test]
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.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());
assert!(h.expected_information_gain(teams).is_ok());
}
+162
View File
@@ -0,0 +1,162 @@
//! Bounds that any correct implementation must satisfy, swept rather than
//! spot-checked.
//!
//! The crate's docs call the `ln k` ceiling "the sharpest available test of an
//! implementation", and record that an early prototype returned 4.77 nats. It
//! was violated again — 3.237828 nats against `ln 2` — because the existing
//! check sampled one fixture and the violation lives in a specific regime: a
//! large ratio between the widest and narrowest performance sigma, where the
//! shared prediction grid could not resolve the narrow density and returned
//! probabilities greater than one.
//!
//! A single fixture cannot defend a bound like this. A sweep can.
use trueskill_tt::{
ConstantDrift, GameOptions, Gaussian, InferenceError, Rating, expected_information_gain,
};
type R = Rating<i64, ConstantDrift>;
/// How many random matchups the ceiling sweep draws.
///
/// Scaled by build profile rather than fixed. Each sample runs a full inference
/// pass per outcome, and that is about **19x** faster in release — measured,
/// 20 000 samples take 12.1s released against 23s for 2 000 in debug. `just
/// test` runs three debug feature combinations and one release one, so a fixed
/// count pays the slow price three times and the fast one once, which is
/// exactly backwards.
///
/// The debug run is here to prove the sweep still compiles and holds on a small
/// sample; the release run is the one that actually searches. The violation
/// this guards was found at a rate near 1.8%, so even the debug count expects
/// tens of hits in the regime.
#[cfg(debug_assertions)]
const SAMPLES: usize = 1_000;
#[cfg(not(debug_assertions))]
const SAMPLES: usize = 50_000;
/// Deterministic LCG, so a failure is reproducible from the printed seed.
struct Lcg(u64);
impl Lcg {
fn next_f64(&mut self) -> f64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
// Top 53 bits to [0, 1).
((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
}
fn in_range(&mut self, lo: f64, hi: f64) -> f64 {
lo + (hi - lo) * self.next_f64()
}
/// Log-uniform, so the sweep spends its samples across magnitudes rather
/// than crowding the top of the range — the violations live at small sigma.
fn log_uniform(&mut self, lo: f64, hi: f64) -> f64 {
let t = self.next_f64();
(lo.ln() + t * (hi.ln() - lo.ln())).exp()
}
}
#[test]
fn information_gain_never_exceeds_the_entropy_of_the_outcome() {
let mut rng = Lcg(0x5eed_1234_abcd_ef01);
let ceiling = 2.0_f64.ln();
let mut evaluated = 0usize;
let mut refused = 0usize;
for i in 0..SAMPLES {
let mu_a = rng.in_range(-100.0, 100.0);
let mu_b = rng.in_range(-100.0, 100.0);
let sigma_a = rng.log_uniform(1e-4, 1e2);
let sigma_b = rng.log_uniform(1e-4, 1e2);
let beta = rng.log_uniform(1e-4, 1e1);
let a = R::new(
Gaussian::from_ms(mu_a, sigma_a),
beta,
ConstantDrift::new(0.0),
);
let b = R::new(
Gaussian::from_ms(mu_b, sigma_b),
beta,
ConstantDrift::new(0.0),
);
let options = GameOptions {
p_draw: 0.0,
..GameOptions::default()
};
match expected_information_gain(&[&[a], &[b]], &options) {
Ok(gain) => {
evaluated += 1;
assert!(
gain.is_finite(),
"sample {i}: non-finite gain {gain} \
(mu {mu_a}, {mu_b}; sigma {sigma_a:e}, {sigma_b:e}; beta {beta:e})"
);
assert!(
gain >= 0.0,
"sample {i}: negative gain {gain} \
(mu {mu_a}, {mu_b}; sigma {sigma_a:e}, {sigma_b:e}; beta {beta:e})"
);
assert!(
gain <= ceiling + 1e-9,
"sample {i}: gain {gain} exceeds ln 2 = {ceiling} \
(mu {mu_a}, {mu_b}; sigma {sigma_a:e}, {sigma_b:e}; beta {beta:e})"
);
}
// Refusing to answer is acceptable; answering wrongly is not.
Err(InferenceError::GridTooCoarse { .. }) => refused += 1,
Err(e) => panic!("sample {i}: unexpected error {e:?}"),
}
}
// The sweep must actually exercise the function, not pass by refusing
// everything.
assert!(
evaluated * 2 > SAMPLES,
"only {evaluated} of {SAMPLES} samples were evaluated ({refused} refused); \
the sweep is no longer testing anything"
);
// And it must still reach the regime where the ceiling was violated —
// large sigma ratios, which is exactly where the grid now refuses. Without
// this the sweep could drift into only-easy inputs and stop being a guard.
assert!(
refused > 0,
"no sample reached the coarse-grid regime; the sweep no longer covers \
the case that produced 3.24 nats"
);
}
/// The regime that produced 3.237828 nats, pinned exactly.
#[test]
fn the_known_ceiling_violation_no_longer_answers_wrongly() {
let a = R::new(
Gaussian::from_ms(9.577_887_112_129_012, 0.000_132_507_526_585_134_38),
0.000_307_235_559_013_096_2,
ConstantDrift::new(0.0),
);
let b = R::new(
Gaussian::from_ms(-14.114_932_828_525_696, 91.586_690_140_921_16),
0.000_307_235_559_013_096_2,
ConstantDrift::new(0.0),
);
let options = GameOptions {
p_draw: 0.0,
..GameOptions::default()
};
match expected_information_gain(&[&[a], &[b]], &options) {
Ok(gain) => assert!(
gain <= 2.0_f64.ln() + 1e-9,
"returned {gain}, over the ln 2 ceiling"
),
Err(InferenceError::GridTooCoarse { needed, max, .. }) => {
assert!(needed > max, "needed {needed} should exceed max {max}");
}
Err(e) => panic!("unexpected error {e:?}"),
}
}
+152
View File
@@ -0,0 +1,152 @@
//! No prediction path may answer from a fit it cannot answer from.
//!
//! `converge` grew a `NonFiniteResult` guard; nothing stopped a caller from
//! ignoring that error and predicting anyway. The three failures that produced
//! were each differently wrong: `Ok(NaN)`, a panic out of a `Result`-returning
//! method, and `Ok([0.0, 0.0])` — finite, plausible, summing to zero against a
//! doc that promises one.
//!
//! Every test here has a healthy control, so none can pass by everything
//! returning `Err`.
use trueskill_tt::{
ConstantDrift, Event, Gaussian, History, InferenceError, Member, Outcome, Team,
};
type H = History;
fn build(beta: f64, prior: Option<Gaussian>, outcome: Outcome) -> H {
let mut h: H = History::builder()
.beta(beta)
.drift(ConstantDrift::new(0.0))
.build();
let member = |k: &'static str| match prior {
Some(p) => Member::new(k).with_prior(p),
None => Member::new(k),
};
let _ = h.add_events(vec![Event {
time: 1,
teams: [
Team::with_members([member("a")]),
Team::with_members([member("b")]),
]
.into_iter()
.collect(),
outcome,
}]);
h
}
/// Point-mass priors with `beta(0.0)` on a *ranked* event: `converge` reports
/// `NonFiniteResult` and the stored posteriors are `pi: NaN, tau: NaN`.
fn nan_poisoned() -> H {
let mut h = build(
0.0,
Some(Gaussian::from_ms(0.0, 0.0)),
Outcome::winner(0, 2),
);
let err = h.converge().expect_err("this fixture must not converge");
assert!(
matches!(err, InferenceError::NonFiniteStep { .. }),
"{err:?}"
);
h
}
/// The same degenerate parameters on a *scored* event, where inference
/// converges cleanly and leaves legitimate point-mass posteriors behind. The
/// fit is fine; it is prediction that has nothing to work with.
fn degenerate_but_converged() -> H {
let mut h = build(
0.0,
Some(Gaussian::from_ms(0.0, 0.0)),
Outcome::scores([1.0, 0.0]),
);
h.converge().expect("this fixture converges");
h
}
fn healthy() -> H {
let mut h = build(1.0, None, Outcome::winner(0, 2));
h.converge().expect("control converges");
h
}
macro_rules! all_predictions {
($h:ident, $f:expr) => {{
let teams: &[&[&&'static str]] = &[&[&"a"], &[&"b"]];
let f = $f;
f("quality", $h.quality(teams).map(|_| ()));
f(
"predict_win_probabilities",
$h.predict_win_probabilities(teams).map(|_| ()),
);
f("predict_outcome", $h.predict_outcome(teams).map(|_| ()));
f(
"predict_ranking",
$h.predict_ranking(teams, &[0, 1]).map(|_| ()),
);
f(
"expected_information_gain",
$h.expected_information_gain(teams).map(|_| ()),
);
}};
}
#[test]
fn a_nan_poisoned_fit_is_refused_by_every_prediction_path() {
let h = nan_poisoned();
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
match r {
Err(InferenceError::NonFiniteSkill { .. }) => {}
other => panic!("{name} answered from a NaN fit: {other:?}"),
}
});
}
#[test]
fn degenerate_performances_are_refused_rather_than_answered_wrongly() {
let h = degenerate_but_converged();
// The fit itself is sound — the posteriors are point masses, not NaN.
let skill = h.current_skill("a").expect("a played");
assert_eq!(skill.sigma(), 0.0);
assert!(skill.mu().is_finite());
// `quality` previously PANICKED here, out of a method that returns
// `Result`: the contrast covariance is exactly singular when beta is zero
// and every skill is a point mass.
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
match r {
Err(InferenceError::NoPerformanceVariance) => {}
other => panic!("{name} predicted from a degenerate fit: {other:?}"),
}
});
}
#[test]
fn the_control_history_answers_every_prediction() {
let h = healthy();
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
assert!(r.is_ok(), "{name} failed on a healthy history: {r:?}");
});
}
#[test]
fn win_probabilities_sum_to_one_on_the_control() {
// The promise the `Ok([0.0, 0.0])` case broke. Asserted on the control so
// the guard above cannot be "fixed" by making every path error.
let h = healthy();
let p = h
.predict_win_probabilities(&[&[&"a"], &[&"b"]])
.expect("control predicts");
let total: f64 = p.iter().sum();
assert!(
(total - 1.0).abs() < 1e-6,
"win probabilities sum to {total}"
);
}
+7
View File
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 8859be600e638573980f78622b8fcd8b4553ca34a9a041746c417f7e4293f89c # shrinks to games = [(0, 1), (0, 1), (4, 6), (2, 0), (0, 1), (0, 6), (0, 1), (2, 0), (6, 4), (0, 1), (0, 2), (6, 4), (1, 0), (4, 0), (0, 2)]
+188
View File
@@ -0,0 +1,188 @@
//! Property-based tests over generated histories.
//!
//! The golden suite pins exact values against the Python/Julia reference on a
//! handful of fixtures. These pin *invariants* over inputs nobody wrote by
//! hand, which is where the defects this crate has actually shipped were
//! hiding: a linear evidence product that underflowed only past ~1000 teams,
//! and a batching path no golden exercised because every golden ingests in one
//! call.
mod common;
use common::assert_finite;
use proptest::prelude::*;
use smallvec::smallvec;
use trueskill_tt::{ConvergenceOptions, Event, History, Member, Outcome, Team};
/// Distinct competitors, so no event pits someone against themselves.
fn pairs() -> impl Strategy<Value = Vec<(usize, usize)>> {
prop::collection::vec((0usize..8, 0usize..8), 1..24)
.prop_map(|v| v.into_iter().filter(|(a, b)| a != b).collect::<Vec<_>>())
.prop_filter("needs at least one valid pair", |v| !v.is_empty())
}
const KEYS: [&str; 8] = ["a", "b", "c", "d", "e", "f", "g", "h"];
fn history_from(games: &[(usize, usize)]) -> History {
let mut h = History::builder()
.convergence(ConvergenceOptions {
// 200 was not enough: the batched side stopped at the cap with a
// step of 3.4e-9, so this test was comparing two truncated fits and
// attributing the gap to ingestion order.
max_iter: 20_000,
epsilon: 1e-10,
..ConvergenceOptions::default()
})
.build();
let events: Vec<Event<i64, &'static str>> = games
.iter()
.enumerate()
.map(|(i, &(a, b))| Event {
time: i as i64 + 1,
teams: smallvec![
Team::with_members([Member::new(KEYS[a])]),
Team::with_members([Member::new(KEYS[b])]),
],
outcome: Outcome::winner(0, 2),
})
.collect();
h.add_events(events).unwrap();
h
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(48))]
/// Whatever the schedule of games, convergence must not produce NaN or an
/// improper posterior. `converge` returns `NonFiniteResult` rather than
/// silently reporting a NaN step as converged, so a break shows up here as
/// either an Err or a non-finite curve point.
#[test]
fn converged_posteriors_are_always_finite(games in pairs()) {
let mut h = history_from(&games);
let _ = h.converge().unwrap();
for key in KEYS {
// A generated schedule need not touch every key, and an unplayed
// key is `None` rather than an empty curve.
let Some(curve) = h.learning_curve(key) else {
continue;
};
for (time, g) in curve {
assert_finite(g, &format!("{key} at t={time}"));
}
}
}
/// Log-evidence is a log probability: finite, and never above zero.
///
/// The linear-product implementation this replaced underflowed to zero on
/// long chains, making `ln(0)` = -inf — finite-ness is the property that
/// would have caught it.
#[test]
fn log_evidence_is_a_finite_log_probability(games in pairs()) {
let mut h = history_from(&games);
let _ = h.converge().unwrap();
let batch = h.log_evidence();
let filtered = h.filtered_log_evidence();
prop_assert!(batch.is_finite(), "batch log-evidence {batch} is not finite");
prop_assert!(batch <= 0.0, "batch log-evidence {batch} exceeds zero");
prop_assert!(filtered.is_finite(), "filtered log-evidence {filtered} is not finite");
prop_assert!(filtered <= 0.0, "filtered log-evidence {filtered} exceeds zero");
}
/// Filtered estimates must not depend on whether `converge` has run — the
/// property the whole forward-only design rests on.
#[test]
fn filtered_evidence_is_invariant_to_convergence(games in pairs()) {
let mut h = history_from(&games);
let before = h.filtered_log_evidence();
let _ = h.converge().unwrap();
let after = h.filtered_log_evidence();
prop_assert!(
(before - after).abs() < 1e-8,
"filtered evidence moved across converge(): {before} -> {after}"
);
}
/// Ingesting the same games one at a time must reach the same fixed point
/// as ingesting them in one call.
#[test]
fn ingestion_order_does_not_change_the_answer(games in pairs()) {
let batched = {
let mut h = history_from(&games);
let report = h.converge().unwrap();
prop_assert!(
report.converged,
"batched side stopped at {} iterations with step {:?}; comparing \
two fits that have not converged measures truncation, not order",
report.iterations,
report.final_step
);
h
};
let incremental = {
let mut h = History::builder()
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-10,
..ConvergenceOptions::default()
})
.build();
for (i, &(a, b)) in games.iter().enumerate() {
h.add_events([Event {
time: i as i64 + 1,
teams: smallvec![
Team::with_members([Member::new(KEYS[a])]),
Team::with_members([Member::new(KEYS[b])]),
],
outcome: Outcome::winner(0, 2),
}])
.unwrap();
}
let report = h.converge().unwrap();
prop_assert!(
report.converged,
"incremental side stopped at {} iterations with step {:?}",
report.iterations,
report.final_step
);
h
};
for key in KEYS {
let one = batched.current_skill(key);
let other = incremental.current_skill(key);
match (one, other) {
(Some(one), Some(other)) => {
prop_assert!(
(one.mu() - other.mu()).abs() < 1e-6
&& (one.sigma() - other.sigma()).abs() < 1e-6,
"{key}: batched mu={} sigma={}, incremental mu={} sigma={}",
one.mu(),
one.sigma(),
other.mu(),
other.sigma()
);
}
(None, None) => {}
_ => prop_assert!(false, "{key} present in only one history"),
}
}
}
}
+212
View File
@@ -0,0 +1,212 @@
//! `quality()` beyond two teams.
//!
//! The historical golden (two equal singletons) is asserted in
//! `src/lib.rs::tests::test_quality`. These cover the N-group generalisation,
//! which previously panicked with an out-of-bounds index at 3+ groups.
use trueskill_tt::{Gaussian, quality};
const BETA: f64 = 25.0 / 3.0 / 2.0;
fn rating(mu: f64, sigma: f64) -> Gaussian {
Gaussian::from_ms(mu, sigma)
}
#[test]
fn three_equal_groups_is_finite_and_in_range() {
let r = rating(25.0, 3.0);
let q = quality(&[&[r], &[r], &[r]], BETA);
assert!(q.is_finite(), "quality must be finite, got {q}");
assert!((0.0..=1.0).contains(&q), "quality out of range: {q}");
}
#[test]
fn quality_supports_many_groups() {
let r = rating(25.0, 3.0);
for n in 2..=8 {
let holders: Vec<[Gaussian; 1]> = (0..n).map(|_| [r]).collect();
let groups: Vec<&[Gaussian]> = holders.iter().map(|g| g.as_slice()).collect();
let q = quality(&groups, BETA);
assert!(q.is_finite(), "n={n}: quality must be finite, got {q}");
assert!((0.0..=1.0).contains(&q), "n={n}: out of range: {q}");
}
}
/// Equal-strength groups are the best-matched case: introducing a skill gap
/// must lower quality.
#[test]
fn imbalance_lowers_quality() {
let strong = rating(40.0, 3.0);
let average = rating(25.0, 3.0);
let balanced = quality(&[&[average], &[average], &[average]], BETA);
let lopsided = quality(&[&[strong], &[average], &[average]], BETA);
assert!(
lopsided < balanced,
"expected imbalanced quality {lopsided} < balanced {balanced}"
);
}
/// Quality is a property of the multiset of groups, not their order.
#[test]
fn quality_is_permutation_invariant() {
let a = rating(30.0, 2.0);
let b = rating(25.0, 3.0);
let c = rating(20.0, 4.0);
let forward = quality(&[&[a], &[b], &[c]], BETA);
let reversed = quality(&[&[c], &[b], &[a]], BETA);
assert!(
(forward - reversed).abs() < 1e-9,
"permutation changed quality: {forward} vs {reversed}"
);
}
#[test]
fn multi_player_groups_work() {
let r = rating(25.0, 3.0);
let q = quality(&[&[r, r], &[r, r], &[r, r]], BETA);
assert!(q.is_finite());
assert!((0.0..=1.0).contains(&q));
}
#[test]
fn uneven_group_sizes_work() {
let r = rating(25.0, 3.0);
let q = quality(&[&[r, r], &[r], &[r, r, r]], BETA);
assert!(q.is_finite(), "got {q}");
assert!((0.0..=1.0).contains(&q), "got {q}");
}
#[test]
#[should_panic(expected = "at least 2 teams")]
fn single_group_panics_with_clear_message() {
let r = rating(25.0, 3.0);
let _ = quality(&[&[r]], BETA);
}
#[test]
#[should_panic(expected = "at least 2 teams")]
fn zero_groups_panics_with_clear_message() {
let _ = quality(&[], BETA);
}
#[test]
#[should_panic(expected = "non-empty")]
fn empty_group_panics_with_clear_message() {
let r = rating(25.0, 3.0);
let _ = quality(&[&[r], &[]], BETA);
}
#[test]
fn history_predict_quality_supports_three_teams() {
use trueskill_tt::History;
let mut h = History::default();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"b", &"c", 2).unwrap();
let _ = h.converge().unwrap();
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}");
}
/// `quality()` for N identical teams has a closed form, which pins the N-group
/// determinant path across the whole range rather than at a single golden.
///
/// For two identical single-player teams the standard result is
/// `sqrt(2b^2 / (2b^2 + s1^2 + s2^2))`. With the conventional parameters
/// (`sigma = 25/3`, `beta = 25/6`) that ratio is exactly `1/5`, and the N-group
/// generalisation is `(1/5)^((n-1)/2)` — one factor per adjacent pair.
///
/// The n=3 and n=5 values this produces (0.200 and 0.040) are also what the
/// `trueskill` Python package returns for the same configuration, so this
/// doubles as the cross-implementation check the README asked for.
#[test]
fn quality_of_identical_teams_follows_its_closed_form() {
let g = Gaussian::from_ms(25.0, 25.0 / 3.0);
let beta = 25.0 / 6.0;
for n in 2..=10usize {
let groups: Vec<Vec<Gaussian>> = (0..n).map(|_| vec![g]).collect();
let refs: Vec<&[Gaussian]> = groups.iter().map(Vec::as_slice).collect();
let got = quality(&refs, beta);
let expected = 0.2f64.powf((n - 1) as f64 / 2.0);
assert!(
(got - expected).abs() / expected < 1e-9,
"n={n}: quality {got}, closed form {expected}"
);
}
}
/// Spot-check against the two values the `trueskill` Python package is known
/// to produce for this configuration, stated as literals so a future change to
/// the closed-form reasoning above cannot quietly take these with it.
#[test]
fn quality_matches_the_reference_implementation() {
let g = Gaussian::from_ms(25.0, 25.0 / 3.0);
let beta = 25.0 / 6.0;
let three: Vec<Vec<Gaussian>> = (0..3).map(|_| vec![g]).collect();
let refs: Vec<&[Gaussian]> = three.iter().map(Vec::as_slice).collect();
assert!((quality(&refs, beta) - 0.200).abs() < 1e-9);
let five: Vec<Vec<Gaussian>> = (0..5).map(|_| vec![g]).collect();
let refs: Vec<&[Gaussian]> = five.iter().map(Vec::as_slice).collect();
assert!((quality(&refs, beta) - 0.040).abs() < 1e-9);
}
/// `quality()` used to compute `det(ata) / det(middle)` in linear space. Both
/// are products of `k - 1` diagonal entries, so they leave `f64`'s range long
/// before their ratio does — and the ratio is the only thing the answer needs.
///
/// Measured before the fix: at the crate defaults 150 groups was correct, 200
/// returned `0`, and 250 returned `NaN` where the truth is `9.51e-88`. With a
/// small beta it bit sooner — `sigma = beta = 1e-3` returned `NaN` at 60 groups
/// against a true `1.32e-9`, a value that is entirely ordinary.
///
/// For `k` single-member groups with equal means the answer has a closed form,
/// `(beta / sqrt(beta^2 + sigma^2))^(k-1)`, so this checks against arithmetic
/// rather than against a recorded output.
#[test]
fn quality_matches_its_closed_form_past_the_overflow_point() {
for (sigma, beta) in [(25.0 / 3.0, 25.0 / 6.0), (1e-3, 1e-3), (50.0, 25.0 / 6.0)] {
let rating = vec![Gaussian::from_ms(25.0, sigma)];
for k in [2usize, 50, 60, 150, 200, 250, 300] {
let groups: Vec<&[Gaussian]> = (0..k).map(|_| rating.as_slice()).collect();
let got = quality(&groups, beta);
let expected = (beta / (beta * beta + sigma * sigma).sqrt()).powi(k as i32 - 1);
assert!(
got.is_finite(),
"sigma {sigma}, beta {beta}, {k} groups: got {got}"
);
// Subnormal results have no relative precision left to check.
if expected > f64::MIN_POSITIVE {
let rel = ((got - expected) / expected).abs();
assert!(
rel < 1e-11,
"sigma {sigma}, beta {beta}, {k} groups: got {got:e}, \
closed form {expected:e}, rel {rel:e}"
);
}
}
}
}
/// The overflow was in the intermediates, never in the answer: every value
/// above is an ordinary float. This pins the specific case that returned `NaN`
/// where the true answer is nine orders of magnitude inside the normal range.
#[test]
fn a_small_beta_does_not_overflow_at_sixty_groups() {
let rating = vec![Gaussian::from_ms(25.0, 1e-3)];
let groups: Vec<&[Gaussian]> = (0..60).map(|_| rating.as_slice()).collect();
let got = quality(&groups, 1e-3);
assert!((got - 1.317_089e-9).abs() / 1.317_089e-9 < 1e-6, "{got:e}");
}
+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);
}
+169
View File
@@ -0,0 +1,169 @@
//! Converging, appending, and converging again must reach the same fixed point
//! as converging once over the whole event set.
//!
//! `tests/ingestion_equivalence.rs` covers a different question: it varies how
//! events are *batched* but converges only at the end. This file converges
//! between batches, which is the path a caller takes when it fits, serves for a
//! while, then ingests more.
//!
//! The property matters beyond ergonomics. It says `converge` reaches a fixed
//! point determined by the events, ratings and configuration alone — not by the
//! message state it started from. That is what makes a restored snapshot safe:
//! an inexact one cannot corrupt the answer, only cost an extra sweep. See #45.
use smallvec::smallvec;
use trueskill_tt::{ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team};
fn tight() -> ConvergenceOptions {
ConvergenceOptions {
max_iter: 5_000,
epsilon: 1e-12,
alpha: 1.0,
}
}
fn ev(a: &str, b: &str, time: i64) -> Event<i64, String> {
Event {
time,
teams: smallvec![
Team::with_members([Member::new(a.to_string())]),
Team::with_members([Member::new(b.to_string())]),
],
outcome: Outcome::winner(0, 2),
}
}
/// Ingest each chunk in turn, converging fully after every one.
fn fit_in_chunks(chunks: Vec<Events>) -> Vec<(String, Gaussian)> {
let mut h: History<String> = History::builder()
.key_type::<String>()
.convergence(tight())
.build();
for chunk in chunks {
h.add_events(chunk).unwrap();
let report = h.converge().unwrap();
assert!(
report.converged,
"a chunk failed to converge, so any comparison would be measuring \
truncation rather than the fixed point; final step {:?}",
report.final_step
);
}
let mut skills: Vec<(String, Gaussian)> = h
.learning_curves()
.into_iter()
.map(|(k, curve)| (k, curve.last().unwrap().1))
.collect();
skills.sort_by(|a, b| a.0.cmp(&b.0));
skills
}
fn assert_same(a: &[(String, Gaussian)], b: &[(String, Gaussian)], what: &str) {
assert_eq!(a.len(), b.len(), "{what}: competitor count differs");
for ((ka, ga), (kb, gb)) in a.iter().zip(b) {
assert_eq!(ka, kb, "{what}: key order differs");
// Measured: 6.2e-13 for a later append, 8.9e-11 for an interleaved one.
// The bar is well clear of both but far under anything that would let a
// genuine divergence through.
assert!(
(ga.mu() - gb.mu()).abs() < 1e-8 && (ga.sigma() - gb.sigma()).abs() < 1e-8,
"{what}: {ka} differs — one-shot mu={} sigma={}, chunked mu={} sigma={}",
ga.mu(),
ga.sigma(),
gb.mu(),
gb.sigma()
);
}
}
type Events = Vec<Event<i64, String>>;
/// Two chunks of events: the first at times 0..20, the second at 100..120.
fn fixture() -> (Events, Events) {
let names = ["a", "b", "c", "d", "e"];
let mut seed = 7u64;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let (mut early, mut late) = (Vec::new(), Vec::new());
for t in 0..40i64 {
let i = (rnd() % 5) as usize;
let mut j = (rnd() % 5) as usize;
if j == i {
j = (j + 1) % 5;
}
if t < 20 {
early.push(ev(names[i], names[j], t));
} else {
late.push(ev(names[i], names[j], 100 + t));
}
}
(early, late)
}
/// The ordinary case: new events are strictly later than everything fitted.
#[test]
fn appending_later_events_matches_a_single_fit() {
let (early, late) = fixture();
let all: Vec<_> = early.iter().cloned().chain(late.iter().cloned()).collect();
assert_same(
&fit_in_chunks(vec![all]),
&fit_in_chunks(vec![early, late]),
"append strictly later",
);
}
/// The case the design question suspected might be weaker: appended events
/// interleave with slices that are already fitted, so the append legitimately
/// revises the past. It is not weaker — Through Time revises the past on every
/// converge regardless, so there is nothing special about doing it in two steps.
#[test]
fn appending_interleaved_events_matches_a_single_fit() {
let (early, late) = fixture();
let all: Vec<_> = early.iter().cloned().chain(late.iter().cloned()).collect();
// Split by parity so the second chunk is back-dated into the first's range.
let first: Vec<_> = all.iter().step_by(2).cloned().collect();
let second: Vec<_> = all.iter().skip(1).step_by(2).cloned().collect();
let together: Vec<_> = first
.iter()
.cloned()
.chain(second.iter().cloned())
.collect();
assert_same(
&fit_in_chunks(vec![together]),
&fit_in_chunks(vec![first, second]),
"append interleaved",
);
}
/// Converging an already-converged history is a no-op, which is what makes a
/// restored snapshot worth having: the work is skipped rather than redone.
#[test]
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<String> = History::builder()
.key_type::<String>()
.convergence(tight())
.build();
h.add_events(all).unwrap();
let first = h.converge().unwrap();
assert!(first.converged);
let again = h.converge().unwrap();
assert_eq!(
again.iterations, 1,
"a converged history should settle immediately, not re-grind"
);
assert!(again.converged);
}
+24 -16
View File
@@ -6,7 +6,7 @@ fn record_winner_builds_history() {
.mu(25.0) .mu(25.0)
.sigma(25.0 / 3.0) .sigma(25.0 / 3.0)
.beta(25.0 / 6.0) .beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0)) .drift(ConstantDrift::new(25.0 / 300.0))
.convergence(ConvergenceOptions { .convergence(ConvergenceOptions {
max_iter: 30, max_iter: 30,
epsilon: 1e-6, epsilon: 1e-6,
@@ -15,26 +15,34 @@ fn record_winner_builds_history() {
.build(); .build();
h.record_winner(&"alice", &"bob", 1).unwrap(); h.record_winner(&"alice", &"bob", 1).unwrap();
h.converge().unwrap(); let _ = h.converge().unwrap();
let a_idx = h.lookup(&"alice").unwrap(); // `lookup` returned an `Index` that nothing public accepted, so the
let b_idx = h.lookup(&"bob").unwrap(); // observable claim is the one worth making: two distinct competitors, each
// with their own posterior, and the winner ahead.
assert_ne!(a_idx, b_idx); assert_eq!(h.competitor_count(), 2);
let alice = h.current_skill("alice").expect("alice played");
let bob = h.current_skill("bob").expect("bob played");
assert!(alice.mu() > bob.mu());
} }
/// The same key names the same competitor across events, which is what
/// interning bought and the only part of it a caller can observe.
#[test] #[test]
fn intern_is_idempotent() { fn a_repeated_key_is_one_competitor() {
let mut h: History = History::builder().build(); let mut h: History = History::builder().build();
let a1 = h.intern(&"alice"); h.record_winner(&"alice", &"bob", 1).unwrap();
let a2 = h.intern(&"alice"); h.record_winner(&"alice", &"carol", 2).unwrap();
assert_eq!(a1, a2);
assert_eq!(h.competitor_count(), 3);
assert_eq!(h.learning_curve("alice").expect("known").len(), 2);
} }
#[test] #[test]
fn lookup_returns_none_for_missing() { fn an_unknown_key_is_unknown() {
let h: History = History::builder().build(); let h: History = History::builder().build();
assert!(h.lookup(&"nobody").is_none()); assert!(h.current_skill("nobody").is_none());
assert!(h.learning_curve("nobody").is_none());
} }
#[test] #[test]
@@ -43,13 +51,13 @@ fn record_draw_with_p_draw_set() {
.mu(25.0) .mu(25.0)
.sigma(25.0 / 3.0) .sigma(25.0 / 3.0)
.beta(25.0 / 6.0) .beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0)) .drift(ConstantDrift::new(25.0 / 300.0))
.p_draw(0.25) .p_draw(0.25)
.build(); .build();
h.record_draw(&"alice", &"bob", 1).unwrap(); h.record_draw(&"alice", &"bob", 1).unwrap();
h.converge().unwrap(); let _ = h.converge().unwrap();
assert!(h.lookup(&"alice").is_some()); assert!(h.current_skill("alice").is_some());
assert!(h.lookup(&"bob").is_some()); assert!(h.current_skill("bob").is_some());
} }
+344
View File
@@ -0,0 +1,344 @@
//! Configuring a competitor before anything is observed about them.
//!
//! The configuration a competitor needs is usually a property of the domain —
//! "every layout is static" — not of whichever event happens to mention them
//! first. Stating it per-event meant every ingestion path had to remember it,
//! and two of the four paths could not state it at all.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
Team,
};
type H = History;
const PINNED: Gaussian = Gaussian::from_ms(2.0, 0.5);
fn history() -> H {
History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.5))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build()
}
fn duel(
a: &'static str,
b: &'static str,
t: i64,
m: Option<Member<&'static str>>,
) -> Event<i64, &'static str> {
Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([m.unwrap_or_else(|| Member::new(b))]),
],
outcome: Outcome::scores([5.0, 2.0]),
}
}
fn skills(h: &H) -> Vec<(&'static str, Gaussian)> {
["player", "layout"]
.into_iter()
.map(|k| (k, h.current_skill(&k).unwrap()))
.collect()
}
/// The headline contract.
#[test]
fn registering_matches_configuring_on_the_first_event() {
let configured = {
let mut h = history();
h.add_events(vec![
duel(
"player",
"layout",
1,
Some(
Member::new("layout")
.with_drift_scale(0.0)
.with_prior(PINNED),
),
),
duel("player", "layout", 2, None),
])
.unwrap();
let _ = h.converge().unwrap();
h
};
let registered = {
let mut h = history();
h.register(
Member::new("layout")
.with_drift_scale(0.0)
.with_prior(PINNED),
)
.unwrap();
h.add_events(vec![
duel("player", "layout", 1, None),
duel("player", "layout", 2, None),
])
.unwrap();
let _ = h.converge().unwrap();
h
};
for ((k, a), (_, b)) in skills(&configured).into_iter().zip(skills(&registered)) {
assert_eq!(a.mu(), b.mu(), "{k} mu");
assert_eq!(a.variance(), b.variance(), "{k} variance");
}
}
/// The case `EventBuilder` and the typed path cannot reach: a competitor whose
/// first appearance arrives through the two-argument convenience route.
#[test]
fn registration_reaches_a_competitor_first_seen_through_record_winner() {
let mut h = history();
h.register(
Member::new("layout")
.with_drift_scale(0.0)
.with_prior(PINNED),
)
.unwrap();
h.record_winner(&"player", &"layout", 1).unwrap();
h.record_winner(&"player", &"layout", 2).unwrap();
let _ = h.converge().unwrap();
let rating = h.rating(&"layout").unwrap();
assert_eq!(rating.drift_scale(), 0.0);
assert_eq!(rating.prior().mu(), PINNED.mu());
// Pinned means pinned: no drift across the two slices.
let curve = h.learning_curve(&"layout").unwrap();
assert!(curve.len() >= 2);
let widest = curve
.iter()
.map(|(_, g)| g.sigma())
.fold(f64::MIN, f64::max);
let narrowest = curve
.iter()
.map(|(_, g)| g.sigma())
.fold(f64::MAX, f64::min);
assert!(
(widest - narrowest) / widest < 1e-9,
"{narrowest} .. {widest}"
);
}
#[test]
fn registering_a_known_competitor_is_an_error() {
let mut h = history();
h.record_winner(&"player", &"layout", 1).unwrap();
let err = h.register(Member::new("layout")).unwrap_err();
assert!(
matches!(err, InferenceError::AlreadyRegistered { .. }),
"{err:?}"
);
}
#[test]
fn registering_twice_is_an_error() {
let mut h = history();
h.register(Member::new("layout").with_drift_scale(0.0))
.unwrap();
let err = h
.register(Member::new("layout").with_drift_scale(1.0))
.unwrap_err();
assert!(
matches!(err, InferenceError::AlreadyRegistered { .. }),
"{err:?}"
);
// The first registration stands.
assert_eq!(h.rating(&"layout").unwrap().drift_scale(), 0.0);
}
/// `weight` is per-event and meaningless here, so it is rejected rather than
/// dropped — dropping it silently is the defect class this whole area keeps
/// producing.
#[test]
fn a_weight_on_a_registration_is_rejected() {
let mut h = history();
let err = h
.register(Member::new("layout").with_weight(0.5))
.unwrap_err();
assert!(
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Weight,
..
}
),
"{err:?}"
);
}
#[test]
fn an_invalid_drift_scale_on_a_registration_is_rejected() {
for bad in [-1.0, f64::NAN, f64::INFINITY] {
let mut h = history();
let err = h
.register(Member::new("layout").with_drift_scale(bad))
.unwrap_err();
assert!(
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::DriftScale,
..
}
),
"{bad}: {err:?}"
);
}
}
/// Registration makes the fit independent of the order events arrive in,
/// which is what the per-event shape could not guarantee.
#[test]
fn registration_makes_the_fit_order_independent() {
let build = |reversed: bool| {
let mut h = history();
h.register(
Member::new("layout")
.with_drift_scale(0.0)
.with_prior(PINNED),
)
.unwrap();
let mut events = vec![
duel("player", "layout", 1, None),
duel("player", "layout", 2, None),
duel("player", "layout", 3, None),
];
if reversed {
events.reverse();
}
h.add_events(events).unwrap();
let _ = h.converge().unwrap();
h
};
let forward = build(false);
let backward = build(true);
for ((k, a), (_, b)) in skills(&forward).into_iter().zip(skills(&backward)) {
assert_eq!(a.mu(), b.mu(), "{k} mu");
assert_eq!(a.variance(), b.variance(), "{k} variance");
}
}
/// `rating` is the read-back that made a configuration mistake detectable from
/// outside the crate at all. Every other accessor reports what inference
/// inferred; this reports what it was told.
#[test]
fn rating_reads_back_what_was_stored() {
let mut h = history();
assert!(h.rating(&"nobody").is_none());
h.register(
Member::new("layout")
.with_drift_scale(0.25)
.with_prior(PINNED),
)
.unwrap();
let r = h.rating(&"layout").unwrap();
assert_eq!(r.drift_scale(), 0.25);
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();
assert_eq!(h.rating(&"player").unwrap().drift_scale(), 1.0);
}
/// The decision this issue turned on: two different values for one competitor
/// are an error whether they arrive in one batch or two.
///
/// Last-write-wins across batches cut against the invariant
/// `tests/ingestion_equivalence.rs` protects — the same contradictory events
/// errored when batched and succeeded, order-dependently, one at a time.
mod conflicting_configuration {
use super::*;
fn seed(scale: f64) -> Event<i64, &'static str> {
duel(
"player",
"layout",
1,
Some(Member::new("layout").with_drift_scale(scale)),
)
}
#[test]
fn within_one_batch_is_an_error() {
let mut h = history();
let err = h.add_events(vec![seed(0.0), seed(1.0)]).unwrap_err();
assert!(
matches!(
err,
InferenceError::ConflictingCompetitorConfig {
field: trueskill_tt::CompetitorField::DriftScale,
..
}
),
"{err:?}"
);
}
#[test]
fn across_two_batches_is_also_an_error() {
let mut h = history();
h.add_events(vec![seed(0.0)]).unwrap();
let err = h.add_events(vec![seed(1.0)]).unwrap_err();
assert!(
matches!(
err,
InferenceError::ConflictingCompetitorConfig {
field: trueskill_tt::CompetitorField::DriftScale,
..
}
),
"{err:?}"
);
// Rejected before anything mutates: the first declaration stands.
assert_eq!(h.rating(&"layout").unwrap().drift_scale(), 0.0);
}
/// Repeating the *same* value stays inert, which is the expected shape
/// when the configuration is a property of the domain.
#[test]
fn repeating_the_same_value_is_inert() {
let mut h = history();
h.add_events(vec![seed(0.0)]).unwrap();
h.add_events(vec![seed(0.0)]).unwrap();
assert_eq!(h.rating(&"layout").unwrap().drift_scale(), 0.0);
}
/// A registration and a later event that agree are fine; one that
/// disagrees is the same error.
#[test]
fn a_registration_conflicts_with_a_later_event() {
let mut h = history();
h.register(Member::new("layout").with_drift_scale(0.0))
.unwrap();
h.add_events(vec![seed(0.0)]).unwrap();
let mut h2 = history();
h2.register(Member::new("layout").with_drift_scale(0.0))
.unwrap();
let err = h2.add_events(vec![seed(1.0)]).unwrap_err();
assert!(
matches!(err, InferenceError::ConflictingCompetitorConfig { .. }),
"{err:?}"
);
}
}
+3 -3
View File
@@ -9,7 +9,7 @@ fn scored_two_team_one_event_pulls_winner_up() {
.mu(0.0) .mu(0.0)
.sigma(2.0) .sigma(2.0)
.beta(1.0) .beta(1.0)
.drift(ConstantDrift(0.0)) .drift(ConstantDrift::new(0.0))
.score_sigma(1.0) .score_sigma(1.0)
.build(); .build();
@@ -46,7 +46,7 @@ fn scored_zero_margin_treats_as_tie() {
.mu(0.0) .mu(0.0)
.sigma(2.0) .sigma(2.0)
.beta(1.0) .beta(1.0)
.drift(ConstantDrift(0.0)) .drift(ConstantDrift::new(0.0))
.score_sigma(1.0) .score_sigma(1.0)
.build(); .build();
@@ -88,7 +88,7 @@ fn scored_three_team_partial_order() {
.mu(0.0) .mu(0.0)
.sigma(2.0) .sigma(2.0)
.beta(1.0) .beta(1.0)
.drift(ConstantDrift(0.0)) .drift(ConstantDrift::new(0.0))
.score_sigma(1.0) .score_sigma(1.0)
.build(); .build();
+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
);
}
}
+152
View File
@@ -0,0 +1,152 @@
//! The `Time` generic, exercised end to end.
//!
//! `History<T: Time, ..>` has always been generic over the time axis, `Untimed`
//! has always been exported, and `Drift<T>` is generic specifically so that
//! "seasonal or calendar-aware drift is expressible without going through
//! `i64`". None of it was reachable: every construction route pinned `T = i64`,
//! `HistoryBuilder`'s fields are private, and its `Default` existed only for the
//! `i64` instantiation.
//!
//! Nothing in the repository constructed a non-`i64` history, which is why that
//! went unnoticed. This file is the guard against it recurring — it is as much
//! about the generic being *exercised* as about any single assertion.
use trueskill_tt::{ConstantDrift, Drift, History, HistoryBuilder, Time, Untimed};
/// A domain time type: a season number. Exactly what the `Time` trait exists
/// to support, and what a consumer with `chrono` dates would write.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Season(u16);
impl Time for Season {
fn elapsed_to(&self, later: &Self) -> i64 {
i64::from(later.0.saturating_sub(self.0))
}
}
/// Drift that only accumulates between seasons, not within one — the
/// calendar-aware case the trait's own docs cite.
#[derive(Copy, Clone, Debug)]
struct SeasonalDrift {
per_season: f64,
}
impl Drift<Season> for SeasonalDrift {
fn variance_delta(&self, from: &Season, to: &Season) -> f64 {
self.variance_for_elapsed(from.elapsed_to(to))
}
fn variance_for_elapsed(&self, elapsed: i64) -> f64 {
elapsed.max(0) as f64 * self.per_season * self.per_season
}
}
#[test]
fn an_untimed_history_fits_through_the_builder() {
let mut h = History::builder().time_type::<Untimed>().build();
for _ in 0..5 {
h.record_winner(&"alice", &"bob", Untimed).unwrap();
}
assert!(h.converge().unwrap().converged);
let alice = h.current_skill(&"alice").unwrap();
let bob = h.current_skill(&"bob").unwrap();
assert!(alice.mu() > bob.mu(), "{alice:?} vs {bob:?}");
assert!(alice.sigma().is_finite() && alice.sigma() > 0.0);
}
/// `Untimed::elapsed_to` is always 0, so no drift accumulates however many
/// events there are. That is the property the type exists for, and it had never
/// been checked.
#[test]
fn untimed_accumulates_no_drift() {
fn final_sigma<T: Time + Copy>(time: T, drift: ConstantDrift) -> f64 {
let mut h = History::builder().time_type::<T>().drift(drift).build();
for _ in 0..8 {
h.record_winner(&"a", &"b", time).unwrap();
}
let _ = h.converge().unwrap();
h.current_skill(&"a").unwrap().sigma()
}
// Under Untimed the drift setting cannot matter, because elapsed is always 0.
let none = final_sigma(Untimed, ConstantDrift::new(0.0));
let large = final_sigma(Untimed, ConstantDrift::new(5.0));
assert_eq!(
none.to_bits(),
large.to_bits(),
"Untimed must ignore drift entirely: {none} vs {large}"
);
}
#[test]
fn a_custom_time_type_and_a_custom_drift_work_together() {
let mut h = History::builder()
.time_type::<Season>()
.drift(SeasonalDrift { per_season: 0.5 })
.build();
for season in 1..=4u16 {
for _ in 0..3 {
h.record_winner(&"veteran", &"rookie", Season(season))
.unwrap();
}
}
assert!(h.converge().unwrap().converged);
let curve = h.learning_curve(&"veteran").unwrap();
assert_eq!(curve.len(), 4, "one point per season: {curve:?}");
for (season, g) in &curve {
assert!(
g.mu().is_finite() && g.sigma() > 0.0,
"season {season:?}: {g:?}"
);
}
// Times come back as the domain type, not as an integer.
assert_eq!(curve[0].0, Season(1));
assert_eq!(curve[3].0, Season(4));
}
/// Seasonal drift must actually widen a gap across seasons — otherwise the
/// custom `Drift` is being ignored and the test above would pass regardless.
#[test]
fn a_custom_drift_is_actually_consulted() {
fn sigma_with(per_season: f64) -> f64 {
let mut h = History::builder()
.time_type::<Season>()
.drift(SeasonalDrift { per_season })
.build();
for season in 1..=6u16 {
h.record_winner(&"a", &"b", Season(season)).unwrap();
}
let _ = h.converge().unwrap();
h.current_skill(&"a").unwrap().sigma()
}
let still = sigma_with(0.0);
let drifting = sigma_with(2.0);
assert!(
drifting > still * 1.05,
"a drifting fit must be less certain: {drifting} vs {still}"
);
}
/// The other axis: a custom key type, through the same mechanism.
#[test]
fn key_type_replaces_builder_with_key() {
let mut h = History::builder().key_type::<String>().build();
h.record_winner(&"alice".to_string(), &"bob".to_string(), 1)
.unwrap();
assert!(h.converge().unwrap().converged);
assert!(h.current_skill("alice").is_some());
}
/// Both axes at once, via the explicit constructor rather than the setters.
#[test]
fn new_constructs_on_any_axis_directly() {
let mut h = HistoryBuilder::<String, Season>::new().build();
h.record_winner(&"a".to_string(), &"b".to_string(), Season(7))
.unwrap();
assert!(h.converge().unwrap().converged);
assert_eq!(h.learning_curve("a").unwrap()[0].0, Season(7));
}
+337
View File
@@ -0,0 +1,337 @@
//! The joint must span slices, because Through Time reads each competitor at
//! their own last appearance.
//!
//! The exact posterior of a multi-slice scored history is still Gaussian: the
//! prior, the drift between appearances, and the scored likelihoods are all
//! Gaussian. So it can be written out by hand and compared against, which is
//! the check a single-slice fixture cannot make.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team, UnknownKeys,
};
const SIGMA0: f64 = 6.0;
const BETA: f64 = 1.0;
const SCORE_SIGMA: f64 = 2.0;
const GAMMA: f64 = 0.5;
type H = History;
fn history(gamma: f64) -> H {
History::builder()
.mu(0.0)
.sigma(SIGMA0)
.beta(BETA)
.score_sigma(SCORE_SIGMA)
.drift(ConstantDrift::new(gamma))
.unknown_keys(UnknownKeys::Reject)
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build()
}
fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> {
Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([sa, sb]),
}
}
fn inverse(mut a: Vec<Vec<f64>>) -> Vec<Vec<f64>> {
let n = a.len();
let mut inv: Vec<Vec<f64>> = (0..n)
.map(|i| (0..n).map(|j| f64::from(u8::from(i == j))).collect())
.collect();
for col in 0..n {
let mut piv = col;
for r in col + 1..n {
if a[r][col].abs() > a[piv][col].abs() {
piv = r;
}
}
a.swap(col, piv);
inv.swap(col, piv);
let d = a[col][col];
for j in 0..n {
a[col][j] /= d;
inv[col][j] /= d;
}
for r in 0..n {
if r == col {
continue;
}
let f = a[r][col];
for j in 0..n {
a[r][j] -= f * a[col][j];
inv[r][j] -= f * inv[col][j];
}
}
}
inv
}
/// Two competitors, two slices ten units apart, one duel in each.
///
/// The exact precision is written out explicitly here rather than obtained
/// from the crate, so this is an independent check rather than a restatement.
/// Variables are `[a0, b0, a1, b1]`.
#[test]
fn a_two_slice_joint_matches_the_exact_posterior() {
let mut h = history(GAMMA);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "b", 10, 4.0, 3.0),
])
.unwrap();
let report = h.converge().unwrap();
assert!(report.converged, "{:?}", report.final_step);
let prior_prec = 1.0 / (SIGMA0 * SIGMA0);
let drift_prec = 1.0 / (10.0 * GAMMA * GAMMA);
let obs_prec = 1.0 / (SCORE_SIGMA * SCORE_SIGMA + 2.0 * BETA * BETA);
let mut lambda = vec![vec![0.0; 4]; 4];
// priors on the first appearances
lambda[0][0] += prior_prec;
lambda[1][1] += prior_prec;
// drift a0-a1 and b0-b1
for (p, q) in [(0usize, 2usize), (1, 3)] {
lambda[p][p] += drift_prec;
lambda[q][q] += drift_prec;
lambda[p][q] -= drift_prec;
lambda[q][p] -= drift_prec;
}
// one duel per slice: contrast (+1, -1) on that slice's variables
for (p, q) in [(0usize, 1usize), (2, 3)] {
lambda[p][p] += obs_prec;
lambda[q][q] += obs_prec;
lambda[p][q] -= obs_prec;
lambda[q][p] -= obs_prec;
}
let cov = inverse(lambda);
// 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
.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}",
got.sigma()
);
let exact_single = cov[2][2].sqrt();
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}",
got_single.sigma()
);
}
/// The case that motivated this: competitors read at *different* slices, with
/// the last slice holding only one of them. Under the old latest-slice joint
/// this was `UnknownKey`.
#[test]
fn competitors_last_seen_in_different_slices_are_comparable() {
let mut h = history(GAMMA);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "c", 10, 4.0, 3.0),
// the final slice holds one duel that does not involve b at all
duel("a", "c", 20, 6.0, 1.0),
])
.unwrap();
let _ = h.converge().unwrap();
// 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());
}
}
/// The mean must agree with what message passing reports, which is exact even
/// with cycles. Only the second moment needs the joint.
#[test]
fn means_agree_with_the_marginals() {
let mut h = history(GAMMA);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("b", "c", 5, 3.0, 1.0),
duel("a", "c", 10, 4.0, 2.0),
])
.unwrap();
let _ = h.converge().unwrap();
for k in ["a", "b", "c"] {
let marginal = h.current_skill(&k).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}"
);
}
}
/// With zero drift a competitor has one latent skill however many slices it
/// appears in, so spreading the same events over time must not change the
/// answer. This exercises the appearance-merging path.
#[test]
fn zero_drift_makes_slice_layout_irrelevant() {
let spread = {
let mut h = history(0.0);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "b", 10, 4.0, 3.0),
duel("a", "b", 20, 6.0, 1.0),
])
.unwrap();
let _ = h.converge().unwrap();
h.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap()
};
let together = {
let mut h = history(0.0);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "b", 0, 4.0, 3.0),
duel("a", "b", 0, 6.0, 1.0),
])
.unwrap();
let _ = h.converge().unwrap();
h.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap()
};
assert!(
(spread.sigma() - together.sigma()).abs() < 1e-9,
"zero drift: spread {} vs together {}",
spread.sigma(),
together.sigma()
);
}
/// More drift means less is carried forward from old evidence, so a comparison
/// against a competitor last seen long ago must widen.
#[test]
fn drift_widens_a_comparison_across_time() {
let mut previous = 0.0;
for gamma in [0.0f64, 0.1, 0.5, 2.0] {
let mut h = history(gamma);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "c", 100, 4.0, 3.0),
])
.unwrap();
let _ = h.converge().unwrap();
// b was last seen at time 0; a at time 100.
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}",
g.sigma()
);
previous = g.sigma();
}
}
/// `posterior_of_at` pins the reading to a moment, where `posterior_of` takes
/// each competitor wherever they were last seen.
#[test]
fn posterior_of_at_reads_as_of_a_time() {
let mut h = history(GAMMA);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "b", 10, 4.0, 3.0),
duel("a", "b", 20, 6.0, 1.0),
])
.unwrap();
let _ = h.converge().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);
assert!((late.sigma() - latest.sigma()).abs() < 1e-9);
// Reading at time 0 is a different quantity, and the smoothed estimate
// there is informed by everything that came after.
assert!(
(early.mu() - late.mu()).abs() > 1e-6,
"as-of-0 and as-of-20 should differ: {} vs {}",
early.mu(),
late.mu()
);
// A time before any event has nothing to read.
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.
#[test]
fn a_time_between_slices_reads_the_previous_appearance() {
let mut h = history(GAMMA);
h.add_events(vec![
duel("a", "b", 0, 5.0, 2.0),
duel("a", "b", 100, 4.0, 3.0),
])
.unwrap();
let _ = h.converge().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);
}
+96
View File
@@ -0,0 +1,96 @@
//! The traits a consumer needs on the public types, pinned so they cannot be
//! removed by accident.
//!
//! This is written from a consumer's position — deriving `Debug` on a struct
//! that *holds* a `History` — because that is the thing that failed. Asserting
//! `History: Debug` in isolation would not have caught the generic-bound half:
//! `Rating` derives `PartialEq`, but that is only usable if `D: PartialEq`, and
//! the crate's own only `Drift` impl did not satisfy it.
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, ConvergenceReport, Event, GameOptions, Gaussian, History,
HistoryBuilder, InferenceError, Member, Outcome, Rating, Team,
};
/// The reported failure, verbatim: a consumer holding a history in app state.
#[derive(Debug)]
#[allow(
dead_code,
reason = "held only so `derive(Debug)` has something to render"
)]
struct App {
history: History,
}
#[test]
fn a_struct_holding_a_history_can_derive_debug() {
let app = App {
history: History::default(),
};
let rendered = format!("{app:?}");
// Summarising, not a dump of every skill store — the same choice `Joint`'s
// manual `Debug` makes about its n² factorisation.
assert!(rendered.contains("competitors"), "{rendered}");
assert!(rendered.contains("time_slices"), "{rendered}");
assert!(
!rendered.contains("SkillStore"),
"History's Debug should summarise, not dump: {rendered}"
);
}
#[test]
fn history_builder_is_debug_and_clone() {
let b: HistoryBuilder = History::builder();
let cloned = b.clone();
assert!(!format!("{cloned:?}").is_empty());
}
#[test]
fn config_and_input_value_types_are_comparable() {
assert_eq!(ConstantDrift::new(0.1), ConstantDrift::new(0.1));
assert_ne!(ConstantDrift::new(0.1), ConstantDrift::new(0.2));
assert_eq!(ConvergenceOptions::default(), ConvergenceOptions::default());
assert_eq!(GameOptions::default(), GameOptions::default());
// `Rating: PartialEq` is only reachable through `D: PartialEq`.
assert_eq!(Rating::<i64, ConstantDrift>::default(), Rating::default());
assert_ne!(
Rating::default(),
Rating::<i64, ConstantDrift>::default().with_drift_scale(2.0)
);
assert_eq!(Member::new("a"), Member::new("a"));
assert_ne!(Member::new("a"), Member::new("b"));
assert_eq!(
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("a")])
);
let event = || Event {
time: 1,
teams: [
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
]
.into_iter()
.collect(),
outcome: Outcome::winner(0, 2),
};
assert_eq!(event(), event());
assert_eq!(Gaussian::default(), Gaussian::default());
}
#[test]
fn a_history_is_send_and_sync_and_default() {
fn assert_send_sync<X: Send + Sync>() {}
assert_send_sync::<History>();
assert_send_sync::<InferenceError>();
let mut h = History::default();
let report: ConvergenceReport = h.converge().expect("an empty history converges");
assert_eq!(report, report.clone());
}
+400
View File
@@ -0,0 +1,400 @@
//! Input validation must hold in **release**, where `debug_assert!` is gone.
//!
//! The engine guards itself with `debug_assert!`, which documents invariants
//! but vanishes in the profile users actually ship. Anything reachable from the
//! public API has to be rejected with an `InferenceError` instead, at the
//! boundary, rather than becoming NaN or an out-of-bounds panic deep inside
//! `run_chain`.
//!
//! `GameOptions` and `ConvergenceOptions` both have public fields, so the
//! eager asserts on `HistoryBuilder` do not cover the `Game` constructors —
//! a caller can build the options struct directly.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, Game, GameOptions, Gaussian, History, InferenceError,
Member, Outcome, Rating, Team,
};
type R = Rating<i64, ConstantDrift>;
fn rating() -> R {
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(0.0),
)
}
fn options_with_alpha(alpha: f64) -> GameOptions {
GameOptions {
convergence: ConvergenceOptions {
alpha,
..ConvergenceOptions::default()
},
..GameOptions::default()
}
}
/// `alpha == 0.0` leaves every EP update unapplied, so inference silently
/// returns the priors — the worst possible failure, since the output looks
/// entirely reasonable.
#[test]
fn ranked_rejects_a_zero_damping_factor() {
let (a, b) = (rating(), rating());
let err = Game::<i64, _>::ranked(
&[&[a], &[b]],
Outcome::winner(0, 2),
&options_with_alpha(0.0),
)
.expect_err("alpha = 0 must be rejected");
assert!(
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"got {err:?}"
);
}
#[test]
fn ranked_rejects_an_out_of_range_damping_factor() {
let (a, b) = (rating(), rating());
for alpha in [-0.5, 1.5, f64::NAN] {
let err = Game::<i64, _>::ranked(
&[&[a], &[b]],
Outcome::winner(0, 2),
&options_with_alpha(alpha),
)
.expect_err("alpha out of (0, 1] must be rejected");
assert!(
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"alpha={alpha}: got {err:?}"
);
}
}
#[test]
fn scored_rejects_a_bad_damping_factor() {
let (a, b) = (rating(), rating());
let err = Game::<i64, _>::scored(
&[&[a], &[b]],
Outcome::scores([21.0, 9.0]),
&options_with_alpha(0.0),
)
.expect_err("alpha = 0 must be rejected");
assert!(
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"got {err:?}"
);
}
/// Already covered by `Game::ranked`, asserted here so the release-mode
/// guarantee is stated in one place.
#[test]
fn ranked_rejects_an_out_of_range_draw_probability() {
let (a, b) = (rating(), rating());
for p_draw in [-0.5, 1.0, 1.5] {
let options = GameOptions {
p_draw,
..GameOptions::default()
};
assert!(
Game::<i64, _>::ranked(&[&[a], &[b]], Outcome::winner(0, 2), &options).is_err(),
"p_draw={p_draw} must be rejected"
);
}
}
#[test]
fn scored_rejects_a_non_positive_noise() {
let (a, b) = (rating(), rating());
for score_sigma in [0.0, -1.0, f64::NAN] {
let options = GameOptions {
score_sigma,
..GameOptions::default()
};
assert!(
Game::<i64, _>::scored(&[&[a], &[b]], Outcome::scores([21.0, 9.0]), &options).is_err(),
"score_sigma={score_sigma} must be rejected"
);
}
}
/// A tie with no draw probability makes the truncation margin zero and the
/// two-sided update evaluate 0/0. Ingestion must refuse it.
#[test]
fn ingestion_rejects_a_tie_without_a_draw_probability() {
let mut h = History::builder().p_draw(0.0).build();
let err = h
.add_events(vec![Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::draw(2),
}])
.expect_err("a tie with p_draw = 0 must be rejected");
assert!(
matches!(err, InferenceError::TieWithoutDrawProbability { .. }),
"got {err:?}"
);
}
/// `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() {
for sigma in [0.0, -1.0, f64::NAN] {
let mut h = History::builder().build();
let err = h
.add_events(vec![Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::scores_with_noise([21.0, 9.0], sigma),
}])
.expect_err("a non-positive per-event sigma must be rejected");
assert!(
matches!(err, InferenceError::InvalidParameter { .. }),
"sigma={sigma}: got {err:?}"
);
}
}
/// Per-team weights must match that team's membership. The top-level length
/// checks in ingestion do not cover the inner dimension.
#[test]
fn ingestion_rejects_weights_that_do_not_match_their_team() {
let mut h = History::builder().build();
let mut team = Team::with_members([Member::new("a"), Member::new("b")]);
team.members[0].weight = 1.0;
let err = h
.event(0)
.team(["a", "b"])
.team(["c"])
// Three weights for a two-member team.
.weights([1.0, 1.0, 1.0])
.winner(0)
.commit()
.expect_err("a weight/member length mismatch must be rejected");
assert!(
matches!(err, InferenceError::MismatchedShape { .. }),
"got {err:?}"
);
}
/// `mu`, `sigma` and `beta` were the last unvalidated setters on
/// `HistoryBuilder`, next to `p_draw`, `score_sigma` and `convergence`, which
/// all assert eagerly.
///
/// Two of the rejected values are the quiet kind. A negative `sigma` or `beta`
/// enters inference only as its square, so it produced bit-identical results
/// to the positive value — the sign was dropped without comment.
mod builder_parameters {
use trueskill_tt::History;
#[test]
#[should_panic(expected = "mu must be finite")]
fn a_non_finite_mu_is_rejected() {
let _ = History::builder().mu(f64::NAN);
}
#[test]
#[should_panic(expected = "sigma must be finite and positive")]
fn a_zero_sigma_is_rejected() {
let _ = History::builder().sigma(0.0);
}
#[test]
#[should_panic(expected = "sigma must be finite and positive")]
fn a_negative_sigma_is_rejected() {
let _ = History::builder().sigma(-8.33);
}
#[test]
#[should_panic(expected = "sigma must be finite and positive")]
fn an_infinite_sigma_is_rejected() {
let _ = History::builder().sigma(f64::INFINITY);
}
#[test]
#[should_panic(expected = "beta must be finite and non-negative")]
fn a_negative_beta_is_rejected() {
let _ = History::builder().beta(-4.17);
}
#[test]
#[should_panic(expected = "beta must be finite and non-negative")]
fn a_non_finite_beta_is_rejected() {
let _ = History::builder().beta(f64::NAN);
}
/// Zero beta is deliberately allowed: performance is then exactly skill.
/// It has to reach a different fit than a positive beta, or "allowed"
/// would just mean "not checked".
#[test]
fn a_zero_beta_is_allowed_and_changes_the_fit() {
let fit = |beta: f64| {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(beta)
.build();
h.record_winner(&"a", &"b", 1).unwrap();
let _ = h.converge().unwrap();
h.current_skill(&"a").unwrap()
};
let zero = fit(0.0);
let positive = fit(25.0 / 6.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.variance() - positive.variance()).abs() > 1e-6,
"zero beta must not merely be ignored: {zero:?} vs {positive:?}"
);
}
}
/// The constructors below `HistoryBuilder`, which 0.8.0's validation did not
/// reach.
///
/// `sigma`, `beta` and `gamma` all enter inference only as squares, so a
/// negative value behaves as its absolute value and the sign vanishes without
/// comment. Measured before these guards: `from_ms(25.0, -8.33)` and
/// `Rating::new(_, -4.17, _)` returned results bit identical to their positive
/// counterparts, and `Rating::new(_, NaN, _)` reached `Game::ranked`, which
/// returned `Ok` carrying `Gaussian { pi: NaN, tau: NaN }`.
mod constructor_parameters {
use trueskill_tt::{ConstantDrift, Gaussian, History, InferenceError, Rating};
#[test]
#[should_panic(expected = "sigma must not be negative")]
fn a_negative_sigma_is_rejected_by_from_ms() {
let _ = Gaussian::from_ms(25.0, -8.33);
}
/// NaN must pass, and that is deliberate: a broken fit produces a NaN
/// sigma and `converge` reports it as `NonFiniteResult`. Rejecting it here
/// would turn reporting into a panic inside inference.
#[test]
fn a_nan_sigma_passes_through_from_ms() {
let g = Gaussian::from_ms(25.0, f64::NAN);
// `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]
#[should_panic(expected = "beta must be finite and non-negative")]
fn a_negative_beta_is_rejected_by_rating_new() {
let _ =
Rating::<i64, ConstantDrift>::new(Gaussian::default(), -4.17, ConstantDrift::new(0.0));
}
#[test]
#[should_panic(expected = "beta must be finite and non-negative")]
fn a_nan_beta_is_rejected_by_rating_new() {
let _ = Rating::<i64, ConstantDrift>::new(
Gaussian::default(),
f64::NAN,
ConstantDrift::new(0.0),
);
}
#[test]
fn a_zero_beta_is_accepted_by_rating_new() {
let _ =
Rating::<i64, ConstantDrift>::new(Gaussian::default(), 0.0, ConstantDrift::new(0.0));
}
/// `ConstantDrift` rejects at construction now that its field is private.
#[test]
#[should_panic(expected = "gamma must be finite and non-negative")]
fn a_negative_gamma_is_rejected_by_constant_drift_new() {
let _ = ConstantDrift::new(-0.0833);
}
#[test]
#[should_panic(expected = "gamma must be finite and non-negative")]
fn a_non_finite_gamma_is_rejected_by_constant_drift_new() {
let _ = ConstantDrift::new(f64::NAN);
}
#[test]
fn gamma_reads_back_what_was_given() {
assert_eq!(ConstantDrift::new(0.25).gamma(), 0.25);
assert_eq!(ConstantDrift::new(0.0).gamma(), 0.0);
}
/// `HistoryBuilder::drift` is generic and cannot inspect an arbitrary
/// `Drift`, so the check on the variance each competitor accumulates is
/// still needed — it is the only thing standing between a custom
/// implementation and a NaN fit. `ConstantDrift` can no longer reach it,
/// so this uses an implementation that can.
#[test]
fn a_custom_drift_returning_a_bad_variance_is_rejected_at_convergence() {
#[derive(Clone, Copy, Debug)]
struct BadDrift(f64);
impl trueskill_tt::Drift<i64> for BadDrift {
fn variance_delta(&self, _from: &i64, _to: &i64) -> f64 {
self.0
}
fn variance_for_elapsed(&self, _elapsed: i64) -> f64 {
self.0
}
}
for bad in [f64::NAN, f64::INFINITY, -1.0] {
let mut h = History::builder().drift(BadDrift(bad)).build();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"a", &"b", 5).unwrap();
let err = h.converge().unwrap_err();
assert!(
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::DriftVariance,
..
}
),
"drift {bad}: {err:?}"
);
}
}
/// An ordinary drift is untouched.
#[test]
fn an_ordinary_drift_still_converges() {
let mut h = History::builder()
.drift(ConstantDrift::new(25.0 / 300.0))
.build();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"a", &"b", 5).unwrap();
assert!(h.converge().unwrap().converged);
}
}
+196
View File
@@ -0,0 +1,196 @@
//! `expected_variance_reduction`: which matchup best sharpens a given question.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
UnknownKeys,
};
type H = History;
fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> {
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([sa, sb]),
}
}
fn base() -> Vec<Event<i64, &'static str>> {
vec![
round("a", "b", 5.0, 2.0),
round("a", "c", 6.0, 1.0),
round("b", "c", 4.0, 3.0),
round("c", "d", 2.0, 1.0),
round("a", "d", 7.0, 2.0),
]
}
fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
let mut h: History = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.0))
.unknown_keys(policy)
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build();
let mut ev = base();
if let Some(e) = extra {
ev.push(e);
}
h.add_events(ev).unwrap();
let _ = h.converge().unwrap();
h
}
/// The closed form must equal what actually happens if the matchup is played.
/// This is the assertion that makes the whole call trustworthy: a wrong
/// acquisition function returns plausible numbers and quietly picks worse
/// matchups forever.
#[test]
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
.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
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2);
assert!(
(predicted - actual).abs() / actual.abs() < 1e-9,
"{x} vs {y}: predicted {predicted}, actual {actual}"
);
}
}
/// The reduction cannot depend on the score, because for a Gaussian likelihood
/// the posterior variance update is data-independent. This is why the call
/// needs no expectation despite its name.
#[test]
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
.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
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2),
);
}
for w in seen.windows(2) {
assert!(
(w[0] - w[1]).abs() < 1e-12,
"variance reduction moved with the observed score: {seen:?}"
);
}
}
/// The point of the call: it must rank candidate matchups usefully. Playing the
/// pair you are trying to separate helps most; an unrelated pair helps least.
#[test]
fn it_ranks_candidates_by_how_much_they_answer_the_question() {
let h = fit(None, UnknownKeys::Reject);
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();
assert!(direct > 0.0 && unrelated > 0.0);
assert!(
direct > 5.0 * unrelated,
"playing the target pair should dominate: {direct} vs {unrelated}"
);
}
/// A matchup between two competitors nobody has seen still teaches something
/// about them, but nothing about a target that does not involve them.
#[test]
fn an_unrelated_unseen_matchup_teaches_nothing_about_the_target() {
let h = fit(None, UnknownKeys::Prior);
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!(
reduction.abs() < 1e-12,
"an unseen pair shares nothing with the target: {reduction}"
);
}
#[test]
fn shape_errors_are_reported() {
let h = fit(None, UnknownKeys::Reject);
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
assert!(matches!(
h.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"]], &target),
Err(InferenceError::MismatchedShape {
expected: 2,
got: 1,
..
})
));
assert!(matches!(
h.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
Err(InferenceError::UnknownKey { .. })
));
}